ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
764 lines (661 loc) • 25.2 kB
text/typescript
/**
* 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.
*/
import chokidar from 'chokidar';
import path from 'path';
import chalk from 'chalk';
import fs from 'fs/promises';
import { EventEmitter } from 'events';
import { TestGenerator } from './testGenerator';
import { TestRunner } from './testRunner';
import { ChecklistGenerator } from './checklistGenerator';
import { ensureDirectoryExists } from '../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: any = null;
// Attempt to load the AI security analyzer
try {
const aiSecurityAnalyzerPath = path.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/**'
];
/**
* Options for the enhanced watcher
*/
export interface EnhancedWatcherOptions {
/** Glob patterns to include */
include?: string[];
/** Glob patterns to exclude */
exclude?: string[];
/** Debounce delay in milliseconds */
debounceMs?: number;
/** Whether to generate tests on file changes */
generateTests?: boolean;
/** Whether to analyze security on file changes */
analyzeSecurity?: boolean;
/** Whether to use AI-enhanced security analysis */
useAIAnalysis?: boolean;
/** Whether to generate checklists on file changes */
generateChecklists?: boolean;
/** Output directory for generated tests */
testOutputDir?: string;
/** Output directory for security reports */
securityOutputDir?: string;
/** Output directory for checklists */
checklistOutputDir?: string;
/** Test format (playwright or selenium) */
testFormat?: 'playwright' | 'selenium';
/** Browser to use for running tests */
browser?: string;
/** Whether to run browser in headless mode */
headless?: boolean;
/** OpenAI API key for AI-enhanced security analysis */
openAIApiKey?: string;
/** Minimum file size change (in bytes) to trigger analysis */
minFileSizeChange?: number;
/** Maximum concurrent tasks */
maxConcurrentTasks?: number;
/** Whether to report progress in real-time */
reportProgress?: boolean;
/** Specific file types to generate tests for */
testableFileTypes?: string[];
/** Specific file types to analyze for security */
securityFileTypes?: string[];
}
/**
* File change event details
*/
export interface FileChangeEvent {
/** Type of event (add, change, unlink) */
type: 'add' | 'change' | 'unlink';
/** Path to the file that changed */
path: string;
/** Timestamp of the event */
timestamp: number;
}
/**
* Analysis result
*/
export interface AnalysisResult {
/** Path to the analyzed file */
filePath: string;
/** Whether the analysis was successful */
success: boolean;
/** Type of analysis performed */
type: 'test' | 'security' | 'checklist';
/** Output file path (if applicable) */
outputPath?: string;
/** Error message (if applicable) */
error?: string;
/** Analysis duration in milliseconds */
duration: number;
/** Timestamp when the analysis was completed */
timestamp: number;
}
/**
* Enhanced file watcher for real-time QA and security feedback
* Designed to work alongside IDE extensions and tools like Cursor AI
*/
export class EnhancedWatcher extends EventEmitter {
private options: Required<EnhancedWatcherOptions>;
private watcher: chokidar.FSWatcher | null = null;
private testGenerator: TestGenerator;
private testRunner: TestRunner;
private checklistGenerator: ChecklistGenerator;
private processingFiles: Set<string> = new Set();
private fileContents: Map<string, { content: string, timestamp: number }> = new Map();
private taskQueue: Array<{ file: string, task: () => Promise<void> }> = [];
private isProcessingQueue = false;
private activeTasks = 0;
private stopping = false;
/**
* Create a new enhanced watcher
* @param options Watcher options
*/
constructor(options: EnhancedWatcherOptions = {}) {
super();
// 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' as 'playwright' | 'selenium',
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({
format: this.options.testFormat || 'playwright',
timeout: 60
});
this.testRunner = new TestRunner({
browser: 'chromium',
headless: true,
timeout: 60,
reporter: 'list',
workers: 1
});
this.checklistGenerator = new 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(): boolean {
return aiSecurityAnalyzer !== null && !!this.options.openAIApiKey;
}
/**
* Start watching files
* @param directoryPath Path to the directory to watch
* @returns Function to stop watching
*/
watch(directoryPath: string): () => void {
// Normalize directory path
const absolutePath = path.isAbsolute(directoryPath)
? directoryPath
: path.resolve(process.cwd(), directoryPath);
// Ensure output directories exist
this.ensureOutputDirectories();
// Log configuration
this.logConfiguration(absolutePath);
// Initialize watcher
this.watcher = chokidar.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.join(absolutePath, filePath)))
.on('change', (filePath) => this.handleFileChange('change', path.join(absolutePath, filePath)))
.on('unlink', (filePath) => this.handleFileChange('unlink', path.join(absolutePath, filePath)))
.on('ready', () => {
console.log(chalk.green(`${figures.tick} Initial scan complete. Watching for changes...`));
this.emit('ready');
})
.on('error', (error) => {
console.error(chalk.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(): Promise<void> {
if (this.stopping) return;
this.stopping = true;
console.log(chalk.yellow(`${figures.info} Stopping file watcher...`));
// Wait for active tasks to complete
if (this.activeTasks > 0) {
console.log(chalk.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.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: string,
analysisTypes: {
generateTests?: boolean;
analyzeSecurity?: boolean;
useAI?: boolean;
generateChecklists?: boolean;
} = {}
): Promise<AnalysisResult[]> {
// 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.isAbsolute(filePath)
? filePath
: path.resolve(process.cwd(), filePath);
// Ensure output directories exist
this.ensureOutputDirectories();
// Run analysis
const results: AnalysisResult[] = [];
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
*/
private async handleFileChange(eventType: 'add' | 'change' | 'unlink', filePath: string): Promise<void> {
// 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: FileChangeEvent = {
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
*/
private addToTaskQueue(file: string, task: () => Promise<void>): void {
this.taskQueue.push({ file, task });
// If the queue isn't being processed, start processing
if (!this.isProcessingQueue) {
this.processTaskQueue();
}
}
/**
* Process tasks in the queue
*/
private async processTaskQueue(): Promise<void> {
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.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
*/
private async generateTestsForFile(filePath: string): Promise<AnalysisResult> {
const startTime = Date.now();
const fileName = path.basename(filePath);
if (this.options.reportProgress) {
console.log(chalk.blue(`${figures.pointer} Generating tests for ${fileName}...`));
}
try {
// Create output path based on file path
const relativePath = path.relative(process.cwd(), filePath);
const outputDir = path.join(this.options.testOutputDir, path.dirname(relativePath));
// Generate tests
const result = await this.testGenerator.generateTests(filePath, outputDir);
const duration = Date.now() - startTime;
if (this.options.reportProgress) {
console.log(chalk.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.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
*/
private async analyzeFileSecurity(filePath: string, useAI: boolean): Promise<AnalysisResult> {
const startTime = Date.now();
const fileName = path.basename(filePath);
if (this.options.reportProgress) {
console.log(chalk.blue(`${figures.pointer} Analyzing security of ${fileName}${useAI ? ' with AI' : ''}...`));
}
try {
// Create output path
const baseFileName = path.basename(filePath, path.extname(filePath));
const reportFileName = `${baseFileName}-security${useAI ? '-ai' : ''}.md`;
const outputPath = path.join(this.options.securityOutputDir, reportFileName);
// Ensure output directory exists
await ensureDirectoryExists(path.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 fs.writeFile(outputPath, report);
success = true;
}
const duration = Date.now() - startTime;
if (this.options.reportProgress) {
if (success) {
console.log(chalk.green(`${figures.tick} Security analysis of ${fileName} completed in ${duration}ms`));
} else {
console.log(chalk.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.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
*/
private async generateChecklistForFile(filePath: string): Promise<AnalysisResult> {
const startTime = Date.now();
const fileName = path.basename(filePath);
if (this.options.reportProgress) {
console.log(chalk.blue(`${figures.pointer} Generating checklist for ${fileName}...`));
}
try {
// Create output path
const baseFileName = path.basename(filePath, path.extname(filePath));
const outputPath = path.join(this.options.checklistOutputDir, `${baseFileName}-checklist.md`);
// Generate checklist
const result = await this.checklistGenerator.generateChecklist(filePath, path.dirname(outputPath));
const duration = Date.now() - startTime;
if (this.options.reportProgress) {
console.log(chalk.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.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
*/
private async hasContentChangedSignificantly(filePath: string): Promise<boolean> {
try {
// Read current content
const content = await fs.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
*/
private shouldProcessFile(filePath: string, eventType: string): boolean {
// Skip unlink events for now
if (eventType === 'unlink') {
return false;
}
// Get file extension
const ext = path.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
*/
private isTestableFile(filePath: string): boolean {
const ext = path.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
*/
private isSecurityAnalyzableFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return this.options.securityFileTypes.includes(ext);
}
/**
* Ensure all output directories exist
*/
private async ensureOutputDirectories(): Promise<void> {
await ensureDirectoryExists(this.options.testOutputDir);
await ensureDirectoryExists(this.options.securityOutputDir);
await ensureDirectoryExists(this.options.checklistOutputDir);
}
/**
* Log watcher configuration
* @param watchPath Path being watched
*/
private logConfiguration(watchPath: string): void {
console.log(chalk.cyan(`\n${figures.pointer} ctrl.shift.left Enhanced Watcher Starting`));
console.log(chalk.dim(`${figures.line} Watching: ${watchPath}`));
console.log(chalk.dim(`${figures.line} Generating Tests: ${this.options.generateTests ? 'Yes' : 'No'}`));
console.log(chalk.dim(`${figures.line} Security Analysis: ${this.options.analyzeSecurity ? 'Yes' : 'No'}`));
console.log(chalk.dim(`${figures.line} AI-Enhanced Security: ${this.options.useAIAnalysis ? (this.isAIAvailable() ? 'Yes' : 'No (API key missing)') : 'No'}`));
console.log(chalk.dim(`${figures.line} Generating Checklists: ${this.options.generateChecklists ? 'Yes' : 'No'}`));
console.log(chalk.dim(`${figures.line} Output Directories:`));
console.log(chalk.dim(`${figures.line} Tests: ${this.options.testOutputDir}`));
console.log(chalk.dim(`${figures.line} Security Reports: ${this.options.securityOutputDir}`));
console.log(chalk.dim(`${figures.line} Checklists: ${this.options.checklistOutputDir}`));
console.log(chalk.dim(`${figures.line} Max Concurrent Tasks: ${this.options.maxConcurrentTasks}`));
console.log('');
}
}
export default EnhancedWatcher;