397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
15 KiB
JavaScript
276 lines
15 KiB
JavaScript
// Тесты 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);
|
||
});
|
||
});
|