UNPKG

erosolar-cli

Version:

Unified AI agent framework for the command line - Multi-provider support with schema-driven tools, code intelligence, and transparent reasoning

888 lines 34.5 kB
/** * Comprehensive validation runner for AI software engineering. * Validates all changes (TypeScript, tests, lint) and provides intelligent error parsing * with actionable fix suggestions - similar to Claude Code's validation flow. */ import { exec } from 'node:child_process'; import { readFile, access } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; const execAsync = promisify(exec); // ============================================================================ // Error Parsers // ============================================================================ /** * Parse TypeScript compiler errors into structured format */ export function parseTypeScriptErrors(output) { const errors = []; // Match patterns like: src/file.ts(10,5): error TS2322: Type 'string' is not assignable... // or: src/file.ts:10:5 - error TS2322: Type 'string' is not assignable... const patterns = [ /^(.+?)\((\d+),(\d+)\):\s*(error|warning)\s+(TS\d+):\s*(.+)$/gm, /^(.+?):(\d+):(\d+)\s*-\s*(error|warning)\s+(TS\d+):\s*(.+)$/gm, ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(output)) !== null) { const [, file, line, column, severity, code, message] = match; const error = { type: 'typescript', file, line: parseInt(line, 10), column: parseInt(column, 10), code, message: message.trim(), severity: severity === 'error' ? 'error' : 'warning', suggestedFix: generateTypeScriptFix(code, message.trim(), file), }; errors.push(error); } } return errors; } /** * Parse Jest/test runner errors into structured format */ export function parseTestErrors(output) { const errors = []; // Match Jest failure patterns // FAIL src/tests/foo.test.ts const failPattern = /FAIL\s+(.+\.(?:test|spec)\.[jt]sx?)/g; let match; while ((match = failPattern.exec(output)) !== null) { const file = match[1]; // Try to extract specific test failure details const testNamePattern = /✕\s+(.+?)(?:\s+\((\d+)\s*ms\))?$/gm; let testMatch; while ((testMatch = testNamePattern.exec(output)) !== null) { errors.push({ type: 'test', file, message: `Test failed: ${testMatch[1]}`, severity: 'error', suggestedFix: { description: 'Review test assertion and fix the code or update the test', autoFixable: false, fixType: 'manual', fixDetails: { manualSteps: [ `Open ${file}`, `Find test: "${testMatch[1]}"`, 'Review assertion failure', 'Fix the code or update the expected value', ], }, }, }); } } // Match assertion errors const assertPattern = /expect\((.+?)\)\.(.+?)\((.+?)\)/g; while ((match = assertPattern.exec(output)) !== null) { errors.push({ type: 'test', message: `Assertion failed: expect(${match[1]}).${match[2]}(${match[3]})`, severity: 'error', rawOutput: match[0], }); } return errors; } /** * Parse ESLint errors into structured format */ export function parseLintErrors(output) { const errors = []; // Match ESLint output patterns // /path/to/file.ts // 10:5 error 'foo' is defined but never used @typescript-eslint/no-unused-vars let currentFile = null; const lines = output.split('\n'); for (const line of lines) { const fileMatch = line.match(/^([^\s].+\.[jt]sx?)$/); if (fileMatch) { currentFile = fileMatch[1]; continue; } const errorMatch = line.match(/^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(.+)$/); if (errorMatch && currentFile) { const [, lineNum, column, severity, message, rule] = errorMatch; errors.push({ type: 'lint', file: currentFile, line: parseInt(lineNum, 10), column: parseInt(column, 10), code: rule, message: message.trim(), severity: severity === 'error' ? 'error' : 'warning', suggestedFix: generateLintFix(rule, message.trim(), currentFile, parseInt(lineNum, 10)), }); } } return errors; } // ============================================================================ // Fix Generators // ============================================================================ /** * Generate suggested fix for TypeScript errors */ function generateTypeScriptFix(code, _message, file) { const fixes = { // Type assignment errors 'TS2322': () => ({ description: 'Type mismatch - update the type annotation or fix the value', autoFixable: false, fixType: 'manual', fixDetails: { file, manualSteps: [ 'Check if the assigned value is correct', 'Update the type annotation if the value is intentional', 'Or fix the value to match the expected type', ], }, }), // Property does not exist 'TS2339': () => ({ description: 'Property does not exist on type', autoFixable: false, fixType: 'manual', fixDetails: { file, manualSteps: [ 'Check if the property name is spelled correctly', 'Add the property to the interface/type definition', 'Use optional chaining (?.) if property might not exist', ], }, }), // Cannot find name 'TS2304': () => ({ description: 'Cannot find name - likely missing import', autoFixable: true, fixType: 'command', fixDetails: { command: 'Add the missing import statement at the top of the file', manualSteps: [ 'Identify what needs to be imported', 'Add import statement', 'Or declare the variable/type if it should be local', ], }, }), // Module not found 'TS2307': () => ({ description: 'Cannot find module - check import path or install package', autoFixable: false, fixType: 'manual', fixDetails: { manualSteps: [ 'Check if the import path is correct', 'If it\'s an npm package, run: npm install <package>', 'If it\'s a local file, verify the path exists', ], }, }), // Argument type mismatch 'TS2345': () => ({ description: 'Argument type mismatch', autoFixable: false, fixType: 'manual', fixDetails: { file, manualSteps: [ 'Check the function signature for expected parameter types', 'Cast the argument if appropriate: (arg as ExpectedType)', 'Or fix the argument value to match expected type', ], }, }), // Object comparison warning 'TS2839': () => ({ description: 'Object comparison by reference - use proper comparison', autoFixable: true, fixType: 'edit', fixDetails: { file, manualSteps: [ 'Extract one side to a variable first', 'Or use JSON.stringify for deep comparison', 'Or use a proper equality function', ], }, }), // Unused variable 'TS6133': () => ({ description: 'Variable declared but never used', autoFixable: true, fixType: 'edit', fixDetails: { file, manualSteps: [ 'Remove the unused variable', 'Or prefix with underscore if intentionally unused: _variable', 'Or use the variable where intended', ], }, }), // Implicit any 'TS7006': () => ({ description: 'Parameter implicitly has an any type', autoFixable: true, fixType: 'edit', fixDetails: { file, manualSteps: [ 'Add explicit type annotation to the parameter', 'Example: (param: string) instead of (param)', ], }, }), }; const fixGenerator = fixes[code]; return fixGenerator?.(); } /** * Generate suggested fix for ESLint errors */ function generateLintFix(rule, _message, file, _line) { const fixes = { // Unused variables '@typescript-eslint/no-unused-vars': () => ({ description: 'Remove unused variable or prefix with underscore', autoFixable: true, fixType: 'command', fixDetails: { command: `eslint --fix ${file}`, manualSteps: [ 'Remove the unused variable declaration', 'Or prefix with underscore: const _unused = ...', ], }, }), // Missing return type '@typescript-eslint/explicit-function-return-type': () => ({ description: 'Add explicit return type to function', autoFixable: false, fixType: 'manual', fixDetails: { file, manualSteps: [ 'Add return type annotation after function parameters', 'Example: function foo(): ReturnType { ... }', ], }, }), // Prefer const 'prefer-const': () => ({ description: 'Use const instead of let for variables that are never reassigned', autoFixable: true, fixType: 'command', fixDetails: { command: `eslint --fix ${file}`, }, }), // No explicit any '@typescript-eslint/no-explicit-any': () => ({ description: 'Replace any with a specific type', autoFixable: false, fixType: 'manual', fixDetails: { file, manualSteps: [ 'Replace `any` with the actual expected type', 'Use `unknown` if type is truly unknown and add type guards', ], }, }), }; const fixGenerator = fixes[rule]; return fixGenerator?.(); } // ============================================================================ // Insight Helpers // ============================================================================ function analyzeErrorCodes(errors) { const counts = new Map(); for (const error of errors) { const key = error.code || error.type; counts.set(key, (counts.get(key) ?? 0) + 1); } return Array.from(counts.entries()) .map(([code, count]) => ({ code, count })) .sort((a, b) => b.count - a.count) .slice(0, 5); } function analyzeTopFiles(errors) { const counts = new Map(); for (const error of errors) { if (!error.file) continue; counts.set(error.file, (counts.get(error.file) ?? 0) + 1); } return Array.from(counts.entries()) .map(([file, count]) => ({ file, count })) .sort((a, b) => b.count - a.count) .slice(0, 5); } function buildValidationInsights(errors, warnings) { const rootCauseHints = new Set(); const recoveryPlan = []; const hasTypeScriptErrors = errors.some(e => e.type === 'typescript'); const hasTestFailures = errors.some(e => e.type === 'test'); const hasLintErrors = errors.some(e => e.type === 'lint'); const hasBuildErrors = errors.some(e => e.type === 'build'); const autoFixable = errors.filter(e => e.suggestedFix?.autoFixable).length; const codes = analyzeErrorCodes(errors); const topFiles = analyzeTopFiles(errors); const totalErrors = errors.length; if (totalErrors === 0 && warnings.length === 0) { return { rootCauseHints: [], recoveryPlan: [], dominantErrorCodes: [], topFiles: [], }; } // Root-cause heuristics if (errors.some(e => e.code === 'TS2307')) { rootCauseHints.add('TypeScript cannot find modules (TS2307) - check import paths or install missing dependencies.'); } if (errors.some(e => e.code === 'TS2304')) { rootCauseHints.add('Missing identifiers (TS2304) - add imports or definitions before rerunning types.'); } if (errors.some(e => e.code === '@typescript-eslint/no-unused-vars')) { rootCauseHints.add('Unused variable lint errors detected - these are usually auto-fixable with eslint --fix.'); } if (hasTestFailures) { rootCauseHints.add('Tests are failing - inspect the first failing test output and fix the assertion/code before rerunning all tests.'); } if (hasBuildErrors) { rootCauseHints.add('Build failed - resolve TypeScript errors first, then re-run the build to catch bundler issues.'); } if (warnings.length > 0 && !hasTypeScriptErrors && !hasLintErrors && !hasTestFailures && !hasBuildErrors) { rootCauseHints.add('Only warnings detected - ensure they are intentional or adjust configurations to silence intentional warnings.'); } if (totalErrors > 25 && codes[0]) { rootCauseHints.add(`High error volume (${totalErrors}) - tackle the dominant issue (${codes[0].code}) first to unlock cascading fixes.`); } // Recovery plan (ordered, concise) const topCode = codes.at(0); if (topCode) { const { code, count } = topCode; recoveryPlan.push(`Start with the dominant issue: ${code} (appears ${count}×). Fixing this usually removes many downstream errors.`); } if (hasTypeScriptErrors && errors.some(e => e.code === 'TS2307')) { recoveryPlan.push('Verify import paths and run dependency install if needed (npm install <package>) to resolve module-not-found errors.'); } if (hasTypeScriptErrors) { recoveryPlan.push('Iterate with quick_typecheck after each fix to confirm the type surface is clean before running full validation.'); } if (hasLintErrors && autoFixable > 0) { recoveryPlan.push('Apply auto-fixes (e.g., npm run lint -- --fix) to clear stylistic/unused issues quickly.'); } if (hasTestFailures) { recoveryPlan.push('Re-run the specific failing test file in watch/verbose mode to verify fixes before full test suite (e.g., npm test -- <file>).'); } if (recoveryPlan.length === 0) { recoveryPlan.push('Address the first reported error, rerun quick_typecheck, then re-run validate_all_changes once the main blockers are fixed.'); } return { rootCauseHints: Array.from(rootCauseHints).slice(0, 6), recoveryPlan: recoveryPlan.slice(0, 6), dominantErrorCodes: codes, topFiles, }; } function withInsights(result) { const insights = buildValidationInsights(result.errors, result.warnings); return { ...result, insights }; } // ============================================================================ // Validation Runner // ============================================================================ export class ValidationRunner { config; constructor(config) { this.config = { workingDir: config.workingDir, phases: config.phases ?? ['typescript', 'build', 'test'], stopOnFirstFailure: config.stopOnFirstFailure ?? false, timeout: config.timeout ?? 300000, verbose: config.verbose ?? false, }; } /** * Run all validation phases */ async runAll() { const startTime = Date.now(); const allErrors = []; const allWarnings = []; const summaryParts = []; for (const phase of this.config.phases) { const result = await this.runPhase(phase); allErrors.push(...result.errors); allWarnings.push(...result.warnings); summaryParts.push(`${phase}: ${result.success ? '✓' : '✗'}`); if (!result.success && this.config.stopOnFirstFailure) { break; } } const autoFixableCount = allErrors.filter(e => e.suggestedFix?.autoFixable).length; return withInsights({ success: allErrors.length === 0, phase: 'all', errors: allErrors, warnings: allWarnings, summary: summaryParts.join(' | '), durationMs: Date.now() - startTime, autoFixableCount, }); } /** * Run a specific validation phase */ async runPhase(phase) { switch (phase) { case 'typescript': return this.runTypeScriptValidation(); case 'build': return this.runBuildValidation(); case 'test': return this.runTestValidation(); case 'lint': return this.runLintValidation(); default: throw new Error(`Unknown validation phase: ${phase}`); } } /** * Run TypeScript type checking */ async runTypeScriptValidation() { const startTime = Date.now(); try { // Check if tsconfig.json exists await access(join(this.config.workingDir, 'tsconfig.json')); } catch { return withInsights({ success: true, phase: 'typescript', errors: [], warnings: [], summary: 'TypeScript: skipped (no tsconfig.json)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } try { const { stdout, stderr } = await execAsync('npx tsc --noEmit', { cwd: this.config.workingDir, timeout: this.config.timeout, maxBuffer: 10 * 1024 * 1024, }); const output = stdout + stderr; const errors = parseTypeScriptErrors(output); const warnings = errors.filter(e => e.severity === 'warning'); const actualErrors = errors.filter(e => e.severity === 'error'); return withInsights({ success: actualErrors.length === 0, phase: 'typescript', errors: actualErrors, warnings, summary: `TypeScript: ${actualErrors.length} error(s), ${warnings.length} warning(s)`, durationMs: Date.now() - startTime, autoFixableCount: errors.filter(e => e.suggestedFix?.autoFixable).length, }); } catch (error) { const output = (error.stdout || '') + (error.stderr || ''); const errors = parseTypeScriptErrors(output); if (errors.length === 0) { errors.push({ type: 'typescript', message: error.message || 'TypeScript compilation failed', severity: 'error', rawOutput: output, }); } const actualErrors = errors.filter(e => e.severity === 'error'); const warnings = errors.filter(e => e.severity === 'warning'); return withInsights({ success: false, phase: 'typescript', errors: actualErrors, warnings, summary: `TypeScript: ${actualErrors.length} error(s)`, durationMs: Date.now() - startTime, autoFixableCount: errors.filter(e => e.suggestedFix?.autoFixable).length, }); } } /** * Run build validation */ async runBuildValidation() { const startTime = Date.now(); try { // Check if build script exists const packageJson = await readFile(join(this.config.workingDir, 'package.json'), 'utf-8'); const pkg = JSON.parse(packageJson); if (!pkg.scripts?.build) { return withInsights({ success: true, phase: 'build', errors: [], warnings: [], summary: 'Build: skipped (no build script)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } } catch { return withInsights({ success: true, phase: 'build', errors: [], warnings: [], summary: 'Build: skipped (no package.json)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } try { await execAsync('npm run build', { cwd: this.config.workingDir, timeout: this.config.timeout, maxBuffer: 10 * 1024 * 1024, }); return withInsights({ success: true, phase: 'build', errors: [], warnings: [], summary: 'Build: ✓ passed', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } catch (error) { const output = (error.stdout || '') + (error.stderr || ''); // Try to parse as TypeScript errors first let errors = parseTypeScriptErrors(output); if (errors.length === 0) { errors = [{ type: 'build', message: 'Build failed', severity: 'error', rawOutput: output.slice(0, 2000), }]; } return withInsights({ success: false, phase: 'build', errors, warnings: [], summary: `Build: ✗ failed (${errors.length} error(s))`, durationMs: Date.now() - startTime, autoFixableCount: errors.filter(e => e.suggestedFix?.autoFixable).length, }); } } /** * Run test validation */ async runTestValidation() { const startTime = Date.now(); try { // Check if test script exists const packageJson = await readFile(join(this.config.workingDir, 'package.json'), 'utf-8'); const pkg = JSON.parse(packageJson); if (!pkg.scripts?.test) { return withInsights({ success: true, phase: 'test', errors: [], warnings: [], summary: 'Tests: skipped (no test script)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } } catch { return withInsights({ success: true, phase: 'test', errors: [], warnings: [], summary: 'Tests: skipped (no package.json)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } try { const { stdout, stderr } = await execAsync('npm test', { cwd: this.config.workingDir, timeout: this.config.timeout, maxBuffer: 10 * 1024 * 1024, }); const output = stdout + stderr; // Check for test failures even in "successful" exit const errors = parseTestErrors(output); const actualErrors = errors.filter(e => e.severity === 'error'); return withInsights({ success: actualErrors.length === 0, phase: 'test', errors: actualErrors, warnings: [], summary: `Tests: ${actualErrors.length === 0 ? '✓ passed' : `✗ ${actualErrors.length} failure(s)`}`, durationMs: Date.now() - startTime, autoFixableCount: 0, }); } catch (error) { const output = (error.stdout || '') + (error.stderr || ''); const errors = parseTestErrors(output); if (errors.length === 0) { errors.push({ type: 'test', message: 'Tests failed', severity: 'error', rawOutput: output.slice(0, 2000), }); } return withInsights({ success: false, phase: 'test', errors, warnings: [], summary: `Tests: ✗ failed (${errors.length} failure(s))`, durationMs: Date.now() - startTime, autoFixableCount: 0, }); } } /** * Run lint validation */ async runLintValidation() { const startTime = Date.now(); try { // Check if lint script exists const packageJson = await readFile(join(this.config.workingDir, 'package.json'), 'utf-8'); const pkg = JSON.parse(packageJson); if (!pkg.scripts?.lint) { return withInsights({ success: true, phase: 'lint', errors: [], warnings: [], summary: 'Lint: skipped (no lint script)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } } catch { return withInsights({ success: true, phase: 'lint', errors: [], warnings: [], summary: 'Lint: skipped (no package.json)', durationMs: Date.now() - startTime, autoFixableCount: 0, }); } try { const { stdout, stderr } = await execAsync('npm run lint', { cwd: this.config.workingDir, timeout: this.config.timeout, maxBuffer: 10 * 1024 * 1024, }); const output = stdout + stderr; const errors = parseLintErrors(output); const actualErrors = errors.filter(e => e.severity === 'error'); const warnings = errors.filter(e => e.severity === 'warning'); return withInsights({ success: actualErrors.length === 0, phase: 'lint', errors: actualErrors, warnings, summary: `Lint: ${actualErrors.length} error(s), ${warnings.length} warning(s)`, durationMs: Date.now() - startTime, autoFixableCount: errors.filter(e => e.suggestedFix?.autoFixable).length, }); } catch (error) { const output = (error.stdout || '') + (error.stderr || ''); const errors = parseLintErrors(output); if (errors.length === 0) { errors.push({ type: 'lint', message: 'Lint check failed', severity: 'error', rawOutput: output.slice(0, 2000), }); } const actualErrors = errors.filter(e => e.severity === 'error'); const warnings = errors.filter(e => e.severity === 'warning'); return withInsights({ success: false, phase: 'lint', errors: actualErrors, warnings, summary: `Lint: ✗ ${actualErrors.length} error(s)`, durationMs: Date.now() - startTime, autoFixableCount: errors.filter(e => e.suggestedFix?.autoFixable).length, }); } } } // ============================================================================ // Formatting Helpers // ============================================================================ /** * Format validation result for display */ export function formatValidationResult(result) { const lines = []; lines.push(`## Validation ${result.success ? 'Passed ✓' : 'Failed ✗'}`); lines.push(`Duration: ${(result.durationMs / 1000).toFixed(1)}s`); lines.push(''); lines.push(`Summary: ${result.summary}`); if (result.insights) { const { rootCauseHints, recoveryPlan, dominantErrorCodes, topFiles } = result.insights; if (rootCauseHints.length > 0) { lines.push(''); lines.push('### Root Cause Hints'); for (const hint of rootCauseHints) { lines.push(`- ${hint}`); } } if (recoveryPlan.length > 0) { lines.push(''); lines.push('### Recommended Recovery Plan'); recoveryPlan.forEach((step, idx) => { lines.push(`${idx + 1}. ${step}`); }); } if (dominantErrorCodes.length > 0) { lines.push(''); lines.push('### Dominant Errors'); dominantErrorCodes.slice(0, 3).forEach(({ code, count }) => { lines.push(`- ${code}: ${count} occurrence(s)`); }); } if (topFiles.length > 0) { lines.push(''); lines.push('### Files With Most Errors'); topFiles.slice(0, 3).forEach(({ file, count }) => { lines.push(`- ${file}: ${count} issue(s)`); }); } } if (result.errors.length > 0) { lines.push(''); lines.push(`### Errors (${result.errors.length})`); for (const error of result.errors.slice(0, 20)) { const location = error.file ? `${error.file}${error.line ? `:${error.line}` : ''}${error.column ? `:${error.column}` : ''}` : ''; const code = error.code ? `[${error.code}]` : ''; lines.push(`- ${location} ${code} ${error.message}`); if (error.suggestedFix) { lines.push(` Fix: ${error.suggestedFix.description}`); if (error.suggestedFix.autoFixable) { lines.push(` (Auto-fixable)`); } } } if (result.errors.length > 20) { lines.push(`... and ${result.errors.length - 20} more errors`); } } if (result.warnings.length > 0) { lines.push(''); lines.push(`### Warnings (${result.warnings.length})`); for (const warning of result.warnings.slice(0, 10)) { const location = warning.file ? `${warning.file}:${warning.line || '?'}` : ''; lines.push(`- ${location} ${warning.message}`); } if (result.warnings.length > 10) { lines.push(`... and ${result.warnings.length - 10} more warnings`); } } if (result.autoFixableCount > 0) { lines.push(''); lines.push(`### Auto-fixable: ${result.autoFixableCount} issue(s)`); lines.push('Use the auto-fix feature to automatically resolve these issues.'); } return lines.join('\n'); } /** * Format errors for AI consumption (structured for intelligent fixing) */ export function formatErrorsForAI(errors, insights) { const lines = []; lines.push('# Validation Errors for AI Analysis'); lines.push(''); lines.push('The following errors need to be fixed. For each error:'); lines.push('1. Read the file at the specified location'); lines.push('2. Understand the context around the error'); lines.push('3. Apply the suggested fix or determine the appropriate correction'); lines.push(''); if (insights) { if (insights.rootCauseHints.length > 0) { lines.push('## Root Cause Hints'); lines.push(''); for (const hint of insights.rootCauseHints) { lines.push(`- ${hint}`); } lines.push(''); } if (insights.recoveryPlan.length > 0) { lines.push('## Recommended Recovery Plan'); lines.push(''); insights.recoveryPlan.forEach((step, idx) => { lines.push(`${idx + 1}. ${step}`); }); lines.push(''); } } const groupedByFile = new Map(); for (const error of errors) { const key = error.file || 'unknown'; if (!groupedByFile.has(key)) { groupedByFile.set(key, []); } groupedByFile.get(key).push(error); } for (const [file, fileErrors] of groupedByFile) { lines.push(`## ${file}`); lines.push(''); for (const error of fileErrors) { lines.push(`### Line ${error.line || '?'}: ${error.code || error.type}`); lines.push(`Message: ${error.message}`); if (error.suggestedFix) { lines.push(`Suggested fix: ${error.suggestedFix.description}`); lines.push(`Auto-fixable: ${error.suggestedFix.autoFixable ? 'Yes' : 'No'}`); if (error.suggestedFix.fixDetails.manualSteps) { lines.push('Steps:'); for (const step of error.suggestedFix.fixDetails.manualSteps) { lines.push(` - ${step}`); } } } lines.push(''); } } return lines.join('\n'); } //# sourceMappingURL=validationRunner.js.map