c7e02eeac9
router-prehook, router-stop-gate, router-tool-gate теперь читают stdin через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно доходит до Anthropic API и в state-файл — никаких mojibake типа 'посмотри'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 lines
3.8 KiB
JavaScript
107 lines
3.8 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
shouldBlock,
|
|
decodeRoutingTag,
|
|
isReadOnlyBash,
|
|
decideDecision,
|
|
} from './router-tool-gate.mjs';
|
|
|
|
const enforcementState = {
|
|
enforcementRequired: true,
|
|
skillInvokedThisTurn: false,
|
|
classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' },
|
|
chainProgress: [],
|
|
};
|
|
|
|
describe('isReadOnlyBash', () => {
|
|
it('detects ls / cat / grep / git status as read-only', () => {
|
|
expect(isReadOnlyBash('ls -la')).toBe(true);
|
|
expect(isReadOnlyBash('cat file.txt')).toBe(true);
|
|
expect(isReadOnlyBash('grep "x" file')).toBe(true);
|
|
expect(isReadOnlyBash('git status')).toBe(true);
|
|
expect(isReadOnlyBash('git log')).toBe(true);
|
|
expect(isReadOnlyBash('git rev-parse HEAD')).toBe(true);
|
|
});
|
|
|
|
it('does not classify git commit / push as read-only', () => {
|
|
expect(isReadOnlyBash('git commit -m "x"')).toBe(false);
|
|
expect(isReadOnlyBash('git push origin main')).toBe(false);
|
|
});
|
|
|
|
it('does not classify rm / cp / mv as read-only', () => {
|
|
expect(isReadOnlyBash('rm file')).toBe(false);
|
|
expect(isReadOnlyBash('cp a b')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('decodeRoutingTag', () => {
|
|
it('parses direct_justified=true with reason', () => {
|
|
const r = decodeRoutingTag('<!-- routing: direct_justified=true reason="micro fix per user override" -->');
|
|
expect(r.directJustified).toBe(true);
|
|
expect(r.reason).toContain('micro fix');
|
|
});
|
|
|
|
it('returns null on missing tag', () => {
|
|
expect(decodeRoutingTag('just a regular response')).toBeNull();
|
|
});
|
|
|
|
it('rejects direct_justified=true WITHOUT reason', () => {
|
|
const r = decodeRoutingTag('<!-- routing: direct_justified=true -->');
|
|
expect(r).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('shouldBlock', () => {
|
|
it('blocks Edit on enforcement state without skill invoked', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true);
|
|
});
|
|
|
|
it('does NOT block when skill invoked this turn', () => {
|
|
const state = { ...enforcementState, skillInvokedThisTurn: true };
|
|
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block when enforcement not required', () => {
|
|
const state = { ...enforcementState, enforcementRequired: false };
|
|
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block when routing-tag has direct_justified=true with reason', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '<!-- routing: direct_justified=true reason="testing" -->', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block read-only Bash', () => {
|
|
expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false);
|
|
});
|
|
|
|
it('warn-only mode never blocks (always returns false)', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('decideDecision', () => {
|
|
it('returns decision: block with message when shouldBlock=true', () => {
|
|
const r = decideDecision('Edit', enforcementState, '', { warnOnly: false });
|
|
expect(r.decision).toBe('block');
|
|
expect(r.reason).toMatch(/#19/);
|
|
});
|
|
|
|
it('returns empty (proceed) when shouldBlock=false', () => {
|
|
const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false });
|
|
expect(r.decision).toBeUndefined();
|
|
});
|
|
|
|
it('warn-only mode logs to stderr but does not block', () => {
|
|
const r = decideDecision('Edit', enforcementState, '', { warnOnly: true });
|
|
expect(r.decision).toBeUndefined();
|
|
expect(r.warning).toMatch(/#19/);
|
|
});
|
|
});
|
|
|
|
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
|
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
|
const mod = await import('./router-tool-gate.mjs');
|
|
expect(typeof mod.shouldBlock).toBe('function');
|
|
});
|
|
});
|