automagik-genie
Version:
Universal AI development companion that can be initialized in any codebase
264 lines (220 loc) • 9.42 kB
JavaScript
/**
* TDD Validator for automagik-genie Node.js project
* Ensures Test-Driven Development practices are followed
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
class TDDValidator {
constructor() {
this.projectRoot = path.dirname(__dirname);
this.testsDir = path.join(this.projectRoot, 'tests');
}
runTests() {
try {
const result = execSync('npm test', {
cwd: this.projectRoot,
encoding: 'utf8',
stdio: 'pipe'
});
return {
success: true,
output: result,
hasFailures: false
};
} catch (error) {
return {
success: false,
output: error.stdout + error.stderr,
hasFailures: true
};
}
}
validateTDDCycle(tool, filePath, content) {
// Handle empty or invalid file paths
if (!filePath || filePath.trim() === '') {
return { allowed: true, reason: "Change approved" };
}
// Allow test files - they should be created first in RED phase
if (this.isTestFile(filePath)) {
return { allowed: true, reason: "Test file creation/modification allowed" };
}
// Allow configuration files (check before non-JS files)
if (this.isConfigFile(filePath)) {
return { allowed: true, reason: "Configuration file - allowed" };
}
// Allow non-JavaScript files to pass through
if (!filePath.endsWith('.js') && !filePath.endsWith('.ts')) {
return { allowed: true, reason: "Non-JavaScript file" };
}
// Check if this is a new implementation file
const fileExists = fs.existsSync(filePath);
if (!fileExists) {
// For new implementation files, ensure we have tests
const hasTests = this.findCorrespondingTests(filePath);
if (!hasTests) {
return {
allowed: false,
reason: `❌ RED PHASE VIOLATION: Creating implementation file '${filePath}' without corresponding tests. Create tests first!`
};
}
}
// Run tests to check current state
const testResults = this.runTests();
// If tests are failing, we're in RED phase - implementation changes are allowed
if (testResults.hasFailures) {
return {
allowed: true,
reason: "✅ GREEN PHASE: Tests failing, implementation changes allowed"
};
}
// If all tests pass and we're adding new functions/classes, this should be blocked in strict TDD
if (this.hasNewFunctionality(content) && !testResults.hasFailures && !fileExists) {
// Block new implementation files when all tests pass - strict TDD enforcement
return {
allowed: false,
reason: "❌ TDD VIOLATION: Creating new implementation file with functionality when all tests pass. Write failing tests first!"
};
}
// If modifying existing file with new functionality, warn but allow
if (this.hasNewFunctionality(content) && !testResults.hasFailures && fileExists) {
return {
allowed: true,
reason: "⚠️ REFACTOR PHASE: Adding functionality to existing file when tests pass, ensure new functionality has corresponding failing tests first"
};
}
return { allowed: true, reason: "Change approved" };
}
isTestFile(filePath) {
const fileName = path.basename(filePath);
return fileName.includes('.test.') ||
fileName.includes('.spec.') ||
fileName.startsWith('test_') ||
fileName.endsWith('_test.js') ||
filePath.includes('/tests/') ||
filePath.includes('/test/');
}
isConfigFile(filePath) {
const configFiles = [
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
'jest.config.js', 'jest.config.json', 'babel.config.js',
'.eslintrc.js', '.eslintrc.json', '.prettierrc',
'tsconfig.json', 'webpack.config.js', 'rollup.config.js'
];
const fileName = path.basename(filePath);
return configFiles.includes(fileName) ||
fileName.startsWith('.') ||
filePath.includes('node_modules/');
}
findCorrespondingTests(filePath) {
const fileName = path.basename(filePath, path.extname(filePath));
// Handle absolute paths by making them relative to project root
let relativePath;
if (path.isAbsolute(filePath)) {
relativePath = path.relative(this.projectRoot, filePath);
} else {
relativePath = filePath;
}
const dirName = path.dirname(relativePath);
// Common test patterns for Node.js projects
const testPatterns = [
// If the file itself is already a test file, return true
filePath,
// Same directory patterns
path.join(dirName, `${fileName}.test.js`),
path.join(dirName, `${fileName}.spec.js`),
path.join(dirName, `test_${fileName}.js`),
// Tests directory patterns
path.join('tests', `${fileName}.test.js`),
path.join('tests', `${fileName}.spec.js`),
path.join('test', `${fileName}.test.js`),
path.join('test', `${fileName}.spec.js`),
// Mirrored structure in tests
path.join('tests', dirName, `${fileName}.test.js`),
path.join('test', dirName, `${fileName}.test.js`),
];
return testPatterns.some(pattern => {
const fullPath = path.join(this.projectRoot, pattern);
return fs.existsSync(fullPath);
});
}
hasNewFunctionality(content) {
// Simple heuristic - look for function declarations, class declarations, exports
const functionalityPatterns = [
/function\s+\w+/, // function declarations
/const\s+\w+\s*=\s*\(/, // arrow functions assigned to const
/class\s+\w+/, // class declarations
/module\.exports\s*=/, // CommonJS exports
/exports\.\w+/, // CommonJS named exports
/export\s+(function|class|const)/, // ES6 exports
];
return functionalityPatterns.some(pattern => pattern.test(content));
}
}
async function main() {
try {
// Read JSON input from stdin
let inputData = '';
if (process.stdin.isTTY) {
// If no stdin, create test scenario for debugging
inputData = JSON.stringify({
tool: "Write",
tool_input: {
file_path: "lib/example.js",
content: "function hello() { return 'world'; }"
}
});
} else {
// Read from stdin
for await (const chunk of process.stdin) {
inputData += chunk;
}
}
const input = JSON.parse(inputData);
const validator = new TDDValidator();
const tool = input.tool || "Unknown";
const toolInput = input.tool_input || {};
let validationResults = [];
// Handle different tool types
if (["Write", "Edit", "MultiEdit"].includes(tool)) {
if (tool === "MultiEdit") {
// Handle multiple edits
const edits = toolInput.edits || [];
const filePath = toolInput.file_path || "";
for (const edit of edits) {
const result = validator.validateTDDCycle(
tool,
filePath,
edit.new_string || ""
);
validationResults.push(result);
if (!result.allowed) break;
}
} else {
// Handle single edit
const filePath = toolInput.file_path || "";
const content = toolInput.content || toolInput.new_string || "";
const result = validator.validateTDDCycle(tool, filePath, content);
validationResults.push(result);
}
}
// Check if any validation failed
const failedValidation = validationResults.find(r => !r.allowed);
if (failedValidation) {
// Output error message to stderr and exit with code 1 to block the operation
console.error(failedValidation.reason);
process.exit(1);
}
// All validations passed - exit with code 0 to allow the operation
process.exit(0);
} catch (error) {
// On any error, allow the operation but log the error to stderr
console.error(`TDD Validator Error: ${error.message} - Operation allowed`);
process.exit(0);
}
}
if (require.main === module) {
main();
}
module.exports = { TDDValidator };