64e962e330
enforce-verify-record extractTestMetrics now recognises the project Pest JSON reporter ({"result":"passed/failed",...}); previously every Pest run was recorded as a failed sentinel, blocking all Pest-verified commits (mirrors enforce-tdd-gate fix 1d2d43a6). enforce-tdd-real-test-verifier TEST_FILE_RE second dot escaped so .env.testing is no longer false-matched as a test file.
107 lines
4.8 KiB
JavaScript
107 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Rule #4 (companion) — Record verification artifact.
|
|
*
|
|
* PostToolUse on Bash. If the command was a full project test run AND it
|
|
* passed (exit 0 + recognisable PASS marker in stdout), write a sentinel
|
|
* `~/.claude/runtime/verify-pass-<session>.json` consumed by the
|
|
* enforce-verify-before-push gate.
|
|
*
|
|
* Failed runs ALSO record a sentinel with result=fail — so the gate can
|
|
* distinguish "never ran" from "ran and failed".
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
|
*/
|
|
|
|
import {
|
|
readStdin,
|
|
parseEventJson,
|
|
writeSentinel,
|
|
exitDecision,
|
|
detectFullTestRun,
|
|
} from './enforce-hook-helpers.mjs';
|
|
|
|
export function extractTestMetrics(stdout) {
|
|
const out = { tests_total: null, tests_passed: null, tests_failed: null };
|
|
if (typeof stdout !== 'string') return out;
|
|
// vitest summary lines:
|
|
// "Tests 3708 passed (3708)"
|
|
// "Tests 924 passed | 3 skipped (927)" ← was missed pre-2026-05-26
|
|
// "Tests 1 failed | 631 passed (632)"
|
|
// "Tests 1 failed | 920 passed | 3 skipped (924)" ← was missed pre-2026-05-26
|
|
let m = stdout.match(/Tests\s+(\d+)\s+passed\s*\((\d+)\)/);
|
|
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[2]; out.tests_failed = 0; return out; }
|
|
m = stdout.match(/Tests\s+(\d+)\s+passed\s*\|\s*(\d+)\s+skipped\s*\((\d+)\)/);
|
|
if (m) { out.tests_passed = +m[1]; out.tests_failed = 0; out.tests_total = +m[3]; return out; }
|
|
m = stdout.match(/Tests\s+(\d+)\s+failed\s*\|\s*(\d+)\s+passed\s*\|\s*\d+\s+skipped\s*\((\d+)\)/);
|
|
if (m) { out.tests_failed = +m[1]; out.tests_passed = +m[2]; out.tests_total = +m[3]; return out; }
|
|
m = stdout.match(/Tests\s+(\d+)\s+failed\s*\|\s*(\d+)\s+passed\s*\((\d+)\)/);
|
|
if (m) { out.tests_failed = +m[1]; out.tests_passed = +m[2]; out.tests_total = +m[3]; return out; }
|
|
// Pest: "Tests: 742 passed (1908 assertions)"
|
|
m = stdout.match(/Tests:\s+(\d+)\s+passed/);
|
|
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[1]; out.tests_failed = 0; return out; }
|
|
// Pest JSON reporter: {"tool":"pest","result":"passed","tests":14,"passed":14,"assertions":22}
|
|
// {"tool":"pest","result":"failed","tests":15,"passed":14,"errors":1,...}
|
|
// Mirrors the JSON recognition added to enforce-tdd-gate.mjs in commit 1d2d43a6.
|
|
const jres = stdout.match(/"result"\s*:\s*"(passed|failed)"/);
|
|
if (jres) {
|
|
const pM = stdout.match(/"passed"\s*:\s*(\d+)/);
|
|
const fM = stdout.match(/"failed"\s*:\s*(\d+)/);
|
|
const eM = stdout.match(/"errors"\s*:\s*(\d+)/);
|
|
const tM = stdout.match(/"tests"\s*:\s*(\d+)/);
|
|
out.tests_passed = pM ? +pM[1] : null;
|
|
let failed = (fM ? +fM[1] : 0) + (eM ? +eM[1] : 0);
|
|
if (jres[1] === 'failed' && failed === 0) failed = 1; // result=failed but no count → force >=1
|
|
out.tests_failed = failed;
|
|
out.tests_total = tM ? +tM[1] : (out.tests_passed ?? null);
|
|
return out;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function decideRecord({ toolName, command, exitCode, stdout }) {
|
|
if (toolName !== 'Bash') return null;
|
|
const kind = detectFullTestRun(command);
|
|
if (!kind) return null;
|
|
const metrics = extractTestMetrics(stdout || '');
|
|
// PASS criteria — actual test outcomes drive verdict, not exit code:
|
|
// - tests_failed parseable AND zero (e.g., "Tests 8091 passed (8091)"
|
|
// or "Tests 0 failed | 8091 passed"). Exit code may still be 1 if
|
|
// test FILES failed to load (infra failures like worktree CRLF or
|
|
// ruflo dormant copies) — those don't count.
|
|
// - tests_failed unparseable BUT exit code 0 AND tests_passed > 0
|
|
// (legacy vitest output format).
|
|
const passed = (metrics.tests_failed !== null && metrics.tests_failed === 0 && metrics.tests_passed > 0)
|
|
|| (exitCode === 0 && metrics.tests_passed && metrics.tests_failed === null);
|
|
return {
|
|
command_kind: kind,
|
|
command: String(command).slice(0, 200),
|
|
exit_code: exitCode,
|
|
result: passed ? 'pass' : 'fail',
|
|
tests_total: metrics.tests_total,
|
|
tests_passed: metrics.tests_passed,
|
|
tests_failed: metrics.tests_failed,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const raw = await readStdin();
|
|
const event = parseEventJson(raw);
|
|
const toolName = event.tool_name || '';
|
|
const command = (event.tool_input && event.tool_input.command) || '';
|
|
const resp = event.tool_response || {};
|
|
const exitCode = typeof resp.exitCode === 'number' ? resp.exitCode : (typeof resp.exit_code === 'number' ? resp.exit_code : null);
|
|
const stdout = typeof resp.stdout === 'string' ? resp.stdout : '';
|
|
|
|
const record = decideRecord({ toolName, command, exitCode, stdout });
|
|
if (record) writeSentinel('verify-pass', event.session_id, record);
|
|
exitDecision({ block: false });
|
|
} catch {
|
|
exitDecision({ block: false });
|
|
}
|
|
}
|
|
|
|
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-verify-record.mjs');
|
|
if (isCli) main();
|