feat(project-graph): слоистый граф районов + staleness O16 (sub-plan B Tasks 1-8 + SE7/SE8/SE11/V-3 + sharp-edges B1/B3)
This commit is contained in:
@@ -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<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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user