Files
brain/tools/project-graph.mjs

227 lines
11 KiB
JavaScript

#!/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<string,string[]>}}
*/
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: `<sha>`»). 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,
};
}