From 404a938256b13cbb20efbfdb622366e455e6d180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 4 Jun 2026 19:26:21 +0300 Subject: [PATCH] test(m3-c): coverage-machine invariants on 3-A contracts + plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Машина 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. --- ...06-04-router-mentor-3c-coverage-machine.md | 379 ++++++++++++++++++ tools/m3c-coverage-invariants.test.mjs | 26 ++ 2 files changed, 405 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-router-mentor-3c-coverage-machine.md create mode 100644 tools/m3c-coverage-invariants.test.mjs diff --git a/docs/superpowers/plans/2026-06-04-router-mentor-3c-coverage-machine.md b/docs/superpowers/plans/2026-06-04-router-mentor-3c-coverage-machine.md new file mode 100644 index 00000000..06d1c160 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-router-mentor-3c-coverage-machine.md @@ -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 ""`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd`. +- Тест-файлы — в `\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 "" 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 "" 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 "" 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** (селектор/рассуждение) — машина охвата только ВЕРИФИЦИРУЕТ. ✅ diff --git a/tools/m3c-coverage-invariants.test.mjs b/tools/m3c-coverage-invariants.test.mjs new file mode 100644 index 00000000..98b2ead2 --- /dev/null +++ b/tools/m3c-coverage-invariants.test.mjs @@ -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); + }); +});