feat(enforce): T7 — Rule #3+#6 TDD-gate + writing-plans enforce (PreToolUse Edit/Write/MultiEdit)
This commit is contained in:
@@ -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 <test-file> / 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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user