397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.7 KiB
JavaScript
126 lines
4.7 KiB
JavaScript
#!/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,
|
|
findOverrideAttempt,
|
|
logOverride,
|
|
exitDecision,
|
|
detectGitCommandKind,
|
|
readSentinel,
|
|
sentinelAgeSec,
|
|
isDocsOnlyChange,
|
|
listChangedFiles,
|
|
} 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, overrideAttempt, changedPaths }) {
|
|
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 };
|
|
|
|
// Docs-only short-circuit (2026-05-27): if EVERY staged/unpushed file is a
|
|
// `.md` document, the regression gate adds no value — there's no executable
|
|
// code in the change set, so a fresh test run can't tell us anything new.
|
|
// Empty/missing changedPaths → unknown → fall through to normal checks.
|
|
if (isDocsOnlyChange(changedPaths)) return { block: false };
|
|
|
|
// Silent-reject bug fix (2026-05-26): when user typed an override phrase that
|
|
// requires justification (e.g. "ремонт инфраструктуры") but forgot the
|
|
// "<prefix> <reason>" line, emit an explicit diagnostic — not the generic
|
|
// "no verification artifact" message that misled users into thinking the
|
|
// override mechanism was broken.
|
|
if (overrideAttempt && overrideAttempt.requires_justification) {
|
|
return {
|
|
block: true,
|
|
message: [
|
|
`[enforce-verify-before-push] Override phrase "${overrideAttempt.phrase}" found, but missing justification line.`,
|
|
`Add a line "${overrideAttempt.requires_justification} <one-line причина>" in the SAME prompt.`,
|
|
``,
|
|
`Example:`,
|
|
` ${overrideAttempt.phrase}`,
|
|
` ${overrideAttempt.requires_justification} observer refresh after brainstorm session`,
|
|
].join('\n'),
|
|
};
|
|
}
|
|
|
|
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}\`.`,
|
|
].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 overrideAttempt = override ? null : findOverrideAttempt(userPrompt, ruleKey);
|
|
|
|
const sentinel = readSentinel('verify-pass', event.session_id);
|
|
const age = sentinelAgeSec('verify-pass', event.session_id);
|
|
const changedPaths = (kind === 'commit' || kind === 'push') ? listChangedFiles(kind) : [];
|
|
|
|
const result = decide({ toolName, command, sentinel, sentinelAge: age, override, overrideAttempt, changedPaths });
|
|
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();
|