diff --git a/tools/enforce-verify-before-push.mjs b/tools/enforce-verify-before-push.mjs new file mode 100644 index 00000000..35106c5c --- /dev/null +++ b/tools/enforce-verify-before-push.mjs @@ -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-.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(); diff --git a/tools/enforce-verify-before-push.test.mjs b/tools/enforce-verify-before-push.test.mjs new file mode 100644 index 00000000..cde931da --- /dev/null +++ b/tools/enforce-verify-before-push.test.mjs @@ -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); + }); +}); diff --git a/tools/enforce-verify-record.mjs b/tools/enforce-verify-record.mjs new file mode 100644 index 00000000..4f49a7a3 --- /dev/null +++ b/tools/enforce-verify-record.mjs @@ -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-.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();