397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
4.0 KiB
JavaScript
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();
|