397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
630 lines
28 KiB
JavaScript
630 lines
28 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,
|
||
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([]);
|
||
});
|
||
});
|