Files
brain/tools/enforce-hook-helpers.test.mjs
T

630 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
parseEventJson,
parseCoverageLine,
lastTurnEntries,
lastUserPromptText,
lastAssistantText,
turnToolUses,
turnToolResults,
loadOverrideVocab,
_resetVocabCache,
findOverride,
findOverrideAttempt,
isProductionCodePath,
isMemoryPath,
isDocsOnlyPath,
isDocsOnlyChange,
detectGitCommandKind,
detectFullTestRun,
sessionToolUses,
logHookOutcome,
runtimeDir,
logSafeBaselineAction,
readSafeBaselineActions,
} from './enforce-hook-helpers.mjs';
import {
disciplineOutcome as _disc,
FAIL_CLOSE_DISCIPLINE_HOOKS as _failClose,
FAIL_QUIET_OBSERVATION_HOOKS as _failQuiet,
} from './enforce-hook-helpers.mjs';
const disciplineOutcome = _disc;
const FAIL_CLOSE_DISCIPLINE_HOOKS = _failClose;
const FAIL_QUIET_OBSERVATION_HOOKS = _failQuiet;
describe('disciplineOutcome — fail-CLOSE (M7 Фаза 0, правило 1)', () => {
it('decideFn бросает → block:true (fail-CLOSE, не тихий пропуск)', async () => {
const r = await disciplineOutcome(() => { throw new Error('boom'); }, { label: 'floor' });
expect(r.block).toBe(true);
expect(r.message).toMatch(/fail-CLOSE/);
});
it('decideFn → {block:false} → пропуск', async () => {
const r = await disciplineOutcome(() => ({ block: false }));
expect(r.block).toBe(false);
});
it('async decideFn → {block:true,message} → блок с сообщением', async () => {
const r = await disciplineOutcome(async () => ({ block: true, message: 'нельзя' }));
expect(r.block).toBe(true);
expect(r.message).toBe('нельзя');
});
it('некорректный исход (undefined / нет поля block / не объект) → fail-CLOSE block:true', async () => {
for (const bad of [undefined, null, {}, 'строка', 42]) {
const r = await disciplineOutcome(() => bad);
expect(r.block).toBe(true);
}
});
});
describe('FAIL_CLOSE / FAIL_QUIET списки (P-7)', () => {
it('оба непусты', () => {
expect(FAIL_CLOSE_DISCIPLINE_HOOKS.length).toBeGreaterThan(0);
expect(FAIL_QUIET_OBSERVATION_HOOKS.length).toBeGreaterThan(0);
});
it('дисциплина и наблюдение не пересекаются', () => {
const q = new Set(FAIL_QUIET_OBSERVATION_HOOKS);
for (const h of FAIL_CLOSE_DISCIPLINE_HOOKS) expect(q.has(h)).toBe(false);
});
it('ядро защиты числится в fail-CLOSE', () => {
for (const h of ['enforce-floor', 'enforce-supreme-gate', 'enforce-snapshot'])
expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(h);
});
it('skill-журналер (Фаза 3, SE-K) числится в fail-CLOSE — манифест Фазы 6', () => {
expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain('enforce-skill-journaler');
});
});
// Фаза 4a поглощённая дисциплина (§4.2): coverage/todowrite журнал-факт fail-CLOSE.
// Символ FAIL_CLOSE_DISCIPLINE_HOOKS — из ./enforce-hook-helpers.mjs (см. import выше).
describe('FAIL_CLOSE_DISCIPLINE_HOOKS — Фаза 4a поглощённая дисциплина', () => {
for (const name of ['enforce-coverage-verify', 'enforce-todowrite-skill-verifier']) {
it(`includes ${name} (журнал-факт fail-CLOSE, манифест Фазы 6)`, () => {
expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(name);
});
}
});
// Фаза 4b поглощённая Stop-дисциплина (§4.2): rationalization + self-debrief fail-CLOSE.
// Символ FAIL_CLOSE_DISCIPLINE_HOOKS — из ./enforce-hook-helpers.mjs (см. import выше).
describe('FAIL_CLOSE_DISCIPLINE_HOOKS — Фаза 4b Stop-дисциплина', () => {
for (const name of ['enforce-rationalization-audit', 'enforce-self-debrief-detector']) {
it(`includes ${name} (fail-CLOSE, манифест Фазы 6)`, () => {
expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(name);
});
}
});
describe('safe-baseline action log', () => {
it('appends and reads action records', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-'));
logSafeBaselineAction('s', { tool: 'Edit', action: 'hard_block' }, { baseDir: dir });
const recs = readSafeBaselineActions('s', { baseDir: dir });
expect(recs[0]).toMatchObject({ tool: 'Edit', action: 'hard_block' });
rmSync(dir, { recursive: true, force: true });
});
it('returns [] when no file', () => {
expect(readSafeBaselineActions('nope', { baseDir: tmpdir() })).toEqual([]);
});
});
// v4: override surface removed per spec §4.2 — stubs return null/empty
describe('v4 override stubs', () => {
it('loadOverrideVocab returns empty phrases array (stub)', () => {
_resetVocabCache();
expect(loadOverrideVocab()).toEqual({ phrases: [] });
});
it('findOverride always returns null (vocab removed in v4)', () => {
_resetVocabCache();
expect(findOverride('срочно: ремонт', 'verify-before-push')).toBe(null);
expect(findOverride('memory dump fix it now', 'memory-coverage')).toBe(null);
expect(findOverride('', 'anything')).toBe(null);
});
it('findOverrideAttempt always returns null (vocab removed in v4)', () => {
_resetVocabCache();
expect(findOverrideAttempt('срочно push it', 'verify-before-push')).toBe(null);
expect(findOverrideAttempt('', 'anything')).toBe(null);
});
});
describe('logHookOutcome', () => {
const ledgerPath = () => join(runtimeDir(), 'hook-outcomes.jsonl');
beforeEach(() => {
try { fs.unlinkSync(ledgerPath()); } catch { /* may not exist */ }
});
it('appends a JSONL line with rule/outcome/session_id/ts', () => {
logHookOutcome('chain-recommendation', 'blocked', 'sess-abc');
const raw = readFileSync(ledgerPath(), 'utf-8');
const line = JSON.parse(raw.trim().split('\n').pop());
expect(line.rule).toBe('chain-recommendation');
expect(line.outcome).toBe('blocked');
expect(line.session_id).toBe('sess-abc');
expect(typeof line.ts).toBe('string');
expect(line.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('does not throw on null session_id', () => {
expect(() => logHookOutcome('rule', 'passed-skill', null)).not.toThrow();
});
it('appends, not overwrites', () => {
logHookOutcome('rule', 'blocked', 's1');
logHookOutcome('rule', 'passed-skill', 's1');
const lines = readFileSync(ledgerPath(), 'utf-8').trim().split('\n');
expect(lines.length).toBeGreaterThanOrEqual(2);
});
});
describe('sessionToolUses', () => {
it('returns ALL tool uses across the full session, not just last turn', () => {
const entries = [
// turn 1
{ type: 'user', message: { content: [{ type: 'text', text: 'first' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo a' } }] } },
// turn 2
{ type: 'user', message: { content: [{ type: 'text', text: 'second' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'composer sast' } }] } },
// turn 3 (current)
{ type: 'user', message: { content: [{ type: 'text', text: 'third' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } }] } },
];
const uses = sessionToolUses(entries);
expect(uses).toHaveLength(3);
expect(uses.map(u => u.input.command)).toEqual(['echo a', 'composer sast', 'git status']);
});
it('returns [] for empty entries', () => {
expect(sessionToolUses([])).toEqual([]);
});
it('skips non-tool_use blocks', () => {
const entries = [
{ type: 'assistant', message: { content: [
{ type: 'text', text: 'hi' },
{ type: 'tool_use', name: 'Bash', input: { command: 'pwd' } },
] } },
];
const uses = sessionToolUses(entries);
expect(uses).toHaveLength(1);
expect(uses[0].name).toBe('Bash');
});
});
describe('parseEventJson', () => {
it('parses well-formed JSON', () => {
expect(parseEventJson('{"a":1}')).toEqual({ a: 1 });
});
it('returns empty object on broken JSON', () => {
expect(parseEventJson('not-json')).toEqual({});
});
it('returns empty object on empty input', () => {
expect(parseEventJson('')).toEqual({});
expect(parseEventJson(null)).toEqual({});
});
});
describe('parseCoverageLine', () => {
it('extracts skill coverage', () => {
const t = 'экономия: 100%\n\ncoverage: skill:superpowers:test-driven-development\n\nок поехали';
expect(parseCoverageLine(t)).toEqual({ channel: 'skill', id: 'superpowers:test-driven-development' });
});
it('extracts direct coverage', () => {
expect(parseCoverageLine('coverage: direct:memory-sync')).toEqual({ channel: 'direct', id: 'memory-sync' });
});
it('extracts node coverage', () => {
expect(parseCoverageLine('coverage: node:#19')).toEqual({ channel: 'node', id: '#19' });
});
it('is case-insensitive on channel keyword', () => {
expect(parseCoverageLine('Coverage: Skill:foo')).toEqual({ channel: 'skill', id: 'foo' });
});
it('returns null when no coverage line present', () => {
expect(parseCoverageLine('just some text')).toBeNull();
});
it('returns null on non-string input', () => {
expect(parseCoverageLine(null)).toBeNull();
expect(parseCoverageLine(42)).toBeNull();
});
});
describe('lastTurnEntries / lastUserPromptText / lastAssistantText / turnToolUses', () => {
const entries = [
{ message: { role: 'user', content: 'old prompt' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'old reply' }] } },
{ message: { role: 'user', content: 'new prompt' } },
{ message: { role: 'assistant', content: [
{ type: 'text', text: 'I will edit' },
{ type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } },
] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'ok', is_error: false }] } },
];
it('lastTurnEntries starts from last real user prompt', () => {
const turn = lastTurnEntries(entries);
expect(turn).toHaveLength(3); // new prompt + assistant + tool_result
expect(turn[0].message.content).toBe('new prompt');
});
it('lastUserPromptText returns last user prompt string', () => {
expect(lastUserPromptText(entries)).toBe('new prompt');
});
it('lastAssistantText concatenates assistant text blocks of last turn only', () => {
expect(lastAssistantText(entries)).toContain('I will edit');
expect(lastAssistantText(entries)).not.toContain('old reply');
});
it('turnToolUses returns only tool_use blocks from last turn', () => {
const uses = turnToolUses(entries);
expect(uses).toHaveLength(1);
expect(uses[0].name).toBe('Edit');
expect(uses[0].input.file_path).toBe('a.mjs');
});
it('turnToolResults includes is_error flag and concatenated text', () => {
const results = turnToolResults(entries);
expect(results).toHaveLength(1);
expect(results[0].is_error).toBe(false);
expect(results[0].content).toBe('ok');
});
it('handles array text content in user message', () => {
const eps = [
{ message: { role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } },
];
expect(lastUserPromptText(eps)).toBe('hello\n world');
});
// ── Sibling-session find 2026-05-30 ──
// Skill bodies are harness-injected as role:'user' messages with isMeta:true
// AND a top-level sourceToolUseID linking them to the originating Skill tool_use.
// Without skipping them, lastTurnEntries treats the skill body as the turn
// boundary and detectLegitSkillActive (used by enforce-normative-content-rules)
// misses the Skill tool_use that lives in the assistant message BEFORE the body.
//
// The discriminator MUST be (isMeta === true && typeof sourceToolUseID === 'string')
// — NOT a blanket `skip isMeta`, because isMeta:true also appears on:
// * "Continue from where you left off." auto-resume (no sourceToolUseID)
// * Stop hook feedback strings (no sourceToolUseID)
// * <local-command-caveat> wrappers (no sourceToolUseID)
// Those are real user-equivalent boundaries and must remain visible.
it('lastTurnEntries skips skill body injections (isMeta + sourceToolUseID)', () => {
const eps = [
{ message: { role: 'user', content: 'real user prompt with coverage line' } },
{ message: { role: 'assistant', content: [
{ type: 'text', text: 'invoking skill' },
{ type: 'tool_use', name: 'Skill', input: { skill: 'claude-md-management:revise-claude-md' } },
] } },
// Harness injects skill body as if it were a user message:
{ isMeta: true, sourceToolUseID: 'toolu_skillcall_abc', message: { role: 'user', content: [{ type: 'text', text: 'Base directory for this skill: ...' }] } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'skill output' }] } },
];
const turn = lastTurnEntries(eps);
expect(turn).toHaveLength(4); // user prompt + assistant Skill + skill-body + assistant follow-up
expect(turn[0].message.content).toBe('real user prompt with coverage line');
});
it('lastTurnEntries does NOT skip "Continue from where you left off" (isMeta but no sourceToolUseID)', () => {
const eps = [
{ message: { role: 'user', content: 'older user prompt that should stay outside turn' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'older reply' }] } },
// Auto-resume injection — isMeta but NOT tool-spawned:
{ isMeta: true, message: { role: 'user', content: [{ type: 'text', text: 'Continue from where you left off.' }] } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'resumed reply' }] } },
];
const turn = lastTurnEntries(eps);
expect(turn).toHaveLength(2); // the Continue message + the resumed reply (NOT the older prompt)
const firstTextBlock = turn[0].message.content[0] || {};
expect(firstTextBlock.text).toBe('Continue from where you left off.');
});
it('turnToolUses includes Skill tool_use spawned in same turn as the injected skill body', () => {
const eps = [
{ message: { role: 'user', content: 'real user prompt' } },
{ message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Skill', input: { skill: 'claude-md-management:revise-claude-md' } },
] } },
{ isMeta: true, sourceToolUseID: 'toolu_skillcall_def', message: { role: 'user', content: [{ type: 'text', text: 'Base directory ...' }] } },
{ message: { role: 'assistant', content: [
{ type: 'text', text: 'about to edit memory' },
{ type: 'tool_use', name: 'Write', input: { file_path: 'memory/foo.md' } },
] } },
];
const uses = turnToolUses(eps);
const names = uses.map((u) => u.name);
expect(names).toContain('Skill');
expect(names).toContain('Write');
});
});
describe('loadOverrideVocab / findOverride (v4 stubs)', () => {
beforeEach(() => { _resetVocabCache(); });
afterEach(() => { _resetVocabCache(); });
it('loadOverrideVocab always returns empty phrases (stub ignores path arg)', () => {
const v = loadOverrideVocab('/any/path/vocab.json');
expect(v.phrases).toHaveLength(0);
});
it('findOverride always returns null regardless of vocab arg (stub)', () => {
const v = { phrases: [{ phrase: 'СРОЧНО', suppresses: ['verify-before-push'] }] };
expect(findOverride('очень срочно нужно', 'verify-before-push', v)).toBeNull();
expect(findOverride('hello world', 'verify-before-push', v)).toBeNull();
});
it('findOverride returns null regardless of rule key (stub)', () => {
const v = { phrases: [{ phrase: 'без скилов', suppresses: ['skill-required'] }] };
expect(findOverride('без скилов давай', 'tdd-gate', v)).toBeNull();
expect(findOverride('без скилов давай', 'skill-required', v)).toBeNull();
});
it('findOverride returns null on empty prompt / vocab (unchanged)', () => {
expect(findOverride('', 'x', { phrases: [] })).toBeNull();
expect(findOverride(null, 'x', { phrases: [{ phrase: 'a', suppresses: ['x'] }] })).toBeNull();
});
it('loadOverrideVocab default returns empty phrases (stub smoke)', () => {
_resetVocabCache();
const v = loadOverrideVocab();
expect(Array.isArray(v.phrases)).toBe(true);
expect(v.phrases.length).toBe(0);
});
});
describe('findOverride — requires_justification [v4: always null]', () => {
const testVocab = {
phrases: [{
phrase: 'ремонт инфраструктуры',
suppresses: ['classifier-mismatch'],
requires_justification: 'ремонт:',
description: 'master kill',
}],
};
it('stub: null even without justification (was null before too)', () => {
expect(findOverride('ремонт инфраструктуры', 'classifier-mismatch', testVocab)).toBeNull();
});
it('stub: null even with valid justification (vocab removed in v4)', () => {
expect(findOverride('ремонт инфраструктуры\nремонт: fix.mjs', 'classifier-mismatch', testVocab)).toBeNull();
});
it('stub: null when justification empty (same as before, now via stub)', () => {
expect(findOverride('ремонт инфраструктуры\nремонт: ', 'classifier-mismatch', testVocab)).toBeNull();
});
});
describe('findOverrideAttempt [v4: always null]', () => {
const testVocab = {
phrases: [
{ phrase: 'ремонт инфраструктуры', suppresses: ['verify-before-push', 'classifier-mismatch'], requires_justification: 'ремонт:', description: 'master kill' },
{ phrase: 'срочно', suppresses: ['verify-before-push'], description: 'no justification required' },
],
};
it('stub: null even when justification line missing (vocab removed in v4)', () => {
expect(findOverrideAttempt('ремонт инфраструктуры', 'verify-before-push', testVocab)).toBeNull();
});
it('stub: null even when justification IS provided (vocab removed in v4)', () => {
expect(findOverrideAttempt('ремонт инфраструктуры\nремонт: observer refresh', 'verify-before-push', testVocab)).toBeNull();
});
it('stub: null for срочно override (vocab removed in v4)', () => {
expect(findOverrideAttempt('срочно надо', 'verify-before-push', testVocab)).toBeNull();
});
it('returns null when phrase substring not in prompt (still null via stub)', () => {
expect(findOverrideAttempt('hello world', 'verify-before-push', testVocab)).toBeNull();
});
it('returns null when rule key not in suppresses (still null via stub)', () => {
expect(findOverrideAttempt('ремонт инфраструктуры', 'tdd-gate-other', testVocab)).toBeNull();
});
it('returns null on empty / null prompt (unchanged)', () => {
expect(findOverrideAttempt('', 'verify-before-push', testVocab)).toBeNull();
expect(findOverrideAttempt(null, 'verify-before-push', testVocab)).toBeNull();
});
});
describe('isProductionCodePath', () => {
it('classifies tools/*.mjs as production', () => {
expect(isProductionCodePath('tools/router-classifier.mjs')).toBe(true);
expect(isProductionCodePath('c:/моя/проекты/портал crm/Документация/tools/foo.mjs')).toBe(true);
});
it('excludes test files', () => {
expect(isProductionCodePath('tools/router-classifier.test.mjs')).toBe(false);
expect(isProductionCodePath('tools/foo.spec.mjs')).toBe(false);
});
it('classifies app/app/**.php as production', () => {
expect(isProductionCodePath('app/app/Http/Controllers/X.php')).toBe(true);
});
it('excludes app/tests/**', () => {
expect(isProductionCodePath('app/tests/Feature/X.php')).toBe(false);
});
it('classifies resources/js/**.vue|ts|tsx|js as production', () => {
expect(isProductionCodePath('resources/js/views/Dashboard.vue')).toBe(true);
expect(isProductionCodePath('resources/js/api/admin.ts')).toBe(true);
});
it('excludes *.spec.ts/*.test.ts', () => {
expect(isProductionCodePath('resources/js/views/Dashboard.spec.ts')).toBe(false);
expect(isProductionCodePath('resources/js/views/Dashboard.test.ts')).toBe(false);
});
it('returns false for non-production paths', () => {
expect(isProductionCodePath('docs/x.md')).toBe(false);
expect(isProductionCodePath('CLAUDE.md')).toBe(false);
expect(isProductionCodePath('package.json')).toBe(false);
});
});
describe('isMemoryPath', () => {
it('matches user-memory store .md files', () => {
expect(isMemoryPath('C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference.md')).toBe(true);
expect(isMemoryPath('/Users/x/.claude/projects/proj/memory/foo.md')).toBe(true);
});
it('matches MEMORY.md regardless of folder', () => {
expect(isMemoryPath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\MEMORY.md')).toBe(true);
expect(isMemoryPath('/foo/MEMORY.md')).toBe(true);
});
it('returns false for normal docs', () => {
expect(isMemoryPath('docs/x.md')).toBe(false);
expect(isMemoryPath('CLAUDE.md')).toBe(false);
});
});
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');
expect(detectGitCommandKind('LEFTHOOK=0 git push')).toBe('push');
});
it('detects commit', () => {
expect(detectGitCommandKind('git commit -m "x"')).toBe('commit');
});
it('detects cherry-pick', () => {
expect(detectGitCommandKind('git cherry-pick abc123')).toBe('cherry-pick');
});
it('detects branch -f', () => {
expect(detectGitCommandKind('git branch -f main HEAD')).toBe('branch-force');
expect(detectGitCommandKind('git branch -d feature')).toBe('branch-force');
});
it('detects rebase', () => {
expect(detectGitCommandKind('git rebase main')).toBe('rebase');
});
it('returns null for non-git commands', () => {
expect(detectGitCommandKind('ls -la')).toBeNull();
expect(detectGitCommandKind('git status')).toBeNull();
});
});
describe('detectFullTestRun', () => {
it('detects vitest run as full when no specific path', () => {
expect(detectFullTestRun('npx vitest run')).toBe('vitest-full');
expect(detectFullTestRun('npx vitest run --reporter=basic')).toBe('vitest-full');
});
it('returns null for narrow vitest with specific test path', () => {
expect(detectFullTestRun('npx vitest run tools/foo.test.mjs')).toBeNull();
});
it('detects pest / composer test', () => {
expect(detectFullTestRun('php artisan test')).toBe('pest');
expect(detectFullTestRun('composer test')).toBe('pest');
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
});
it('returns null for non-test commands', () => {
expect(detectFullTestRun('git status')).toBeNull();
});
it('returns null when "vitest run" appears INSIDE a git commit message (false-positive guard)', () => {
// Real bug we hit during bootstrap: commit message saying "full vitest run
// (8092/8092)" caused detectFullTestRun to match and overwrite sentinel.
expect(detectFullTestRun('git commit -m "feat: full vitest run all green"')).toBeNull();
expect(detectFullTestRun('LEFTHOOK=0 git commit -m "ran pest"')).toBeNull();
expect(detectFullTestRun('echo "pest passed" && ls')).toBeNull();
expect(detectFullTestRun('cat sentinel | grep vitest')).toBeNull();
});
it('still detects vitest in compound command starting with cd or having cat/echo segments', () => {
// Second bug: overly aggressive guard blocked legitimate vitest run that
// appeared in a compound command with cd / cat / echo somewhere.
// We want: ANY segment starting with `npx vitest run` (or pest) counts.
expect(detectFullTestRun('cd /path && npx vitest run tools/ 2>&1 | tail -5')).toBe('vitest-full');
expect(detectFullTestRun('LEFTHOOK=0 npx vitest run')).toBe('vitest-full');
expect(detectFullTestRun('npx vitest run && echo done')).toBe('vitest-full');
expect(detectFullTestRun('cd app && composer test')).toBe('pest');
expect(detectFullTestRun('cd app && php artisan test')).toBe('pest');
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
});
it('returns null when git commit message itself contains a compound that looks like test run (third false-positive)', () => {
// Third bug: split-by-&& naively splits inside quoted commit messages.
// A commit message like `git commit -m "... npx vitest run ..."` would
// produce a segment `npx vitest run` from inside the quoted string.
// Fix: identify FIRST real command (after cd/env), if it's git/etc → null.
expect(detectFullTestRun('git commit -m "fix: command like cd ... && npx vitest run"')).toBeNull();
expect(detectFullTestRun('cd /path && git commit -m "and then npx vitest run && echo done"')).toBeNull();
expect(detectFullTestRun('git push origin main')).toBeNull();
expect(detectFullTestRun('cd app && cp src dst')).toBeNull();
});
});
// judge verdict log — added for LLM-judge brain-retro factoring
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join as joinPath } from 'node:path';
import { logJudgeVerdict, readJudgeVerdicts } from './enforce-hook-helpers.mjs';
describe('judge verdict log', () => {
it('appends and reads back verdict records (RED: logJudgeVerdict not yet exported)', () => {
const dir = mkdtempSync(joinPath(tmpdir(), 'jv-'));
logJudgeVerdict('sess1', { tool: 'Edit', verdict: 'YES' }, { baseDir: dir });
logJudgeVerdict('sess1', { tool: 'Bash', verdict: 'NO' }, { baseDir: dir });
const recs = readJudgeVerdicts('sess1', { baseDir: dir });
expect(recs.length).toBe(2);
expect(recs[0]).toMatchObject({ tool: 'Edit', verdict: 'YES' });
expect(typeof recs[0].ts).toBe('string');
rmSync(dir, { recursive: true, force: true });
});
it('returns [] when no file', () => {
expect(readJudgeVerdicts('nope', { baseDir: tmpdir() })).toEqual([]);
});
});