Files
portal/tools/enforce-verify-record.mjs
T
Дмитрий 64e962e330 fix(hooks): parse Pest JSON reporter in verify-record + escape dot in tdd-real-test regex
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.
2026-06-03 18:51:02 +03:00

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();