Files
portal/docs/superpowers/plans/2026-06-04-router-mentor-3b-node-graph.md
T
Дмитрий 699da97dc2 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

19 KiB
Raw Blame History

Машина 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.mjsbuildNodeGraph(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
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

#!/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: Commitgit -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: Падающие тесты (добавить)
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: Реализация (дописать)
/** Близнецы — активные узлы той же 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: Commitgit -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: Падающие тесты (добавить)
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: Реализация (дописать)
/**
 * 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: Commitgit -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
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: Commitgit -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)

  • Стабильная карта из РЕЕСТРА, не graphifybuildNodeGraph(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 (выбор рассуждением) — отмечена.