#!/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; // JSON reporter (composer test / php artisan test → pest): {"result":"failed",...} // or {"failed":N}/{"errors":N} with N>0. command-not-found / error REDs lack the // English "Failed" keyword above, so recognise the structured marker too. if (/"result"\s*:\s*"failed"/.test(txt)) return true; if (/"(?:failed|errors)"\s*:\s*[1-9]/.test(txt)) 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 / 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();