Files
brain/tools/reading-discipline.test.mjs
T

276 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тесты sub-plan D — дисциплина чтения наставника (§5.8/§6.7/§5.7 спеки R6.3).
// Amendments R6.3: SE4 (синк типов ↔ производители: narrative-doc удалён),
// SE12 (.env по basename), SE5 (DANGER контракт инъекций — Task 3/7).
import { describe, it, expect } from 'vitest';
import { classifyReadingContent, READING_CONTENT_TYPES } from './reading-discipline.mjs';
describe('READING_CONTENT_TYPES', () => {
it('заморожен и содержит ожидаемые типы (SE4: каждый тип имеет производителя)', () => {
expect(Object.isFrozen(READING_CONTENT_TYPES)).toBe(true);
for (const t of ['code', 'spec-design', 'legal', 'scientific', 'data', 'config'])
expect(READING_CONTENT_TYPES).toContain(t);
});
it('SE4: narrative-doc удалён (производителя нет — generic .md уходит в abstain)', () => {
expect(READING_CONTENT_TYPES).not.toContain('narrative-doc');
});
});
describe('classifyReadingContent (ДР-5, машинно)', () => {
it('код по расширению → code', () => {
expect(classifyReadingContent({ ext: '.mjs', path: 'tools/x.mjs' }).contentType).toBe('code');
expect(classifyReadingContent({ ext: '.php', path: 'app/app/Services/X.php' }).contentType).toBe('code');
expect(classifyReadingContent({ ext: '.vue', path: 'app/resources/js/X.vue' }).contentType).toBe('code');
});
it('данные → data, конфиг → config', () => {
expect(classifyReadingContent({ ext: '.jsonl', path: 'docs/observer/episodes.jsonl' }).contentType).toBe('data');
expect(classifyReadingContent({ ext: '.yaml', path: 'app/deptrac.yaml' }).contentType).toBe('config');
});
it('SE12: .env по basename → config (extname(".env")==="" — без basename-детекта мёртв)', () => {
expect(classifyReadingContent({ ext: '', path: 'app/.env' }).contentType).toBe('config');
expect(classifyReadingContent({ ext: '', path: '.env' }).contentType).toBe('config');
});
it('md в spec-папке → spec-design', () => {
expect(classifyReadingContent({ ext: '.md', path: 'docs/superpowers/specs/x-design.md' }).contentType).toBe('spec-design');
expect(classifyReadingContent({ ext: '.md', path: 'docs/adr/ADR-018.md' }).contentType).toBe('spec-design');
});
it('graphNodeType=code подтверждает code даже при неясном ext', () => {
expect(classifyReadingContent({ ext: '', path: 'x', graphNodeType: 'code' }).contentType).toBe('code');
});
it('md вне известных spec-папок → abstain (не выдумывать legal/narrative)', () => {
const r = classifyReadingContent({ ext: '.md', path: 'docs/random.md' });
expect(r.abstain).toBe(true);
expect(r.contentType).toBe(null);
});
it('неизвестное расширение → abstain', () => {
const r = classifyReadingContent({ ext: '.xyz', path: 'a/b.xyz' });
expect(r.abstain).toBe(true);
});
});
// Task 2 — grepHeaderFallback (ESM hoisting: import внизу легален)
import { grepHeaderFallback } from './reading-discipline.mjs';
describe('grepHeaderFallback', () => {
it('заголовок плана → spec-design', () => {
expect(grepHeaderFallback({ headerText: '# Foo Implementation Plan\n> For agentic workers' })).toBe('spec-design');
});
it('маркеры закона → legal', () => {
expect(grepHeaderFallback({ headerText: 'Федеральный закон № 152-ФЗ\nСтатья 1.' })).toBe('legal');
});
it('SE4: научные маркеры Abstract / DOI: / arXiv → scientific', () => {
expect(grepHeaderFallback({ headerText: 'Title\nAbstract\nWe study...' })).toBe('scientific');
expect(grepHeaderFallback({ headerText: 'DOI: 10.1000/xyz123' })).toBe('scientific');
expect(grepHeaderFallback({ headerText: 'препринт arXiv:2406.01234' })).toBe('scientific');
});
it('решение владельца 2026-06-11: голое «Статья» без научных co-маркеров → null (5.2, не путать с legal/публицистикой)', () => {
expect(grepHeaderFallback({ headerText: 'Статья поступила в редакцию вчера' })).toBe(null);
});
it('нет маркеров → null (→ воздержание)', () => {
expect(grepHeaderFallback({ headerText: 'просто какой-то текст без маркеров' })).toBe(null);
});
it('пустой/не-строка → null', () => {
expect(grepHeaderFallback({ headerText: '' })).toBe(null);
expect(grepHeaderFallback({})).toBe(null);
});
});
// Task 3 — classifyReadKind (ESM hoisting: import внизу легален)
import { classifyReadKind, READ_KINDS } from './reading-discipline.mjs';
describe('classifyReadKind (4 вида, §5.8 CD-R6-B)', () => {
const gp = '.claude/worktrees/graphify-spike/graphify-out/';
it('READ_KINDS заморожен', () => {
expect(Object.isFrozen(READ_KINDS)).toBe(true);
expect(READ_KINDS).toEqual(['graph-map', 'authorial-raw', 'critic-probe', 'harness-mandatory']);
});
it('путь в graphify-out → graph-map (вид-1)', () => {
const k = classifyReadKind({ path: gp + 'graph.json', frozenPlan: true, graphPathPrefix: gp });
expect(k).toBe('graph-map');
});
it('frozenPlan + шаг плана авторизует путь → harness-mandatory (вид-4)', () => {
const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, planAuthorizesPath: () => true, graphPathPrefix: gp });
expect(k).toBe('harness-mandatory');
});
it('наставник пометил критик-проверку → critic-probe (вид-3)', () => {
const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true, planAuthorizesPath: () => false, graphPathPrefix: gp });
expect(k).toBe('critic-probe');
});
it('frozenPlan + НЕ шаг плана + не probe → authorial-raw (вид-2, мишень гейта)', () => {
const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, planAuthorizesPath: () => false, graphPathPrefix: gp });
expect(k).toBe('authorial-raw');
});
it('разговорный (нет frozenPlan), сырьё → authorial-raw (но гейт его не блокирует — Task 4)', () => {
const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: false, graphPathPrefix: gp });
expect(k).toBe('authorial-raw');
});
});
// Task 4 — readingGateDecision (ESM hoisting: import внизу легален)
import { readingGateDecision } from './reading-discipline.mjs';
describe('readingGateDecision (§5.8 impl-only)', () => {
it('граф-карта/probe/harness → не блок', () => {
for (const rk of ['graph-map', 'critic-probe', 'harness-mandatory'])
expect(readingGateDecision({ readKind: rk, frozenPlan: true }).block).toBe(false);
});
it('авторское сырьё в impl-режиме (frozenPlan) → БЛОК (гейт ДР-1)', () => {
const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: true });
expect(d.block).toBe(true);
expect(d.reason).toMatch(/ДР-1/);
});
it('авторское сырьё в разговорном (нет frozenPlan) → НЕ блок, но сигнал', () => {
const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: false });
expect(d.block).toBe(false);
expect(d.signal).toBe(true);
});
});
// Task 5 — read-LOG (ESM hoisting: import внизу легален)
import { recordRead, readLogSignal } from './reading-discipline.mjs';
describe('read-LOG (SE-R7-5)', () => {
it('копит только разговорные авторские сырьё-чтения', () => {
let s = { reads: [] };
s = recordRead(s, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
s = recordRead(s, { path: 'b.mjs', readKind: 'graph-map', frozenPlan: false }); // не считается
s = recordRead(s, { path: 'c.mjs', readKind: 'authorial-raw', frozenPlan: true }); // impl — не разговорный
expect(s.reads.map((r) => r.path)).toEqual(['a.mjs']);
});
it('immutable — не мутирует вход', () => {
const s0 = { reads: [] };
recordRead(s0, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
expect(s0.reads).toEqual([]);
});
it('сигнал при превышении порога', () => {
const s = { reads: [{ path: '1' }, { path: '2' }, { path: '3' }] };
const sig = readLogSignal(s, { threshold: 2 });
expect(sig.frontLoadCount).toBe(3);
expect(sig.warn).toBe(true);
});
it('нет сигнала под порогом', () => {
expect(readLogSignal({ reads: [{ path: '1' }] }, { threshold: 2 }).warn).toBe(false);
});
});
// Task 6 — probe-cap (ESM hoisting: import внизу легален)
import { checkProbeCap, MENTOR_PROBE_CAP } from './reading-discipline.mjs';
describe('probe-cap (✅O19)', () => {
it('MENTOR_PROBE_CAP = 2', () => {
expect(MENTOR_PROBE_CAP).toBe(2);
});
it('под лимитом → allowed', () => {
expect(checkProbeCap(0).allowed).toBe(true);
expect(checkProbeCap(1).allowed).toBe(true);
});
it('на лимите и выше → НЕ allowed', () => {
expect(checkProbeCap(2).allowed).toBe(false);
expect(checkProbeCap(5).allowed).toBe(false);
});
it('кастомный cap уважается', () => {
expect(checkProbeCap(2, 3).allowed).toBe(true);
});
it('битый счётчик → не allowed (fail-closed, иначе вид-3 = free-reading backdoor)', () => {
expect(checkProbeCap('x').allowed).toBe(false);
expect(checkProbeCap(undefined).allowed).toBe(false);
expect(checkProbeCap(-1).allowed).toBe(false);
});
});
// Task 7 — decideReadEvent сборка (ESM hoisting: import внизу легален)
import { decideReadEvent } from './reading-discipline.mjs';
describe('decideReadEvent (сборка под wiring C2-W2)', () => {
const gp = '.claude/worktrees/graphify-spike/graphify-out/';
it('impl-режим, авторское сырьё кода → block + contentType=code', () => {
const d = decideReadEvent({
ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true,
planAuthorizesPath: () => false, graphPathPrefix: gp,
});
expect(d.readKind).toBe('authorial-raw');
expect(d.content.contentType).toBe('code');
expect(d.gate.block).toBe(true);
});
it('критик-проверка под лимитом → не блок + probe allowed', () => {
const d = decideReadEvent({
ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true,
planAuthorizesPath: () => false, probeCountThisRound: 1, graphPathPrefix: gp,
});
expect(d.readKind).toBe('critic-probe');
expect(d.gate.block).toBe(false);
expect(d.probe.allowed).toBe(true);
});
it('критик-проверка сверх лимита → probe не allowed', () => {
const d = decideReadEvent({
ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true,
planAuthorizesPath: () => false, probeCountThisRound: 2, graphPathPrefix: gp,
});
expect(d.probe.allowed).toBe(false);
});
it('граф-карта → свободно, probe не оценивается', () => {
const d = decideReadEvent({ ext: '.json', path: gp + 'graph.json', frozenPlan: true, graphPathPrefix: gp });
expect(d.readKind).toBe('graph-map');
expect(d.gate.block).toBe(false);
expect(d.probe).toBe(null);
});
it('abstain метаданных + headerText с маркером → grep-fallback добирает тип', () => {
const d = decideReadEvent({
ext: '.md', path: 'docs/random.md', headerText: 'DOI: 10.1000/x', frozenPlan: false, graphPathPrefix: gp,
});
expect(d.content.contentType).toBe('scientific');
expect(d.content.abstain).toBe(false);
});
});
// Sharp-edges фиксы F-D1..F-D7 (решение владельца «чинить все», 2026-06-11)
describe('sharp-edges фиксы (F-D1..F-D7)', () => {
const gp = '.claude/worktrees/graphify-spike/graphify-out/';
it('F-D1: ..-сегмент в граф-пути НЕ даёт graph-map (traversal-обход ДР-1 закрыт)', () => {
const k = classifyReadKind({
path: gp + '../../../../tools/x.mjs', frozenPlan: true,
planAuthorizesPath: () => false, graphPathPrefix: gp,
});
expect(k).toBe('authorial-raw');
expect(readingGateDecision({ readKind: k, frozenPlan: true }).block).toBe(true);
});
it('F-D1: честный граф-путь без .. остаётся graph-map', () => {
expect(classifyReadKind({ path: gp + 'graph.json', graphPathPrefix: gp })).toBe('graph-map');
});
it('F-D2: planAuthorizesPath бросил → не исключение, а authorial-raw (к блоку)', () => {
const k = classifyReadKind({
path: 'tools/x.mjs', frozenPlan: true,
planAuthorizesPath: () => { throw new Error('boom'); }, graphPathPrefix: gp,
});
expect(k).toBe('authorial-raw');
});
it('F-D4: неизвестный readKind в impl-режиме → к блоку (не free-pass)', () => {
expect(readingGateDecision({ readKind: 'опечатка', frozenPlan: true }).block).toBe(true);
});
it('F-D4: неизвестный readKind в разговорном → сигнал, не блок', () => {
const d = readingGateDecision({ readKind: undefined, frozenPlan: false });
expect(d.block).toBe(false);
expect(d.signal).toBe(true);
});
it('F-D5: .ENV (верхний регистр) → config по basename (Windows case-insensitive)', () => {
expect(classifyReadingContent({ ext: '', path: 'app/.ENV' }).contentType).toBe('config');
});
it('F-D6: битый read-LOG (reads не массив) → warn + липкий corrupt в recordRead', () => {
expect(readLogSignal({ reads: 'мусор' }).warn).toBe(true);
const s = recordRead({ reads: 'мусор' }, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
expect(s.corrupt).toBe(true);
expect(s.reads.map((r) => r.path)).toEqual(['a.mjs']);
const s2 = recordRead(s, { path: 'b.mjs', readKind: 'graph-map', frozenPlan: false });
expect(s2.corrupt).toBe(true);
expect(readLogSignal(s2).warn).toBe(true);
});
it('F-D6: отсутствующий logState — легитимный свежий старт, не corrupt', () => {
const s = recordRead(undefined, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
expect(s.corrupt).toBeUndefined();
});
it('F-D7: невалидный cap (Infinity/строка/0) → fail-closed, не молчаливое отключение', () => {
expect(checkProbeCap(0, Infinity).allowed).toBe(false);
expect(checkProbeCap(0, 'x').allowed).toBe(false);
expect(checkProbeCap(0, 0).allowed).toBe(false);
});
});