UNPKG

ctrlshiftleft

Version:

AI-powered toolkit for embedding QA and security testing into development workflows

595 lines 25.1 kB
"use strict"; /** * Enhanced File Watcher for ctrl.shift.left * * This module provides advanced file watching capabilities with AI-powered security analysis, * designed to integrate with developer workflows, including Cursor AI and other code generation tools. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EnhancedWatcher = void 0; const chokidar_1 = __importDefault(require("chokidar")); const path_1 = __importDefault(require("path")); const chalk_1 = __importDefault(require("chalk")); const promises_1 = __importDefault(require("fs/promises")); const events_1 = require("events"); const testGenerator_1 = require("./testGenerator"); const testRunner_1 = require("./testRunner"); const checklistGenerator_1 = require("./checklistGenerator"); const fileUtils_1 = require("../utils/fileUtils"); // Use a compatible way to import figures const figures = { tick: '✓', cross: '✖', warning: '⚠', info: 'ℹ', pointer: '❯', line: '│' }; // Dynamic import for the AI security analyzer let aiSecurityAnalyzer = null; // Attempt to load the AI security analyzer try { const aiSecurityAnalyzerPath = path_1.default.join(__dirname, '..', 'ai-security-analyzer.js'); // We'll use require since this module is CommonJS and we're in a TypeScript file aiSecurityAnalyzer = require(aiSecurityAnalyzerPath); } catch (error) { console.warn('AI security analyzer could not be loaded. AI-enhanced security analysis will not be available.'); } /** * File patterns to watch by default */ const DEFAULT_WATCH_PATTERNS = [ '**/*.{js,jsx,ts,tsx,vue,svelte,html}', '!node_modules/**', '!dist/**', '!build/**', '!coverage/**', '!**/*.test.*', '!**/*.spec.*', '!**/tests/**' ]; /** * Enhanced file watcher for real-time QA and security feedback * Designed to work alongside IDE extensions and tools like Cursor AI */ class EnhancedWatcher extends events_1.EventEmitter { /** * Create a new enhanced watcher * @param options Watcher options */ constructor(options = {}) { super(); this.watcher = null; this.processingFiles = new Set(); this.fileContents = new Map(); this.taskQueue = []; this.isProcessingQueue = false; this.activeTasks = 0; this.stopping = false; // Set default options - handle all optional parameters and their defaults this.options = { include: options.include ?? DEFAULT_WATCH_PATTERNS, exclude: options.exclude ?? [], debounceMs: options.debounceMs ?? 500, generateTests: options.generateTests ?? true, analyzeSecurity: options.analyzeSecurity ?? true, useAIAnalysis: options.useAIAnalysis ?? false, generateChecklists: options.generateChecklists ?? true, testOutputDir: options.testOutputDir ?? './tests', securityOutputDir: options.securityOutputDir ?? './security-reports', checklistOutputDir: options.checklistOutputDir ?? './checklists', testFormat: options.testFormat ?? 'playwright', openAIApiKey: options.openAIApiKey ?? process.env.OPENAI_API_KEY ?? '', minFileSizeChange: options.minFileSizeChange ?? 10, // bytes maxConcurrentTasks: options.maxConcurrentTasks ?? 3, reportProgress: options.reportProgress ?? true, testableFileTypes: options.testableFileTypes ?? ['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte'], securityFileTypes: options.securityFileTypes ?? ['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.html', '.css'], browser: options.browser ?? 'chromium', headless: options.headless ?? true }; // Initialize core services this.testGenerator = new testGenerator_1.TestGenerator({ format: this.options.testFormat || 'playwright', timeout: 60 }); this.testRunner = new testRunner_1.TestRunner({ browser: 'chromium', headless: true, timeout: 60, reporter: 'list', workers: 1 }); this.checklistGenerator = new checklistGenerator_1.ChecklistGenerator(); // Set OpenAI API key if provided if (aiSecurityAnalyzer && this.options.openAIApiKey) { aiSecurityAnalyzer.setApiKey(this.options.openAIApiKey); } } /** * Check if AI-enhanced security analysis is available * @returns Boolean indicating if AI analysis is available */ isAIAvailable() { return aiSecurityAnalyzer !== null && !!this.options.openAIApiKey; } /** * Start watching files * @param directoryPath Path to the directory to watch * @returns Function to stop watching */ watch(directoryPath) { // Normalize directory path const absolutePath = path_1.default.isAbsolute(directoryPath) ? directoryPath : path_1.default.resolve(process.cwd(), directoryPath); // Ensure output directories exist this.ensureOutputDirectories(); // Log configuration this.logConfiguration(absolutePath); // Initialize watcher this.watcher = chokidar_1.default.watch(this.options.include, { cwd: absolutePath, ignored: this.options.exclude, ignoreInitial: false, persistent: true, ignorePermissionErrors: true, awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 } }); // Setup event handlers this.watcher .on('add', (filePath) => this.handleFileChange('add', path_1.default.join(absolutePath, filePath))) .on('change', (filePath) => this.handleFileChange('change', path_1.default.join(absolutePath, filePath))) .on('unlink', (filePath) => this.handleFileChange('unlink', path_1.default.join(absolutePath, filePath))) .on('ready', () => { console.log(chalk_1.default.green(`${figures.tick} Initial scan complete. Watching for changes...`)); this.emit('ready'); }) .on('error', (error) => { console.error(chalk_1.default.red(`${figures.cross} Watcher error: ${error.message}`)); this.emit('error', error); }); // Start the task queue processor this.processTaskQueue(); // Return a function to stop watching return () => this.stop(); } /** * Stop watching files */ async stop() { if (this.stopping) return; this.stopping = true; console.log(chalk_1.default.yellow(`${figures.info} Stopping file watcher...`)); // Wait for active tasks to complete if (this.activeTasks > 0) { console.log(chalk_1.default.yellow(`${figures.info} Waiting for ${this.activeTasks} active tasks to complete...`)); await new Promise(resolve => { const checkInterval = setInterval(() => { if (this.activeTasks === 0) { clearInterval(checkInterval); resolve(true); } }, 100); }); } // Close watcher if (this.watcher) { await this.watcher.close(); this.watcher = null; } // Clear task queue this.taskQueue = []; this.isProcessingQueue = false; console.log(chalk_1.default.green(`${figures.tick} File watcher stopped.`)); this.emit('stopped'); this.stopping = false; } /** * Manually analyze a file * @param filePath Path to the file to analyze * @param analysisTypes Types of analysis to perform */ async analyzeFile(filePath, analysisTypes = {}) { // Normalize options const options = { generateTests: analysisTypes.generateTests ?? this.options.generateTests, analyzeSecurity: analysisTypes.analyzeSecurity ?? this.options.analyzeSecurity, useAI: analysisTypes.useAI ?? this.options.useAIAnalysis, generateChecklists: analysisTypes.generateChecklists ?? this.options.generateChecklists }; // Normalize file path const absoluteFilePath = path_1.default.isAbsolute(filePath) ? filePath : path_1.default.resolve(process.cwd(), filePath); // Ensure output directories exist this.ensureOutputDirectories(); // Run analysis const results = []; if (options.generateTests && this.isTestableFile(absoluteFilePath)) { results.push(await this.generateTestsForFile(absoluteFilePath)); } if (options.analyzeSecurity && this.isSecurityAnalyzableFile(absoluteFilePath)) { results.push(await this.analyzeFileSecurity(absoluteFilePath, options.useAI)); } if (options.generateChecklists) { results.push(await this.generateChecklistForFile(absoluteFilePath)); } return results; } /** * Handle file change events * @param eventType Type of event (add, change, unlink) * @param filePath Path to the changed file */ async handleFileChange(eventType, filePath) { // Skip if we're stopping if (this.stopping) return; // Check if this file should be processed if (!this.shouldProcessFile(filePath, eventType)) { return; } // Create file change event const changeEvent = { type: eventType, path: filePath, timestamp: Date.now() }; // Emit file change event this.emit('file:change', changeEvent); // Skip deleted files if (eventType === 'unlink') { this.fileContents.delete(filePath); return; } // Check if content has changed significantly if (eventType === 'change' && !await this.hasContentChangedSignificantly(filePath)) { return; } // Add tasks to queue based on file type and options // Test generation task if (this.options.generateTests && this.isTestableFile(filePath)) { this.addToTaskQueue(filePath, async () => { const result = await this.generateTestsForFile(filePath); this.emit('analysis:complete', result); }); } // Security analysis task if (this.options.analyzeSecurity && this.isSecurityAnalyzableFile(filePath)) { this.addToTaskQueue(filePath, async () => { const result = await this.analyzeFileSecurity(filePath, this.options.useAIAnalysis); this.emit('analysis:complete', result); }); } // Checklist generation task if (this.options.generateChecklists) { this.addToTaskQueue(filePath, async () => { const result = await this.generateChecklistForFile(filePath); this.emit('analysis:complete', result); }); } } /** * Add a task to the queue for a file * @param file File path * @param task Task function */ addToTaskQueue(file, task) { this.taskQueue.push({ file, task }); // If the queue isn't being processed, start processing if (!this.isProcessingQueue) { this.processTaskQueue(); } } /** * Process tasks in the queue */ async processTaskQueue() { if (this.stopping) return; // If already processing or queue is empty, do nothing if (this.isProcessingQueue || this.taskQueue.length === 0) { return; } this.isProcessingQueue = true; // Process items in the queue while respecting maxConcurrentTasks while (this.taskQueue.length > 0 && !this.stopping) { // If we're at the concurrent task limit, wait if (this.activeTasks >= this.options.maxConcurrentTasks) { await new Promise(resolve => setTimeout(resolve, 100)); continue; } // Get the next task const nextTask = this.taskQueue.shift(); if (!nextTask) continue; // Skip if already processing this file if (this.processingFiles.has(nextTask.file)) { continue; } // Mark as processing this.processingFiles.add(nextTask.file); this.activeTasks++; // Run the task nextTask.task() .catch(error => { console.error(chalk_1.default.red(`${figures.cross} Error processing ${nextTask.file}: ${error.message}`)); this.emit('error', { file: nextTask.file, error }); }) .finally(() => { this.processingFiles.delete(nextTask.file); this.activeTasks--; }); } this.isProcessingQueue = false; } /** * Generate tests for a file * @param filePath Path to the file * @returns Analysis result */ async generateTestsForFile(filePath) { const startTime = Date.now(); const fileName = path_1.default.basename(filePath); if (this.options.reportProgress) { console.log(chalk_1.default.blue(`${figures.pointer} Generating tests for ${fileName}...`)); } try { // Create output path based on file path const relativePath = path_1.default.relative(process.cwd(), filePath); const outputDir = path_1.default.join(this.options.testOutputDir, path_1.default.dirname(relativePath)); // Generate tests const result = await this.testGenerator.generateTests(filePath, outputDir); const duration = Date.now() - startTime; if (this.options.reportProgress) { console.log(chalk_1.default.green(`${figures.tick} Generated ${result.testCount} tests for ${fileName} in ${duration}ms`)); } return { filePath, success: true, type: 'test', outputPath: result.files.length > 0 ? result.files[0] : undefined, duration, timestamp: Date.now() }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (this.options.reportProgress) { console.error(chalk_1.default.red(`${figures.cross} Failed to generate tests for ${fileName}: ${errorMessage}`)); } return { filePath, success: false, type: 'test', error: errorMessage, duration: Date.now() - startTime, timestamp: Date.now() }; } } /** * Analyze security of a file * @param filePath Path to the file * @param useAI Whether to use AI-enhanced analysis * @returns Analysis result */ async analyzeFileSecurity(filePath, useAI) { const startTime = Date.now(); const fileName = path_1.default.basename(filePath); if (this.options.reportProgress) { console.log(chalk_1.default.blue(`${figures.pointer} Analyzing security of ${fileName}${useAI ? ' with AI' : ''}...`)); } try { // Create output path const baseFileName = path_1.default.basename(filePath, path_1.default.extname(filePath)); const reportFileName = `${baseFileName}-security${useAI ? '-ai' : ''}.md`; const outputPath = path_1.default.join(this.options.securityOutputDir, reportFileName); // Ensure output directory exists await (0, fileUtils_1.ensureDirectoryExists)(path_1.default.dirname(outputPath)); let success = false; // Use AI analysis if requested and available if (useAI && aiSecurityAnalyzer) { // Use the AI security analyzer const aiResult = await aiSecurityAnalyzer.analyzeWithAI(filePath, { format: 'markdown', output: outputPath }); success = !aiResult.error; } else { // Use the pattern-based analyzer from security utils const securityUtils = require('../utils/securityRiskUtils'); const patterns = securityUtils.SECURITY_PATTERNS; const issues = securityUtils.analyzeSecurity(filePath, patterns); const report = securityUtils.generateSecurityReport(filePath, issues); // Write report to file await promises_1.default.writeFile(outputPath, report); success = true; } const duration = Date.now() - startTime; if (this.options.reportProgress) { if (success) { console.log(chalk_1.default.green(`${figures.tick} Security analysis of ${fileName} completed in ${duration}ms`)); } else { console.log(chalk_1.default.yellow(`${figures.warning} Security analysis of ${fileName} completed with warnings in ${duration}ms`)); } } return { filePath, success, type: 'security', outputPath, duration, timestamp: Date.now() }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (this.options.reportProgress) { console.error(chalk_1.default.red(`${figures.cross} Failed to analyze security of ${fileName}: ${errorMessage}`)); } return { filePath, success: false, type: 'security', error: errorMessage, duration: Date.now() - startTime, timestamp: Date.now() }; } } /** * Generate checklist for a file * @param filePath Path to the file * @returns Analysis result */ async generateChecklistForFile(filePath) { const startTime = Date.now(); const fileName = path_1.default.basename(filePath); if (this.options.reportProgress) { console.log(chalk_1.default.blue(`${figures.pointer} Generating checklist for ${fileName}...`)); } try { // Create output path const baseFileName = path_1.default.basename(filePath, path_1.default.extname(filePath)); const outputPath = path_1.default.join(this.options.checklistOutputDir, `${baseFileName}-checklist.md`); // Generate checklist const result = await this.checklistGenerator.generateChecklist(filePath, path_1.default.dirname(outputPath)); const duration = Date.now() - startTime; if (this.options.reportProgress) { console.log(chalk_1.default.green(`${figures.tick} Generated checklist for ${fileName} with ${result.itemCount} items in ${duration}ms`)); } return { filePath, success: true, type: 'checklist', outputPath, duration, timestamp: Date.now() }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (this.options.reportProgress) { console.error(chalk_1.default.red(`${figures.cross} Failed to generate checklist for ${fileName}: ${errorMessage}`)); } return { filePath, success: false, type: 'checklist', error: errorMessage, duration: Date.now() - startTime, timestamp: Date.now() }; } } /** * Check if file content has changed significantly * @param filePath Path to the file * @returns Whether the content has changed significantly */ async hasContentChangedSignificantly(filePath) { try { // Read current content const content = await promises_1.default.readFile(filePath, 'utf8'); const previous = this.fileContents.get(filePath); // If we don't have previous content, consider it changed if (!previous) { this.fileContents.set(filePath, { content, timestamp: Date.now() }); return true; } // Check if content length change is significant const lenDiff = Math.abs(content.length - previous.content.length); const isSignificant = lenDiff >= this.options.minFileSizeChange; // If significant or it's been a while since last update, update and process const timeSinceLastUpdate = Date.now() - previous.timestamp; if (isSignificant || timeSinceLastUpdate > 60000) { // 1 minute this.fileContents.set(filePath, { content, timestamp: Date.now() }); return true; } return false; } catch (error) { // If we can't read the file, assume it changed return true; } } /** * Check if a file should be processed * @param filePath Path to the file * @param eventType Type of event * @returns Whether the file should be processed */ shouldProcessFile(filePath, eventType) { // Skip unlink events for now if (eventType === 'unlink') { return false; } // Get file extension const ext = path_1.default.extname(filePath).toLowerCase(); // Skip files that don't match any of our target extensions const allTargetExts = [ ...this.options.testableFileTypes, ...this.options.securityFileTypes ]; return allTargetExts.includes(ext); } /** * Check if a file is testable * @param filePath Path to the file * @returns Whether the file is testable */ isTestableFile(filePath) { const ext = path_1.default.extname(filePath).toLowerCase(); return this.options.testableFileTypes.includes(ext); } /** * Check if a file can be analyzed for security * @param filePath Path to the file * @returns Whether the file can be analyzed for security */ isSecurityAnalyzableFile(filePath) { const ext = path_1.default.extname(filePath).toLowerCase(); return this.options.securityFileTypes.includes(ext); } /** * Ensure all output directories exist */ async ensureOutputDirectories() { await (0, fileUtils_1.ensureDirectoryExists)(this.options.testOutputDir); await (0, fileUtils_1.ensureDirectoryExists)(this.options.securityOutputDir); await (0, fileUtils_1.ensureDirectoryExists)(this.options.checklistOutputDir); } /** * Log watcher configuration * @param watchPath Path being watched */ logConfiguration(watchPath) { console.log(chalk_1.default.cyan(`\n${figures.pointer} ctrl.shift.left Enhanced Watcher Starting`)); console.log(chalk_1.default.dim(`${figures.line} Watching: ${watchPath}`)); console.log(chalk_1.default.dim(`${figures.line} Generating Tests: ${this.options.generateTests ? 'Yes' : 'No'}`)); console.log(chalk_1.default.dim(`${figures.line} Security Analysis: ${this.options.analyzeSecurity ? 'Yes' : 'No'}`)); console.log(chalk_1.default.dim(`${figures.line} AI-Enhanced Security: ${this.options.useAIAnalysis ? (this.isAIAvailable() ? 'Yes' : 'No (API key missing)') : 'No'}`)); console.log(chalk_1.default.dim(`${figures.line} Generating Checklists: ${this.options.generateChecklists ? 'Yes' : 'No'}`)); console.log(chalk_1.default.dim(`${figures.line} Output Directories:`)); console.log(chalk_1.default.dim(`${figures.line} Tests: ${this.options.testOutputDir}`)); console.log(chalk_1.default.dim(`${figures.line} Security Reports: ${this.options.securityOutputDir}`)); console.log(chalk_1.default.dim(`${figures.line} Checklists: ${this.options.checklistOutputDir}`)); console.log(chalk_1.default.dim(`${figures.line} Max Concurrent Tasks: ${this.options.maxConcurrentTasks}`)); console.log(''); } } exports.EnhancedWatcher = EnhancedWatcher; exports.default = EnhancedWatcher; //# sourceMappingURL=enhancedWatcher.js.map