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; import { standbyActive, removeSentinel, writeSentinel } from './enforce-hook-helpers.mjs'; describe('standbyActive / removeSentinel (штатный режим)', () => { const S = 'test-standby-sess-1'; afterEach(() => removeSentinel('standby-mode', S)); it('нет файла → false', () => { removeSentinel('standby-mode', S); expect(standbyActive(S)).toBe(false); }); it('active:true → true', () => { writeSentinel('standby-mode', S, { active: true }); expect(standbyActive(S)).toBe(true); }); it('active:false → false', () => { writeSentinel('standby-mode', S, { active: false }); expect(standbyActive(S)).toBe(false); }); it('removeSentinel снимает флаг', () => { writeSentinel('standby-mode', S, { active: true }); removeSentinel('standby-mode', S); expect(standbyActive(S)).toBe(false); }); }); 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) // * 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([]); }); });