test(m3-c): coverage-machine invariants on 3-A contracts + plan

Машина 3-C «Машина охвата A/B/C/D» собрана (TDD): coverage-machine.mjs —
A граф зависимостей (buildDependencyGraph/topoOrder/findHoles/decompositionGroups),
B реестр нужды↔решения (coverageRegistry: дыры+сироты), C requestsChecklist,
D ограничения как нужды (effectiveNeeds), хребет readinessChecklist (4 галочки + §).
Независимый верификатор охвата (рычаг E §6.3). 19 новых тестов, регрессия 2158 GREEN.
This commit is contained in:
Дмитрий
2026-06-04 19:26:21 +03:00
parent 32a19f3a08
commit 404a938256
2 changed files with 405 additions and 0 deletions
@@ -0,0 +1,379 @@
# Машина 3 / под-план 3-C — Машина охвата A/B/C/D — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans.
> **NB сборки:** код строит контроллер по TDD (субагент не проходит TDD-гейт).
**Goal:** на контрактах 3-A построить НЕЗАВИСИМЫЙ механический верификатор полноты плана (C-14): A граф зависимостей (needs↔produces: топосорт-порядок, дыра=нужда-без-producer, цикл=флаг, декомпозиция=связные группы); B реестр нужды↔решения (дыры / сироты-скоупкрип); C чек-лист «просьбы цели → план»; D ограничения = полноправные нужды; хребет — буквальный чек-лист готовности (галочки + указатели §).
**Architecture:** чистый `tools/coverage-machine.mjs` поверх контрактов 3-A (`skill-contract` needs/produces/constraints). Только set/graph-операции, без LLM («умный не баран» — машина считает, роутер рассуждает). Сопоставление need↔produce — по нормализованной строке (lower/trim); семантическое сопоставление — НЕ здесь (это рассуждение роутера 3-D). Это рычаг E дисциплины роутера (§6.3): независимый верификатор охвата.
**Tech Stack:** Node.js ESM, vitest tools-only.
---
## ⚠️ Контекст исполнения (как 3-A/3-B)
- Git только `git -C "<worktree>"`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd`.
- Тест-файлы — в `<worktree>\tools\`, ЦЕЛИКОМ через Write. TDD-гейт: Read плана прямыми слэшами + тест + Bash-RED, потом прод.
## Границы 3-C (что НЕ здесь)
- Выбор скилов рассуждением, сборка цепочки, look-ahead, L-ядро → **3-D** (машина охвата — ВЕРИФИКАТОР готового набора, не селектор).
- **[ДОПУЩЕНИЕ]** Сопоставление need↔produce — по нормализованной строке (lower/trim; равенство или подстрока для просьб). Семантическое сопоставление («spec» ≈ «требования») — рассуждение роутера (3-D), не механика. Машина охвата честно ловит точные дыры/сироты/циклы; нечёткие — задача роутера.
- **[ДОПУЩЕНИЕ]** Извлечение «просьб цели» из текста (C) — мягкий край, выполняется выше (роутер/владелец); 3-C принимает готовый массив просьб и механически сверяет покрытие.
---
## Структура файлов
**Создаём:**
- `tools/coverage-machine.mjs``normToken`, `effectiveNeeds` (D), `buildDependencyGraph`/`topoOrder`/`findHoles`/`decompositionGroups` (A), `coverageRegistry` (B), `requestsChecklist` (C), `readinessChecklist` (хребет).
- Тесты: `tools/coverage-machine.test.mjs` (чистый) + `tools/m3c-coverage-invariants.test.mjs` (на контрактах 3-A через skill-contract-registry).
**Порядок:** A граф+топосорт+дыры+группы (Task 1) → D effectiveNeeds + B реестр + C чек-лист просьб (Task 2) → хребет readinessChecklist + инварианты + регрессия (Task 3).
---
## Task 1: A — граф зависимостей (needs↔produces): topoOrder / findHoles / decompositionGroups
**Files:** Create `tools/coverage-machine.mjs`, `tools/coverage-machine.test.mjs`
- [ ] **Step 1: Падающий тест**`tools/coverage-machine.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { normToken, buildDependencyGraph, topoOrder, findHoles, decompositionGroups } from './coverage-machine.mjs';
const c = (skill, needs, produces, constraints = []) => ({ skill, needs, produces, constraints });
// Y produces 'spec'; X needs 'spec' → Y перед X
const CHAIN = [c('X', ['spec'], ['code']), c('Y', [], ['spec'])];
describe('normToken', () => {
it('lower + trim', () => { expect(normToken(' Spec ')).toBe('spec'); });
});
describe('buildDependencyGraph (A: needs↔produces)', () => {
it('ребро producer→consumer via need', () => {
const g = buildDependencyGraph(CHAIN);
expect(g.edges).toContainEqual({ from: 'Y', to: 'X', via: 'spec' });
});
});
describe('topoOrder (A: порядок = топосортировка, цикл = флаг)', () => {
it('порядок: Y перед X', () => {
const r = topoOrder(CHAIN);
expect(r.cycle).toBe(null);
expect(r.order.indexOf('Y')).toBeLessThan(r.order.indexOf('X'));
});
it('цикл помечается', () => {
const cyc = [c('A', ['b'], ['a']), c('B', ['a'], ['b'])];
const r = topoOrder(cyc);
expect(r.order).toBe(null);
expect(r.cycle.sort()).toEqual(['A', 'B']);
});
});
describe('findHoles (A: нужда без producer; D: ограничения тоже)', () => {
it('нужда, которую никто не производит → дыра', () => {
const h = findHoles([c('X', ['spec'], ['code'])]);
expect(h).toContainEqual({ need: 'spec', neededBy: 'X', kind: 'need' });
});
it('initialInputs закрывают нужду (не дыра)', () => {
expect(findHoles([c('X', ['spec'], ['code'])], { initialInputs: ['spec'] })).toEqual([]);
});
it('ограничение без покрытия → дыра kind=constraint (D)', () => {
const h = findHoles([c('X', [], ['code'], ['must be RLS-safe'])]);
expect(h).toContainEqual({ need: 'must be RLS-safe', neededBy: 'X', kind: 'constraint' });
});
it('produced нужда не дыра', () => {
expect(findHoles(CHAIN)).toEqual([]);
});
});
describe('decompositionGroups (A: связные группы)', () => {
it('связанные скилы — одна группа, несвязанный — отдельная', () => {
const groups = decompositionGroups([...CHAIN, c('Z', [], ['unrelated'])]);
const sizes = groups.map((g) => g.length).sort();
expect(sizes).toEqual([1, 2]);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация**`tools/coverage-machine.mjs`
```js
#!/usr/bin/env node
/**
* coverage-machine — машина охвата A/B/C/D (C-14) поверх контрактов 3-A.
* НЕЗАВИСИМЫЙ механический верификатор полноты плана: set/graph-операции,
* без LLM («умный не баран»). Рычаг E дисциплины роутера (§6.3).
*/
export function normToken(s) { return String(s ?? '').trim().toLowerCase(); }
/** D — эффективные нужды контракта = needs + constraints (ограничения как полноправные нужды). */
export function effectiveNeeds(contract) {
const needs = (contract.needs || []).map((n) => ({ token: n, kind: 'need' }));
const cons = (contract.constraints || []).map((cN) => ({ token: cN, kind: 'constraint' }));
return [...needs, ...cons];
}
/** Множество всего, что производят контракты (нормализованные produces). */
function producedSet(contracts) {
const s = new Set();
for (const c of contracts) for (const p of c.produces || []) s.add(normToken(p));
return s;
}
/** A — граф зависимостей: ребро producer→consumer via need (по needs↔produces). */
export function buildDependencyGraph(contracts) {
const byProduce = new Map(); // normProduce → [skill]
for (const c of contracts) for (const p of c.produces || []) {
const k = normToken(p);
if (!byProduce.has(k)) byProduce.set(k, []);
byProduce.get(k).push(c.skill);
}
const edges = [];
for (const c of contracts) for (const n of c.needs || []) {
const producers = byProduce.get(normToken(n)) || [];
for (const p of producers) if (p !== c.skill) edges.push({ from: p, to: c.skill, via: normToken(n) });
}
return { nodes: contracts.map((c) => c.skill), edges };
}
/** A — топосортировка (Kahn). Цикл → {order:null, cycle:[оставшиеся скилы]}. */
export function topoOrder(contracts) {
const { nodes, edges } = buildDependencyGraph(contracts);
const indeg = new Map(nodes.map((n) => [n, 0]));
const adj = new Map(nodes.map((n) => [n, []]));
for (const e of edges) { indeg.set(e.to, (indeg.get(e.to) || 0) + 1); adj.get(e.from).push(e.to); }
const queue = nodes.filter((n) => (indeg.get(n) || 0) === 0);
const order = [];
while (queue.length) {
const n = queue.shift(); order.push(n);
for (const m of adj.get(n) || []) { indeg.set(m, indeg.get(m) - 1); if (indeg.get(m) === 0) queue.push(m); }
}
if (order.length !== nodes.length) {
const cycle = nodes.filter((n) => !order.includes(n));
return { order: null, cycle };
}
return { order, cycle: null };
}
/** A+D — дыры: нужда/ограничение, которую никто не производит и нет в initialInputs. */
export function findHoles(contracts, { initialInputs = [], includeConstraints = true } = {}) {
const produced = producedSet(contracts);
const inputs = new Set(initialInputs.map(normToken));
const holes = [];
for (const c of contracts) {
const items = includeConstraints ? effectiveNeeds(c) : (c.needs || []).map((n) => ({ token: n, kind: 'need' }));
for (const { token, kind } of items) {
const k = normToken(token);
if (!produced.has(k) && !inputs.has(k)) holes.push({ need: token, neededBy: c.skill, kind });
}
}
return holes;
}
/** A — связные группы (неориентированные компоненты по рёбрам needs↔produces). */
export function decompositionGroups(contracts) {
const { nodes, edges } = buildDependencyGraph(contracts);
const parent = new Map(nodes.map((n) => [n, n]));
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
const union = (a, b) => { parent.set(find(a), find(b)); };
for (const e of edges) union(e.from, e.to);
const groups = new Map();
for (const n of nodes) { const r = find(n); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(n); }
return [...groups.values()];
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" add tools/coverage-machine.mjs tools/coverage-machine.test.mjs` + commit `"feat(m3-c): coverage A — dep graph / topoOrder / holes(+D) / groups (C-14)"`
---
## Task 2: B реестр нужды↔решения (сироты) + C чек-лист просьбы→план
**Files:** Modify `tools/coverage-machine.mjs`, `tools/coverage-machine.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить)**
```js
import { coverageRegistry, requestsChecklist } from './coverage-machine.mjs';
describe('coverageRegistry (B: нужды↔решения, дыры + сироты)', () => {
it('дыра попадает в holes', () => {
const r = coverageRegistry([c('X', ['spec'], ['code'])]);
expect(r.holes.map((h) => h.need)).toContain('spec');
});
it('сирота-скоупкрип: produces никому не нужен и не покрывает просьбу', () => {
// Y produces 'spec' (нужен X). Z produces 'extra' — никому не нужен, просьбы нет → сирота
const r = coverageRegistry([c('X', ['spec'], ['code']), c('Y', [], ['spec']), c('Z', [], ['extra'])], { requests: ['code'] });
expect(r.orphans.map((o) => o.skill)).toContain('Z');
expect(r.orphans.map((o) => o.skill)).not.toContain('Y'); // Y нужен X
});
});
describe('requestsChecklist (C: просьбы цели → план)', () => {
it('просьба, которую кто-то производит → ok', () => {
const list = requestsChecklist(['code'], [c('X', [], ['code'])]);
expect(list).toEqual([{ request: 'code', coveredBy: 'X', ok: true }]);
});
it('непокрытая просьба → ok=false, coveredBy=null', () => {
const list = requestsChecklist(['report'], [c('X', [], ['code'])]);
expect(list[0]).toMatchObject({ request: 'report', coveredBy: null, ok: false });
});
it('покрытие по подстроке (мягкий край)', () => {
const list = requestsChecklist(['csv'], [c('X', [], ['export to csv file'])]);
expect(list[0].ok).toBe(true);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
/** Найти контракт, чей produces покрывает запрос (равенство нормализованных ИЛИ подстрока — мягкий край C). */
function coveringSkill(contracts, request) {
const r = normToken(request);
for (const c of contracts) for (const p of c.produces || []) {
const pp = normToken(p);
if (pp === r || pp.includes(r) || r.includes(pp)) return c.skill;
}
return null;
}
/** C — чек-лист «просьбы цели → план»: каждая просьба сверяется с produces плана. */
export function requestsChecklist(requests, contracts) {
return (requests || []).map((req) => {
const coveredBy = coveringSkill(contracts, req);
return { request: req, coveredBy, ok: coveredBy !== null };
});
}
/** B — двусторонний реестр: дыры (findHoles) + сироты (produces никому не нужен и не покрывает просьбу). */
export function coverageRegistry(contracts, { requests = [], initialInputs = [] } = {}) {
const holes = findHoles(contracts, { initialInputs });
// нужды всех контрактов (для проверки «кому-то нужно»)
const allNeeds = new Set();
for (const c of contracts) for (const n of c.needs || []) allNeeds.add(normToken(n));
const reqTokens = (requests || []).map(normToken);
const orphans = [];
for (const c of contracts) {
const produces = (c.produces || []).map(normToken);
const neededBySomeone = produces.some((p) => [...allNeeds].some((n) => n === p));
const coversRequest = produces.some((p) => reqTokens.some((r) => r === p || p.includes(r) || r.includes(p)));
if (produces.length > 0 && !neededBySomeone && !coversRequest)
orphans.push({ skill: c.skill, reason: 'produces никому не нужен и не покрывает просьбу цели (scope creep?)' });
}
return { holes, orphans };
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" commit -am "feat(m3-c): coverage B (registry/orphans) + C (requests checklist)"`
---
## Task 3: Хребет — readinessChecklist + инварианты на контрактах 3-A + регрессия
**Files:** Modify `tools/coverage-machine.mjs`, `tools/coverage-machine.test.mjs`; Create `tools/m3c-coverage-invariants.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить в coverage-machine.test.mjs)**
```js
import { readinessChecklist } from './coverage-machine.mjs';
describe('readinessChecklist (хребет — галочки + указатели §)', () => {
it('полный план → ready=true, все галочки', () => {
const r = readinessChecklist({ contracts: [c('Y', [], ['spec']), c('X', ['spec'], ['code'])], requests: ['code'] });
expect(r.ready).toBe(true);
expect(r.items.every((i) => i.ok)).toBe(true);
expect(r.items.every((i) => typeof i.pointer === 'string')).toBe(true);
});
it('дыра → ready=false + пункт про дыры провален', () => {
const r = readinessChecklist({ contracts: [c('X', ['spec'], ['code'])], requests: ['code'] });
expect(r.ready).toBe(false);
expect(r.items.find((i) => /дыр|hole/i.test(i.label)).ok).toBe(false);
});
it('цикл → пункт про циклы провален', () => {
const r = readinessChecklist({ contracts: [c('A', ['b'], ['a']), c('B', ['a'], ['b'])], requests: [] });
expect(r.items.find((i) => /цикл|cycle/i.test(i.label)).ok).toBe(false);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
/**
* Хребет машины охвата — буквальный чек-лист готовности плана (галочки + указатели §).
* Объединяет A (дыры/циклы), B (сироты), C (просьбы). ready = все пункты ok.
*/
export function readinessChecklist({ contracts = [], requests = [], initialInputs = [] }) {
const { holes, orphans } = coverageRegistry(contracts, { requests, initialInputs });
const topo = topoOrder(contracts);
const reqList = requestsChecklist(requests, contracts);
const items = [
{ label: 'Все нужды/ограничения покрыты (нет дыр)', ok: holes.length === 0, pointer: '§A findHoles', detail: holes },
{ label: 'Нет циклов зависимостей', ok: topo.cycle === null, pointer: '§A topoOrder', detail: topo.cycle },
{ label: 'Нет сирот-скоупкрипа', ok: orphans.length === 0, pointer: '§B coverageRegistry', detail: orphans },
{ label: 'Все просьбы цели покрыты', ok: reqList.every((r) => r.ok), pointer: '§C requestsChecklist', detail: reqList.filter((r) => !r.ok) },
];
return { ready: items.every((i) => i.ok), items };
}
```
- [ ] **Step 4: GREEN** (coverage-machine).
- [ ] **Step 5: Инвариант на контрактах 3-A**`tools/m3c-coverage-invariants.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildRegistry } from './skill-contract-registry.mjs';
import { readinessChecklist, findHoles } from './coverage-machine.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const cdir = join(here, '..', 'docs', 'registry', 'contracts');
describe('Машина 3-C — охват на реальных контрактах 3-A', () => {
it('контракты 3-A загружаются и прогоняются через машину охвата', () => {
const wp = JSON.parse(readFileSync(join(cdir, 'writing-plans.contract.json'), 'utf8'));
const pd = JSON.parse(readFileSync(join(cdir, 'operations-process-doc.contract.json'), 'utf8'));
const { contracts } = buildRegistry([{ contract: wp }, { contract: pd, currentContent: '' }]);
// машина охвата принимает контракты 3-A без ошибок и даёт детерминированный чек-лист
const r = readinessChecklist({ contracts, requests: ['implementation-plan'] });
expect(Array.isArray(r.items)).toBe(true);
expect(r.items).toHaveLength(4);
// writing-plans produces 'implementation-plan...' → просьба покрыта по подстроке
expect(r.items.find((i) => /просьб/i.test(i.label)).ok).toBe(true);
});
it('findHoles на контракте с непокрытой нуждой ловит дыру', () => {
const wp = JSON.parse(readFileSync(join(cdir, 'writing-plans.contract.json'), 'utf8'));
const holes = findHoles([wp]);
// writing-plans needs 'spec or requirements' — никто в одиночном наборе не производит → дыра
expect(holes.length).toBeGreaterThan(0);
});
});
```
- [ ] **Step 6: GREEN** (m3c invariants).
- [ ] **Step 7: Полная регрессия**`npx vitest run --root ... --config ...` без фильтра → всё зелёное.
- [ ] **Step 8: Commit**`git -C "<worktree>" add tools/coverage-machine.mjs tools/coverage-machine.test.mjs tools/m3c-coverage-invariants.test.mjs docs/superpowers/plans/2026-06-04-router-mentor-3c-coverage-machine.md` + commit `"feat(m3-c): readinessChecklist backbone + invariants on 3-A contracts + plan"`
---
## Self-Review (против канона §2 3-C + C-14)
- **A граф зависимостей (needs↔produces; декомпозиция=группы, порядок=топосорт, дыра=нужда-без-producer, цикл=флаг)** → buildDependencyGraph/topoOrder/findHoles/decompositionGroups. ✅
- **B двусторонний реестр нужды↔решения (дыра / сирота-скоупкрип)** → coverageRegistry (holes + orphans). ✅
- **C чек-лист «просьбы цели → план» (мягкий край на извлечении просьб)** → requestsChecklist (просьбы принимаются готовым массивом — извлечение выше; сопоставление мех. по подстроке). ✅
- **D ограничения как полноправные нужды** → effectiveNeeds + findHoles includeConstraints (дыра kind='constraint'). ✅
- **Хребет — буквальный чек-лист готовности (галочки + указатели §)** → readinessChecklist (4 пункта, pointer на §). ✅
- **На контрактах 3-A, без LLM, рычаг E верификатора (§6.3)** → инварианты на реальных контрактах; чистые set/graph-операции. ✅
- **Граница с 3-D** (селектор/рассуждение) — машина охвата только ВЕРИФИЦИРУЕТ. ✅
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildRegistry } from './skill-contract-registry.mjs';
import { readinessChecklist, findHoles } from './coverage-machine.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const cdir = join(here, '..', 'docs', 'registry', 'contracts');
describe('Машина 3-C — охват на реальных контрактах 3-A', () => {
it('контракты 3-A загружаются и прогоняются через машину охвата', () => {
const wp = JSON.parse(readFileSync(join(cdir, 'writing-plans.contract.json'), 'utf8'));
const pd = JSON.parse(readFileSync(join(cdir, 'operations-process-doc.contract.json'), 'utf8'));
const { contracts } = buildRegistry([{ contract: wp }, { contract: pd, currentContent: '' }]);
const r = readinessChecklist({ contracts, requests: ['implementation-plan'] });
expect(Array.isArray(r.items)).toBe(true);
expect(r.items).toHaveLength(4);
expect(r.items.find((i) => /просьб/i.test(i.label)).ok).toBe(true);
});
it('findHoles на контракте с непокрытой нуждой ловит дыру', () => {
const wp = JSON.parse(readFileSync(join(cdir, 'writing-plans.contract.json'), 'utf8'));
const holes = findHoles([wp]);
expect(holes.length).toBeGreaterThan(0);
});
});