@ghostlygawd/codeweb 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +30 -0
- package/CHANGELOG.md +480 -0
- package/LICENSE +21 -0
- package/README.md +573 -0
- package/agents/codeweb-dissector.md +56 -0
- package/agents/codeweb-domain-mapper.md +63 -0
- package/commands/codeweb.md +57 -0
- package/hooks/hooks.json +47 -0
- package/hooks/post-edit-diff.mjs +83 -0
- package/hooks/pre-edit-impact.mjs +94 -0
- package/hooks/session-brief.mjs +53 -0
- package/package.json +64 -0
- package/scripts/annotate.mjs +46 -0
- package/scripts/bench-ts-engine.mjs +59 -0
- package/scripts/bench.mjs +133 -0
- package/scripts/break-cycles.mjs +0 -0
- package/scripts/brief.mjs +28 -0
- package/scripts/build-report.mjs +182 -0
- package/scripts/campaign.mjs +61 -0
- package/scripts/check-consistency.mjs +45 -0
- package/scripts/ci-gate.mjs +86 -0
- package/scripts/cluster3.mjs +112 -0
- package/scripts/codemod.mjs +130 -0
- package/scripts/context-pack.mjs +118 -0
- package/scripts/deadcode.mjs +96 -0
- package/scripts/diff.mjs +0 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/extract-symbols.mjs +1307 -0
- package/scripts/find-similar.mjs +86 -0
- package/scripts/find.mjs +50 -0
- package/scripts/fitness.mjs +90 -0
- package/scripts/grammars/PROVENANCE.md +36 -0
- package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
- package/scripts/grammars/tree-sitter-go.wasm +0 -0
- package/scripts/grammars/tree-sitter-java.wasm +0 -0
- package/scripts/grammars/tree-sitter-python.wasm +0 -0
- package/scripts/grammars/tree-sitter-rust.wasm +0 -0
- package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
- package/scripts/hotspots.mjs +53 -0
- package/scripts/lib/annotations.mjs +51 -0
- package/scripts/lib/bench-core.mjs +178 -0
- package/scripts/lib/brief-core.mjs +92 -0
- package/scripts/lib/campaign.mjs +73 -0
- package/scripts/lib/claims-check.mjs +44 -0
- package/scripts/lib/cli.mjs +140 -0
- package/scripts/lib/complexity.mjs +68 -0
- package/scripts/lib/dup-check.mjs +51 -0
- package/scripts/lib/find-core.mjs +85 -0
- package/scripts/lib/gate-md.mjs +50 -0
- package/scripts/lib/graph-ops.mjs +319 -0
- package/scripts/lib/hotspots.mjs +34 -0
- package/scripts/lib/query-core.mjs +85 -0
- package/scripts/lib/reading-order.mjs +53 -0
- package/scripts/lib/reliance.mjs +143 -0
- package/scripts/lib/risk.mjs +18 -0
- package/scripts/lib/shingles.mjs +39 -0
- package/scripts/lib/skeleton.mjs +41 -0
- package/scripts/lib/stats.mjs +92 -0
- package/scripts/lib/ts-engine.mjs +605 -0
- package/scripts/mcp-server.mjs +370 -0
- package/scripts/optimize.mjs +152 -0
- package/scripts/overlap.mjs +380 -0
- package/scripts/placement.mjs +93 -0
- package/scripts/query.mjs +94 -0
- package/scripts/reading-order.mjs +36 -0
- package/scripts/refresh.mjs +73 -0
- package/scripts/release-utils.mjs +158 -0
- package/scripts/release.mjs +62 -0
- package/scripts/report-template.html +730 -0
- package/scripts/review.mjs +112 -0
- package/scripts/risk.mjs +77 -0
- package/scripts/run.mjs +96 -0
- package/scripts/screenshot.mjs +104 -0
- package/scripts/simulate-edit.mjs +70 -0
- package/scripts/stats.mjs +31 -0
- package/scripts/trend.mjs +147 -0
- package/scripts/version-sync.mjs +19 -0
- package/skills/codebase-anatomy/SKILL.md +174 -0
- package/skills/codebase-anatomy/references/engine-detection.md +49 -0
- package/skills/codebase-anatomy/references/graph-schema.md +120 -0
- package/skills/codebase-anatomy/references/overlap-heuristics.md +52 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb bench — run the grep-vs-codeweb comparison on YOUR repo, graded by YOUR compiler.
|
|
3
|
+
//
|
|
4
|
+
// The published oracle A/B (bench/results/oracle-ab.json) is a paper artifact; this is the same
|
|
5
|
+
// engine (scripts/lib/bench-core.mjs, shared verbatim) packaged as a product command, so anyone
|
|
6
|
+
// can generate their own evidence — and a recall dip on a real repo is a bug report with a
|
|
7
|
+
// built-in reproducer.
|
|
8
|
+
//
|
|
9
|
+
// Q1 "who depends on X?" cost per task + (TS repos) recall/precision vs the compiler
|
|
10
|
+
// Q2 "what transitively breaks?" one budgeted impact call vs the recursive grep loop
|
|
11
|
+
//
|
|
12
|
+
// usage: bench.mjs <graph.json> [--scope <rel>] [--n 30] [--seed 42] [--json] [--out <file>]
|
|
13
|
+
// --scope repo-relative dir to sample from (default: the whole mapped root)
|
|
14
|
+
// --n symbols to sample (default 30; deterministic given --seed)
|
|
15
|
+
// --json emit the full result object instead of the text summary
|
|
16
|
+
// --out also write the result object to a file
|
|
17
|
+
// Optional at runtime: ripgrep (grep arm; absent -> reported unavailable) and the `typescript`
|
|
18
|
+
// package resolvable from cwd or TS_MODULE (compiler grading; absent -> cost-only).
|
|
19
|
+
|
|
20
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
import { die, emitJson, emitText, loadGraph } from './lib/cli.mjs';
|
|
23
|
+
import { buildIndex } from './lib/graph-ops.mjs';
|
|
24
|
+
import { loadTypescript, rgAvailable, sampleSymbols, makeTsOracle, codewebArm, grepArm, score, impactCost, mean, aggArm, aggImpact } from './lib/bench-core.mjs';
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const USAGE = 'usage: bench.mjs <graph.json> [--scope <rel>] [--n 30] [--seed 42] [--json] [--out <file>]';
|
|
28
|
+
let n = 30, seed = 42, scopeArg = '', outPath = null, asJson = false; const pos = [];
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
if (argv[i] === '--n') n = Math.max(1, parseInt(argv[++i], 10) || 30);
|
|
31
|
+
else if (argv[i] === '--seed') seed = parseInt(argv[++i], 10) || 42;
|
|
32
|
+
else if (argv[i] === '--scope') scopeArg = String(argv[++i] || '').replace(/^\/+|\/+$/g, '');
|
|
33
|
+
else if (argv[i] === '--out') outPath = argv[++i];
|
|
34
|
+
else if (argv[i] === '--json') asJson = true;
|
|
35
|
+
else if (!argv[i].startsWith('-')) pos.push(argv[i]);
|
|
36
|
+
}
|
|
37
|
+
const { graph } = loadGraph(pos[0], { usage: USAGE });
|
|
38
|
+
const index = buildIndex(graph);
|
|
39
|
+
const root = graph.meta?.root;
|
|
40
|
+
if (!root || !existsSync(root)) die('graph.meta.root missing or absent on disk — bench needs the mapped source (rebuild the graph where the source lives)', 2);
|
|
41
|
+
const srcRoot = scopeArg ? join(root, scopeArg) : root;
|
|
42
|
+
if (!existsSync(srcRoot)) die(`--scope not found under the mapped root: ${srcRoot}`, 2);
|
|
43
|
+
const scope = scopeArg;
|
|
44
|
+
|
|
45
|
+
const { sample } = sampleSymbols(graph, index, { scope, n, seed });
|
|
46
|
+
if (!sample.length) die(`no benchable symbols in scope "${scope || '(whole repo)'}" — need exported product functions/classes with fan-in >= 3 (is the graph fresh? is the scope right?)`, 2);
|
|
47
|
+
|
|
48
|
+
// oracle: optional, TS-only (the compiler grades its own language)
|
|
49
|
+
const ts = loadTypescript();
|
|
50
|
+
let oracle = null, oracleReason = 'typescript not resolvable from cwd (npm i typescript, or set TS_MODULE)';
|
|
51
|
+
if (ts) {
|
|
52
|
+
const o = makeTsOracle(ts, { srcRoot, graphRoot: root });
|
|
53
|
+
if (o.tsFileCount > 0) { oracle = o; oracleReason = null; }
|
|
54
|
+
else oracleReason = 'no TypeScript files in scope — cost-only (grading needs a compiler oracle)';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hasRg = rgAvailable();
|
|
58
|
+
const rows = [];
|
|
59
|
+
let oracleExcluded = 0;
|
|
60
|
+
for (const node of sample) {
|
|
61
|
+
const truth = oracle ? oracle.oracleFiles(node) : undefined; // undefined = ungraded run, null = oracle couldn't resolve
|
|
62
|
+
if (oracle && !truth) { oracleExcluded++; continue; }
|
|
63
|
+
const cw = codewebArm(graph, index, node);
|
|
64
|
+
cw.files.delete(node.file);
|
|
65
|
+
const gr = hasRg ? grepArm(node, { srcRoot, graphRoot: root }) : null;
|
|
66
|
+
const row = {
|
|
67
|
+
symbol: node.id, label: node.label,
|
|
68
|
+
codeweb: { files: cw.files.size, bytes: cw.bytes, pages: cw.pages },
|
|
69
|
+
grep: gr ? { files: gr.files.size, bytes: gr.bytes } : null,
|
|
70
|
+
impact: impactCost(graph, index, node, { srcRoot }),
|
|
71
|
+
};
|
|
72
|
+
if (oracle) {
|
|
73
|
+
const truthNoDecl = new Set([...truth].filter((f) => f !== node.file));
|
|
74
|
+
row.truthFiles = truthNoDecl.size;
|
|
75
|
+
Object.assign(row.codeweb, score(cw.files, truthNoDecl));
|
|
76
|
+
if (gr) Object.assign(row.grep, score(gr.files, truthNoDecl));
|
|
77
|
+
}
|
|
78
|
+
rows.push(row);
|
|
79
|
+
}
|
|
80
|
+
if (!rows.length) die(`oracle excluded all ${sample.length} sampled symbol(s) — nothing gradable (try a different --scope or --seed)`, 2);
|
|
81
|
+
|
|
82
|
+
const kb = (b) => `${Math.round(b / 1024 * 10) / 10}KB`;
|
|
83
|
+
const result = {
|
|
84
|
+
design: 'grep-vs-codeweb on this repo, same engine as bench/results/oracle-ab.json: control=rg dump, treatment=codeweb --dependents (budgeted pages of 20); blast radius = one codeweb_impact call vs the graph-assisted recursive grep loop; grading (when available) = TS LanguageService references, file-level.',
|
|
85
|
+
corpus: { root, scope: scope || '(whole repo)', sampled: sample.length, benched: rows.length, oracleExcluded, seed },
|
|
86
|
+
oracle: oracle ? { available: true, gradedBy: 'typescript LanguageService' } : { available: false, reason: oracleReason },
|
|
87
|
+
grepAvailable: hasRg,
|
|
88
|
+
codeweb: oracle && hasRg ? aggArm(rows, 'codeweb') : {
|
|
89
|
+
meanBytes: Math.round(mean(rows.map((r) => r.codeweb.bytes))),
|
|
90
|
+
totalBytes: rows.reduce((s, r) => s + r.codeweb.bytes, 0),
|
|
91
|
+
...(oracle ? { meanRecall: +mean(rows.map((r) => r.codeweb.recall)).toFixed(3), meanPrecision: +mean(rows.map((r) => r.codeweb.precision)).toFixed(3) } : {}),
|
|
92
|
+
},
|
|
93
|
+
grep: hasRg ? (oracle ? aggArm(rows, 'grep') : {
|
|
94
|
+
meanBytes: Math.round(mean(rows.map((r) => r.grep.bytes))),
|
|
95
|
+
totalBytes: rows.reduce((s, r) => s + r.grep.bytes, 0),
|
|
96
|
+
}) : { unavailable: 'ripgrep (rg) not on PATH' },
|
|
97
|
+
impact: hasRg ? aggImpact(rows) : {
|
|
98
|
+
meanImpactSize: Math.round(mean(rows.map((r) => r.impact.impactSize))),
|
|
99
|
+
codewebMeanBytes: Math.round(mean(rows.map((r) => r.impact.codewebBytes))),
|
|
100
|
+
grepUnavailable: 'ripgrep (rg) not on PATH',
|
|
101
|
+
},
|
|
102
|
+
tokensNote: 'tokens ~ bytes/4; cost = what the channel injects into the agent context',
|
|
103
|
+
rows,
|
|
104
|
+
};
|
|
105
|
+
if (outPath) writeFileSync(resolve(outPath), JSON.stringify(result, null, 2));
|
|
106
|
+
if (asJson) { emitJson(result); }
|
|
107
|
+
else {
|
|
108
|
+
const L = [];
|
|
109
|
+
L.push(`codeweb bench — ${root} (scope: ${scope || 'whole repo'}, ${rows.length} symbols, seed ${seed})`);
|
|
110
|
+
L.push('');
|
|
111
|
+
L.push(' Q1 "who depends on X?" — context cost per task');
|
|
112
|
+
L.push(` codeweb ${kb(result.codeweb.meanBytes)}/task (budgeted pages, as MCP serves it)`);
|
|
113
|
+
if (hasRg) {
|
|
114
|
+
const ratio = result.codeweb.meanBytes ? (result.grep.meanBytes / result.codeweb.meanBytes).toFixed(1) : '—';
|
|
115
|
+
L.push(` grep ${kb(result.grep.meanBytes)}/task (the rg dump an agent must read) ${ratio}x more context`);
|
|
116
|
+
} else L.push(' grep unavailable (install ripgrep to compare)');
|
|
117
|
+
if (oracle) {
|
|
118
|
+
L.push(` graded by the TypeScript compiler (${rows.length}/${sample.length} oracle-resolvable):`);
|
|
119
|
+
L.push(` codeweb recall ${result.codeweb.meanRecall} precision ${result.codeweb.meanPrecision}`);
|
|
120
|
+
if (hasRg) {
|
|
121
|
+
L.push(` grep recall ${result.grep.meanRecall} precision ${result.grep.meanPrecision}`);
|
|
122
|
+
const wins = rows.filter((r) => r.codeweb.recall >= r.grep.recall).length;
|
|
123
|
+
L.push(` per-task recall: codeweb >= grep on ${wins}/${rows.length}`);
|
|
124
|
+
}
|
|
125
|
+
} else L.push(` ungraded — ${oracleReason}`);
|
|
126
|
+
L.push('');
|
|
127
|
+
L.push(' Q2 "what transitively breaks?" — blast radius');
|
|
128
|
+
L.push(` codeweb ${kb(result.impact.codewebMeanBytes)}/call (one budgeted impact call, mean closure ${result.impact.meanImpactSize} symbols)`);
|
|
129
|
+
if (hasRg) L.push(` grep ${kb(result.impact.grepMeanBytes)}/loop (${result.impact.grepMeanRounds} rounds, graph-assisted lower bound) ${result.impact.costRatio}x more context`);
|
|
130
|
+
else L.push(' grep unavailable (install ripgrep to compare)');
|
|
131
|
+
if (outPath) { L.push(''); L.push(` full rows: ${resolve(outPath)}`); }
|
|
132
|
+
emitText(L.join('\n'));
|
|
133
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb brief — the day-one page: what this repo is, where things live, what everything
|
|
3
|
+
// hangs off, where the tests are, what's already known to be wrong. The first call of a
|
|
4
|
+
// session (or injected automatically by hooks/session-brief.mjs), replacing the exploratory
|
|
5
|
+
// token burn with ~2KB of pre-computed orientation.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/brief.mjs <graph.json> [--json]
|
|
8
|
+
|
|
9
|
+
import { die, emitJson, emitText, loadGraph, checkStaleness } from './lib/cli.mjs';
|
|
10
|
+
import { buildIndex } from './lib/graph-ops.mjs';
|
|
11
|
+
import { buildBrief, renderBrief } from './lib/brief-core.mjs';
|
|
12
|
+
import { attachActivity } from './lib/stats.mjs';
|
|
13
|
+
|
|
14
|
+
const USAGE = 'usage: brief.mjs <graph.json> [--json]';
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
let json = false; const pos = [];
|
|
17
|
+
for (const t of argv) { if (t === '--json') json = true; else if (!t.startsWith('-')) pos.push(t); else die(USAGE, 2); }
|
|
18
|
+
|
|
19
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
20
|
+
const brief = attachActivity(buildBrief(graph, buildIndex(graph)), abs);
|
|
21
|
+
const stale = checkStaleness(graph);
|
|
22
|
+
if (stale) brief.stale = stale;
|
|
23
|
+
|
|
24
|
+
if (json) { emitJson(brief); } else {
|
|
25
|
+
let text = renderBrief(brief);
|
|
26
|
+
if (stale) text += `\nnote: graph is stale for ${stale.count}+ file(s) — run codeweb_refresh for current numbers.`;
|
|
27
|
+
emitText(text);
|
|
28
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb report renderer: graph.json -> self-contained interactive report.html
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node build-report.mjs [path/to/graph.json] [--out report.html] [--open]
|
|
6
|
+
//
|
|
7
|
+
// Reads a codeweb graph (see skills/codebase-anatomy/references/graph-schema.md), normalizes it
|
|
8
|
+
// (defaults, dangling-edge removal, computed stats), injects it into report-template.html, and
|
|
9
|
+
// writes a single self-contained HTML file. No network/CDN required.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const a = { _: [], open: false, out: null, md: true };
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const t = argv[i];
|
|
22
|
+
if (t === '--open') a.open = true;
|
|
23
|
+
else if (t === '--no-md') a.md = false;
|
|
24
|
+
else if (t === '--out') a.out = argv[++i];
|
|
25
|
+
else a._.push(t);
|
|
26
|
+
}
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const args = parseArgs(process.argv.slice(2));
|
|
31
|
+
const graphPath = resolve(args._[0] || join('.codeweb', 'graph.json'));
|
|
32
|
+
|
|
33
|
+
if (!existsSync(graphPath)) {
|
|
34
|
+
console.error(`[codeweb] graph not found: ${graphPath}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let graph;
|
|
39
|
+
try {
|
|
40
|
+
graph = JSON.parse(readFileSync(graphPath, 'utf8'));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error(`[codeweb] invalid JSON in ${graphPath}: ${e.message}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- normalize ---
|
|
47
|
+
graph.meta = graph.meta || {};
|
|
48
|
+
graph.nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
49
|
+
graph.edges = Array.isArray(graph.edges) ? graph.edges : [];
|
|
50
|
+
graph.domains = Array.isArray(graph.domains) ? graph.domains : [];
|
|
51
|
+
graph.overlaps = Array.isArray(graph.overlaps) ? graph.overlaps : [];
|
|
52
|
+
|
|
53
|
+
for (const n of graph.nodes) if (!n.domain) n.domain = 'unassigned';
|
|
54
|
+
|
|
55
|
+
const ids = new Set(graph.nodes.map((n) => n.id));
|
|
56
|
+
const beforeEdges = graph.edges.length;
|
|
57
|
+
graph.edges = graph.edges.filter((e) => ids.has(e.from) && ids.has(e.to));
|
|
58
|
+
const droppedEdges = beforeEdges - graph.edges.length;
|
|
59
|
+
|
|
60
|
+
if (graph.domains.length === 0) {
|
|
61
|
+
const counts = {};
|
|
62
|
+
for (const n of graph.nodes) counts[n.domain] = (counts[n.domain] || 0) + 1;
|
|
63
|
+
graph.domains = Object.entries(counts).map(([name, nodes]) => ({ name, nodes, summary: '' }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
graph.meta.generatedAt = new Date().toISOString();
|
|
67
|
+
graph.meta.stats = {
|
|
68
|
+
files: new Set(graph.nodes.map((n) => n.file).filter(Boolean)).size,
|
|
69
|
+
nodes: graph.nodes.length,
|
|
70
|
+
edges: graph.edges.length,
|
|
71
|
+
domains: graph.domains.length,
|
|
72
|
+
overlaps: graph.overlaps.length,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Persist the normalized graph back to disk so graph.json matches what the report renders:
|
|
76
|
+
// generatedAt, the computed meta.stats, default-filled domains, and the dangling-edge drop
|
|
77
|
+
// become part of the machine-readable artifact (they previously lived only in report.html/md).
|
|
78
|
+
// Minified, matching the upstream writers (cluster3.mjs / overlap.mjs).
|
|
79
|
+
writeFileSync(graphPath, JSON.stringify(graph));
|
|
80
|
+
|
|
81
|
+
// --- render ---
|
|
82
|
+
const templatePath = join(here, 'report-template.html');
|
|
83
|
+
if (!existsSync(templatePath)) {
|
|
84
|
+
console.error(`[codeweb] template missing: ${templatePath}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const template = readFileSync(templatePath, 'utf8');
|
|
88
|
+
|
|
89
|
+
// The report is self-contained and shareable (teammate, blog post, GitHub Pages), so it must not
|
|
90
|
+
// embed the absolute LOCAL source path. meta.root is only a disk pointer the query tools use to
|
|
91
|
+
// read bodies — graph.json on disk (written above) keeps it; the template renders meta.target,
|
|
92
|
+
// never meta.root. Strip it from the embedded copy without mutating the on-disk graph.
|
|
93
|
+
const embed = { ...graph, meta: { ...graph.meta } };
|
|
94
|
+
delete embed.meta.root;
|
|
95
|
+
|
|
96
|
+
// Escape "<" so the JSON can live inside a <script type="application/json"> tag without ever
|
|
97
|
+
// forming "</script>". Inside JSON, "<" only appears within string values, where < is a
|
|
98
|
+
// valid escape that decodes back to "<".
|
|
99
|
+
const json = JSON.stringify(embed).replace(/</g, '\\u003c');
|
|
100
|
+
|
|
101
|
+
// Function replacement avoids String.replace's $-pattern interpretation in the payload.
|
|
102
|
+
const html = template.replace('__GRAPH_DATA__', () => json);
|
|
103
|
+
|
|
104
|
+
const outPath = args.out ? resolve(args.out) : join(dirname(graphPath), 'report.html');
|
|
105
|
+
writeFileSync(outPath, html);
|
|
106
|
+
|
|
107
|
+
const s = graph.meta.stats;
|
|
108
|
+
console.log(`[codeweb] wrote ${outPath}`);
|
|
109
|
+
console.log(
|
|
110
|
+
`[codeweb] ${s.nodes} nodes, ${s.edges} edges, ${s.domains} domains, ${s.overlaps} overlaps` +
|
|
111
|
+
(droppedEdges ? ` (dropped ${droppedEdges} dangling edges)` : '')
|
|
112
|
+
);
|
|
113
|
+
console.log(`[codeweb] updated ${graphPath} (meta.generatedAt + meta.stats persisted)`);
|
|
114
|
+
|
|
115
|
+
if (args.md) {
|
|
116
|
+
const mdPath = join(dirname(outPath), 'report.md');
|
|
117
|
+
writeFileSync(mdPath, buildMermaid(graph));
|
|
118
|
+
console.log(`[codeweb] wrote ${mdPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (args.open) {
|
|
122
|
+
const cmd =
|
|
123
|
+
process.platform === 'win32'
|
|
124
|
+
? `start "" "${outPath}"`
|
|
125
|
+
: process.platform === 'darwin'
|
|
126
|
+
? `open "${outPath}"`
|
|
127
|
+
: `xdg-open "${outPath}"`;
|
|
128
|
+
try {
|
|
129
|
+
execSync(cmd, { stdio: 'ignore', shell: true });
|
|
130
|
+
} catch {
|
|
131
|
+
/* opening is best-effort */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Mermaid / markdown fallback (renders on GitHub, no JS) ---
|
|
136
|
+
function mmId(s) { return 'd_' + String(s).replace(/[^A-Za-z0-9_]/g, '_'); }
|
|
137
|
+
function buildMermaid(g) {
|
|
138
|
+
const byId = new Map((g.nodes || []).map((n) => [n.id, n]));
|
|
139
|
+
const m = g.meta || {}, st = m.stats || {};
|
|
140
|
+
const out = [
|
|
141
|
+
'# codeweb — system map (static)',
|
|
142
|
+
'',
|
|
143
|
+
'> Auto-generated by codeweb. Open `report.html` for the interactive version.',
|
|
144
|
+
'',
|
|
145
|
+
`**Target:** ${m.target || '?'}${m.mode ? ' · ' + m.mode : ''} · ` +
|
|
146
|
+
`${st.nodes || 0} nodes · ${st.edges || 0} edges · ${st.domains || 0} domains · ${st.overlaps || 0} overlaps` +
|
|
147
|
+
`${m.engine ? ' · engine: ' + m.engine : ''}`,
|
|
148
|
+
'',
|
|
149
|
+
'## Domain graph',
|
|
150
|
+
'',
|
|
151
|
+
'```mermaid',
|
|
152
|
+
'flowchart LR',
|
|
153
|
+
];
|
|
154
|
+
const cnt = {};
|
|
155
|
+
(g.nodes || []).forEach((n) => { cnt[n.domain] = (cnt[n.domain] || 0) + 1; });
|
|
156
|
+
const domains = (g.domains && g.domains.length ? g.domains.map((d) => d.name) : Object.keys(cnt));
|
|
157
|
+
domains.forEach((d) => out.push(` ${mmId(d)}["${d} (${cnt[d] || 0})"]`));
|
|
158
|
+
const agg = new Map();
|
|
159
|
+
(g.edges || []).forEach((e) => {
|
|
160
|
+
const a = byId.get(e.from), b = byId.get(e.to);
|
|
161
|
+
if (!a || !b || a.domain === b.domain) return;
|
|
162
|
+
const k = a.domain < b.domain ? a.domain + '|' + b.domain : b.domain + '|' + a.domain;
|
|
163
|
+
agg.set(k, (agg.get(k) || 0) + (e.weight || 1));
|
|
164
|
+
});
|
|
165
|
+
for (const [k, w] of agg) { const p = k.split('|'); out.push(` ${mmId(p[0])} ===|${w}| ${mmId(p[1])}`); }
|
|
166
|
+
out.push('```', '', '## Overlap / consolidation opportunities', '');
|
|
167
|
+
const ov = g.overlaps || [];
|
|
168
|
+
if (!ov.length) { out.push('_None recorded._', ''); }
|
|
169
|
+
else {
|
|
170
|
+
const rank = { high: 0, medium: 1, low: 2 };
|
|
171
|
+
ov.slice().sort((a, b) => (rank[a.severity] == null ? 3 : rank[a.severity]) - (rank[b.severity] == null ? 3 : rank[b.severity]))
|
|
172
|
+
.forEach((o, i) => {
|
|
173
|
+
out.push(`### ${i + 1}. ${o.title || '(untitled)'} — \`${o.severity || 'low'}\``);
|
|
174
|
+
out.push(`- **kind:** ${o.kind || '?'} · **domains:** ${(o.domains || []).join(', ')}`);
|
|
175
|
+
if (o.evidence) out.push(`- **evidence:** ${o.evidence}`);
|
|
176
|
+
if (o.recommendation) out.push(`- **→ consolidate:** ${o.recommendation}`);
|
|
177
|
+
if ((o.nodes || []).length) out.push(`- **nodes:** ${o.nodes.map((n) => '`' + n + '`').join(', ')}`);
|
|
178
|
+
out.push('');
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return out.join('\n');
|
|
182
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb campaign CLI (F5) — run the three advisors (optimize / deadcode / break-cycles) and compose
|
|
3
|
+
// their outputs into ONE ordered, gated, ROI-ranked optimization worklist with cumulative projected
|
|
4
|
+
// deltas. Read-only PLAN: it never writes source — each step is for the agent + gate to execute. Built
|
|
5
|
+
// on ./lib/campaign.mjs. The advisors stay authoritative (one truth); campaign only orchestrates.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node campaign.mjs <graph.json> [--json] [--budget N] [--git] (or set CODEWEB_WS)
|
|
8
|
+
// Exit: 0 ok (advisory), 2 usage/IO.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { resolve, dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { normalizeGraph } from './lib/graph-ops.mjs';
|
|
15
|
+
import { planCampaign } from './lib/campaign.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const USAGE = 'usage: campaign.mjs <graph.json> [--json] [--budget N] [--git] (or set CODEWEB_WS)';
|
|
19
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
let json = false, budget = Infinity, git = false; const pos = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const t = argv[i];
|
|
25
|
+
if (t === '--json') json = true;
|
|
26
|
+
else if (t === '--budget') budget = Math.max(0, parseInt(argv[++i], 10) || 0);
|
|
27
|
+
else if (t === '--git') git = true;
|
|
28
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
29
|
+
}
|
|
30
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
31
|
+
|
|
32
|
+
// run each advisor as its tested artifact (--json); a failed advisor degrades to empty (the campaign
|
|
33
|
+
// still composes whatever the others found).
|
|
34
|
+
const advise = (script, extra = []) => {
|
|
35
|
+
const r = spawnSync(process.execPath, [join(HERE, script), abs, ...extra, '--json'], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
36
|
+
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
37
|
+
};
|
|
38
|
+
const optimize = advise('optimize.mjs') || { opportunities: [] };
|
|
39
|
+
const deadcode = advise('deadcode.mjs') || { safe: [] };
|
|
40
|
+
const breakCycles = advise('break-cycles.mjs') || { cycles: [] };
|
|
41
|
+
|
|
42
|
+
const plan = planCampaign(graph, { optimize, deadcode, breakCycles, budget });
|
|
43
|
+
const t0 = plan.totals;
|
|
44
|
+
const payload = {
|
|
45
|
+
target: graph.meta?.target || 'target',
|
|
46
|
+
summary: `${t0.steps} step(s): ${t0.cuts} cut, ${t0.deletes} delete, ${t0.merges} merge — projected -${t0.locReclaimed} LOC, ${t0.cyclesBroken} cycle(s) broken (gate-green in order)`,
|
|
47
|
+
...plan,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (json) { emitJson(payload); } else {
|
|
51
|
+
|
|
52
|
+
const t = plan.totals;
|
|
53
|
+
console.log(`codeweb campaign: ${payload.target} — ${t.steps} step(s): ${t.cuts} cut, ${t.deletes} delete, ${t.merges} merge`);
|
|
54
|
+
console.log(` projected: -${t.locReclaimed} LOC, ${t.cyclesBroken} cycle(s) broken (all steps stay gate-green in order)`);
|
|
55
|
+
for (const s of plan.steps) {
|
|
56
|
+
const tag = s.type.toUpperCase().padEnd(6);
|
|
57
|
+
const what = s.type === 'cut' ? `${s.files.join(' <-> ')}` : s.type === 'delete' ? s.op.ids.join(', ') : `${s.op.ids.join(' + ')} -> ${s.op.into}`;
|
|
58
|
+
console.log(` [${tag}] ${what} (roi ${s.roi}; +${s.delta.locReclaimed} LOC, +${s.delta.cyclesBroken} cycle; cumulative -${s.cumulative.locReclaimed} LOC)`);
|
|
59
|
+
}
|
|
60
|
+
finish();
|
|
61
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fail when codeweb's public comms disagree with the source of truth:
|
|
4
|
+
* - a version string out of sync with package.json
|
|
5
|
+
* - a documented MCP tool count != the actual TOOLS table in mcp-server.mjs
|
|
6
|
+
* - a missing CHANGELOG entry for the current version
|
|
7
|
+
*
|
|
8
|
+
* This applies codeweb's own "fail on regression" philosophy to its marketing.
|
|
9
|
+
* Exit 1 on any problem, 0 when aligned. Read-only.
|
|
10
|
+
*
|
|
11
|
+
* node scripts/check-consistency.mjs (or: npm run check-consistency)
|
|
12
|
+
*/
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { checkConsistency } from './release-utils.mjs';
|
|
17
|
+
import { auditClaims } from './lib/claims-check.mjs';
|
|
18
|
+
|
|
19
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const r = checkConsistency(ROOT);
|
|
21
|
+
|
|
22
|
+
// Spec C: the claims audit — every evidence source cited by the ledger and the README must exist.
|
|
23
|
+
// A receipt whose backing file is gone is a rotted claim, and rotted claims fail the build.
|
|
24
|
+
try {
|
|
25
|
+
const product = JSON.parse(readFileSync(join(ROOT, 'site', 'data', 'product.json'), 'utf8'));
|
|
26
|
+
const readme = readFileSync(join(ROOT, 'README.md'), 'utf8');
|
|
27
|
+
const audit = auditClaims(ROOT, { product, readme });
|
|
28
|
+
if (!audit.ok) {
|
|
29
|
+
r.ok = false;
|
|
30
|
+
for (const m of audit.missing) r.problems.push(`claim source missing: ${m.where} cites "${m.source}"`);
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
r.ok = false;
|
|
34
|
+
r.problems.push(`claims audit could not run: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (r.ok) {
|
|
38
|
+
process.stdout.write(`check-consistency: OK — v${r.version}, ${r.count} tools, all surfaces aligned.\n`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.stderr.write(`check-consistency: ${r.problems.length} problem(s) for v${r.version} (${r.count} tools):\n`);
|
|
43
|
+
for (const p of r.problems) process.stderr.write(` x ${p}\n`);
|
|
44
|
+
process.stderr.write('Fix with: node scripts/version-sync.mjs (then update CHANGELOG.md if needed)\n');
|
|
45
|
+
process.exit(1);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb CI gate — fail a PR when an edit makes the structure worse.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/ci-gate.mjs --base <ref> [--repo <path>] [--target <subdir>] [--md <file>]
|
|
5
|
+
//
|
|
6
|
+
// Builds the BEFORE graph from a base ref (materialized in an ephemeral git worktree) and the AFTER
|
|
7
|
+
// graph from the current working tree, then runs the diff regression gate. Exit 1 (listing the
|
|
8
|
+
// regressions) when an edit introduces a new dependency cycle, a new duplication finding, or makes
|
|
9
|
+
// an existing symbol lose all its callers; exit 0 otherwise; exit 2 on usage/IO error. Read-only
|
|
10
|
+
// over the repo — the base worktree is removed afterwards. Reuses the canonical run.mjs pipeline and
|
|
11
|
+
// the diff.mjs gate verbatim, so the gate's verdict matches `diff` exactly.
|
|
12
|
+
//
|
|
13
|
+
// --md writes the structural review as a PR-comment-ready markdown digest (lib/gate-md.mjs) —
|
|
14
|
+
// best-effort: a digest failure never changes the gate's verdict.
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, dirname, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
21
|
+
import { gateComment } from './lib/gate-md.mjs';
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const a = { base: null, repo: '.', target: '.', md: null };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const t = argv[i];
|
|
29
|
+
if (t === '--base') a.base = argv[++i];
|
|
30
|
+
else if (t === '--repo') a.repo = argv[++i];
|
|
31
|
+
else if (t === '--target') a.target = argv[++i];
|
|
32
|
+
else if (t === '--md') a.md = argv[++i];
|
|
33
|
+
}
|
|
34
|
+
return a;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
38
|
+
if (!opts.base) {
|
|
39
|
+
console.error('usage: ci-gate.mjs --base <ref> [--repo <path>] [--target <subdir>] [--md <file>]');
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const repo = resolve(opts.repo);
|
|
44
|
+
const node = process.execPath;
|
|
45
|
+
const buildGraph = (srcDir, label, ws) => {
|
|
46
|
+
execFileSync(node, [join(HERE, 'run.mjs'), srcDir, '--target', label, '--out-dir', ws], { stdio: 'ignore' });
|
|
47
|
+
return join(ws, 'graph.json');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const base = mkdtempSync(join(tmpdir(), 'codeweb-gate-'));
|
|
51
|
+
const afterWs = join(base, 'after'), beforeWs = join(base, 'before'), wt = join(base, 'wt');
|
|
52
|
+
let code = 0;
|
|
53
|
+
try {
|
|
54
|
+
// AFTER = the current working tree (what the PR proposes to merge)
|
|
55
|
+
const afterGraph = buildGraph(join(repo, opts.target), 'after', afterWs);
|
|
56
|
+
// BEFORE = the base ref, materialized read-only in an ephemeral worktree
|
|
57
|
+
const r = spawnSync('git', ['-C', repo, 'worktree', 'add', '--detach', '--force', wt, opts.base], { encoding: 'utf8' });
|
|
58
|
+
if (r.status !== 0) {
|
|
59
|
+
// throw (NOT process.exit) so the finally block still removes the worktree + temp dir — a bare
|
|
60
|
+
// process.exit() runs synchronously and skips finally, leaking the scratch dir on every failure.
|
|
61
|
+
throw new Error(`cannot create base worktree for "${opts.base}" (need full history — actions/checkout with fetch-depth: 0): ${r.stderr}`);
|
|
62
|
+
}
|
|
63
|
+
const beforeGraph = buildGraph(join(wt, opts.target), 'before', beforeWs);
|
|
64
|
+
// GATE — diff.mjs prints the delta + regressions and sets the exit code we propagate.
|
|
65
|
+
const d = spawnSync(node, [join(HERE, 'diff.mjs'), beforeGraph, afterGraph], { stdio: 'inherit' });
|
|
66
|
+
if (d.status == null) {
|
|
67
|
+
console.error(`[codeweb] gate inconclusive — diff was terminated${d.signal ? ` by ${d.signal}` : ''}`);
|
|
68
|
+
code = 1;
|
|
69
|
+
} else {
|
|
70
|
+
code = d.status;
|
|
71
|
+
}
|
|
72
|
+
// PR-comment digest — a second diff over the already-built graphs (ms), rendered budgeted
|
|
73
|
+
if (opts.md) {
|
|
74
|
+
try {
|
|
75
|
+
const dj = spawnSync(node, [join(HERE, 'diff.mjs'), beforeGraph, afterGraph, '--json'], { encoding: 'utf8', maxBuffer: 1 << 26 });
|
|
76
|
+
writeFileSync(opts.md, gateComment(JSON.parse(dj.stdout)));
|
|
77
|
+
} catch (e) { console.error(`[codeweb] gate comment not written: ${(e && e.message) || e}`); }
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(`[codeweb] gate error: ${(e && e.message) || e}`);
|
|
81
|
+
code = 2;
|
|
82
|
+
} finally {
|
|
83
|
+
try { spawnSync('git', ['-C', repo, 'worktree', 'remove', '--force', wt]); } catch { /* best-effort */ }
|
|
84
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
process.exit(code);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// codeweb — clustering v3 (directory-anchored, dir-seeded label propagation + light de-hub)
|
|
2
|
+
// Names domains after the repo's own module structure (directories) instead of utility symbols
|
|
3
|
+
// (the old "lib · log" failure). Mechanism: (1) seed each node's label with its 2-level
|
|
4
|
+
// directory, (2) run label-propagation that migrates a node across dirs only where call-cohesion
|
|
5
|
+
// clearly pulls it (ties keep the current dir, so the directory prior wins), (3) name each
|
|
6
|
+
// domain by its dominant member directory.
|
|
7
|
+
//
|
|
8
|
+
// De-hub (HUB_INDEG): exclude high-in-degree nodes from the clustering adjacency. This began as
|
|
9
|
+
// a workaround for false super-hubs the extractor used to fabricate (unresolved `log()`/`get()`
|
|
10
|
+
// wired to the first global def — e.g. discord/ecc-bot.mjs:log at in-degree 127). That bug is
|
|
11
|
+
// fixed at extraction now, so de-hub no longer changes the domain COUNT (17 either way). But it
|
|
12
|
+
// still does real work: the surviving GENUINE utility hubs (utils.log, utils.readFile,
|
|
13
|
+
// state-store.get) otherwise bridge their callers' home directory into lib — measured at ~20
|
|
14
|
+
// nodes (mostly hooks/* calling lib utils) pulled out of their home dir when de-hub is removed.
|
|
15
|
+
// Keeping it makes domain assignment track directory structure, so it stays.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
const WS = process.env.CODEWEB_WS || '.live'; // per-target workspace dir (orchestrator sets this)
|
|
20
|
+
const FRAG = `${WS}/fragment.json`;
|
|
21
|
+
const GRAPH = `${WS}/graph.json`;
|
|
22
|
+
// exclude genuine utility hubs from clustering adjacency (keeps callers in their home dir; see header).
|
|
23
|
+
// CODEWEB_HUB_INDEG overrides the threshold for A/B regression testing of the de-hub decision
|
|
24
|
+
// (mirrors extract-symbols' CODEWEB_LEGACY_FALLBACK lever): set it absurdly high to disable de-hub
|
|
25
|
+
// and watch callers bleed into their hubs' home dir. Default 12 — the shipped behavior is unchanged.
|
|
26
|
+
const hubOverride = Number(process.env.CODEWEB_HUB_INDEG);
|
|
27
|
+
const HUB_INDEG = Number.isFinite(hubOverride) ? hubOverride : 12; // honor any finite override (incl. 0); unset -> 12
|
|
28
|
+
const PASSES = 12;
|
|
29
|
+
|
|
30
|
+
const dir2 = (file) => {
|
|
31
|
+
const p = file.split('/');
|
|
32
|
+
if (p.length === 1) return '(root)';
|
|
33
|
+
return p.slice(0, Math.min(2, p.length - 1)).join('/');
|
|
34
|
+
};
|
|
35
|
+
const pretty = (label) => (label === '(root)' ? 'CLI scripts (root)' : label);
|
|
36
|
+
|
|
37
|
+
const frag = JSON.parse(readFileSync(FRAG, 'utf8'));
|
|
38
|
+
const nodes = frag.nodes, edges = frag.edges;
|
|
39
|
+
const fragMeta = frag.meta || {};
|
|
40
|
+
const id2i = new Map(nodes.map((n, i) => [n.id, i]));
|
|
41
|
+
|
|
42
|
+
// in-degree -> identify hubs to exclude from the clustering adjacency (also reused for summaries)
|
|
43
|
+
const indeg = nodes.map(() => 0);
|
|
44
|
+
for (const e of edges) { const b = id2i.get(e.to); if (b != null) indeg[b]++; }
|
|
45
|
+
const isHub = indeg.map((d) => d >= HUB_INDEG);
|
|
46
|
+
|
|
47
|
+
// de-hubbed undirected adjacency (drop self-loops and any edge touching a hub)
|
|
48
|
+
const adj = nodes.map(() => []);
|
|
49
|
+
for (const e of edges) {
|
|
50
|
+
const a = id2i.get(e.from), b = id2i.get(e.to);
|
|
51
|
+
if (a == null || b == null || a === b) continue;
|
|
52
|
+
if (isHub[a] || isHub[b]) continue;
|
|
53
|
+
adj[a].push(b); adj[b].push(a);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// seed labels with directory, then propagate (ties keep current => directory prior wins)
|
|
57
|
+
let label = nodes.map((n) => dir2(n.file));
|
|
58
|
+
for (let pass = 0; pass < PASSES; pass++) {
|
|
59
|
+
let changed = 0;
|
|
60
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
61
|
+
if (isHub[i] || !adj[i].length) continue;
|
|
62
|
+
const cnt = new Map();
|
|
63
|
+
for (const j of adj[i]) cnt.set(label[j], (cnt.get(label[j]) || 0) + 1);
|
|
64
|
+
let best = label[i], bc = cnt.get(label[i]) || 0;
|
|
65
|
+
for (const [l, c] of cnt) if (c > bc) { best = l; bc = c; }
|
|
66
|
+
if (best !== label[i]) { label[i] = best; changed++; }
|
|
67
|
+
}
|
|
68
|
+
if (!changed) break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// assign domains; name each cluster by its dominant member directory (robust if a label migrated)
|
|
72
|
+
const groups = new Map();
|
|
73
|
+
nodes.forEach((n, i) => { if (!groups.has(label[i])) groups.set(label[i], []); groups.get(label[i]).push(i); });
|
|
74
|
+
const nameOf = new Map();
|
|
75
|
+
for (const [lbl, idxs] of groups) {
|
|
76
|
+
const dc = {}; idxs.forEach((i) => { const d = dir2(nodes[i].file); dc[d] = (dc[d] || 0) + 1; });
|
|
77
|
+
const domDir = Object.entries(dc).sort((a, b) => b[1] - a[1])[0][0];
|
|
78
|
+
nameOf.set(lbl, pretty(domDir));
|
|
79
|
+
}
|
|
80
|
+
nodes.forEach((n, i) => { n.domain = nameOf.get(label[i]); });
|
|
81
|
+
|
|
82
|
+
// domain summaries — deterministic DESCRIPTIVE labels: size, file count, the key symbols (exported
|
|
83
|
+
// preferred, then fan-in), and the role composition when a domain is mostly supporting code. This is
|
|
84
|
+
// the "what does this area do" one-liner the report + reading-order surface.
|
|
85
|
+
const byDomain = new Map();
|
|
86
|
+
nodes.forEach((n, i) => { if (!byDomain.has(n.domain)) byDomain.set(n.domain, []); byDomain.get(n.domain).push(i); });
|
|
87
|
+
const domains = [...byDomain.entries()].map(([name, idxs]) => {
|
|
88
|
+
const top = idxs.slice()
|
|
89
|
+
.sort((a, b) => (nodes[b].exports === true) - (nodes[a].exports === true) || indeg[b] - indeg[a])
|
|
90
|
+
.slice(0, 5).map((i) => nodes[i].label).filter((l) => l !== '<module>');
|
|
91
|
+
const files = new Set(idxs.map((i) => nodes[i].file)).size;
|
|
92
|
+
const roles = {};
|
|
93
|
+
idxs.forEach((i) => { const r = nodes[i].role || 'product'; roles[r] = (roles[r] || 0) + 1; });
|
|
94
|
+
const domRole = Object.entries(roles).sort((a, b) => b[1] - a[1])[0][0];
|
|
95
|
+
const roleNote = domRole !== 'product' ? ` (mostly ${domRole} code)` : '';
|
|
96
|
+
return { name, nodes: idxs.length, role: domRole, summary: `${idxs.length} symbols across ${files} file(s)${roleNote}; key: ${top.join(', ')}.` };
|
|
97
|
+
}).sort((a, b) => b.nodes - a.nodes);
|
|
98
|
+
|
|
99
|
+
const graph = {
|
|
100
|
+
meta: { ...fragMeta, mode: 'internal', depth: 'symbol', engine: `${fragMeta.engine || 'regex'} + de-hubbed dir-seeded call-cohesion clustering` },
|
|
101
|
+
nodes, edges, domains, overlaps: [],
|
|
102
|
+
};
|
|
103
|
+
writeFileSync(GRAPH, JSON.stringify(graph));
|
|
104
|
+
|
|
105
|
+
// stats
|
|
106
|
+
const isolated = adj.filter((a) => a.length === 0).length;
|
|
107
|
+
const hubCount = isHub.filter(Boolean).length;
|
|
108
|
+
console.log(`hubs stripped (indeg>=${HUB_INDEG}): ${hubCount}`);
|
|
109
|
+
console.log(`domains: ${domains.length} (was 32, hub-named)`);
|
|
110
|
+
console.log(`isolated-after-dehub: ${isolated} (${Math.round(isolated / nodes.length * 100)}%)`);
|
|
111
|
+
console.log('--- top 18 domains ---');
|
|
112
|
+
for (const d of domains.slice(0, 18)) console.log(String(d.nodes).padStart(4), d.name);
|