// Тесты 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); }); });