Files
brain/tools/enforce-verify-record.mjs
T

91 lines
4.0 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; }
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();