Files
portal/tools/enforce-hook-helpers.test.mjs
T
Дмитрий b93e5af439 chore(brain-retro): export CHAIN_OUTCOME_BUCKETS + clean up redundant fs import (Phase 4 #2 review fixes)
Code-quality review of Task B (Phase 4) flagged two minor fixes:
- Export CHAIN_OUTCOME_BUCKETS for external consumers (test + future cuts)
  no longer hard-code bucket names.
- Replace fs.readFileSync via duplicate `import fs from 'fs'` with the
  already-imported named `readFileSync` in helpers test.

+1 regression test on the export.
2026-05-28 15:48:42 +03:00

479 lines
20 KiB
JavaScript

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();
});
});