diff --git a/tools/enforce-tdd-gate.mjs b/tools/enforce-tdd-gate.mjs new file mode 100644 index 00000000..65480a67 --- /dev/null +++ b/tools/enforce-tdd-gate.mjs @@ -0,0 +1,216 @@ +#!/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.`, + ``, + `Override: "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`, + ].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.`, + ``, + `Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`, + ].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.`, + ``, + `Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры".`, + ].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(); diff --git a/tools/enforce-tdd-gate.test.mjs b/tools/enforce-tdd-gate.test.mjs new file mode 100644 index 00000000..becc7cf5 --- /dev/null +++ b/tools/enforce-tdd-gate.test.mjs @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-tdd-gate.mjs'; + +function userMsg(text) { + return { message: { role: 'user', content: text } }; +} +function assistantUses(uses) { + return { message: { role: 'assistant', content: uses.map((u, i) => ({ type: 'tool_use', id: u.id || `t${i}`, name: u.name, input: u.input })) } }; +} +function toolResults(results) { + return { message: { role: 'user', content: results.map((r) => ({ type: 'tool_result', tool_use_id: r.id, content: r.content, is_error: r.is_error || false })) } }; +} + +describe('enforce-tdd-gate / decide', () => { + it('allows non-production paths', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'docs/x.md', + transcriptEntries: [], + }); + expect(r.block).toBe(false); + }); + + it('allows test files themselves', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.test.mjs', + transcriptEntries: [], + }); + expect(r.block).toBe(false); + }); + + it('blocks prod edit with no preceding test edit', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [userMsg('do it')], + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/without preceding test edit/); + }); + + it('blocks when test edited but no vitest RED observed', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('do it'), + assistantUses([{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }]), + ], + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/no vitest.*RED/); + }); + + it('allows after test edit + vitest RED', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('do it'), + assistantUses([ + { id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ]), + toolResults([{ id: 't2', content: 'Tests 1 failed | 0 passed' }]), + ], + }); + expect(r.block).toBe(false); + }); + + it('allows when "fail" word in vitest stdout', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('do it'), + assistantUses([ + { id: 't1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } }, + { id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ]), + toolResults([{ id: 't2', content: 'FAIL tools/foo.test.mjs' }]), + ], + }); + expect(r.block).toBe(false); + }); + + it('allows when override phrase present', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [userMsg('срочно надо')], + override: { phrase: 'срочно', suppresses: ['tdd-gate'] }, + }); + expect(r.block).toBe(false); + }); + + it('blocks feature-classified prod edit without plan indicator', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('добавь фичу X'), + assistantUses([{ id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }]), + ], + classification: { task_type: 'feature' }, + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/requires a plan/); + }); + + it('allows feature edit when Skill(superpowers:writing-plans) invoked', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('добавь фичу X'), + assistantUses([ + { id: 't0', name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, + { id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ]), + toolResults([{ id: 't2', content: 'Tests 1 failed' }]), + ], + classification: { task_type: 'feature' }, + }); + expect(r.block).toBe(false); + }); + + it('allows feature edit when plan file is referenced', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('добавь фичу X'), + assistantUses([ + { id: 't0', name: 'Read', input: { file_path: 'docs/superpowers/plans/2026-05-26-foo.md' } }, + { id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ]), + toolResults([{ id: 't2', content: 'Tests 1 failed' }]), + ], + classification: { task_type: 'feature' }, + }); + expect(r.block).toBe(false); + }); + + it('does NOT require plan for non-feature task types', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + userMsg('chore'), + assistantUses([ + { id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { id: 't2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ]), + toolResults([{ id: 't2', content: 'Tests 1 failed' }]), + ], + classification: { task_type: 'cleanup-but-not-strictly' }, + }); + expect(r.block).toBe(false); + }); +});