ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
595 lines • 25.1 kB
JavaScript
"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