feat(enforce): T4 — Rule #4 verify-before-push + companion PostToolUse recorder
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rule #4 — Require fresh verification artifact before git commit / push.
|
||||
*
|
||||
* PreToolUse on Bash. If command is git commit / push, check the
|
||||
* verify-pass-<session>.json sentinel:
|
||||
* - missing → block
|
||||
* - age > MAX_AGE_SEC → block ("stale")
|
||||
* - result !== 'pass' → block ("last run failed")
|
||||
*
|
||||
* Override phrases: `срочно` / `быстрый коммит` / `ремонт инфраструктуры`.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
||||
*/
|
||||
|
||||
import {
|
||||
readStdin,
|
||||
parseEventJson,
|
||||
readTranscript,
|
||||
lastUserPromptText,
|
||||
findOverride,
|
||||
logOverride,
|
||||
exitDecision,
|
||||
detectGitCommandKind,
|
||||
readSentinel,
|
||||
sentinelAgeSec,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
const RULE_KEY_COMMIT = 'verify-before-commit';
|
||||
const RULE_KEY_PUSH = 'verify-before-push';
|
||||
const MAX_AGE_SEC = 30 * 60; // 30 min
|
||||
|
||||
export function decide({ toolName, command, sentinel, sentinelAge, override }) {
|
||||
if (toolName !== 'Bash' || typeof command !== 'string') return { block: false };
|
||||
const kind = detectGitCommandKind(command);
|
||||
if (kind !== 'commit' && kind !== 'push') return { block: false };
|
||||
if (override) return { block: false };
|
||||
|
||||
if (!sentinel) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] No verification artifact found.`,
|
||||
`Run a full test suite first (vitest run / composer test) before \`git ${kind}\`.`,
|
||||
``,
|
||||
`Override: "срочно" / "быстрый коммит" / "ремонт инфраструктуры" in your prompt.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (sentinel.result !== 'pass') {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] Last verification FAILED (result=${sentinel.result}, exit=${sentinel.exit_code}).`,
|
||||
`Tests: ${sentinel.tests_passed}/${sentinel.tests_total} passed, ${sentinel.tests_failed} failed.`,
|
||||
`Re-run the suite and address failures before \`git ${kind}\`.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (sentinelAge !== null && sentinelAge > MAX_AGE_SEC) {
|
||||
return {
|
||||
block: true,
|
||||
message: [
|
||||
`[enforce-verify-before-push] Verification artifact is stale (age ${sentinelAge}s > ${MAX_AGE_SEC}s).`,
|
||||
`Re-run the full test suite before \`git ${kind}\`.`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
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 transcript = readTranscript(event.transcript_path);
|
||||
const userPrompt = lastUserPromptText(transcript);
|
||||
const kind = detectGitCommandKind(command);
|
||||
const ruleKey = kind === 'commit' ? RULE_KEY_COMMIT : RULE_KEY_PUSH;
|
||||
const override = findOverride(userPrompt, ruleKey);
|
||||
if (override) logOverride(ruleKey, override, event.session_id);
|
||||
|
||||
const sentinel = readSentinel('verify-pass', event.session_id);
|
||||
const age = sentinelAgeSec('verify-pass', event.session_id);
|
||||
|
||||
const result = decide({ toolName, command, sentinel, sentinelAge: age, override });
|
||||
exitDecision(result);
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-verify-before-push.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-verify-before-push.mjs';
|
||||
import { decideRecord, extractTestMetrics } from './enforce-verify-record.mjs';
|
||||
|
||||
describe('enforce-verify-record / decideRecord', () => {
|
||||
it('returns null for non-Bash', () => {
|
||||
expect(decideRecord({ toolName: 'Edit', command: 'foo' })).toBeNull();
|
||||
});
|
||||
it('returns null for non-test command', () => {
|
||||
expect(decideRecord({ toolName: 'Bash', command: 'git status', exitCode: 0, stdout: '' })).toBeNull();
|
||||
});
|
||||
it('returns null for narrow vitest (specific test file)', () => {
|
||||
expect(decideRecord({ toolName: 'Bash', command: 'npx vitest run tools/foo.test.mjs', exitCode: 0, stdout: '' })).toBeNull();
|
||||
});
|
||||
it('records PASS on full vitest run with all-passed summary', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'npx vitest run', exitCode: 0,
|
||||
stdout: 'Tests 3708 passed (3708)',
|
||||
});
|
||||
expect(rec.result).toBe('pass');
|
||||
expect(rec.tests_total).toBe(3708);
|
||||
expect(rec.tests_passed).toBe(3708);
|
||||
});
|
||||
it('records FAIL on full vitest run with failed summary', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'npx vitest run', exitCode: 1,
|
||||
stdout: 'Tests 3 failed | 600 passed (603)',
|
||||
});
|
||||
expect(rec.result).toBe('fail');
|
||||
expect(rec.tests_failed).toBe(3);
|
||||
});
|
||||
it('records pest', () => {
|
||||
const rec = decideRecord({
|
||||
toolName: 'Bash', command: 'composer test', exitCode: 0,
|
||||
stdout: 'Tests: 742 passed (1908 assertions)',
|
||||
});
|
||||
expect(rec.result).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-verify-record / extractTestMetrics', () => {
|
||||
it('parses vitest all-passed', () => {
|
||||
expect(extractTestMetrics('Tests 3708 passed (3708)')).toMatchObject({
|
||||
tests_passed: 3708, tests_total: 3708, tests_failed: 0,
|
||||
});
|
||||
});
|
||||
it('parses vitest mixed failure', () => {
|
||||
expect(extractTestMetrics('Tests 1 failed | 631 passed (632)')).toMatchObject({
|
||||
tests_failed: 1, tests_passed: 631, tests_total: 632,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce-verify-before-push / decide', () => {
|
||||
it('allows non-Bash', () => {
|
||||
expect(decide({ toolName: 'Edit', command: '' }).block).toBe(false);
|
||||
});
|
||||
it('allows non-git Bash', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'ls -la' }).block).toBe(false);
|
||||
});
|
||||
it('blocks git commit without sentinel', () => {
|
||||
const r = decide({ toolName: 'Bash', command: 'git commit -m "x"' });
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/No verification/);
|
||||
});
|
||||
it('blocks git push without sentinel', () => {
|
||||
expect(decide({ toolName: 'Bash', command: 'git push origin main' }).block).toBe(true);
|
||||
});
|
||||
it('blocks when sentinel result=fail', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'fail', exit_code: 1, tests_passed: 600, tests_total: 603, tests_failed: 3 },
|
||||
sentinelAge: 60,
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/FAILED/);
|
||||
});
|
||||
it('blocks when sentinel is stale', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'pass' },
|
||||
sentinelAge: 60 * 60, // 1 hour > 30 min
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.message).toMatch(/stale/);
|
||||
});
|
||||
it('allows when sentinel is fresh + pass', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git commit -m "x"',
|
||||
sentinel: { result: 'pass' },
|
||||
sentinelAge: 120,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('allows when override phrase present', () => {
|
||||
const r = decide({
|
||||
toolName: 'Bash', command: 'git push',
|
||||
sentinel: null,
|
||||
override: { phrase: 'срочно', suppresses: ['verify-before-push'] },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/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)" or "Tests N failed | M passed (TOTAL)"
|
||||
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+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 || '');
|
||||
const passed = exitCode === 0 && metrics.tests_failed !== null && metrics.tests_failed === 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();
|
||||
Reference in New Issue
Block a user