397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|