diff --git a/tools/project-graph.mjs b/tools/project-graph.mjs new file mode 100644 index 00000000..554366cc --- /dev/null +++ b/tools/project-graph.mjs @@ -0,0 +1,226 @@ +#!/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, + }; +} diff --git a/tools/project-graph.test.mjs b/tools/project-graph.test.mjs new file mode 100644 index 00000000..5aa38dba --- /dev/null +++ b/tools/project-graph.test.mjs @@ -0,0 +1,247 @@ +// tools/project-graph.test.mjs +import { describe, it, expect } from 'vitest'; +import { parseGraph, nodeDistrict, DISTRICT_RULES } from './project-graph.mjs'; + +describe('parseGraph', () => { + it('парсит node-link JSON в {nodes, links}', () => { + const text = JSON.stringify({ + directed: false, multigraph: false, graph: {}, + nodes: [{ id: 'a', label: 'A', file_type: 'code', source_file: 'tools/x.mjs', community: 3 }], + links: [{ source: 'a', target: 'b', relation: 'references' }], + }); + const g = parseGraph(text); + expect(g.nodes).toHaveLength(1); + expect(g.links).toHaveLength(1); + expect(g.nodes[0].id).toBe('a'); + }); + it('битый JSON → {nodes:[], links:[]} (не крашит)', () => { + expect(parseGraph('{не json')).toEqual({ nodes: [], links: [] }); + }); + it('отсутствующие массивы → пустые', () => { + expect(parseGraph('{}')).toEqual({ nodes: [], links: [] }); + }); + it('не-строка → {nodes:[], links:[]}', () => { + expect(parseGraph(null)).toEqual({ nodes: [], links: [] }); + }); +}); + +describe('nodeDistrict', () => { + it('docs/superpowers → docs-superpowers', () => { + expect(nodeDistrict('docs/superpowers/specs/x.md')).toBe('docs-superpowers'); + }); + it('docs/observer → docs-observer', () => { + expect(nodeDistrict('docs/observer/STATUS.md')).toBe('docs-observer'); + }); + it('app/resources/js → app-frontend', () => { + expect(nodeDistrict('app/resources/js/views/DealsView.vue')).toBe('app-frontend'); + }); + it('app/app (php backend) → app-backend', () => { + expect(nodeDistrict('app/app/Services/LeadRouter.php')).toBe('app-backend'); + }); + it('tools/ → tools', () => { + expect(nodeDistrict('tools/router-engine.mjs')).toBe('tools'); + }); + it('неизвестный/пустой источник → other', () => { + expect(nodeDistrict('some/random/thing.txt')).toBe('other'); + expect(nodeDistrict('')).toBe('other'); + expect(nodeDistrict(null)).toBe('other'); + }); + it('DISTRICT_RULES заморожен и упорядочен (specific перед generic)', () => { + expect(Object.isFrozen(DISTRICT_RULES)).toBe(true); + const docsSuperIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/superpowers/'); + const docsIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/'); + expect(docsSuperIdx).toBeGreaterThanOrEqual(0); + expect(docsSuperIdx).toBeLessThan(docsIdx); // specific раньше generic + }); +}); + +import { pruneJunk, PRUNE_PREFIXES } from './project-graph.mjs'; + +describe('pruneJunk', () => { + const nodes = [ + { id: '1', source_file: 'docs/x.md' }, + { id: '2', source_file: 'app/public/build/assets/app-x.css' }, + { id: '3', source_file: 'tools/router-engine.mjs' }, + { id: '4', source_file: null }, + ]; + it('узлы с junk-префиксом отсечены, остальные сохранены', () => { + const r = pruneJunk(nodes, ['app/public/build/']); + expect(r.kept.map((n) => n.id)).toEqual(['1', '3', '4']); + expect(r.prunedCount).toBe(1); + }); + it('PRUNE_PREFIXES заморожен и включает подтверждённый build', () => { + expect(Object.isFrozen(PRUNE_PREFIXES)).toBe(true); + expect(PRUNE_PREFIXES).toContain('app/public/build/'); + }); + it('не-массив → {kept:[], prunedCount:0}', () => { + expect(pruneJunk(null, PRUNE_PREFIXES)).toEqual({ kept: [], prunedCount: 0 }); + }); +}); + +import { nodeDegrees, buildDistrictMap } from './project-graph.mjs'; + +describe('nodeDegrees', () => { + it('считает степень по вхождениям id в source/target', () => { + const links = [ + { source: 'a', target: 'b' }, + { source: 'a', target: 'c' }, + { source: 'b', target: 'c' }, + ]; + const deg = nodeDegrees(links); + expect(deg.get('a')).toBe(2); + expect(deg.get('b')).toBe(2); + expect(deg.get('c')).toBe(2); + }); + it('пустые/битые links → пустая карта', () => { + expect(nodeDegrees(null).size).toBe(0); + }); +}); + +describe('buildDistrictMap (layer-0)', () => { + const nodes = [ + { id: 'a', label: 'A', source_file: 'tools/router-engine.mjs', community: 5 }, + { id: 'b', label: 'B', source_file: 'tools/judge-engine.mjs', community: 5 }, + { id: 'c', label: 'C', source_file: 'tools/escape-grant.mjs', community: 7 }, + { id: 'd', label: 'D', source_file: 'docs/observer/STATUS.md', community: null }, + { id: 'j', label: 'J', source_file: 'app/public/build/assets/x.css', community: 1 }, + ]; + const links = [ + { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'b', target: 'c' }, + ]; + it('район tools имеет 3 узла, топ по степени, communities вторично', () => { + const map = buildDistrictMap(nodes, links, { topNodesPerDistrict: 2 }); + const tools = map.find((d) => d.district === 'tools'); + expect(tools.nodeCount).toBe(3); + expect(tools.topNodes[0].id).toBe('a'); // степень 2 (наибольшая, lexically первый при равенстве) + expect(tools.topNodes).toHaveLength(2); // cap + expect(tools.communities).toEqual([5, 7]); // вторичный сигнал, non-null, отсортирован + }); + it('junk-узлы (build) НЕ попадают в карту', () => { + const map = buildDistrictMap(nodes, links, {}); + expect(map.find((d) => d.district === 'app-frontend' || d.district === 'app-backend')).toBeUndefined(); + expect(map.every((d) => d.district !== 'other' || d.nodeCount > 0)).toBe(true); + }); + it('район docs-observer: 1 узел, communities пуст (все null)', () => { + const map = buildDistrictMap(nodes, links, {}); + const obs = map.find((d) => d.district === 'docs-observer'); + expect(obs.nodeCount).toBe(1); + expect(obs.communities).toEqual([]); + }); +}); + +import { districtDetail } from './project-graph.mjs'; + +describe('districtDetail (layer-1)', () => { + const nodes = [ + { id: 'a', label: 'A', file_type: 'code', source_file: 'tools/router-engine.mjs', community: 5 }, + { id: 'b', label: 'B', file_type: 'code', source_file: 'tools/judge-engine.mjs', community: 5 }, + { id: 'c', label: 'C', file_type: 'code', source_file: 'tools/escape-grant.mjs', community: 7 }, + { id: 'd', label: 'D', file_type: 'document', source_file: 'docs/observer/STATUS.md', community: null }, + ]; + it('возвращает узлы района + группировку по community', () => { + const r = districtDetail(nodes, 'tools', {}); + expect(r.district).toBe('tools'); + expect(r.nodes.map((n) => n.id).sort()).toEqual(['a', 'b', 'c']); + expect(r.byCommunity['5'].sort()).toEqual(['a', 'b']); + expect(r.byCommunity['7']).toEqual(['c']); + }); + it('пустой район → пустые', () => { + const r = districtDetail(nodes, 'nope', {}); + expect(r.nodes).toEqual([]); + expect(r.byCommunity).toEqual({}); + }); + it('узлы с community:null группируются под "none"', () => { + const r = districtDetail(nodes, 'docs-observer', {}); + expect(r.byCommunity.none).toEqual(['d']); + }); +}); + +import { readStaleness, gatherStalenessInputs } from './project-graph.mjs'; + +describe('readStaleness (✅O16 pure)', () => { + it('needs_update присутствует → stale true', () => { + const r = readStaleness({ needsUpdatePresent: true, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 }); + expect(r).toEqual({ stale: true, commits_behind: 0, uncommitted: 0 }); + }); + it('commit рассинхрон → stale true даже без needs_update', () => { + const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'bbb', commitsBehind: 12, uncommittedCount: 3 }); + expect(r).toEqual({ stale: true, commits_behind: 12, uncommitted: 3 }); + }); + it('всё чисто → stale false', () => { + const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 }); + expect(r).toEqual({ stale: false, commits_behind: 0, uncommitted: 0 }); + }); + it('кривые числа → 0 (не NaN)', () => { + const r = readStaleness({ needsUpdatePresent: true, commitsBehind: 'x', uncommittedCount: undefined }); + expect(r.commits_behind).toBe(0); + expect(r.uncommitted).toBe(0); + }); + it('commits_behind=null сохраняется (SE11: устарел, величина неизвестна)', () => { + const r = readStaleness({ needsUpdatePresent: true, commitsBehind: null, uncommittedCount: 2 }); + expect(r.stale).toBe(true); + expect(r.commits_behind).toBe(null); + expect(r.uncommitted).toBe(2); + }); +}); + +describe('gatherStalenessInputs (обёртка через моки)', () => { + it('собирает входы из инъектированных fs/git', () => { + const readFileImpl = (p) => p.endsWith('needs_update') ? 'Run /graphify --update (4 changed)' : 'Built from commit: `81cbd8c1`'; + const existsImpl = () => true; + const gitImpl = (args) => args.includes('rev-list') ? '7' : ' M a.mjs\n?? b.mjs'; + const inp = gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit: 'deadbeef' }); + expect(inp.needsUpdatePresent).toBe(true); + expect(inp.graphCommit).toBe('81cbd8c1'); + expect(inp.commitsBehind).toBe(7); + expect(inp.uncommittedCount).toBe(2); + }); + it('git rev-list бросил (граф-commit вне истории) → commitsBehind null (SE11)', () => { + const readFileImpl = () => 'Built from commit: `81cbd8c1`'; + const existsImpl = () => false; + const gitImpl = (args) => { if (args.includes('rev-list')) throw new Error('bad revision'); return ''; }; + const inp = gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit: 'deadbeef' }); + expect(inp.commitsBehind).toBe(null); + }); +}); + +import { buildGraphSection } from './project-graph.mjs'; + +describe('buildGraphSection (CD-R6-G)', () => { + const districtMap = [ + { district: 'tools', nodeCount: 3, topNodes: [{ id: 'a', label: 'A', degree: 2 }], communities: [5, 7] }, + { district: 'docs-observer', nodeCount: 1, topNodes: [], communities: [] }, + ]; + const staleness = { stale: true, commits_behind: 12, uncommitted: 3 }; + it('секция содержит kind, районы (layer-0) и inline staleness', () => { + const s = buildGraphSection({ districtMap, staleness }); + expect(s.kind).toBe('project-graph'); + expect(s.layer0).toBe(districtMap); + expect(s.staleness).toEqual(staleness); + expect(s.districtCount).toBe(2); + }); + it('пустая карта → districtCount 0, staleness сохранён', () => { + const s = buildGraphSection({ districtMap: [], staleness }); + expect(s.districtCount).toBe(0); + expect(s.staleness.stale).toBe(true); + }); + it('отсутствующий staleness → безопасный дефолт stale:true (fail-safe сигнал)', () => { + const s = buildGraphSection({ districtMap: [] }); + expect(s.staleness.stale).toBe(true); // нет сигнала → считаем сталым (§5.4 сигнал ВСЕГДА) + }); + it('commits_behind=null сохраняется в секции (SE-B1: SE11 не схлопывается в 0)', () => { + const s = buildGraphSection({ districtMap: [], staleness: { stale: true, commits_behind: null, uncommitted: 2 } }); + expect(s.staleness.commits_behind).toBe(null); + expect(s.staleness.uncommitted).toBe(2); + }); +}); + +describe('readStaleness — SE-B3 (нечитаемый граф → консервативно stale)', () => { + it('graphCommit пуст (не прочитан) при наличии headCommit → stale:true', () => { + const r = readStaleness({ needsUpdatePresent: false, graphCommit: '', headCommit: 'deadbeef', commitsBehind: null, uncommittedCount: 0 }); + expect(r.stale).toBe(true); + }); + it('headCommit пуст → stale:true (свежесть не подтверждена)', () => { + const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: '', commitsBehind: 0, uncommittedCount: 0 }); + expect(r.stale).toBe(true); + }); +});