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 PASS when exit=1 but tests_failed=0 (infra file-load failures)', () => { // E.g. worktree CRLF copies of test files crash to load → exit code 1 // but all actual tests passed. const rec = decideRecord({ toolName: 'Bash', command: 'npx vitest run', exitCode: 1, stdout: 'Test Files 95 failed | 411 passed (506)\n Tests 8091 passed (8091)', }); expect(rec.result).toBe('pass'); }); 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, }); }); it('parses vitest passed with skipped', () => { // Vitest 4.x summary when some tests are .skip()'ed: // "Tests 924 passed | 3 skipped (927)" // Previously fell through all regexes → result=fail (false negative). expect(extractTestMetrics('Tests 924 passed | 3 skipped (927)')).toMatchObject({ tests_passed: 924, tests_failed: 0, tests_total: 927, }); }); it('parses vitest failed+passed+skipped triplet', () => { expect(extractTestMetrics('Tests 1 failed | 920 passed | 3 skipped (924)')).toMatchObject({ tests_failed: 1, tests_passed: 920, tests_total: 924, }); }); }); 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); }); it('emits helpful diagnostic when override phrase matched but justification missing', () => { // Silent-reject bug fix: user typed "ремонт инфраструктуры" but forgot // the "ремонт: " line. Old behaviour: generic "No verification artifact". // New behaviour: explicit "phrase found but missing 'ремонт: ' line". const r = decide({ toolName: 'Bash', command: 'git commit -m "x"', sentinel: null, override: null, overrideAttempt: { phrase: 'ремонт инфраструктуры', requires_justification: 'ремонт:', suppresses: ['verify-before-push'], }, }); expect(r.block).toBe(true); expect(r.message).toMatch(/ремонт инфраструктуры/); expect(r.message).toMatch(/ремонт:/); expect(r.message).toMatch(/justification|причин/i); }); it('falls back to generic message when overrideAttempt is null (phrase not even typed)', () => { const r = decide({ toolName: 'Bash', command: 'git commit -m "x"', sentinel: null, override: null, overrideAttempt: null, }); expect(r.block).toBe(true); expect(r.message).toMatch(/No verification/); // 1A (2026-05-31): не рекламировать мёртвые override-фразы (findOverride — заглушка v4). expect(r.message).not.toMatch(/Override:/); expect(r.message).not.toMatch(/срочно|ремонт инфраструктуры/); }); it('does NOT emit override-missing-justification diagnostic for overrides without requires_justification', () => { // "срочно" doesn't need justification — if it matched, override would've been set. // overrideAttempt without requires_justification means something else (logic bug), // fall through to normal sentinel checks. const r = decide({ toolName: 'Bash', command: 'git commit -m "x"', sentinel: null, override: null, overrideAttempt: { phrase: 'срочно', suppresses: ['verify-before-push'], // no requires_justification }, }); 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); }); });