From 8266755c2ebc3d6d4b2ff807257348ee4e7ec099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 27 May 2026 08:23:17 +0300 Subject: [PATCH] feat(enforce-verify-before-push): docs-only short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/enforce-hook-helpers.mjs | 56 +++++++++++++++++++ tools/enforce-hook-helpers.test.mjs | 58 ++++++++++++++++++++ tools/enforce-verify-before-push.mjs | 13 ++++- tools/enforce-verify-before-push.test.mjs | 67 +++++++++++++++++++++++ 4 files changed, 192 insertions(+), 2 deletions(-) diff --git a/tools/enforce-hook-helpers.mjs b/tools/enforce-hook-helpers.mjs index 1163df6a..d2c1783c 100644 --- a/tools/enforce-hook-helpers.mjs +++ b/tools/enforce-hook-helpers.mjs @@ -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(); diff --git a/tools/enforce-hook-helpers.test.mjs b/tools/enforce-hook-helpers.test.mjs index 1b3339dc..bea8cc0c 100644 --- a/tools/enforce-hook-helpers.test.mjs +++ b/tools/enforce-hook-helpers.test.mjs @@ -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'); diff --git a/tools/enforce-verify-before-push.mjs b/tools/enforce-verify-before-push.mjs index 94aa9d27..ff8cd1ab 100644 --- a/tools/enforce-verify-before-push.mjs +++ b/tools/enforce-verify-before-push.mjs @@ -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 // " " 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 }); diff --git a/tools/enforce-verify-before-push.test.mjs b/tools/enforce-verify-before-push.test.mjs index 8a6cde01..fdcd8012 100644 --- a/tools/enforce-verify-before-push.test.mjs +++ b/tools/enforce-verify-before-push.test.mjs @@ -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); + }); });