101 lines
5.3 KiB
JavaScript
101 lines
5.3 KiB
JavaScript
|
|
#!/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: 'граф актуален' };
|
||
|
|
}
|