Files
brain/tools/enforce-tdd-gate.mjs
T

211 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Rule #3 + #6 — TDD-gate + writing-plans enforce for production code.
*
* PreToolUse on Edit / Write / MultiEdit. Pattern-matches file path against
* production-code heuristic (isProductionCodePath). When matched:
* 1. (#6) For feature/bugfix/refactor/cleanup classified tasks: require
* Skill(superpowers:writing-plans) OR existing plan-file reference in
* current turn.
* 2. (#3) Require preceding test edit + a `Bash` run of vitest/pest with
* a "fail" / "FAIL" / "Failed" indicator in its stdout (RED phase).
*
* Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".
*
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
*/
import {
readStdin,
parseEventJson,
readTranscript,
lastUserPromptText,
lastTurnEntries,
findOverride,
logOverride,
exitDecision,
isProductionCodePath,
readRouterState,
} from './enforce-hook-helpers.mjs';
const RULE_KEY_TDD = 'tdd-gate';
const RULE_KEY_PLAN = 'writing-plans-required';
/** Map a production path to expected test path patterns (heuristic). */
function expectedTestPathMatchers(prodPath) {
const n = String(prodPath || '').replace(/\\/g, '/');
const matchers = [];
// tools/foo.mjs → tools/foo.test.mjs / tools/foo.spec.mjs
let m = n.match(/(.*\/)?([^/]+)\.mjs$/);
if (m) {
matchers.push(`${m[1] || ''}${m[2]}.test.mjs`);
matchers.push(`${m[1] || ''}${m[2]}.spec.mjs`);
}
// app/app/Path/X.php → app/tests/**/XTest.php OR app/tests/**/X*.php
m = n.match(/\/app\/app\/(.+)\/([^/]+)\.php$/);
if (m) {
matchers.push(`/app/tests/Unit/${m[2]}Test.php`);
matchers.push(`/app/tests/Feature/${m[2]}Test.php`);
// Loose containment
matchers.push(`/app/tests/.+${m[2]}Test.php`);
}
// resources/js/views/X.vue → X.spec.ts / X.test.ts loose
m = n.match(/\/resources\/js\/(.+\/)?([^/]+)\.(vue|ts|tsx|js)$/);
if (m) {
matchers.push(`/resources/js/${m[1] || ''}${m[2]}.spec.ts`);
matchers.push(`/resources/js/${m[1] || ''}${m[2]}.test.ts`);
matchers.push(`/resources/js/${m[1] || ''}__tests__/${m[2]}.spec.ts`);
}
return matchers;
}
function hasMatchingTestEdit(turn, prodPath) {
const matchers = expectedTestPathMatchers(prodPath);
const basename = String(prodPath || '').replace(/\\/g, '/').split('/').pop().split('.')[0];
for (const e of turn) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (!b || b.type !== 'tool_use') continue;
if (!['Edit', 'Write', 'MultiEdit'].includes(b.name)) continue;
const p = (b.input && (b.input.file_path || b.input.notebook_path) || '').replace(/\\/g, '/');
if (!p) continue;
// Check test-file pattern (loose contains-basename + test/spec)
if (/\.(test|spec)\.[a-z0-9]+$/i.test(p) && p.includes(basename)) return true;
// Check explicit matchers
for (const m of matchers) {
const mPattern = m.replace(/[.+]/g, '\\$&').replace(/\\\.\\\+/g, '.+');
if (new RegExp(mPattern + '$').test(p)) return true;
}
}
}
return false;
}
function hasFailingTestRun(turn) {
// Look for Bash tool_use followed by tool_result containing a failure indicator
// OR PASS line with N failed > 0.
const bashIds = new Set();
for (const e of turn) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_use' && b.name === 'Bash') {
const cmd = (b.input && b.input.command) || '';
if (/\b(vitest|pest|phpunit)\b/.test(cmd)) bashIds.add(b.id);
}
}
}
if (bashIds.size === 0) return false;
for (const e of turn) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_result' && bashIds.has(b.tool_use_id)) {
const txt = typeof b.content === 'string' ? b.content
: Array.isArray(b.content) ? b.content.map((p) => p && p.text).filter(Boolean).join('\n') : '';
if (/\b(fail|FAIL|Failed|×)\b/.test(txt)) return true;
// Numeric: "Tests N failed | M passed" with N>0
const m = txt.match(/Tests\s+(\d+)\s+failed/);
if (m && Number(m[1]) > 0) return true;
}
}
}
return false;
}
function hasPlanIndicator(turn) {
for (const e of turn) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_use') {
if (b.name === 'Skill' && b.input && /writing-plans/i.test(String(b.input.skill || ''))) return true;
const p = (b.input && (b.input.file_path || b.input.notebook_path) || '');
if (/docs\/superpowers\/plans\//i.test(p)) return true;
// Also accept Read of a plan file (existing plan)
if (b.name === 'Read' && /docs\/superpowers\/plans\//i.test(p)) return true;
}
if (b && b.type === 'text' && /docs\/superpowers\/plans\//.test(b.text || '')) return true;
}
}
return false;
}
export function decide({
toolName, filePath, transcriptEntries, classification, override, overridePlan,
}) {
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) return { block: false };
if (!isProductionCodePath(filePath)) return { block: false };
const turn = lastTurnEntries(transcriptEntries);
// Rule #6 — plan requirement for feature/bugfix/refactor/cleanup.
const taskType = classification && classification.task_type;
if (!overridePlan && taskType && /^(feature|bugfix|refactor|cleanup)$/i.test(taskType)) {
if (!hasPlanIndicator(turn)) {
return {
block: true,
message: [
`[enforce-tdd-gate] task_type="${taskType}" requires a plan before production-code edit.`,
`Either invoke superpowers:writing-plans via Skill tool,`,
`or reference an existing plan file (docs/superpowers/plans/...) in this turn first.`,
].join('\n'),
};
}
}
// Rule #3 — TDD gate.
if (override) return { block: false };
const hasTest = hasMatchingTestEdit(turn, filePath);
if (!hasTest) {
return {
block: true,
message: [
`[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`,
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`,
`Then run vitest/pest to confirm RED, then return to this prod-code Edit.`,
].join('\n'),
};
}
if (!hasFailingTestRun(turn)) {
return {
block: true,
message: [
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`,
`Run the test suite (vitest run <test-file> / composer test) to confirm RED before prod-code edit.`,
].join('\n'),
};
}
return { block: false };
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
const toolName = event.tool_name || '';
const filePath = (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '';
const transcript = readTranscript(event.transcript_path);
const userPrompt = lastUserPromptText(transcript);
const override = findOverride(userPrompt, RULE_KEY_TDD);
const overridePlan = findOverride(userPrompt, RULE_KEY_PLAN);
if (override) logOverride(RULE_KEY_TDD, override, event.session_id);
if (overridePlan) logOverride(RULE_KEY_PLAN, overridePlan, event.session_id);
const state = readRouterState(event.session_id);
const classification = state && state.classification ? {
task_type: state.classification.task_type,
} : null;
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan });
exitDecision(result);
} catch {
exitDecision({ block: false });
}
}
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-tdd-gate.mjs');
if (isCli) main();