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:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user