397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
246 lines
9.5 KiB
JavaScript
246 lines
9.5 KiB
JavaScript
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 "ремонт: <reason>" line. Old behaviour: generic "No verification artifact".
|
|
// New behaviour: explicit "phrase found but missing 'ремонт: <reason>' 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);
|
|
});
|
|
});
|