Files
brain/tools/reading-discipline.test.mjs
T
Дмитрий e91aa021f0 feat: A - чтение под опечатанным планом свободно (ДР-1 снят в impl)
Под планом авторское чтение больше не блок: свой вывод, лог упавшего шага,
новый файл доступны. Чтение не двигает очередь шагов; impl-чтения логируются
с пометкой impl:true для ретро и не считаются во фронт-лоад порог. Секреты
держит отдельный read-path-deny. Свод зелёный: 4221 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:40:23 +03:00

285 lines
16 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) → НЕ блок (A: ДР-1 снят), но сигнал', () => {
const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: true });
expect(d.block).toBe(false);
expect(d.signal).toBe(true);
});
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('копит разговорные сырьё-чтения (без метки) и impl-чтения (impl:true) для ретро', () => {
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 }); // A: impl — пишется с пометкой
expect(s.reads.map((r) => r.path)).toEqual(['a.mjs', 'c.mjs']);
expect(s.reads.find((r) => r.path === 'a.mjs').impl).toBeUndefined();
expect(s.reads.find((r) => r.path === 'c.mjs').impl).toBe(true);
});
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);
});
it('A: impl-чтения (impl:true) НЕ считаются во фронт-лоад порог', () => {
const s = { reads: [{ path: '1' }, { path: 'i1', impl: true }, { path: 'i2', impl: true }] };
const sig = readLogSignal(s, { threshold: 2 });
expect(sig.frontLoadCount).toBe(1); // только разговорный '1'
expect(sig.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-режим, авторское сырьё кода → НЕ блок (A: ДР-1 снят) + 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(false);
});
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');
// A: ДР-1 снят в impl — authorial-raw (вкл. traversal-путь) больше не блок; классификация (не graph-map) держит.
expect(readingGateDecision({ readKind: k, frozenPlan: true }).block).toBe(false);
});
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-режиме → authorial-raw → НЕ блок (A: ДР-1 снят)', () => {
expect(readingGateDecision({ readKind: 'опечатка', frozenPlan: true }).block).toBe(false);
});
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);
});
});