c14fb72e84
Closes Stream H Task 6 (H4). Retires the manual approval-write workaround
the controller used throughout Stream H Tasks 1-5.
Two changes:
1. Pure module tools/askuser-answer-parser.mjs gains toApprovalRecord(answer, opts)
exporter that detects a git verb in the user's free-form answer and returns
a Stream B-compatible {type:'approve_git_operation', command, ts} record
(matches loadApprovedGitOps reader format in shell-content-rules.mjs:125).
Returns null for non-git answers and for stop/abort/cancel keywords.
2. New PostToolUse(AskUserQuestion) wrapper tools/enforce-askuser-answer-parser.mjs
reads each question/answer pair, calls toApprovalRecord, appends matching
records to ~/.claude/runtime/askuser-decisions-<sess>.jsonl. Fail-open
observability — never blocks AskUserQuestion.
Regression: vitest tools 1742/1742 GREEN (was 1731; +5 toApprovalRecord tests
under "toApprovalRecord (Stream H Task 6 — schema sync)" including non-string
guard, +6 wrapper-hook tests under "enforce-askuser-answer-parser wrapper
(Stream H Task 6)" including missing session_id fail-open guard).
DEFERRED: settings.json registration (matcher "AskUserQuestion", PostToolUse,
fail-open, timeout 2000ms) — batched with H5/H6/H7/H8 hook activations at end
of Phase H-α/H-β. Hook code is fully implemented and unit-tested; activation
pending settings.json update.
Stream H Task 6 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
265 lines
9.3 KiB
JavaScript
265 lines
9.3 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
stripInvisible,
|
||
normalizeAnswer,
|
||
normalizeCommand,
|
||
STOP_KEYWORDS,
|
||
isStopAnswer,
|
||
detectStopWithFallback,
|
||
parseAskUserResult,
|
||
matchesApproval,
|
||
detectOtherSocialEng,
|
||
buildApprovalRecord,
|
||
toApprovalRecord,
|
||
} from './askuser-answer-parser.mjs';
|
||
|
||
describe('askuser-answer-parser / stripInvisible (E33)', () => {
|
||
it('strips ZWSP inside a word', () => {
|
||
// "вы<ZWSP>полнение" → "выполнение"
|
||
expect(stripInvisible('выполнение')).toBe('выполнение');
|
||
});
|
||
|
||
it('strips ZWNJ, ZWJ, RTL override, BOM, soft hyphen', () => {
|
||
expect(stripInvisible('abcd')).toBe('abcd');
|
||
});
|
||
|
||
it('leaves normal text untouched', () => {
|
||
expect(stripInvisible('обычный текст')).toBe('обычный текст');
|
||
});
|
||
|
||
it('handles non-string by returning empty string', () => {
|
||
expect(stripInvisible(null)).toBe('');
|
||
expect(stripInvisible(undefined)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / normalizeAnswer', () => {
|
||
it('lowercases, strips invisible, collapses whitespace, trims', () => {
|
||
expect(normalizeAnswer(' СТОП сейчас ')).toBe('стоп сейчас');
|
||
});
|
||
|
||
it('returns empty string for non-string', () => {
|
||
expect(normalizeAnswer(42)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / normalizeCommand (E34)', () => {
|
||
it('collapses internal whitespace runs to single space', () => {
|
||
expect(normalizeCommand('git rebase main')).toBe('git rebase main');
|
||
});
|
||
|
||
it('trims leading/trailing whitespace, keeps case', () => {
|
||
expect(normalizeCommand(' git Rebase main ')).toBe('git Rebase main');
|
||
});
|
||
|
||
it('returns empty string for non-string', () => {
|
||
expect(normalizeCommand(null)).toBe('');
|
||
});
|
||
});
|
||
|
||
|
||
describe('askuser-answer-parser / STOP_KEYWORDS (S27)', () => {
|
||
it('includes core Russian + English stop tokens', () => {
|
||
for (const kw of ['стоп', 'отмена', 'хватит', 'не надо', 'cancel', 'abort', 'stop', 'halt', 'quit']) {
|
||
expect(STOP_KEYWORDS).toContain(kw);
|
||
}
|
||
});
|
||
|
||
it('has at least 40 entries (S27 +25 variants)', () => {
|
||
expect(STOP_KEYWORDS.length).toBeGreaterThanOrEqual(40);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / isStopAnswer', () => {
|
||
it('matches exact single-word stop', () => {
|
||
expect(isStopAnswer('стоп')).toBe(true);
|
||
expect(isStopAnswer('Отмена')).toBe(true);
|
||
});
|
||
|
||
it('matches stop word surrounded by other tokens', () => {
|
||
expect(isStopAnswer('нет, стоп пожалуйста')).toBe(true);
|
||
});
|
||
|
||
it('matches multi-word stop phrase', () => {
|
||
expect(isStopAnswer('на этом всё')).toBe(true);
|
||
expect(isStopAnswer('всё, поехали назад')).toBe(true);
|
||
});
|
||
|
||
it('matches even with invisible Unicode injected', () => {
|
||
expect(isStopAnswer('стоп')).toBe(true);
|
||
});
|
||
|
||
it('does not match a normal approval answer', () => {
|
||
expect(isStopAnswer('да, выполняй вариант A')).toBe(false);
|
||
});
|
||
|
||
it('does not false-match substring inside unrelated word', () => {
|
||
// "нетворкинг" contains "нет" as substring but not as token
|
||
expect(isStopAnswer('нетворкинг событие')).toBe(false);
|
||
});
|
||
|
||
it('returns false for non-string', () => {
|
||
expect(isStopAnswer(null)).toBe(false);
|
||
});
|
||
|
||
it('matches a stop token with a trailing comma', () => {
|
||
expect(isStopAnswer('нет, это лишнее')).toBe(true);
|
||
expect(isStopAnswer('стоп.')).toBe(true);
|
||
});
|
||
|
||
it('still matches multi-word phrase without the comma', () => {
|
||
expect(isStopAnswer('всё поехали назад')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / detectStopWithFallback', () => {
|
||
it('returns true on keyword match without calling LLM', async () => {
|
||
let called = false;
|
||
const judge = async () => { called = true; return true; };
|
||
const r = await detectStopWithFallback('отмена', { llmJudge: judge });
|
||
expect(r).toBe(true);
|
||
expect(called).toBe(false);
|
||
});
|
||
|
||
it('default stub returns false for ambiguous text', async () => {
|
||
const r = await detectStopWithFallback('может не сейчас');
|
||
expect(r).toBe(false);
|
||
});
|
||
|
||
it('uses injected llmJudge for ambiguous text', async () => {
|
||
const judge = async (text) => text.includes('не сейчас');
|
||
const r = await detectStopWithFallback('может не сейчас', { llmJudge: judge });
|
||
expect(r).toBe(true);
|
||
});
|
||
|
||
it('fails closed-safe (false) if llmJudge throws', async () => {
|
||
const judge = async () => { throw new Error('llm down'); };
|
||
const r = await detectStopWithFallback('что-то непонятное', { llmJudge: judge });
|
||
expect(r).toBe(false);
|
||
});
|
||
});
|
||
|
||
|
||
describe('askuser-answer-parser / parseAskUserResult', () => {
|
||
it('extracts a single selected answer label', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { 'Какой вариант?': 'Вариант A' },
|
||
});
|
||
expect(r.selections).toEqual(['Вариант A']);
|
||
expect(r.stop).toBe(false);
|
||
});
|
||
|
||
it('handles multiSelect (array of selections) and flattens all text', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { 'Что включить?': ['Фича 1', 'Фича 2'] },
|
||
});
|
||
expect(r.selections).toEqual(['Фича 1', 'Фича 2']);
|
||
});
|
||
|
||
it('pulls annotations notes into allText (approval source S15)', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { Q: 'Other' },
|
||
annotations: { Q: { notes: 'git rebase main' } },
|
||
});
|
||
expect(r.allText).toContain('git rebase main');
|
||
});
|
||
|
||
it('flags stop when a selection is a stop keyword', () => {
|
||
const r = parseAskUserResult({ answers: { Q: 'стоп' } });
|
||
expect(r.stop).toBe(true);
|
||
});
|
||
|
||
it('returns empty structure for malformed input', () => {
|
||
const r = parseAskUserResult(null);
|
||
expect(r.selections).toEqual([]);
|
||
expect(r.allText).toEqual([]);
|
||
expect(r.stop).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / matchesApproval (E34)', () => {
|
||
it('matches identical commands', () => {
|
||
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
|
||
});
|
||
|
||
it('matches across whitespace differences', () => {
|
||
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
|
||
});
|
||
|
||
it('does not match different commands', () => {
|
||
expect(matchesApproval('git rebase main', 'git reset --hard main')).toBe(false);
|
||
});
|
||
|
||
it('is case-sensitive (commands differ by case are different)', () => {
|
||
expect(matchesApproval('git rebase Main', 'git rebase main')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / detectOtherSocialEng (E29 + v4.0 RU)', () => {
|
||
it('flags English "type in Other" suggestion', () => {
|
||
expect(detectOtherSocialEng('please type in Other: recovery').flagged).toBe(true);
|
||
});
|
||
|
||
it('flags Russian "впиши в Другое" variants', () => {
|
||
expect(detectOtherSocialEng('впиши в "Другое" recovery').flagged).toBe(true);
|
||
expect(detectOtherSocialEng('нажми "Другое" и впиши команду').flagged).toBe(true);
|
||
expect(detectOtherSocialEng('укажи в графе Другое').flagged).toBe(true);
|
||
});
|
||
|
||
it('does not flag innocent text', () => {
|
||
expect(detectOtherSocialEng('выбери подходящий вариант').flagged).toBe(false);
|
||
});
|
||
|
||
it('handles non-string', () => {
|
||
expect(detectOtherSocialEng(null).flagged).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / buildApprovalRecord', () => {
|
||
it('builds a pure record with normalized pattern', () => {
|
||
const rec = buildApprovalRecord({
|
||
kind: 'approve_git_operation',
|
||
pattern: 'git rebase main',
|
||
sessionId: 'sess-1',
|
||
nowMs: 1000,
|
||
});
|
||
expect(rec.kind).toBe('approve_git_operation');
|
||
expect(rec.approved_action_pattern).toBe('git rebase main');
|
||
expect(rec.session_id).toBe('sess-1');
|
||
expect(rec.approved_at_ms).toBe(1000);
|
||
});
|
||
});
|
||
|
||
describe('toApprovalRecord (Stream H Task 6 — schema sync)', () => {
|
||
it('returns null for non-git-pattern answer', () => {
|
||
expect(toApprovalRecord('cancel', { question: 'continue?' })).toBeNull();
|
||
});
|
||
it('returns {type, command, ts} for approved git push pattern', () => {
|
||
const r = toApprovalRecord('подтверди git push origin main', {
|
||
question: 'разрешить git push?',
|
||
nowMs: 1700000000000,
|
||
});
|
||
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git push origin main', ts: 1700000000000 });
|
||
});
|
||
it('returns {type, command, ts} for approved git commit pattern', () => {
|
||
const r = toApprovalRecord('git commit -m "fix: x"', {
|
||
question: 'разрешить коммит?',
|
||
nowMs: 1700000000000,
|
||
});
|
||
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git commit -m "fix: x"', ts: 1700000000000 });
|
||
});
|
||
it('uses current ms when nowMs not provided', () => {
|
||
const before = Date.now();
|
||
const r = toApprovalRecord('git add tools/x.mjs', { question: 'разрешить add?' });
|
||
const after = Date.now();
|
||
expect(r).not.toBeNull();
|
||
expect(r.ts).toBeGreaterThanOrEqual(before);
|
||
expect(r.ts).toBeLessThanOrEqual(after);
|
||
});
|
||
it('returns null for non-string answer', () => {
|
||
expect(toApprovalRecord(null)).toBeNull();
|
||
expect(toApprovalRecord(undefined)).toBeNull();
|
||
expect(toApprovalRecord(42)).toBeNull();
|
||
});
|
||
});
|