@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
JavaScript
/**
* 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