Files

101 lines
5.3 KiB
JavaScript
Raw Permalink Normal View History

#!/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-объект {nodes, chains} —
* обычно от 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 (фикс-1: суб-скилы superpowers не отдельные узлы → ссылка
* superpowers:writing-plans заземляется в зонтичный узел superpowers #19).
* Суффикс пробуется ПЕРВЫМ — если сам суффикс является узлом, он и побеждает
* (superpowers:architecture-patterns → #38). Не нашли → 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 parts = low.split(':');
const suf = parts[parts.length - 1];
if (graph.bySlug.has(suf)) return graph.bySlug.get(suf);
if (graph.byName.has(suf)) return graph.byName.get(suf);
const pre = parts[0];
if (graph.bySlug.has(pre)) return graph.bySlug.get(pre);
if (graph.byName.has(pre)) return graph.byName.get(pre);
}
return null;
}
/** Близнецы — активные узлы той же 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);
}
/**
* 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: 'граф актуален' };
}