#!/usr/bin/env node /** * project-graph (§5.3/§5.4) — слоистая подача project-графа наставнику. * Читает graphify-out graph.json (networkx node-link) + staleness-сигнал. * Чистое ядро (I/O инъектируется), образец — context-verity / plan-lock.refResolves. * Гибрид (владелец 2026-06-10): директорий-ось = ПЕРВИЧНАЯ партиция районов; * 1009 communities = ВТОРИЧНЫЙ cohesion-сигнал (god-node hints внутри района). * Карта районов — ДЁШЕВАЯ «карта метро» (ДР-3 слой-0), НЕ содержимое файлов. */ /** Защитный парс node-link JSON → {nodes, links}. Любой сбой → пустые массивы. */ export function parseGraph(text) { if (typeof text !== 'string') return { nodes: [], links: [] }; let obj; try { obj = JSON.parse(text); } catch { return { nodes: [], links: [] }; } if (!obj || typeof obj !== 'object') return { nodes: [], links: [] }; return { nodes: Array.isArray(obj.nodes) ? obj.nodes : [], links: Array.isArray(obj.links) ? obj.links : [], }; } /** * Правила район<-source_file (ПЕРВИЧНАЯ ось, Гибрид). Порядок ВАЖЕН: specific * префикс ДО generic (docs/superpowers/ до docs/). PLACEHOLDER (V-3/SE8): baseline * из плана; Step 0 enumeration (node -e) режется router-gate'ом → НЕ верифицировано * против живого graph.json. Заземлить выводом enumeration (owner-терминал, Task 8), * сохраняя «specific раньше generic» + OTHER<10%. */ export const DISTRICT_RULES = Object.freeze([ { prefix: 'docs/superpowers/', name: 'docs-superpowers' }, { prefix: 'docs/observer/', name: 'docs-observer' }, { prefix: 'docs/adr/', name: 'docs-adr' }, { prefix: 'docs/registry/', name: 'docs-registry' }, { prefix: 'docs/', name: 'docs' }, { prefix: 'app/resources/js/', name: 'app-frontend' }, { prefix: 'app/tests/', name: 'app-tests' }, { prefix: 'app/database/', name: 'app-db' }, { prefix: 'app/', name: 'app-backend' }, { prefix: 'tools/', name: 'tools' }, { prefix: 'db/', name: 'db' }, { prefix: '.claude/skills/', name: 'claude-skills' }, { prefix: '.claude/agents/', name: 'claude-agents' }, { prefix: '.claude/', name: 'claude-config' }, ]); /** Район узла по source_file (первый совпавший префикс; fallback 'other'). */ export function nodeDistrict(sourceFile, rules = DISTRICT_RULES) { const sf = typeof sourceFile === 'string' ? sourceFile : ''; if (!sf) return 'other'; for (const r of rules) { if (sf.startsWith(r.prefix)) return r.name; } return 'other'; } /** * Junk-префиксы source_file (build/зависимости/осиротевшее). PLACEHOLDER (V-3/SE8): * baseline из плана; 'app/public/build/' подтверждён [needs_update.log build-CSS]. * Step 0 enumeration (node -e) режется router-gate'ом → node_modules/vendor/.git * НЕ верифицированы против живого graph.json → добавлять только подтверждённые (>0) * через owner-терминал enumeration (Task 8). */ export const PRUNE_PREFIXES = Object.freeze([ 'app/public/build/', ]); /** Отсечь узлы с junk-префиксом source_file. @returns {{kept, prunedCount}} */ export function pruneJunk(nodes, prunePrefixes = PRUNE_PREFIXES) { const list = Array.isArray(nodes) ? nodes : []; const kept = []; let prunedCount = 0; for (const n of list) { const sf = (n && typeof n.source_file === 'string') ? n.source_file : ''; if (sf && prunePrefixes.some((p) => sf.startsWith(p))) { prunedCount++; continue; } kept.push(n); } return { kept, prunedCount }; } /** Степень каждого узла = число вхождений его id в source/target всех links. */ export function nodeDegrees(links) { const deg = new Map(); if (!Array.isArray(links)) return deg; for (const l of links) { if (!l || typeof l !== 'object') continue; for (const end of [l.source, l.target]) { if (typeof end === 'string' && end) deg.set(end, (deg.get(end) || 0) + 1); } } return deg; } /** * LAYER-0: карта районов (§5.3). Прунит junk → группирует по району (директорий-ось) * → на район: nodeCount + topNodes (по степени, god-nodes района) + communities * (вторичный cohesion-сигнал, Гибрид). Чистая; links/rules/префиксы инъектируемы. * @returns {Array<{district, nodeCount, topNodes:[{id,label,degree}], communities:number[]}>} */ export function buildDistrictMap(nodes, links, opts = {}) { const rules = opts.rules || DISTRICT_RULES; const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES; const topN = Number.isInteger(opts.topNodesPerDistrict) ? opts.topNodesPerDistrict : 5; const { kept } = pruneJunk(nodes, prunePrefixes); const deg = nodeDegrees(links); const byDistrict = new Map(); for (const n of kept) { if (!n || typeof n !== 'object') continue; const d = nodeDistrict(n.source_file, rules); if (!byDistrict.has(d)) byDistrict.set(d, []); byDistrict.get(d).push(n); } const out = []; for (const [district, ns] of byDistrict) { const ranked = ns .map((n) => ({ id: n.id, label: n.label, degree: deg.get(n.id) || 0 })) .sort((x, y) => (y.degree - x.degree) || String(x.id).localeCompare(String(y.id))) .slice(0, topN); const comms = Array.from(new Set( ns.map((n) => n.community).filter((c) => typeof c === 'number'), )).sort((a, b) => a - b); out.push({ district, nodeCount: ns.length, topNodes: ranked, communities: comms }); } return out.sort((a, b) => b.nodeCount - a.nodeCount || a.district.localeCompare(b.district)); } /** * LAYER-1: детали района (§5.3). Узлы района + группировка по community (вторично, * Гибрид). community:null → ключ 'none'. Прунинг применяется (junk не в детали). * @returns {{district, nodes:[{id,label,file_type,community}], byCommunity:Record}} */ export function districtDetail(nodes, district, opts = {}) { const rules = opts.rules || DISTRICT_RULES; const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES; const { kept } = pruneJunk(nodes, prunePrefixes); const inDistrict = kept.filter( (n) => n && typeof n === 'object' && nodeDistrict(n.source_file, rules) === district, ); const byCommunity = {}; for (const n of inDistrict) { const key = typeof n.community === 'number' ? String(n.community) : 'none'; (byCommunity[key] ||= []).push(n.id); } return { district, nodes: inDistrict.map((n) => ({ id: n.id, label: n.label, file_type: n.file_type, community: n.community ?? null, })), byCommunity, }; } const toInt = (v) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0); /** * ✅O16 каноничное inline staleness-поле. Чистое: примитивы → {stale, commits_behind, * uncommitted}. **СВЕЖО требует ПОЛОЖИТЕЛЬНОГО подтверждения** (§5.4 «сигнал ВСЕГДА»): * stale = needs_update присутствует ИЛИ граф-commit ≠ HEAD ИЛИ невозможно сравнить * (graphCommit/headCommit пуст). SE-B3: нечитаемый граф (битый junction, SE8) НЕ * маскируется под «свежо» — любой пробел сравнения → консервативно stale:true. * SE11: commitsBehind === null сохраняется как null («устарел, величина неизвестна»). */ export function readStaleness(inputs = {}) { const present = inputs.needsUpdatePresent === true; const haveBoth = !!inputs.graphCommit && !!inputs.headCommit; const mismatch = haveBoth && inputs.graphCommit !== inputs.headCommit; return { stale: present || mismatch || !haveBoth, // !haveBoth = свежесть не подтверждена (SE-B3) commits_behind: inputs.commitsBehind === null ? null : toInt(inputs.commitsBehind), uncommitted: toInt(inputs.uncommittedCount), }; } /** * Тонкая обёртка: собирает сырые входы для readStaleness из инъектированных fs/git. * needsUpdatePath — graphify-out/needs_update; graphReportPath — GRAPH_REPORT.md * (строка «Built from commit: ``»). gitImpl(argsArray) → stdout-строка. * SE11: ошибка `git rev-list` (граф-commit вне истории) → commitsBehind = null * (НЕИЗВЕСТНО), НЕ 0. */ export function gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit, needsUpdatePath = '.claude/worktrees/graphify-spike/graphify-out/needs_update', graphReportPath = '.claude/worktrees/graphify-spike/graphify-out/GRAPH_REPORT.md' }) { const needsUpdatePresent = (() => { try { return !!existsImpl(needsUpdatePath); } catch { return false; } })(); let graphCommit = ''; try { const rep = String(readFileImpl(graphReportPath) || ''); const m = rep.match(/Built from commit:\s*`?([0-9a-f]{7,40})`?/i); if (m) graphCommit = m[1]; } catch { graphCommit = ''; } let commitsBehind = null; try { if (graphCommit && headCommit) commitsBehind = toInt(gitImpl(['rev-list', '--count', `${graphCommit}..${headCommit}`])); } catch { commitsBehind = null; } let uncommittedCount = 0; try { const st = String(gitImpl(['status', '--porcelain']) || '').trim(); uncommittedCount = st ? st.split('\n').filter((l) => l.trim()).length : 0; } catch { uncommittedCount = 0; } return { needsUpdatePresent, graphCommit, headCommit, commitsBehind, uncommittedCount }; } /** * CD-R6-G: собрать ОТДЕЛЬНУЮ project-граф секцию промпта (layer-0 карта районов + * inline staleness ✅O16). НЕ skill-каталог. Готова к wiring в buildRouterPrompt (C). * Отсутствующий staleness → fail-safe stale:true (сигнал ВСЕГДА, §5.4). */ export function buildGraphSection({ districtMap = [], staleness } = {}) { const layer0 = Array.isArray(districtMap) ? districtMap : []; const safeStaleness = (staleness && typeof staleness === 'object') ? { stale: !!staleness.stale, // SE-B1: SE11-null («величина неизвестна») сохраняется, НЕ схлопывается в 0. commits_behind: staleness.commits_behind === null ? null : toInt(staleness.commits_behind), uncommitted: toInt(staleness.uncommitted), } : { stale: true, commits_behind: 0, uncommitted: 0 }; return { kind: 'project-graph', districtCount: layer0.length, layer0, staleness: safeStaleness, }; }