@maxgfr/codeindex 2.17.0 → 2.18.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/README.md +26 -3
- package/docs/MIGRATION.md +107 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +243 -19
- package/scripts/engine.mjs +1768 -723
- package/src/ast/extract.ts +180 -142
- package/src/coupling.ts +0 -0
- package/src/delta.ts +417 -0
- package/src/derived.ts +17 -0
- package/src/engine-cli.ts +201 -26
- package/src/engine.ts +31 -4
- package/src/extract/code.ts +4 -1
- package/src/graph.ts +0 -0
- package/src/mcp.ts +469 -212
- package/src/pool.ts +271 -0
- package/src/preload.ts +159 -0
- package/src/render/scip.ts +80 -20
- package/src/render/symbols-json.ts +3 -2
- package/src/scan.ts +142 -17
- package/src/traverse.ts +164 -0
- package/src/types.ts +1 -1
- package/src/viz.ts +91 -1
package/src/scan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import type { FileRecord } from "./types.js";
|
|
3
|
-
import { walk, readText, type WalkResult } from "./walk.js";
|
|
2
|
+
import type { FileRecord, FileKind } from "./types.js";
|
|
3
|
+
import { walk, readText, type WalkResult, type WalkedFile } from "./walk.js";
|
|
4
4
|
import { headCommit } from "./git.js";
|
|
5
5
|
import { sha1 } from "./hash.js";
|
|
6
6
|
import { classify, MARKDOWN_EXT } from "./classify.js";
|
|
@@ -73,6 +73,18 @@ export interface ScanOptions {
|
|
|
73
73
|
// from the tree. Exists for callers that already walked (e.g. a freshness
|
|
74
74
|
// probe) so scanRepo does not pay a second full directory traversal.
|
|
75
75
|
precomputedWalk?: WalkResult;
|
|
76
|
+
// Records a worker pool already extracted (rel → record + the stat the worker
|
|
77
|
+
// itself observed). Consulted after the cache fastpaths and before the read,
|
|
78
|
+
// and only when the worker's stat matches the walk's. Records are built by the
|
|
79
|
+
// shared buildCodeRecord, so a hit is byte-identical to extracting here.
|
|
80
|
+
// Populated by scanRepoParallel — never set this by hand.
|
|
81
|
+
extracted?: Map<string, ExtractedRecord>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ExtractedRecord {
|
|
85
|
+
size: number;
|
|
86
|
+
mtimeMs: number;
|
|
87
|
+
record: FileRecord;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
function countLines(s: string): number {
|
|
@@ -82,9 +94,54 @@ function countLines(s: string): number {
|
|
|
82
94
|
return n;
|
|
83
95
|
}
|
|
84
96
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
97
|
+
// The FileRecord for ONE code file. Factored out because the worker pool builds
|
|
98
|
+
// records off the main thread and they must be identical to the sequential
|
|
99
|
+
// ones, byte for byte — sharing this function is what guarantees that rather
|
|
100
|
+
// than two copies of the same logic drifting apart.
|
|
101
|
+
export function buildCodeRecord(
|
|
102
|
+
rel: string,
|
|
103
|
+
ext: string,
|
|
104
|
+
size: number,
|
|
105
|
+
content: string,
|
|
106
|
+
hash: string,
|
|
107
|
+
lang: string,
|
|
108
|
+
opts: { maxCallsPerFile?: number } = {},
|
|
109
|
+
): FileRecord {
|
|
110
|
+
const record: FileRecord = {
|
|
111
|
+
rel,
|
|
112
|
+
ext,
|
|
113
|
+
size,
|
|
114
|
+
lines: countLines(content),
|
|
115
|
+
hash,
|
|
116
|
+
kind: "code",
|
|
117
|
+
lang,
|
|
118
|
+
headings: [],
|
|
119
|
+
symbols: [],
|
|
120
|
+
refs: [],
|
|
121
|
+
};
|
|
122
|
+
if (content) {
|
|
123
|
+
const code = extractCode(rel, ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
|
|
124
|
+
record.title = basename(rel);
|
|
125
|
+
record.summary = code.summary;
|
|
126
|
+
record.symbols = code.symbols;
|
|
127
|
+
record.refs = code.refs;
|
|
128
|
+
record.pkg = code.pkg;
|
|
129
|
+
record.idents = code.idents;
|
|
130
|
+
record.calls = code.calls;
|
|
131
|
+
record.importedNames = code.importedNames;
|
|
132
|
+
} else {
|
|
133
|
+
record.title = basename(rel);
|
|
134
|
+
}
|
|
135
|
+
return record;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Which walked files this scan keeps, and how each is labelled. Shared by
|
|
139
|
+
// scanRepo and scanSummary so the two can never disagree on a file count or a
|
|
140
|
+
// language histogram — the summary path is exactly this loop, stopped early.
|
|
141
|
+
function* keptFiles(
|
|
142
|
+
root: string,
|
|
143
|
+
opts: ScanOptions,
|
|
144
|
+
): Generator<{ f: WalkedFile; kind: FileKind; lang: string }, WalkTotals, void> {
|
|
88
145
|
const scoped = opts.scope ? [...(opts.include ?? []), `${opts.scope.replace(/\/+$/, "")}/**`] : opts.include;
|
|
89
146
|
const include = compileGlobs(scoped);
|
|
90
147
|
const exclude = compileGlobs(opts.exclude);
|
|
@@ -100,6 +157,61 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
100
157
|
// would describe the encyclopedia instead of the code.
|
|
101
158
|
const outPrefix = opts.out ? opts.out.replace(/\/+$/, "") + "/" : null;
|
|
102
159
|
|
|
160
|
+
for (const f of walked) {
|
|
161
|
+
if (outPrefix && (f.abs === opts.out || f.abs.startsWith(outPrefix))) continue;
|
|
162
|
+
if (include && !include(f.rel)) continue;
|
|
163
|
+
if (exclude && exclude(f.rel)) continue;
|
|
164
|
+
yield { f, kind: classify(f.rel, f.ext), lang: extToLang(f.ext) };
|
|
165
|
+
}
|
|
166
|
+
return { capped, excluded };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface WalkTotals {
|
|
170
|
+
capped: boolean;
|
|
171
|
+
excluded: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The code files this scan would extract, in the same order scanRepo sees them.
|
|
175
|
+
// The worker pool builds its job list from here so the set it extracts is
|
|
176
|
+
// exactly the set scanRepo would have extracted — no file gets a worker record
|
|
177
|
+
// that the sequential loop would have skipped, and vice versa.
|
|
178
|
+
export function keptCodeFiles(root: string, opts: ScanOptions = {}): { f: WalkedFile; lang: string }[] {
|
|
179
|
+
const out: { f: WalkedFile; lang: string }[] = [];
|
|
180
|
+
const it = keptFiles(root, opts);
|
|
181
|
+
for (let step = it.next(); !step.done; step = it.next()) {
|
|
182
|
+
if (step.value.kind === "code") out.push({ f: step.value.f, lang: step.value.lang });
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// File count + language histogram WITHOUT reading or parsing a single file.
|
|
188
|
+
// `codeindex scan` and the MCP `scan_summary` tool only ever report these, but
|
|
189
|
+
// used to pay a full scanRepo — i.e. tree-sitter over the whole repo — to get
|
|
190
|
+
// them (6.5s on a 7k-file repo, versus ~0.1s here).
|
|
191
|
+
export interface ScanSummary {
|
|
192
|
+
root: string;
|
|
193
|
+
commit?: string;
|
|
194
|
+
fileCount: number;
|
|
195
|
+
languages: Record<string, number>;
|
|
196
|
+
capped: boolean;
|
|
197
|
+
excluded: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function scanSummary(root: string, opts: ScanOptions = {}): ScanSummary {
|
|
201
|
+
const languages: Record<string, number> = {};
|
|
202
|
+
let fileCount = 0;
|
|
203
|
+
const it = keptFiles(root, opts);
|
|
204
|
+
let step = it.next();
|
|
205
|
+
for (; !step.done; step = it.next()) {
|
|
206
|
+
languages[step.value.lang] = (languages[step.value.lang] ?? 0) + 1;
|
|
207
|
+
fileCount++;
|
|
208
|
+
}
|
|
209
|
+
return { root, commit: headCommit(root), fileCount, languages, capped: step.value.capped, excluded: step.value.excluded };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Walk the repo once and turn every in-scope file into a FileRecord. Pure file
|
|
213
|
+
// I/O + deterministic extraction — never reads the repo into the model.
|
|
214
|
+
export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
103
215
|
const files: FileRecord[] = [];
|
|
104
216
|
const languages: Record<string, number> = {};
|
|
105
217
|
const docText = new Map<string, string>();
|
|
@@ -114,13 +226,10 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
114
226
|
// With no cache, persisting one is all new bytes — dirty by definition.
|
|
115
227
|
let cacheDirty = cache === undefined;
|
|
116
228
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const kind = classify(f.rel, f.ext);
|
|
123
|
-
const lang = extToLang(f.ext);
|
|
229
|
+
const it = keptFiles(root, opts);
|
|
230
|
+
let step = it.next();
|
|
231
|
+
for (; !step.done; step = it.next()) {
|
|
232
|
+
const { f, kind, lang } = step.value;
|
|
124
233
|
languages[lang] = (languages[lang] ?? 0) + 1;
|
|
125
234
|
mtimes.set(f.rel, f.mtimeMs); // persist the fastpath key for the next build
|
|
126
235
|
|
|
@@ -147,12 +256,23 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
147
256
|
continue;
|
|
148
257
|
}
|
|
149
258
|
|
|
259
|
+
// A record a worker already read, hashed and extracted for this exact file
|
|
260
|
+
// (see pool.ts). Accepted only when the worker's own stat agrees with the
|
|
261
|
+
// walk's, so a file rewritten between the two is re-read here instead.
|
|
262
|
+
//
|
|
263
|
+
// It supplies the HASH as well as the record, which is what lets the cache
|
|
264
|
+
// comparison below stay in its original position. Consulting it any earlier
|
|
265
|
+
// would skip the hash-hit branch, and a bare `touch` — same content, new
|
|
266
|
+
// mtime — would then report the scan as changed and rewrite every artifact.
|
|
267
|
+
const pre = opts.extracted?.get(f.rel);
|
|
268
|
+
const preUsable = pre && pre.size === f.size && pre.mtimeMs === f.mtimeMs ? pre : undefined;
|
|
269
|
+
|
|
150
270
|
// Read + hash (the staleness oracle stays exact); only EXTRACTION is cached. A
|
|
151
271
|
// hash hit reuses the previous record — content is byte-identical, so every
|
|
152
272
|
// derived field is too. classify()/extToLang() depend only on the path, so
|
|
153
273
|
// kind/lang are stable across the hit.
|
|
154
|
-
const content = readText(f.abs);
|
|
155
|
-
const hash = sha1(content);
|
|
274
|
+
const content = preUsable ? undefined : readText(f.abs);
|
|
275
|
+
const hash = preUsable ? preUsable.record.hash : sha1(content!);
|
|
156
276
|
if (cached && cached.hash === hash) {
|
|
157
277
|
files.push(cached.record);
|
|
158
278
|
if (kind === "doc" && content) docText.set(f.rel, content);
|
|
@@ -167,11 +287,16 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
167
287
|
allReused = false;
|
|
168
288
|
cacheDirty = true;
|
|
169
289
|
|
|
290
|
+
if (preUsable) {
|
|
291
|
+
files.push(preUsable.record);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
170
295
|
const record: FileRecord = {
|
|
171
296
|
rel: f.rel,
|
|
172
297
|
ext: f.ext,
|
|
173
298
|
size: f.size,
|
|
174
|
-
lines: countLines(content),
|
|
299
|
+
lines: countLines(content!),
|
|
175
300
|
hash,
|
|
176
301
|
kind,
|
|
177
302
|
lang,
|
|
@@ -230,8 +355,8 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
230
355
|
languages,
|
|
231
356
|
docText,
|
|
232
357
|
mtimes,
|
|
233
|
-
capped,
|
|
234
|
-
excluded,
|
|
358
|
+
capped: step.value.capped,
|
|
359
|
+
excluded: step.value.excluded,
|
|
235
360
|
contentUnchanged: allReused,
|
|
236
361
|
cacheDirty,
|
|
237
362
|
};
|
package/src/traverse.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Graph traversal over a built link-graph: "what depends on this" (impact) and
|
|
2
|
+
// "what sits next to this" (neighbors). Both are pure functions of a `Graph` —
|
|
3
|
+
// no scan, no filesystem — so a consumer holding a persisted graph.json can
|
|
4
|
+
// answer them without re-walking the repo.
|
|
5
|
+
//
|
|
6
|
+
// Both share one hub gate. A hyper-connected node (a barrel, a types module)
|
|
7
|
+
// otherwise drags the entire graph into any depth-≥2 neighbourhood, which is
|
|
8
|
+
// the difference between an answer and a dump.
|
|
9
|
+
import type { Edge, Graph } from "./types.js";
|
|
10
|
+
import { byStr } from "./sort.js";
|
|
11
|
+
|
|
12
|
+
// Only these edge kinds carry a real "depends on" relation. A doc-link or a
|
|
13
|
+
// mention says something references the name, not that it would break.
|
|
14
|
+
const DEPENDS_KINDS = new Set(["import", "use", "call"]);
|
|
15
|
+
|
|
16
|
+
// The hub-gating threshold over a degree distribution: max(50, p99). p99 = the
|
|
17
|
+
// degree at index min(n-1, floor(0.99n)) of the ASCENDING degree array (numeric
|
|
18
|
+
// sort — deterministic without byStr).
|
|
19
|
+
//
|
|
20
|
+
// Deliberately DEGREE-based even though the graph carries pagerank/betweenness:
|
|
21
|
+
// the gate exists to bound traversal fan-out, and degree IS the fan-out cost —
|
|
22
|
+
// a high-pagerank/low-degree node is safe to expand through. It is also
|
|
23
|
+
// computed per call, so a kind-filtered view gates on that same subgraph rather
|
|
24
|
+
// than on a whole-graph metric that does not describe it. The 50 floor makes it
|
|
25
|
+
// a no-op on small graphs (never worse than ungated).
|
|
26
|
+
export function hubThreshold(degrees: number[]): number {
|
|
27
|
+
const sorted = degrees.slice().sort((a, b) => a - b);
|
|
28
|
+
const n = sorted.length;
|
|
29
|
+
const p99 = n === 0 ? 0 : sorted[Math.min(n - 1, Math.floor(0.99 * n))]!;
|
|
30
|
+
return Math.max(50, p99);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ImpactedFile {
|
|
34
|
+
rel: string;
|
|
35
|
+
module: string;
|
|
36
|
+
depth: number; // hops from the target (1 = direct dependent)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ImpactResult {
|
|
40
|
+
target: string;
|
|
41
|
+
scope: "module" | "file";
|
|
42
|
+
seeds: string[]; // the files whose dependents we traced
|
|
43
|
+
files: ImpactedFile[]; // transitive dependents, nearest first
|
|
44
|
+
modules: string[]; // distinct modules touched
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reverse dependency closure: every file that transitively IMPORTS, USES, or
|
|
48
|
+
// CALLS one of `seeds`, out to `depth` hops (default: the full closure).
|
|
49
|
+
export function reverseClosure(edges: Edge[], seeds: string[], depth = Infinity): Map<string, number> {
|
|
50
|
+
const dependents = new Map<string, Edge[]>(); // target file → incoming depends-on edges
|
|
51
|
+
for (const e of edges) {
|
|
52
|
+
if (e.dangling || !DEPENDS_KINDS.has(e.kind)) continue;
|
|
53
|
+
let arr = dependents.get(e.to);
|
|
54
|
+
if (!arr) dependents.set(e.to, (arr = []));
|
|
55
|
+
arr.push(e);
|
|
56
|
+
}
|
|
57
|
+
const depthOf = new Map<string, number>();
|
|
58
|
+
const seen = new Set<string>(seeds);
|
|
59
|
+
let frontier = [...seeds];
|
|
60
|
+
for (let d = 1; d <= depth && frontier.length; d++) {
|
|
61
|
+
const next: string[] = [];
|
|
62
|
+
for (const node of frontier) {
|
|
63
|
+
for (const e of (dependents.get(node) ?? []).slice().sort((a, b) => byStr(a.from, b.from))) {
|
|
64
|
+
if (seen.has(e.from)) continue;
|
|
65
|
+
seen.add(e.from);
|
|
66
|
+
depthOf.set(e.from, d);
|
|
67
|
+
next.push(e.from);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
frontier = next;
|
|
71
|
+
}
|
|
72
|
+
return depthOf;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// "What breaks if I change this." Accepts a module slug or a file rel.
|
|
76
|
+
export function impactOf(graph: Graph, target: string, depth = Infinity): ImpactResult | undefined {
|
|
77
|
+
const moduleOf = new Map(graph.files.map((f) => [f.rel, f.module]));
|
|
78
|
+
const mod = graph.modules.find((m) => m.slug === target);
|
|
79
|
+
const file = mod ? undefined : graph.files.find((f) => f.rel === target);
|
|
80
|
+
if (!mod && !file) return undefined;
|
|
81
|
+
|
|
82
|
+
const seeds = mod ? mod.members : [file!.rel];
|
|
83
|
+
const depthOf = reverseClosure(graph.fileEdges, seeds, depth);
|
|
84
|
+
const files: ImpactedFile[] = [...depthOf.entries()]
|
|
85
|
+
.map(([rel, d]) => ({ rel, module: moduleOf.get(rel) ?? "root", depth: d }))
|
|
86
|
+
.sort((a, b) => a.depth - b.depth || byStr(a.rel, b.rel));
|
|
87
|
+
const modules = [...new Set(files.map((f) => f.module).filter((m) => m !== target))].sort(byStr);
|
|
88
|
+
|
|
89
|
+
return { target, scope: mod ? "module" : "file", seeds, files, modules };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface NeighborLink {
|
|
93
|
+
node: string;
|
|
94
|
+
direction: "out" | "in";
|
|
95
|
+
kind: string;
|
|
96
|
+
weight: number;
|
|
97
|
+
depth: number;
|
|
98
|
+
confidence?: "extracted" | "inferred"; // only on a `call` edge — see Edge.confidence
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NeighborResult {
|
|
102
|
+
target: string;
|
|
103
|
+
scope: "module" | "file";
|
|
104
|
+
links: NeighborLink[];
|
|
105
|
+
members?: string[]; // for a module target
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Breadth-first walk from `start`, out to `depth` hops, in BOTH directions.
|
|
109
|
+
// With `kinds` set, only those edge kinds are traversed — and the degree
|
|
110
|
+
// distribution feeding the hub gate is measured over that same filtered
|
|
111
|
+
// subgraph, so the gate reflects the view the caller asked for.
|
|
112
|
+
function bfs(edges: Edge[], start: string, depth: number, kinds?: Set<string>): NeighborLink[] {
|
|
113
|
+
const out = new Map<string, Edge[]>();
|
|
114
|
+
const inn = new Map<string, Edge[]>();
|
|
115
|
+
const degree = new Map<string, number>();
|
|
116
|
+
for (const e of edges) {
|
|
117
|
+
if (e.dangling) continue;
|
|
118
|
+
if (kinds && !kinds.has(e.kind)) continue;
|
|
119
|
+
(out.get(e.from) ?? out.set(e.from, []).get(e.from)!).push(e);
|
|
120
|
+
(inn.get(e.to) ?? inn.set(e.to, []).get(e.to)!).push(e);
|
|
121
|
+
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
|
|
122
|
+
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
// A non-start node at or above the threshold is EMITTED as a link but never
|
|
125
|
+
// expanded THROUGH. Only bites at depth ≥ 2 — depth-1 links all come from
|
|
126
|
+
// `start`, which always expands.
|
|
127
|
+
const threshold = hubThreshold([...degree.values()]);
|
|
128
|
+
const seen = new Set<string>([start]);
|
|
129
|
+
const links: NeighborLink[] = [];
|
|
130
|
+
let frontier = [start];
|
|
131
|
+
for (let d = 1; d <= depth; d++) {
|
|
132
|
+
const next: string[] = [];
|
|
133
|
+
for (const node of frontier) {
|
|
134
|
+
if (node !== start && (degree.get(node) ?? 0) >= threshold) continue;
|
|
135
|
+
for (const e of (out.get(node) ?? []).slice().sort((a, b) => byStr(a.to, b.to))) {
|
|
136
|
+
if (seen.has(e.to)) continue;
|
|
137
|
+
links.push({ node: e.to, direction: "out", kind: e.kind, weight: e.weight, depth: d, confidence: e.confidence });
|
|
138
|
+
seen.add(e.to);
|
|
139
|
+
next.push(e.to);
|
|
140
|
+
}
|
|
141
|
+
for (const e of (inn.get(node) ?? []).slice().sort((a, b) => byStr(a.from, b.from))) {
|
|
142
|
+
if (seen.has(e.from)) continue;
|
|
143
|
+
links.push({ node: e.from, direction: "in", kind: e.kind, weight: e.weight, depth: d, confidence: e.confidence });
|
|
144
|
+
seen.add(e.from);
|
|
145
|
+
next.push(e.from);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
frontier = next;
|
|
149
|
+
}
|
|
150
|
+
return links;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// What links to / from a module slug or a file rel, out to `depth` hops.
|
|
154
|
+
export function neighborsOf(graph: Graph, target: string, depth = 1, kinds?: Set<string>): NeighborResult | undefined {
|
|
155
|
+
const mod = graph.modules.find((m) => m.slug === target);
|
|
156
|
+
if (mod) {
|
|
157
|
+
return { target, scope: "module", links: bfs(graph.moduleEdges, target, depth, kinds), members: mod.members };
|
|
158
|
+
}
|
|
159
|
+
const file = graph.files.find((f) => f.rel === target);
|
|
160
|
+
if (file) {
|
|
161
|
+
return { target, scope: "file", links: bfs(graph.fileEdges, target, depth, kinds) };
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single source of truth for the engine version the bundle reports. Kept in
|
|
2
2
|
// lockstep with package.json by the release pipeline. Do not edit by hand
|
|
3
3
|
// outside a release.
|
|
4
|
-
export const ENGINE_VERSION = "2.
|
|
4
|
+
export const ENGINE_VERSION = "2.18.0";
|
|
5
5
|
|
|
6
6
|
// Bumped whenever the on-disk artifact shape changes, so a consumer can reject
|
|
7
7
|
// an index written by an incompatible engine instead of misreading it. The
|
package/src/viz.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Mermaid rendering of the module graph — inline-renderable in Claude, GitHub
|
|
2
2
|
// and most markdown viewers with zero infrastructure (the counter to graph-DB
|
|
3
3
|
// browsers). Deterministic: sorted nodes/edges, stable ids.
|
|
4
|
-
import type { Graph } from "./types.js";
|
|
4
|
+
import type { Graph, ModuleNode, Tier } from "./types.js";
|
|
5
5
|
import { byStr } from "./sort.js";
|
|
6
6
|
|
|
7
7
|
export interface MermaidOptions {
|
|
@@ -42,3 +42,93 @@ export function renderMermaid(graph: Graph, opts: MermaidOptions = {}): string {
|
|
|
42
42
|
if (dropped) lines.push(` %% ${dropped} lighter edges omitted (maxEdges=${maxEdges})`);
|
|
43
43
|
return lines.join("\n") + "\n";
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Clustered whole-graph view. `renderMermaid` above answers "what sits around
|
|
48
|
+
// THIS module" and returns raw mermaid; this one answers "what does the whole
|
|
49
|
+
// repo look like" — grouped into tier subgraphs, capped by MODULE count as well
|
|
50
|
+
// as edge count, fenced, and reporting exactly what it dropped.
|
|
51
|
+
//
|
|
52
|
+
// Two functions rather than one flag because the truncation contract differs:
|
|
53
|
+
// the neighborhood view caps edges only and notes the drop in a comment, while
|
|
54
|
+
// a whole-graph view has to bound nodes first (a 400-module repo is unreadable
|
|
55
|
+
// long before it hits 80 edges) and hand the caller the counts so it can say so
|
|
56
|
+
// in prose. Returning counts from the existing function would have been a
|
|
57
|
+
// breaking change for its CLI and MCP callers.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface ClusteredMermaidResult {
|
|
61
|
+
content: string; // a fenced ```mermaid block, ready to embed in markdown
|
|
62
|
+
shownModules: number;
|
|
63
|
+
totalModules: number;
|
|
64
|
+
shownEdges: number;
|
|
65
|
+
totalEdges: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ClusteredMermaidOptions {
|
|
69
|
+
maxModules?: number; // default 40 — most-connected first
|
|
70
|
+
maxEdges?: number; // default 80 — heaviest first
|
|
71
|
+
title?: string; // leading %% comment; defaults to "module graph"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TIER_LABEL: Record<Tier, string> = { 0: "Foundations", 1: "Features", 2: "Tail" };
|
|
75
|
+
const CLUSTER_MAX_MODULES = 40;
|
|
76
|
+
const CLUSTER_MAX_EDGES = 80;
|
|
77
|
+
|
|
78
|
+
const degreeOf = (m: ModuleNode): number => m.degIn + m.degOut;
|
|
79
|
+
|
|
80
|
+
// Mermaid node ids must be identifier-safe; slugs may contain dashes. Prefixed
|
|
81
|
+
// so a slug starting with a digit still yields a valid id.
|
|
82
|
+
function clusterNodeId(slug: string): string {
|
|
83
|
+
return "m_" + slug.replace(/[^A-Za-z0-9_]/g, "_");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderMermaidClustered(
|
|
87
|
+
graph: Graph,
|
|
88
|
+
opts: ClusteredMermaidOptions = {},
|
|
89
|
+
): ClusteredMermaidResult {
|
|
90
|
+
const maxModules = opts.maxModules ?? CLUSTER_MAX_MODULES;
|
|
91
|
+
const maxEdges = opts.maxEdges ?? CLUSTER_MAX_EDGES;
|
|
92
|
+
|
|
93
|
+
const ranked = graph.modules.slice().sort((a, b) => degreeOf(b) - degreeOf(a) || byStr(a.slug, b.slug));
|
|
94
|
+
const shown = ranked.slice(0, maxModules);
|
|
95
|
+
const shownSet = new Set(shown.map((m) => m.slug));
|
|
96
|
+
|
|
97
|
+
// Only edges whose BOTH ends survived the module cap — a dangling half-edge
|
|
98
|
+
// would render as a node the caller was told was dropped.
|
|
99
|
+
const edges = graph.moduleEdges
|
|
100
|
+
.filter((e) => shownSet.has(e.from) && shownSet.has(e.to))
|
|
101
|
+
.sort((a, b) => b.weight - a.weight || byStr(a.from, b.from) || byStr(a.to, b.to))
|
|
102
|
+
.slice(0, maxEdges);
|
|
103
|
+
|
|
104
|
+
const lines: string[] = [];
|
|
105
|
+
lines.push(
|
|
106
|
+
`%% ${opts.title ?? "module graph"} — ${shown.length} of ${graph.modules.length} modules, ` +
|
|
107
|
+
`${edges.length} of ${graph.moduleEdges.length} edges`,
|
|
108
|
+
);
|
|
109
|
+
if (shown.length < graph.modules.length || edges.length < graph.moduleEdges.length) {
|
|
110
|
+
lines.push("%% truncated to the most-connected modules/edges; see graph.json for the full graph");
|
|
111
|
+
}
|
|
112
|
+
lines.push("flowchart LR");
|
|
113
|
+
|
|
114
|
+
for (const tier of [0, 1, 2] as Tier[]) {
|
|
115
|
+
const inTier = shown.filter((m) => m.tier === tier);
|
|
116
|
+
if (!inTier.length) continue;
|
|
117
|
+
lines.push(` subgraph ${TIER_LABEL[tier]}`);
|
|
118
|
+
for (const m of inTier) lines.push(` ${clusterNodeId(m.slug)}["${m.path.replace(/"/g, "'")}"]`);
|
|
119
|
+
lines.push(" end");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const e of edges) {
|
|
123
|
+
const label = e.weight > 1 ? `|${e.weight}| ` : "";
|
|
124
|
+
lines.push(` ${clusterNodeId(e.from)} -->${label ? " " + label : " "}${clusterNodeId(e.to)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
content: "```mermaid\n" + lines.join("\n") + "\n```\n",
|
|
129
|
+
shownModules: shown.length,
|
|
130
|
+
totalModules: graph.modules.length,
|
|
131
|
+
shownEdges: edges.length,
|
|
132
|
+
totalEdges: graph.moduleEdges.length,
|
|
133
|
+
};
|
|
134
|
+
}
|