UNPKG

@jmkim85/dev-flow-mcp

Version:

MCP-based Dev Flow - AI-powered development workflow management with 13 essential tools for TDD and context management

607 lines 23.9 kB
/** * TDD Workflow Validation * Enforces Test-Driven Development practices */ import { execa } from 'execa'; import { join } from 'path'; import { promises as fs } from 'fs'; export class TDDValidator { projectRoot; constructor(projectRoot) { this.projectRoot = projectRoot; } async validateStageTransition(currentStage, nextStage) { // write_tests → implement: Tests must fail if (currentStage === "write_tests" && nextStage === "implement") { const testResult = await this.runTests(); if (testResult.all_passed) { return { allowed: false, reason: "TDD violation: Tests must fail before implementing. Write failing tests first." }; } } // implement → refactor: Tests must pass if (currentStage === "implement" && nextStage === "refactor") { const testResult = await this.runTests(); if (!testResult.all_passed) { return { allowed: false, reason: "All tests must pass before refactoring.", failed_tests: testResult.failures }; } } // Any stage → complete: Tests must pass (if tests exist) if (nextStage === "complete") { // Skip test validation for analyze stage (no tests written yet) if (currentStage === "analyze") { console.error('ℹ️ Skipping test validation for analyze stage completion'); return { allowed: true }; } // For now, always run tests if not in analyze stage const testResult = await this.runTests(); if (!testResult.all_passed) { return { allowed: false, reason: "All tests must pass before completing the task.", failed_tests: testResult.failures }; } } return { allowed: true }; } async runTests(testCommand) { const projectType = await this.detectProjectType(); const command = testCommand || projectType.test_command; try { const result = await execa(command, { cwd: this.projectRoot, shell: true, timeout: 60000 // 1 minute timeout }); return { all_passed: result.exitCode === 0, output: result.stdout + result.stderr, failures: this.parseTestFailures(result.stderr, projectType.name), exit_code: result.exitCode }; } catch (error) { return { all_passed: false, output: error.stdout + error.stderr, failures: this.parseTestFailures(error.stderr, projectType.name), exit_code: error.exitCode || 1 }; } } async detectProjectType() { const projectTypes = [ { name: "node", test_command: "npm test", file_patterns: ["**/*.ts", "**/*.js"], setup_commands: ["npm install"] }, { name: "python", test_command: "pytest -xvs", file_patterns: ["**/*.py"], setup_commands: ["pip install -r requirements.txt"] }, { name: "rust", test_command: "cargo test", file_patterns: ["**/*.rs"], setup_commands: ["cargo build"] } ]; // Check for project markers with priority order (Node.js first) for (const type of projectTypes) { if (await this.hasProjectMarker(type.name)) { return type; } } // Default to Node.js (most common for MCP projects) return projectTypes[0]; } // NEW IN v2.1.1: Validate completion conditions async validateCompletionConditions(conditions, testCommand) { const results = []; let allPassed = true; for (const condition of conditions) { const result = await this.checkSingleCondition(condition, testCommand); results.push(result); if (!result.passed) { allPassed = false; } } return { all_passed: allPassed, results }; } async checkSingleCondition(condition, testCommand) { const trimmed = condition.trim(); try { // tests_pass if (trimmed === 'tests_pass') { const testResult = await this.runTests(testCommand); return { condition, passed: testResult.all_passed, message: testResult.all_passed ? 'All tests passed' : `Tests failed: ${testResult.failures.join(', ')}` }; } // coverage>X if (trimmed.startsWith('coverage>')) { const threshold = parseInt(trimmed.substring(9)); const coverage = await this.checkTestCoverage(); return { condition, passed: coverage >= threshold, message: `Coverage: ${coverage}% (required: >${threshold}%)` }; } // eslint=0 if (trimmed === 'eslint=0') { const lintResult = await this.checkLinting(); return { condition, passed: lintResult.error_count === 0, message: lintResult.error_count === 0 ? 'No linting errors' : `${lintResult.error_count} linting errors found` }; } // build_success if (trimmed === 'build_success') { const buildResult = await this.checkBuild(); return { condition, passed: buildResult.success, message: buildResult.success ? 'Build successful' : `Build failed: ${buildResult.error}` }; } // no_todos if (trimmed === 'no_todos') { const todoResult = await this.checkTodos(); return { condition, passed: todoResult.count === 0, message: todoResult.count === 0 ? 'No TODO comments found' : `${todoResult.count} TODO comments found` }; } // docs_updated (6-1) if (trimmed === 'docs_updated') { const docsResult = await this.checkDocsUpdated(); return { condition, passed: docsResult.updated, message: docsResult.updated ? 'Documentation appears up to date' : 'Documentation may need updating' }; } // readme_complete (6-1) if (trimmed === 'readme_complete') { const readmeResult = await this.checkReadmeComplete(); return { condition, passed: readmeResult.complete, message: readmeResult.complete ? 'README contains required sections' : `README missing sections: ${readmeResult.missing.join(', ')}` }; } // performance_acceptable (6-1) if (trimmed === 'performance_acceptable') { const perfResult = await this.checkPerformance(); return { condition, passed: perfResult.acceptable, message: perfResult.acceptable ? 'Performance benchmarks met' : `Performance issues: ${perfResult.issues.join(', ')}` }; } // security_scan_pass (6-1) if (trimmed === 'security_scan_pass') { const securityResult = await this.checkSecurity(); return { condition, passed: securityResult.passed, message: securityResult.passed ? 'Security scan passed' : `Security issues: ${securityResult.issues.join(', ')}` }; } // dependencies_secure (6-1) if (trimmed === 'dependencies_secure') { const depResult = await this.checkDependenciesSecurity(); return { condition, passed: depResult.secure, message: depResult.secure ? 'All dependencies are secure' : `Vulnerable dependencies: ${depResult.vulnerabilities.join(', ')}` }; } // Unknown condition return { condition, passed: false, message: `Unknown completion condition: ${condition}` }; } catch (error) { return { condition, passed: false, message: `Error checking condition: ${error.message}` }; } } async checkTestCoverage() { try { // Try common coverage commands const commands = [ 'npm run coverage', 'npm run test:coverage', 'nyc npm test', 'jest --coverage' ]; for (const cmd of commands) { try { const result = await execa(cmd, { cwd: this.projectRoot, shell: true }); // Parse coverage from output (simplified) const coverageMatch = result.stdout.match(/All files\s+\|\s+(\d+\.?\d*)/); if (coverageMatch) { return parseFloat(coverageMatch[1]); } } catch { // Try next command } } // Fallback: assume 0% coverage if no coverage tool found return 0; } catch { return 0; } } async checkLinting() { try { const result = await execa('npx', ['eslint', '.', '--format', 'json'], { cwd: this.projectRoot, reject: false }); const lintResults = JSON.parse(result.stdout); const errorCount = lintResults.reduce((sum, file) => sum + file.errorCount, 0); const warningCount = lintResults.reduce((sum, file) => sum + file.warningCount, 0); return { error_count: errorCount, warnings: warningCount }; } catch { // If ESLint not available, assume no errors return { error_count: 0, warnings: 0 }; } } async checkBuild() { try { const projectType = await this.detectProjectType(); const buildCommands = { node: 'npm run build', python: 'python -m py_compile .', rust: 'cargo build' }; const command = buildCommands[projectType.name] || 'npm run build'; await execa(command, { cwd: this.projectRoot, shell: true }); return { success: true }; } catch (error) { return { success: false, error: error.message || 'Build failed' }; } } async checkTodos() { try { // Search for TODO comments in source files const result = await execa('grep', [ '-r', '-i', 'todo\\|fixme\\|hack', '--include=*.ts', '--include=*.js', '--include=*.py', '--include=*.rs', '.' ], { cwd: this.projectRoot, reject: false }); const lines = result.stdout.split('\n').filter((line) => line.trim()); const files = [...new Set(lines.map((line) => line.split(':')[0]))]; return { count: lines.length, files }; } catch { return { count: 0, files: [] }; } } async hasProjectMarker(projectType) { const markers = { python: ['requirements.txt', 'pyproject.toml', 'setup.py'], node: ['package.json'], rust: ['Cargo.toml'] }; const typeMarkers = markers[projectType] || []; for (const marker of typeMarkers) { try { await fs.access(join(this.projectRoot, marker)); return true; } catch { continue; } } return false; } parseTestFailures(stderr, projectType) { const failures = []; if (projectType === "python") { // Parse pytest failures const lines = stderr.split('\n'); for (const line of lines) { if (line.includes('FAILED') || line.includes('ERROR')) { failures.push(line.trim()); } } } else if (projectType === "node") { // Parse Jest/npm test failures const lines = stderr.split('\n'); for (const line of lines) { if (line.includes('✕') || line.includes('FAIL')) { failures.push(line.trim()); } } } else if (projectType === "rust") { // Parse cargo test failures const lines = stderr.split('\n'); for (const line of lines) { if (line.includes('test result: FAILED')) { failures.push(line.trim()); } } } return failures; } // NEW completion condition checkers (6-1) async checkDocsUpdated() { try { const fs = await import('fs/promises'); const path = await import('path'); // Check if documentation files exist and were modified recently const docFiles = ['README.md', 'CHANGELOG.md', 'docs/', '.devflow/requirements.md', '.devflow/design_spec.md']; const codeFiles = ['src/', 'lib/', 'app/']; let latestDocTime = 0; let latestCodeTime = 0; // Get latest modification time for documentation for (const docFile of docFiles) { const fullPath = path.join(this.projectRoot, docFile); try { const stats = await fs.stat(fullPath); latestDocTime = Math.max(latestDocTime, stats.mtime.getTime()); } catch { // File doesn't exist, continue } } // Get latest modification time for code for (const codeDir of codeFiles) { const fullPath = path.join(this.projectRoot, codeDir); try { const stats = await fs.stat(fullPath); latestCodeTime = Math.max(latestCodeTime, stats.mtime.getTime()); } catch { // Directory doesn't exist, continue } } // If no docs found, consider as not updated if (latestDocTime === 0) { return { updated: false, reason: 'No documentation files found' }; } // If no code found, docs are considered up to date if (latestCodeTime === 0) { return { updated: true }; } // Docs should be updated within 24 hours of code changes const timeDiff = latestCodeTime - latestDocTime; const dayInMs = 24 * 60 * 60 * 1000; return { updated: timeDiff <= dayInMs, reason: timeDiff > dayInMs ? 'Documentation is older than recent code changes' : undefined }; } catch (error) { console.error('Error checking docs updated:', error.message); return { updated: false, reason: `Error checking documentation: ${error.message}` }; } } async checkReadmeComplete() { try { const fs = await import('fs/promises'); const path = await import('path'); const readmePath = path.join(this.projectRoot, 'README.md'); try { const content = await fs.readFile(readmePath, 'utf-8'); // Required sections for a complete README const requiredSections = [ { name: 'Title/Description', patterns: [/^#\s+.+/m] }, { name: 'Installation', patterns: [/##\s*install/i, /##\s*setup/i, /##\s*getting\s*started/i] }, { name: 'Usage', patterns: [/##\s*usage/i, /##\s*example/i, /##\s*how\s*to/i] }, { name: 'License', patterns: [/##\s*license/i, /license/i] } ]; const missing = []; for (const section of requiredSections) { const found = section.patterns.some(pattern => pattern.test(content)); if (!found) { missing.push(section.name); } } return { complete: missing.length === 0, missing }; } catch { return { complete: false, missing: ['README.md file not found'] }; } } catch (error) { console.error('Error checking README completeness:', error.message); return { complete: false, missing: [`Error: ${error.message}`] }; } } async checkPerformance() { try { // This is a simplified performance check // In a real implementation, you would run actual performance tests const issues = []; // Check if performance test files exist const fs = await import('fs/promises'); const path = await import('path'); const perfTestPaths = [ 'test/performance', 'tests/performance', 'benchmark', 'perf' ]; let hasPerfTests = false; for (const testPath of perfTestPaths) { const fullPath = path.join(this.projectRoot, testPath); try { await fs.access(fullPath); hasPerfTests = true; break; } catch { // Directory doesn't exist, continue } } if (!hasPerfTests) { issues.push('No performance tests found'); } // For now, consider acceptable if no major issues found return { acceptable: issues.length === 0, issues }; } catch (error) { console.error('Error checking performance:', error.message); return { acceptable: false, issues: [`Error: ${error.message}`] }; } } async checkSecurity() { try { const issues = []; // Check for common security issues in package.json (for Node.js projects) try { const fs = await import('fs/promises'); const path = await import('path'); const packagePath = path.join(this.projectRoot, 'package.json'); const packageContent = await fs.readFile(packagePath, 'utf-8'); const packageJson = JSON.parse(packageContent); // Check for audit command availability try { const auditResult = await execa('npm', ['audit', '--audit-level=high', '--json'], { cwd: this.projectRoot, reject: false }); const audit = JSON.parse(auditResult.stdout); if (audit.metadata && audit.metadata.vulnerabilities) { const highVulns = audit.metadata.vulnerabilities.high || 0; const criticalVulns = audit.metadata.vulnerabilities.critical || 0; if (highVulns > 0 || criticalVulns > 0) { issues.push(`${highVulns + criticalVulns} high/critical vulnerabilities found`); } } } catch { // npm audit not available or failed, skip } } catch { // Not a Node.js project or package.json not found } return { passed: issues.length === 0, issues }; } catch (error) { console.error('Error checking security:', error.message); return { passed: false, issues: [`Error: ${error.message}`] }; } } async checkDependenciesSecurity() { try { const vulnerabilities = []; // Check Node.js dependencies try { const auditResult = await execa('npm', ['audit', '--audit-level=moderate', '--json'], { cwd: this.projectRoot, reject: false }); const audit = JSON.parse(auditResult.stdout); if (audit.advisories) { Object.values(audit.advisories).forEach((advisory) => { if (advisory.severity === 'high' || advisory.severity === 'critical') { vulnerabilities.push(`${advisory.module_name}: ${advisory.title}`); } }); } } catch { // npm audit not available or no package.json } // Check Python dependencies (if requirements.txt exists) try { const fs = await import('fs/promises'); const path = await import('path'); await fs.access(path.join(this.projectRoot, 'requirements.txt')); try { const safetyResult = await execa('safety', ['check', '--json'], { cwd: this.projectRoot, reject: false }); const safety = JSON.parse(safetyResult.stdout); if (Array.isArray(safety)) { safety.forEach((vuln) => { vulnerabilities.push(`${vuln.package}: ${vuln.advisory}`); }); } } catch { // safety not available } } catch { // No requirements.txt } return { secure: vulnerabilities.length === 0, vulnerabilities }; } catch (error) { console.error('Error checking dependencies security:', error.message); return { secure: false, vulnerabilities: [`Error: ${error.message}`] }; } } } //# sourceMappingURL=tdd-validator.js.map