#!/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-.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();