Files
portal/docs/superpowers/plans/2026-06-04-router-mentor-3b-node-graph.md
T
Дмитрий 62030e23a7 test(m3-b): node-graph invariants on real registry + plan
Машина 3-B «Граф узлов из реестра» собрана (TDD): node-graph.mjs поверх
loadRegistry — buildNodeGraph/resolveNode (ОВ-Д2 заземление) + twinsOf
(subcategory) / hintLinksOf (chains) / conflictsOf (явные) + checkGraphFreshness
(3.6). 20 новых тестов, регрессия tools-only 2139 GREEN.
2026-06-04 19:19:00 +03:00

340 lines
19 KiB
Markdown

# Машина 3 / под-план 3-B — Граф узлов из реестра — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps — checkbox.
> **NB сборки:** код строит контроллер по TDD (субагент не проходит TDD-гейт).
**Goal:** дать роутеру (3-D) и судье (М4) СТАБИЛЬНУЮ карту инструментов из РЕЕСТРА (`docs/registry/nodes.yaml`, НЕ graphify): узлы = скилы/MCP/агенты, рёбра-подсказки = близнецы (общий subcategory) / связи (со-членство в цепочке) / конфликты (явные) + механическое заземление скила в реальный узел (ОВ-Д2) + чувство свежести (3.6).
**Architecture:** чистый `tools/node-graph.mjs` поверх существующего `tools/registry-load.mjs` (`loadRegistry``{nodes, chains, indexById}`; js-yaml+ajv уже в проекте). Граф НЕ парсит YAML сам — потребляет `registry`-объект. Рёбра — ПОДСКАЗКИ (не рецепты, ОВ-Д1): близнецы/связи/конфликты выводятся механически из существующих полей (subcategory / chains / attributes.conflicts_with). Резолв скила в узел — детерминированный (id/slug/name/suffix; выдумка → null). Без LLM («умный не баран»).
**Tech Stack:** Node.js ESM, vitest tools-only. Реюз `loadRegistry` (registry-load.mjs).
---
## ⚠️ Контекст исполнения (как 3-A)
- Git только `git -C "<worktree>"`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd` (router-gate режет цепочки).
- Тест-файлы — в `<worktree>\tools\`, писать ЦЕЛИКОМ через Write.
- TDD-гейт: Read этого плана прямыми слэшами + правка теста + Bash-RED, потом прод.
## Границы 3-B (что НЕ здесь)
- Машина охвата A/B/C/D (граф нужд needs↔produces из КОНТРАКТОВ 3-A) → **3-C**. NB: 3-B — граф УЗЛОВ (инструменты+связи-подсказки), 3-C — граф НУЖД (декомпозиция по контрактам). Разные графы.
- Выбор скила рассуждением, look-ahead, L-ядро → **3-D**.
- **[ДОПУЩЕНИЕ]** Явных рёбер «близнецы/альтернативы/конфликты» в схеме реестра НЕТ. Выводим: близнецы = общий `subcategory`; связи-подсказки = со-членство в `chains`; конфликты = опциональное `attributes.conflicts_with` (массив ref'ов; пусто, если не задано). Фаззи-парсинг free-text `boundaries` НЕ делаем (ненадёжно). Явные конфликт-рёбра — наполнение позже (вне 3-B).
- **[ДОПУЩЕНИЕ]** Члены цепочек вида `superpowers:brainstorming` могут не резолвиться в отдельный узел (если суб-скилы superpowers не отдельные узлы реестра) → в связях-подсказках такие члены пропускаются. Граф корректен для зарегистрированных узлов.
---
## Структура файлов
**Создаём:**
- `tools/node-graph.mjs``buildNodeGraph(registry)`, `resolveNode(graph, ref)` (ОВ-Д2), `twinsOf`, `hintLinksOf`, `conflictsOf`, `checkGraphFreshness` (3.6).
- Тесты: `tools/node-graph.test.mjs` (чистый, на fake-registry), `tools/m3b-node-graph-invariants.test.mjs` (на РЕАЛЬНОМ реестре через loadRegistry).
**Порядок:** buildNodeGraph+resolveNode (Task 1) → twins/hints/conflicts (Task 2) → freshness 3.6 (Task 3) → инварианты на реальном реестре + регрессия (Task 4).
---
## Task 1: buildNodeGraph + resolveNode (ОВ-Д2 заземление)
**Files:** Create `tools/node-graph.mjs`, `tools/node-graph.test.mjs`
- [ ] **Step 1: Падающий тест**`tools/node-graph.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { buildNodeGraph, resolveNode } from './node-graph.mjs';
// fake-registry в форме loadRegistry (nodes + chains)
const REG = {
nodes: [
{ id: '#19', name: 'Superpowers', slug: 'superpowers', subcategory: null, status: 'active' },
{ id: '#36', name: 'adr-kit', slug: 'adr-kit', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#37', name: 'mermaid-skill', slug: 'mermaid', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#38', name: 'architecture-patterns', slug: 'architecture-patterns', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#17', name: 'pg_partman', slug: 'pg-partman', subcategory: null, status: 'dormant' },
],
chains: {
L4: { name: 'diagram', sequence: ['#36', '#37'] },
},
};
describe('buildNodeGraph', () => {
it('builds id/slug/name indexes + node count', () => {
const g = buildNodeGraph(REG);
expect(g.nodes).toHaveLength(5);
expect(g.byId.get('#36').name).toBe('adr-kit');
expect(g.bySlug.get('mermaid').id).toBe('#37');
});
});
describe('resolveNode (ОВ-Д2 — механическое заземление)', () => {
const g = buildNodeGraph(REG);
it('resolves by exact id', () => { expect(resolveNode(g, '#36').slug).toBe('adr-kit'); });
it('resolves by exact slug', () => { expect(resolveNode(g, 'mermaid').id).toBe('#37'); });
it('resolves by exact name', () => { expect(resolveNode(g, 'adr-kit').id).toBe('#36'); });
it('resolves by suffix after colon (skill-ref)', () => {
expect(resolveNode(g, 'superpowers:architecture-patterns').id).toBe('#38');
});
it('invented skill → null (выдумка отклоняется)', () => {
expect(resolveNode(g, 'elasticsearch-mcp')).toBe(null);
expect(resolveNode(g, '#999')).toBe(null);
});
it('empty/garbage → null', () => {
expect(resolveNode(g, '')).toBe(null);
expect(resolveNode(g, null)).toBe(null);
});
});
```
- [ ] **Step 2: RED**`... skill ... node-graph` → FAIL (import).
- [ ] **Step 3: Реализация**`tools/node-graph.mjs`
```js
#!/usr/bin/env node
/**
* node-graph — стабильный граф УЗЛОВ из реестра (3.1/3.2/3.3 + 3.6). Узлы =
* скилы/MCP/агенты из docs/registry/nodes.yaml; рёбра-ПОДСКАЗКИ (не рецепты):
* близнецы (общий subcategory) / связи (со-членство в chains) / конфликты
* (явные attributes.conflicts_with). Резолв скила в узел детерминированный
* (ОВ-Д2: выдумка → null). Без LLM. Потребляет registry от loadRegistry.
*/
/** Построить граф из registry ({nodes, chains}). Индексы id/slug/name + субкатегории + цепочки. */
export function buildNodeGraph(registry) {
const nodes = (registry && registry.nodes) || [];
const chains = (registry && registry.chains) || {};
const byId = new Map(), bySlug = new Map(), byName = new Map();
const subcategoryIndex = new Map();
for (const n of nodes) {
if (n.id) byId.set(String(n.id), n);
if (n.slug) bySlug.set(String(n.slug).toLowerCase(), n);
if (n.name) byName.set(String(n.name).toLowerCase(), n);
if (n.subcategory) {
if (!subcategoryIndex.has(n.subcategory)) subcategoryIndex.set(n.subcategory, []);
subcategoryIndex.get(n.subcategory).push(n);
}
}
return { nodes, chains, byId, bySlug, byName, subcategoryIndex };
}
/**
* ОВ-Д2 — механический резолв ссылки в реальный узел: id (#NN) → slug → name →
* суффикс после ':' (skill-ref как superpowers:brainstorming) как slug/name.
* Не нашли → null (выдумка отклоняется, не выдаём догадку за факт).
*/
export function resolveNode(graph, ref) {
if (!graph || typeof ref !== 'string') return null;
const r = ref.trim();
if (!r) return null;
if (graph.byId.has(r)) return graph.byId.get(r);
const low = r.toLowerCase();
if (graph.bySlug.has(low)) return graph.bySlug.get(low);
if (graph.byName.has(low)) return graph.byName.get(low);
if (r.includes(':')) {
const suf = low.split(':').pop();
if (graph.bySlug.has(suf)) return graph.bySlug.get(suf);
if (graph.byName.has(suf)) return graph.byName.get(suf);
}
return null;
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" add tools/node-graph.mjs tools/node-graph.test.mjs` + commit `"feat(m3-b): node-graph buildNodeGraph + resolveNode (ОВ-Д2 grounding)"`
---
## Task 2: Рёбра-подсказки — twinsOf / hintLinksOf / conflictsOf
**Files:** Modify `tools/node-graph.mjs`, `tools/node-graph.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить)**
```js
import { twinsOf, hintLinksOf, conflictsOf } from './node-graph.mjs';
describe('twinsOf (близнецы = общий subcategory, активные)', () => {
const g = buildNodeGraph(REG);
it('возвращает соседей по subcategory без себя', () => {
const t = twinsOf(g, '#36').map((n) => n.id).sort();
expect(t).toEqual(['#37', '#38']);
});
it('узел без subcategory → нет близнецов', () => {
expect(twinsOf(g, '#19')).toEqual([]);
});
it('несуществующий ref → []', () => { expect(twinsOf(g, 'nope')).toEqual([]); });
});
describe('hintLinksOf (связи = со-членство в цепочке)', () => {
const g = buildNodeGraph(REG);
it('соседи по chains, без себя', () => {
expect(hintLinksOf(g, '#36').map((n) => n.id)).toEqual(['#37']);
expect(hintLinksOf(g, '#37').map((n) => n.id)).toEqual(['#36']);
});
it('узел вне цепочек → []', () => { expect(hintLinksOf(g, '#19')).toEqual([]); });
});
describe('conflictsOf (явные attributes.conflicts_with)', () => {
it('резолвит явные конфликт-рёбра', () => {
const reg = { nodes: [
{ id: '#a', slug: 'a', status: 'active', attributes: { conflicts_with: ['#b'] } },
{ id: '#b', slug: 'b', status: 'active' },
], chains: {} };
const g = buildNodeGraph(reg);
expect(conflictsOf(g, '#a').map((n) => n.id)).toEqual(['#b']);
});
it('нет поля → []', () => {
const g = buildNodeGraph(REG);
expect(conflictsOf(g, '#36')).toEqual([]);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
/** Близнецы — активные узлы той же subcategory (без себя). Роутер ОБЯЗАН сравнить (1.1). */
export function twinsOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self || !self.subcategory) return [];
return (graph.subcategoryIndex.get(self.subcategory) || [])
.filter((n) => n !== self && n.status === 'active');
}
/** Связи-подсказки — узлы, со-встречающиеся с этим в любой цепочке (без себя), дедуп. */
export function hintLinksOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self) return [];
const seen = new Set(), out = [];
for (const chain of Object.values(graph.chains || {})) {
const members = (chain.sequence || []).map((s) => resolveNode(graph, s)).filter(Boolean);
if (!members.includes(self)) continue;
for (const m of members) {
if (m === self || seen.has(m.id)) continue;
seen.add(m.id); out.push(m);
}
}
return out;
}
/** Конфликты — явные attributes.conflicts_with (массив ref'ов), резолвятся в узлы. Free-text не парсим. */
export function conflictsOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self) return [];
const refs = (self.attributes && self.attributes.conflicts_with) || [];
return refs.map((r) => resolveNode(graph, r)).filter(Boolean);
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" commit -am "feat(m3-b): hint edges — twinsOf/hintLinksOf/conflictsOf"`
---
## Task 3: Чувство свежести (3.6)
**Files:** Modify `tools/node-graph.mjs`, `tools/node-graph.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить)**
```js
import { checkGraphFreshness } from './node-graph.mjs';
describe('checkGraphFreshness (3.6)', () => {
it('реестр не новее сборки → fresh', () => {
expect(checkGraphFreshness({ registryMtimeMs: 100, builtAtMs: 200 })).toMatchObject({ fresh: true, stale: false });
});
it('реестр новее сборки → stale + причина', () => {
const r = checkGraphFreshness({ registryMtimeMs: 300, builtAtMs: 200 });
expect(r.stale).toBe(true); expect(r.fresh).toBe(false); expect(r.reason).toMatch(/устар|реестр новее|stale/i);
});
it('нет данных о времени сборки → stale (не доверяем уверенно)', () => {
expect(checkGraphFreshness({ registryMtimeMs: 100, builtAtMs: null }).stale).toBe(true);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
/**
* 3.6 — чувство свежести: реестр новее, чем дата сборки графа/каталога → данные
* могли устареть (роутер предупреждает + пересобирает, не работает уверенно по старью).
* mtimes инъектируются (чистая функция). Нет builtAt → считаем устаревшим.
*/
export function checkGraphFreshness({ registryMtimeMs, builtAtMs }) {
if (typeof builtAtMs !== 'number') return { fresh: false, stale: true, reason: 'нет даты сборки графа — пересобрать' };
if (typeof registryMtimeMs === 'number' && registryMtimeMs > builtAtMs)
return { fresh: false, stale: true, reason: 'реестр новее графа — данные могли устареть, пересобрать' };
return { fresh: true, stale: false, reason: 'граф актуален' };
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" commit -am "feat(m3-b): checkGraphFreshness (3.6 freshness sense)"`
---
## Task 4: Инварианты на РЕАЛЬНОМ реестре + регрессия
**Files:** Create `tools/m3b-node-graph-invariants.test.mjs`
- [ ] **Step 1: Инвариант-тест**`tools/m3b-node-graph-invariants.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadRegistry, clearCache } from './registry-load.mjs';
import { buildNodeGraph, resolveNode, twinsOf, checkGraphFreshness } from './node-graph.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const registryPath = join(here, '..', 'docs', 'registry', 'nodes.yaml');
describe('Машина 3-B — граф на реальном реестре', () => {
it('граф строится из nodes.yaml и резолвит известные узлы', () => {
clearCache();
const reg = loadRegistry({ registryPath, useCache: false });
const g = buildNodeGraph(reg);
expect(g.nodes.length).toBeGreaterThan(50);
expect(resolveNode(g, '#36')).not.toBe(null); // adr-kit by id
expect(resolveNode(g, 'mermaid')).not.toBe(null); // by slug
expect(resolveNode(g, 'totally-made-up-skill')).toBe(null); // выдумка
});
it('близнецы architecture-tooling включают друг друга', () => {
clearCache();
const reg = loadRegistry({ registryPath, useCache: false });
const g = buildNodeGraph(reg);
const adr = resolveNode(g, 'adr-kit');
if (adr && adr.subcategory) {
const twinSlugs = twinsOf(g, adr.id).map((n) => n.slug);
expect(twinSlugs.length).toBeGreaterThanOrEqual(1);
}
});
it('freshness против реальной mtime реестра работает', () => {
const mtime = statSync(registryPath).mtimeMs;
expect(checkGraphFreshness({ registryMtimeMs: mtime, builtAtMs: mtime + 1000 }).fresh).toBe(true);
expect(checkGraphFreshness({ registryMtimeMs: mtime, builtAtMs: mtime - 1000 }).stale).toBe(true);
});
});
```
> **NB:** `loadRegistry` кэширует — используем `useCache: false` + `clearCache()` (как в registry-load.test.mjs), чтобы не словить чужой кэш. Если `resolveNode(g, 'mermaid')` вернёт null — slug в реестре другой; поправить ref по факту (записать в журнал).
- [ ] **Step 2: GREEN**`... m3b-node-graph-invariants`.
- [ ] **Step 3: Полная регрессия**`npx vitest run --root ... --config ...` (без фильтра) → всё зелёное.
- [ ] **Step 4: Commit**`git -C "<worktree>" add tools/m3b-node-graph-invariants.test.mjs docs/superpowers/plans/2026-06-04-router-mentor-3b-node-graph.md` + commit `"test(m3-b): node-graph invariants on real registry + plan"`
---
## Self-Review (против канона §2 3-B)
- **Стабильная карта из РЕЕСТРА, не graphify** → `buildNodeGraph(registry)` поверх `loadRegistry`. ✅
- **Узлы = скилы/MCP/агенты, рёбра = близнецы/связи/конфликты (подсказки)** → twinsOf (subcategory) / hintLinksOf (chains) / conflictsOf (explicit). ✅ (близнецы/связи выведены механически; конфликты — явные, free-text не парсим — [ДОПУЩЕНИЕ] в журнал).
- **Заземление ОВ-Д2: скил механически резолвится в реальный узел, выдумка → отклонение** → `resolveNode` (id/slug/name/suffix; null на выдумку). ✅
- **3.6 свежесть: дата каталога против даты правки реестра** → `checkGraphFreshness` (mtimes инъектируются; реальная mtime в инвариантах). ✅
- **Без LLM, портативно** → чистый модуль, имена не зашиты (всё из реестра). ✅
- **Граница с 3-C** (граф НУЖД из контрактов) и 3-D (выбор рассуждением) — отмечена. ✅