ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
305 lines • 18.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Watcher = 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 diffUtils_1 = require("../utils/diffUtils");
const securityRiskUtils_1 = require("../utils/securityRiskUtils");
/**
* File watcher for auto-running tests and security analysis
* Watches source files for changes and automatically generates tests,
* runs those tests, and performs security analysis.
*/
class Watcher extends events_1.EventEmitter {
/**
* Create a new watcher instance
* @param options Configuration options
*/
constructor(options) {
super();
this.watcher = null;
this.isProcessing = false;
this.fileContents = new Map();
this.securityIssues = new Map();
this.options = {
debounceMs: options.debounceMs || 1000,
ignorePatterns: options.ignorePatterns || ['node_modules', 'dist', 'build', '*.test.*'],
runTests: options.runTests ?? true,
generateChecklists: options.generateChecklists ?? true,
showDiffs: options.showDiffs ?? true,
browser: options.browser || 'chromium',
headless: options.headless ?? true,
workers: options.workers || 1
};
this.testGenerator = new testGenerator_1.TestGenerator({
format: 'playwright',
timeout: 60
});
this.testRunner = new testRunner_1.TestRunner({
browser: this.options.browser,
headless: this.options.headless,
timeout: 60,
reporter: 'list',
workers: this.options.workers
});
this.checklistGenerator = new checklistGenerator_1.ChecklistGenerator();
// Setup test runner events for real-time feedback
this.testRunner.on('test:end', (result) => {
this.emit('test:end', result);
});
this.testRunner.on('error', (error) => {
this.emit('error', error);
});
}
/**
* Watch source files and generate tests on changes
* @param sourcePath Source path to watch
* @param outputDir Output directory for generated tests
*/
async watch(sourcePath, outputDir) {
// Initialize watcher
this.watcher = chokidar_1.default.watch(sourcePath, {
ignored: this.options.ignorePatterns,
ignoreInitial: false,
persistent: true
});
// Setup event handlers
this.watcher
.on('add', (filePath) => this.handleFileChange('add', filePath, outputDir))
.on('change', (filePath) => this.handleFileChange('change', filePath, outputDir))
.on('unlink', (filePath) => this.handleFileChange('unlink', filePath, outputDir))
.on('ready', () => {
console.log(chalk_1.default.green('Initial scan complete. Watching for changes...'));
})
.on('error', (error) => {
console.error(chalk_1.default.red(`Watcher error: ${error}`));
});
}
/**
* Stop watching files and clean up resources
*/
stop() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
/**
* Handle file change events
* @param event File event type (add, change, unlink)
* @param filePath Path to the changed file
* @param outputDir Output directory for generated tests
*/
handleFileChange(event, filePath, outputDir) {
// Skip non-source files
if (!this.isSourceFile(filePath)) {
return;
}
console.log(chalk_1.default.blue(`${event}: ${filePath}`));
// Debounce multiple changes
setTimeout(async () => {
if (this.isProcessing) {
return;
}
this.isProcessing = true;
this.emit('processing:start', { filePath, event });
try {
if (event === 'add' || event === 'change') {
// Store file content for diff analysis
let previousContent = null;
if (this.options.showDiffs && event === 'change') {
previousContent = this.fileContents.get(filePath) || null;
}
// Read and store new content
const newContent = await promises_1.default.readFile(filePath, 'utf8');
this.fileContents.set(filePath, newContent);
// Show diff if enabled and we have previous content
if (this.options.showDiffs && previousContent !== null) {
const changes = (0, diffUtils_1.diff)(previousContent, newContent);
if (changes.length > 1) { // More than just the unchanged content
console.log(chalk_1.default.yellow('Code changes:'));
changes.forEach((change) => {
if (change.added) {
console.log(chalk_1.default.green(`+ ${change.value}`));
}
else if (change.removed) {
console.log(chalk_1.default.red(`- ${change.value}`));
}
});
this.emit('diff', { filePath, changes });
}
}
// Generate tests
console.log(chalk_1.default.yellow(`Generating tests for: ${filePath}`));
try {
const result = await this.testGenerator.generateTests(filePath, outputDir);
if (result.testCount > 0) {
console.log(chalk_1.default.green(`✓ Generated ${result.testCount} tests for ${filePath}`));
console.log(chalk_1.default.gray(` Output: ${result.files.join(', ')}`));
this.emit('tests:generated', {
filePath,
testFiles: result.files,
testCount: result.testCount
});
// Run tests if enabled
if (this.options.runTests && result.files.length > 0) {
console.log(chalk_1.default.yellow(`Running tests for: ${filePath}`));
try {
const testResults = await this.testRunner.runTests(result.files[0]);
this.emit('tests:run', { filePath, results: testResults });
// Report test results
if (testResults.passed === testResults.total) {
console.log(chalk_1.default.green(`✓ All tests passed (${testResults.passed}/${testResults.total})`));
}
else {
console.log(chalk_1.default.red(`✗ Tests failed: ${testResults.failed}/${testResults.total}`));
// Log failed tests
if (testResults.errors && testResults.errors.length > 0) {
console.log(chalk_1.default.red('Failures:'));
testResults.errors.forEach(error => {
console.log(chalk_1.default.red(` - ${error.message}`));
});
}
}
}
catch (testError) {
console.error(chalk_1.default.red(`Error running tests: ${testError.message}`));
}
}
// Generate security and QA checklist if enabled
if (this.options.generateChecklists) {
console.log(chalk_1.default.yellow(`Generating security checklist for: ${filePath}`));
try {
const checklistResult = await this.checklistGenerator.generateChecklist(filePath, path_1.default.join(outputDir, '../checklists'));
if (checklistResult.itemCount > 0) {
console.log(chalk_1.default.green(`✓ Generated checklist with ${checklistResult.itemCount} items`));
console.log(chalk_1.default.gray(` Output: ${checklistResult.file}`));
const securityItems = checklistResult.items.filter(item => item.category === 'Security' && (item.status === 'failed'));
const previousIssues = this.securityIssues.get(filePath) || [];
const currentIssues = securityItems.map(item => item.description);
this.securityIssues.set(filePath, currentIssues);
// Find new security issues
const newIssues = currentIssues.filter(issue => !previousIssues.includes(issue));
if (newIssues.length > 0) {
console.log(chalk_1.default.red(`⚠️ ${newIssues.length} new security issues detected`));
// Get full security items with severity and risk scores
const newSecurityItems = securityItems.filter(item => item.description && newIssues.includes(item.description));
// Display detailed security risk information
if (newSecurityItems.length > 0) {
// Count by severity
const criticalCount = newSecurityItems.filter(item => item.severity === 'critical').length;
const highCount = newSecurityItems.filter(item => item.severity === 'high').length;
const mediumCount = newSecurityItems.filter(item => item.severity === 'medium').length;
const lowCount = newSecurityItems.filter(item => item.severity === 'low').length;
// Show security severity breakdown
if (criticalCount > 0)
console.log(` ${(0, securityRiskUtils_1.formatSeverityBadge)('critical')} ${criticalCount}`);
if (highCount > 0)
console.log(` ${(0, securityRiskUtils_1.formatSeverityBadge)('high')} ${highCount}`);
if (mediumCount > 0)
console.log(` ${(0, securityRiskUtils_1.formatSeverityBadge)('medium')} ${mediumCount}`);
if (lowCount > 0)
console.log(` ${(0, securityRiskUtils_1.formatSeverityBadge)('low')} ${lowCount}`);
// Generate concise risk report for detected issues
newSecurityItems.forEach(item => {
const score = item.riskScore?.score ? chalk_1.default.yellow(item.riskScore.score.toFixed(1)) : '';
const severity = (0, securityRiskUtils_1.formatSeverityBadge)(item.severity || 'medium');
console.log(chalk_1.default.red(` - ${severity} ${item.title || 'Security Issue'} ${score}`));
console.log(chalk_1.default.gray(` ${item.description}`));
if (item.remediation?.description) {
console.log(chalk_1.default.green(` ✓ Remediation: ${item.remediation.description}`));
if (item.remediation.codeExample) {
console.log(chalk_1.default.blue(` Example: ${item.remediation.codeExample}`));
}
}
// Show security references
if (item.references && item.references.length > 0) {
const ref = item.references[0];
const refText = [];
if (ref.cwe)
refText.push(ref.cwe);
if (ref.owasp)
refText.push(ref.owasp);
if (refText.length > 0) {
console.log(chalk_1.default.gray(` References: ${refText.join(', ')}`));
}
}
console.log(); // Add spacing between issues
});
}
else {
// Fallback to simple issue list if no detailed items
newIssues.forEach((issue) => {
console.log(chalk_1.default.red(` - ${issue}`));
});
}
this.emit('security:new-issues', { filePath, issues: newIssues });
}
// Find resolved security issues
const resolvedIssues = previousIssues.filter((issue) => !currentIssues.includes(issue));
if (resolvedIssues.length > 0) {
console.log(chalk_1.default.green(`✓ Security issues resolved:`));
resolvedIssues.forEach((issue) => {
console.log(chalk_1.default.green(` - ${issue}`));
});
this.emit('security:resolved-issues', { filePath, issues: resolvedIssues });
}
}
else {
console.log(chalk_1.default.gray(`No checklist items generated for ${filePath}`));
}
}
catch (checklistError) {
console.error(chalk_1.default.red(`Error generating checklist: ${checklistError.message}`));
}
}
}
else {
console.log(chalk_1.default.gray(`No tests generated for ${filePath}`));
}
}
catch (genError) {
console.error(chalk_1.default.red(`Error generating tests: ${genError.message}`));
}
}
}
catch (error) {
console.error(chalk_1.default.red(`Error processing ${filePath}: ${error.message}`));
this.emit('error', { filePath, error });
}
finally {
this.isProcessing = false;
this.emit('processing:end', { filePath });
}
}, this.options.debounceMs);
}
/**
* Check if file is a source file that should trigger test generation
* @param filePath Path to file to check
* @returns True if the file is a source file that should trigger test generation
*/
isSourceFile(filePath) {
const ext = path_1.default.extname(filePath).toLowerCase();
const validExtensions = ['.js', '.jsx', '.ts', '.tsx'];
if (!validExtensions.includes(ext)) {
return false;
}
const fileName = path_1.default.basename(filePath).toLowerCase();
// Skip test files
if (fileName.includes('.test.') || fileName.includes('.spec.')) {
return false;
}
return true;
}
}
exports.Watcher = Watcher;
//# sourceMappingURL=watcher.js.map