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, } from './enforce-hook-helpers.mjs'; 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'); }); }); describe('loadOverrideVocab / findOverride', () => { let tmp; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'vocab-')); _resetVocabCache(); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); _resetVocabCache(); }); it('loads vocab from explicit path', () => { const p = join(tmp, 'vocab.json'); writeFileSync(p, JSON.stringify({ phrases: [ { phrase: 'без скилов', suppresses: ['skill-required'] }, ], })); const v = loadOverrideVocab(p); expect(v.phrases).toHaveLength(1); }); it('findOverride matches case-insensitively', () => { const v = { phrases: [{ phrase: 'СРОЧНО', suppresses: ['verify-before-push'] }] }; expect(findOverride('очень срочно нужно', 'verify-before-push', v)).toMatchObject({ phrase: 'СРОЧНО' }); expect(findOverride('hello world', 'verify-before-push', v)).toBeNull(); }); it('findOverride returns null if rule key not in suppresses', () => { const v = { phrases: [{ phrase: 'без скилов', suppresses: ['skill-required'] }] }; expect(findOverride('без скилов давай', 'tdd-gate', v)).toBeNull(); expect(findOverride('без скилов давай', 'skill-required', v)).not.toBeNull(); }); it('findOverride returns null on empty prompt / vocab', () => { expect(findOverride('', 'x', { phrases: [] })).toBeNull(); expect(findOverride(null, 'x', { phrases: [{ phrase: 'a', suppresses: ['x'] }] })).toBeNull(); }); it('loads default vocab file when no path given (smoke)', () => { _resetVocabCache(); const v = loadOverrideVocab(); expect(Array.isArray(v.phrases)).toBe(true); expect(v.phrases.length).toBeGreaterThan(0); }); }); describe('findOverride — requires_justification (hole 7)', () => { const testVocab = { phrases: [ { phrase: 'ремонт инфраструктуры', suppresses: ['classifier-mismatch'], requires_justification: 'ремонт:', description: 'master kill — requires justification', }, ], }; it('rejects when phrase present but justification line missing (hole 7)', () => { const r = findOverride('ремонт инфраструктуры', 'classifier-mismatch', testVocab); expect(r).toBeNull(); }); it('accepts when justification line provides target', () => { const r = findOverride('ремонт инфраструктуры\nремонт: enforce-hook-helpers.mjs', 'classifier-mismatch', testVocab); expect(r).not.toBeNull(); expect(r.phrase).toBe('ремонт инфраструктуры'); }); it('rejects when justification line empty after the prefix', () => { const r = findOverride('ремонт инфраструктуры\nремонт: ', 'classifier-mismatch', testVocab); expect(r).toBeNull(); }); }); describe('findOverrideAttempt — diagnostic helper (silent-reject bug fix)', () => { const testVocab = { phrases: [ { phrase: 'ремонт инфраструктуры', suppresses: ['verify-before-push', 'classifier-mismatch'], requires_justification: 'ремонт:', description: 'master kill — requires justification', }, { phrase: 'срочно', suppresses: ['verify-before-push'], description: 'no justification required', }, ], }; it('returns phrase even when justification line missing (so caller can emit helpful diagnostic)', () => { const r = findOverrideAttempt('ремонт инфраструктуры', 'verify-before-push', testVocab); expect(r).not.toBeNull(); expect(r.phrase).toBe('ремонт инфраструктуры'); expect(r.requires_justification).toBe('ремонт:'); }); it('returns phrase when justification IS provided (same behaviour as findOverride for success path)', () => { const r = findOverrideAttempt('ремонт инфраструктуры\nремонт: observer refresh', 'verify-before-push', testVocab); expect(r).not.toBeNull(); expect(r.phrase).toBe('ремонт инфраструктуры'); }); it('returns phrase for non-justification overrides (e.g., срочно)', () => { const r = findOverrideAttempt('срочно надо', 'verify-before-push', testVocab); expect(r).not.toBeNull(); expect(r.phrase).toBe('срочно'); }); it('returns null when phrase substring not in prompt', () => { expect(findOverrideAttempt('hello world', 'verify-before-push', testVocab)).toBeNull(); }); it('returns null when rule key not in suppresses (phrase irrelevant)', () => { const r = findOverrideAttempt('ремонт инфраструктуры', 'tdd-gate-other', testVocab); expect(r).toBeNull(); }); it('returns null on empty / null prompt', () => { 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(); }); });