feat(enforce-verify-before-push): docs-only short-circuit

The verify-before-push hook now skips the regression gate when EVERY
staged/unpushed file is a .md document (memory, docs, specs, plans,
SKILL.md). Code-touching pushes remain fully gated as before; mixed
pushes (even one non-md file) keep the full gate.

Closes the recurring loop where Claude invokes the "ремонт инфраструктуры"
override on every docs-only push — regression adds no value when the
change set has no executable code.

New helpers (tools/enforce-hook-helpers.mjs):
  - isDocsOnlyPath(p): true iff path ends with .md (case-insensitive)
  - isDocsOnlyChange(paths): true iff non-empty AND every entry docs-only
  - listChangedFiles(kind): git diff --cached (commit) / @{u}..HEAD (push)
    Empty result = unknown -> caller MUST fall through to normal gate.

decide() in enforce-verify-before-push.mjs accepts a new changedPaths
arg and short-circuits {block: false} when isDocsOnlyChange === true.
Empty/undefined -> falls through (conservative).

TDD: 13 new tests across enforce-hook-helpers.test.mjs + enforce-verify-
before-push.test.mjs, all GREEN. Tools-only canonical regression 965/965.
This commit is contained in:
Дмитрий
2026-05-27 08:23:17 +03:00
parent 81cbd8c1c2
commit 8266755c2e
4 changed files with 192 additions and 2 deletions
+56
View File
@@ -339,6 +339,62 @@ export function isMemoryPath(p) {
return false;
}
/**
* Returns true if path is docs-only (ends with `.md`, case-insensitive).
*
* Used by verify-before-push to short-circuit regression-gating for docs /
* memory / spec / plan / SKILL.md pushes — no executable code → no test value.
*
* Anything else (.json, .php, .ts, .yaml, .mjs, no-extension files) returns false
* and remains under the normal regression gate.
*/
export function isDocsOnlyPath(p) {
if (typeof p !== 'string' || p.length === 0) return false;
return /\.md$/i.test(p);
}
/**
* Returns true iff `paths` is a non-empty array where EVERY entry is docs-only.
* Empty/non-array → false (unknown = conservative, fall through to normal gate).
*/
export function isDocsOnlyChange(paths) {
if (!Array.isArray(paths) || paths.length === 0) return false;
return paths.every(isDocsOnlyPath);
}
/**
* List changed file paths for an in-progress git commit / push.
* - commit: staged files (`git diff --cached --name-only`)
* - push: commits ahead of upstream (`git diff --name-only @{u}..HEAD`)
*
* Returns [] on any git error (no upstream, detached HEAD, git missing, etc.)
* or unrecognized `kind`. Callers MUST treat empty as "unknown" and NOT short-
* circuit on it.
*
* Security: execFileSync with fixed args, no user-input concatenation.
*/
export function listChangedFiles(kind, cwd) {
try {
let args;
if (kind === 'commit') {
args = ['diff', '--cached', '--name-only'];
} else if (kind === 'push') {
args = ['diff', '--name-only', '@{u}..HEAD'];
} else {
return [];
}
const out = execFileSync('git', args, {
cwd: cwd || process.cwd(),
encoding: 'utf-8',
timeout: 2000,
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.split('\n').map((s) => s.trim()).filter(Boolean);
} catch {
return [];
}
}
export function detectGitCommandKind(cmd) {
if (typeof cmd !== 'string') return null;
const c = cmd.trim();
+58
View File
@@ -16,6 +16,8 @@ import {
findOverrideAttempt,
isProductionCodePath,
isMemoryPath,
isDocsOnlyPath,
isDocsOnlyChange,
detectGitCommandKind,
detectFullTestRun,
} from './enforce-hook-helpers.mjs';
@@ -277,6 +279,62 @@ describe('isMemoryPath', () => {
});
});
describe('isDocsOnlyPath', () => {
it('matches .md files at any depth', () => {
expect(isDocsOnlyPath('CLAUDE.md')).toBe(true);
expect(isDocsOnlyPath('docs/Pravila_raboty_Claude_v1_1.md')).toBe(true);
expect(isDocsOnlyPath('docs/superpowers/specs/2026-05-27-foo-design.md')).toBe(true);
expect(isDocsOnlyPath('memory/feedback_xyz.md')).toBe(true);
expect(isDocsOnlyPath('.claude/skills/audit-portal/SKILL.md')).toBe(true);
expect(isDocsOnlyPath('.claude/agents/normative-sync.md')).toBe(true);
expect(isDocsOnlyPath('db/CHANGELOG_schema.md')).toBe(true);
});
it('is case-insensitive on extension', () => {
expect(isDocsOnlyPath('README.MD')).toBe(true);
expect(isDocsOnlyPath('Foo.Md')).toBe(true);
});
it('rejects code / config / schema files', () => {
expect(isDocsOnlyPath('app/app/Http/Controllers/X.php')).toBe(false);
expect(isDocsOnlyPath('tools/enforce-hook-helpers.mjs')).toBe(false);
expect(isDocsOnlyPath('resources/js/views/Dashboard.vue')).toBe(false);
expect(isDocsOnlyPath('db/schema.sql')).toBe(false);
expect(isDocsOnlyPath('.claude/settings.json')).toBe(false);
expect(isDocsOnlyPath('composer.json')).toBe(false);
expect(isDocsOnlyPath('lefthook.yml')).toBe(false);
expect(isDocsOnlyPath('Dockerfile')).toBe(false);
});
it('rejects empty / non-string inputs', () => {
expect(isDocsOnlyPath('')).toBe(false);
expect(isDocsOnlyPath(null)).toBe(false);
expect(isDocsOnlyPath(undefined)).toBe(false);
expect(isDocsOnlyPath(42)).toBe(false);
});
it('does not match files merely containing ".md" mid-name', () => {
expect(isDocsOnlyPath('foo.mdx')).toBe(false);
expect(isDocsOnlyPath('app/CHANGELOG.md.bak')).toBe(false);
});
});
describe('isDocsOnlyChange', () => {
it('true when every path is .md', () => {
expect(isDocsOnlyChange(['CLAUDE.md'])).toBe(true);
expect(isDocsOnlyChange(['CLAUDE.md', 'docs/x.md', 'memory/y.md'])).toBe(true);
});
it('false when ANY path is non-md', () => {
expect(isDocsOnlyChange(['CLAUDE.md', 'app/Foo.php'])).toBe(false);
expect(isDocsOnlyChange(['tools/x.mjs'])).toBe(false);
expect(isDocsOnlyChange(['docs/x.md', '.claude/settings.json'])).toBe(false);
});
it('false on empty array (unknown → conservative)', () => {
expect(isDocsOnlyChange([])).toBe(false);
});
it('false on non-array input', () => {
expect(isDocsOnlyChange(null)).toBe(false);
expect(isDocsOnlyChange(undefined)).toBe(false);
expect(isDocsOnlyChange('CLAUDE.md')).toBe(false);
});
});
describe('detectGitCommandKind', () => {
it('detects push', () => {
expect(detectGitCommandKind('git push origin main')).toBe('push');
+11 -2
View File
@@ -25,18 +25,26 @@ import {
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 }) {
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
@@ -106,8 +114,9 @@ async function main() {
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 });
const result = decide({ toolName, command, sentinel, sentinelAge: age, override, overrideAttempt, changedPaths });
exitDecision(result);
} catch {
exitDecision({ block: false });
+67
View File
@@ -172,4 +172,71 @@ describe('enforce-verify-before-push / decide', () => {
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
// Docs-only short-circuit (2026-05-27): when EVERY changed path is a docs/spec/
// memory .md file, skip regression gate entirely. Pushing documentation that
// touches no executable code can't break the test suite, so requiring a fresh
// verification artifact is pure friction.
it('allows docs-only commit (all paths are .md) without sentinel', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "docs: update"',
sentinel: null,
changedPaths: ['CLAUDE.md', 'docs/Pravila.md', 'memory/feedback_x.md'],
});
expect(r.block).toBe(false);
});
it('allows docs-only push (all paths are .md) without sentinel', () => {
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: null,
changedPaths: ['memory/x.md'],
});
expect(r.block).toBe(false);
});
it('allows docs-only push EVEN when last sentinel result=fail', () => {
// Failed tests reflect broken code; docs push touches no code, so it's fine.
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: { result: 'fail', exit_code: 1, tests_passed: 600, tests_total: 603, tests_failed: 3 },
sentinelAge: 60,
changedPaths: ['docs/x.md'],
});
expect(r.block).toBe(false);
});
it('blocks when changedPaths is mixed (one non-md file)', () => {
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: null,
changedPaths: ['CLAUDE.md', 'app/Foo.php'],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
it('falls through to normal checks when changedPaths is empty (unknown)', () => {
// git diff failed / no upstream / detached HEAD — caller passes []; we must
// NOT treat empty as "docs-only" (it would silently let code through).
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: null,
changedPaths: [],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
it('falls through to normal checks when changedPaths is undefined (no caller info)', () => {
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: null,
});
expect(r.block).toBe(true);
});
it('docs-only short-circuit applies regardless of stale sentinel', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "docs"',
sentinel: { result: 'pass' },
sentinelAge: 60 * 60 * 24, // 1 day stale
changedPaths: ['docs/x.md'],
});
expect(r.block).toBe(false);
});
});