397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
9.8 KiB
JavaScript
230 lines
9.8 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { findRationalizationPhrases, detectProdEditWithoutTest, audit, decide, stripQuotedContext } from './enforce-rationalization-audit.mjs';
|
||
|
||
describe('stripQuotedContext (false-positive guard for quoted citations)', () => {
|
||
it('removes inline-code backtick content', () => {
|
||
const result = stripQuotedContext('use `временно` keyword here');
|
||
expect(result.toLowerCase()).not.toContain('временно');
|
||
expect(result).toContain('use');
|
||
expect(result).toContain('keyword here');
|
||
});
|
||
|
||
it('removes fenced code block content (multi-line)', () => {
|
||
const result = stripQuotedContext('text before\n```\nblock with временно inside\n```\ntext after');
|
||
expect(result.toLowerCase()).not.toContain('временно');
|
||
expect(result).toContain('text before');
|
||
expect(result).toContain('text after');
|
||
});
|
||
|
||
it('removes Russian guillemet content', () => {
|
||
const result = stripQuotedContext('запрещаем «временно» в опциях');
|
||
expect(result.toLowerCase()).not.toContain('временно');
|
||
expect(result).toContain('запрещаем');
|
||
expect(result).toContain('в опциях');
|
||
});
|
||
|
||
it('removes straight double-quoted strings', () => {
|
||
const result = stripQuotedContext('phrase: "временно" detected');
|
||
expect(result.toLowerCase()).not.toContain('временно');
|
||
expect(result).toContain('phrase');
|
||
expect(result).toContain('detected');
|
||
});
|
||
|
||
it('preserves plain rationalization text outside quotes', () => {
|
||
const result = stripQuotedContext('временно сделаю фикс');
|
||
expect(result.toLowerCase()).toContain('временно');
|
||
});
|
||
|
||
it('handles mixed quoted + plain — strips quoted only', () => {
|
||
const result = stripQuotedContext('я скажу «временно» — но реально временно использую');
|
||
// first «временно» stripped; second plain remains
|
||
const lo = result.toLowerCase();
|
||
const matches = (lo.match(/временно/g) || []).length;
|
||
expect(matches).toBe(1);
|
||
});
|
||
|
||
it('returns empty string for non-string input', () => {
|
||
expect(stripQuotedContext(null)).toBe('');
|
||
expect(stripQuotedContext(undefined)).toBe('');
|
||
expect(stripQuotedContext(42)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('findRationalizationPhrases — does NOT flag quoted citations', () => {
|
||
it('skips inline-code citation', () => {
|
||
expect(findRationalizationPhrases('hook detects `временно` pattern')).toEqual([]);
|
||
});
|
||
|
||
it('skips guillemet citation', () => {
|
||
expect(findRationalizationPhrases('block options containing «временно» keyword')).toEqual([]);
|
||
});
|
||
|
||
it('skips fenced code block citation', () => {
|
||
expect(findRationalizationPhrases('see code:\n```\nphrase: временно\n```\nend')).toEqual([]);
|
||
});
|
||
|
||
it('skips straight-quote citation', () => {
|
||
expect(findRationalizationPhrases('match phrase: "временно" — flagged earlier')).toEqual([]);
|
||
});
|
||
|
||
it('STILL flags real rationalization outside quotes', () => {
|
||
expect(findRationalizationPhrases('я временно пропущу тест')).toContain('временно');
|
||
});
|
||
|
||
it('mixed: flags plain occurrence, ignores quoted occurrence', () => {
|
||
const hits = findRationalizationPhrases('сказал «временно» — реально временно сделал');
|
||
expect(hits).toContain('временно');
|
||
expect(hits.length).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('findRationalizationPhrases', () => {
|
||
it('detects "just this once" in mixed case', () => {
|
||
expect(findRationalizationPhrases('Hmm, Just This Once we will skip')).toContain('just this once');
|
||
});
|
||
it('detects "пока без" Russian', () => {
|
||
expect(findRationalizationPhrases('сделаем пока без тестов')).toContain('пока без');
|
||
});
|
||
it('detects multiple phrases in one text', () => {
|
||
const hits = findRationalizationPhrases('временно делаем потом разберусь');
|
||
expect(hits.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
it('returns empty array on clean text', () => {
|
||
expect(findRationalizationPhrases('coverage: skill:tdd')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('detectProdEditWithoutTest', () => {
|
||
it('flags prod edit without any test edit in turn', () => {
|
||
const uses = [{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } }];
|
||
expect(detectProdEditWithoutTest(uses)).toEqual(['tools/foo.mjs']);
|
||
});
|
||
it('does NOT flag when test also edited', () => {
|
||
const uses = [
|
||
{ name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||
{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||
];
|
||
expect(detectProdEditWithoutTest(uses)).toEqual([]);
|
||
});
|
||
it('does NOT flag for non-prod paths', () => {
|
||
expect(detectProdEditWithoutTest([{ name: 'Edit', input: { file_path: 'docs/x.md' } }])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('audit', () => {
|
||
it('flags rationalization phrases in assistant text', () => {
|
||
const entries = [
|
||
{ message: { role: 'user', content: 'go' } },
|
||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just this once без скила' }] } },
|
||
];
|
||
const flags = audit(entries);
|
||
expect(flags.find((f) => f.kind === 'rationalization-phrase')).toBeTruthy();
|
||
});
|
||
|
||
it('flags prod-edit-without-test', () => {
|
||
const entries = [
|
||
{ message: { role: 'user', content: 'go' } },
|
||
{ message: { role: 'assistant', content: [
|
||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||
] } },
|
||
];
|
||
const flags = audit(entries);
|
||
expect(flags.find((f) => f.kind === 'prod-edit-without-test')).toBeTruthy();
|
||
});
|
||
|
||
it('flags weak commit messages (<12 chars)', () => {
|
||
const entries = [
|
||
{ message: { role: 'user', content: 'go' } },
|
||
{ message: { role: 'assistant', content: [
|
||
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -m "fix"' } },
|
||
] } },
|
||
];
|
||
const flags = audit(entries);
|
||
expect(flags.find((f) => f.kind === 'weak-commit-message')).toBeTruthy();
|
||
});
|
||
|
||
it('returns no flags for clean turn', () => {
|
||
const entries = [
|
||
{ message: { role: 'user', content: 'go' } },
|
||
{ message: { role: 'assistant', content: [
|
||
{ type: 'text', text: 'coverage: skill:tdd\nworking properly' },
|
||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||
] } },
|
||
];
|
||
expect(audit(entries)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('vocab — new phrases', () => {
|
||
it('detects "давай разок"', () => {
|
||
expect(findRationalizationPhrases('давай разок без тестов')).toContain('давай разок');
|
||
});
|
||
it('detects "только сейчас"', () => {
|
||
expect(findRationalizationPhrases('только сейчас пропустим')).toContain('только сейчас');
|
||
});
|
||
it('detects "один раз без правил"', () => {
|
||
expect(findRationalizationPhrases('один раз без правил сделаем')).toContain('один раз без правил');
|
||
});
|
||
it('detects "на этот раз без"', () => {
|
||
expect(findRationalizationPhrases('на этот раз без скила')).toContain('на этот раз без');
|
||
});
|
||
it('detects "я знаю что не надо но"', () => {
|
||
expect(findRationalizationPhrases('я знаю что не надо но пропустим')).toContain('я знаю что не надо но');
|
||
});
|
||
});
|
||
|
||
describe('decide — escalation on 3rd flag', () => {
|
||
const sessionId = 'test-session';
|
||
const textWithPhrase = 'just this once';
|
||
|
||
it('does NOT block when priorFlagCount=0', () => {
|
||
const result = decide({ assistantText: textWithPhrase, sessionId, priorFlagCount: 0 });
|
||
expect(result.block).toBe(false);
|
||
expect(result.detected.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('does NOT block when priorFlagCount=1', () => {
|
||
const result = decide({ assistantText: textWithPhrase, sessionId, priorFlagCount: 1 });
|
||
expect(result.block).toBe(false);
|
||
});
|
||
|
||
it('blocks when priorFlagCount=2 (3rd occurrence)', () => {
|
||
const result = decide({ assistantText: textWithPhrase, sessionId, priorFlagCount: 2 });
|
||
expect(result.block).toBe(true);
|
||
expect(result.message).toMatch(/rationali/i);
|
||
});
|
||
|
||
it('blocks when priorFlagCount=5 (subsequent occurrences)', () => {
|
||
const result = decide({ assistantText: textWithPhrase, sessionId, priorFlagCount: 5 });
|
||
expect(result.block).toBe(true);
|
||
});
|
||
|
||
it('does NOT block clean text even with priorFlagCount=10', () => {
|
||
const result = decide({ assistantText: 'coverage: skill:tdd', sessionId, priorFlagCount: 10 });
|
||
expect(result.block).toBe(false);
|
||
expect(result.detected).toEqual([]);
|
||
});
|
||
|
||
it('override=true suppresses block even on 3rd flag', () => {
|
||
const result = decide({ assistantText: textWithPhrase, sessionId, override: true, priorFlagCount: 2 });
|
||
expect(result.block).toBe(false);
|
||
});
|
||
});
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
describe('enforce-rationalization-audit — fail-CLOSE (М7 Фаза 4b, §2.1 Класс 2)', () => {
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const src = readFileSync(join(here, 'enforce-rationalization-audit.mjs'), 'utf8');
|
||
it('использует exitDisciplineDecision (fail-CLOSE wrapper Фазы 0)', () => {
|
||
expect(src.includes('exitDisciplineDecision')).toBe(true);
|
||
});
|
||
it('НЕ содержит fail-open catch → block:false (анти-SE2)', () => {
|
||
const failOpen = /catch\s*(?:\([^)]*\))?\s*\{[^}]*exitDecision\(\{\s*block:\s*false/s.test(src);
|
||
expect(failOpen).toBe(false);
|
||
});
|
||
});
|