@maxgfr/codeindex 2.7.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 +115 -0
- package/docs/MIGRATION.md +100 -0
- package/package.json +89 -0
- package/scripts/cli.mjs +10 -0
- package/scripts/engine.d.mts +618 -0
- package/scripts/engine.mjs +10655 -0
- package/scripts/grammars/bash.wasm +0 -0
- package/scripts/grammars/c.wasm +0 -0
- package/scripts/grammars/c_sharp.wasm +0 -0
- package/scripts/grammars/cpp.wasm +0 -0
- package/scripts/grammars/go.wasm +0 -0
- package/scripts/grammars/java.wasm +0 -0
- package/scripts/grammars/javascript.wasm +0 -0
- package/scripts/grammars/lua.wasm +0 -0
- package/scripts/grammars/php.wasm +0 -0
- package/scripts/grammars/python.wasm +0 -0
- package/scripts/grammars/ruby.wasm +0 -0
- package/scripts/grammars/rust.wasm +0 -0
- package/scripts/grammars/scala.wasm +0 -0
- package/scripts/grammars/tsx.wasm +0 -0
- package/scripts/grammars/typescript.wasm +0 -0
- package/scripts/grammars/web-tree-sitter.wasm +0 -0
- package/src/ast/extract.ts +713 -0
- package/src/ast/loader.ts +104 -0
- package/src/bm25.ts +156 -0
- package/src/callers.ts +0 -0
- package/src/calls.ts +131 -0
- package/src/categorize.ts +80 -0
- package/src/centrality.ts +148 -0
- package/src/classify.ts +53 -0
- package/src/cli-entry.ts +16 -0
- package/src/community.ts +345 -0
- package/src/complexity.ts +69 -0
- package/src/coupling.ts +0 -0
- package/src/deadcode.ts +46 -0
- package/src/edit.ts +93 -0
- package/src/engine-cli.ts +278 -0
- package/src/engine.ts +148 -0
- package/src/extract/code.ts +376 -0
- package/src/extract/markdown.ts +135 -0
- package/src/git.ts +205 -0
- package/src/glob.ts +56 -0
- package/src/graph.ts +0 -0
- package/src/grep.ts +115 -0
- package/src/hash.ts +13 -0
- package/src/ignore.ts +134 -0
- package/src/lang/c.ts +26 -0
- package/src/lang/common.ts +68 -0
- package/src/lang/csharp.ts +22 -0
- package/src/lang/elixir.ts +18 -0
- package/src/lang/go.ts +22 -0
- package/src/lang/java.ts +19 -0
- package/src/lang/js-ts.ts +112 -0
- package/src/lang/kotlin.ts +20 -0
- package/src/lang/lua.ts +18 -0
- package/src/lang/php.ts +24 -0
- package/src/lang/python.ts +22 -0
- package/src/lang/registry.ts +54 -0
- package/src/lang/ruby.ts +17 -0
- package/src/lang/rust.ts +22 -0
- package/src/lang/scala.ts +19 -0
- package/src/lang/shell.ts +17 -0
- package/src/lang/swift.ts +23 -0
- package/src/mcp.ts +512 -0
- package/src/memory.ts +75 -0
- package/src/modules.ts +128 -0
- package/src/pipeline.ts +59 -0
- package/src/query.ts +118 -0
- package/src/render/graph-json.ts +16 -0
- package/src/render/symbols-json.ts +79 -0
- package/src/repomap.ts +52 -0
- package/src/resolve.ts +950 -0
- package/src/rules.ts +249 -0
- package/src/scan.ts +172 -0
- package/src/sort.ts +12 -0
- package/src/surprise.ts +58 -0
- package/src/tests-map.ts +105 -0
- package/src/types.ts +201 -0
- package/src/util.ts +169 -0
- package/src/viz.ts +44 -0
- package/src/walk.ts +216 -0
- package/src/workspaces.ts +748 -0
package/src/cli-entry.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Standalone CLI/MCP entry — built to scripts/cli.mjs with the engine left
|
|
2
|
+
// EXTERNAL (it imports the sibling scripts/engine.mjs at runtime instead of
|
|
3
|
+
// inlining a second copy). Kept separate from engine.mjs ON PURPOSE: the
|
|
4
|
+
// engine bundle is a pure side-effect-free library so consumers can inline it
|
|
5
|
+
// into their own single-file CLIs — a main-module guard inside it would
|
|
6
|
+
// misfire there and hijack their argv.
|
|
7
|
+
// node scripts/cli.mjs graph --repo .
|
|
8
|
+
// node scripts/cli.mjs mcp
|
|
9
|
+
import { runCli } from "./engine.js";
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
await runCli(process.argv.slice(2));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.error(`codeindex: ${e instanceof Error ? e.message : e}`);
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
package/src/community.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import type { Edge, Graph, ModuleNode } from "./types.js";
|
|
2
|
+
import { byStr } from "./sort.js";
|
|
3
|
+
|
|
4
|
+
// The community id of one module — a contract helper for delta and other
|
|
5
|
+
// graph consumers.
|
|
6
|
+
export function communityOf(graph: Graph, slug: string): number | undefined {
|
|
7
|
+
return graph.modules.find((m) => m.slug === slug)?.community;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Deterministic weighted Louvain (modularity maximization) over the UNDIRECTED
|
|
11
|
+
// module graph, used to cluster modules into navigation "communities". This
|
|
12
|
+
// layer never feeds `find`'s lexical ranking and never touches module slugs
|
|
13
|
+
// (slugs key human-authored prose — reclustering must not renumber them); its
|
|
14
|
+
// only ranking role is biasing graph-expansion ordering toward the seeds'
|
|
15
|
+
// subsystem, and flagging surprising cross-community edges.
|
|
16
|
+
//
|
|
17
|
+
// Determinism is load-bearing: community ids land in graph.json / INDEX.md, which
|
|
18
|
+
// carry the byte-identical rebuild guarantee. So: NO Math.random; every node is
|
|
19
|
+
// swept in a fixed slug-sorted index order; every tie breaks toward the lowest
|
|
20
|
+
// community index; and the final ids are derived from a size-descending,
|
|
21
|
+
// member-lexicographic reindex that is stable run-to-run for an unchanged repo.
|
|
22
|
+
|
|
23
|
+
// Resolution parameter. γ = 1.0 is standard Louvain; larger favours smaller
|
|
24
|
+
// communities. Kept at 1.0 to match the reference behaviour.
|
|
25
|
+
const GAMMA = 1.0;
|
|
26
|
+
|
|
27
|
+
// Local-move sweeps converge (each sweep only accepts strictly-positive gains),
|
|
28
|
+
// but this bounds a pathological float cycle. Aggregation passes strictly shrink
|
|
29
|
+
// the node count, so their bound is a pure safety net.
|
|
30
|
+
const MAX_SWEEPS = 20;
|
|
31
|
+
const MAX_PASSES = 10;
|
|
32
|
+
|
|
33
|
+
// A move is accepted only when it beats the incumbent by more than EPS, so two
|
|
34
|
+
// mathematically-equal gains computed on different float paths never flip the
|
|
35
|
+
// partition — the lower community index (swept first) always wins the tie.
|
|
36
|
+
const EPS = 1e-12;
|
|
37
|
+
|
|
38
|
+
// Oversized-community split: a community holding more than 25% of all nodes AND at
|
|
39
|
+
// least 10 nodes is re-partitioned on its own induced subgraph. Modularity's
|
|
40
|
+
// resolution limit lets a large m merge cohesive sub-clusters that a finer look
|
|
41
|
+
// would keep apart; this recovers navigable subsystems. (graphify's cohesion split
|
|
42
|
+
// is deliberately NOT ported — it targets thousand-node doc-hub graphs; module
|
|
43
|
+
// graphs are far smaller.)
|
|
44
|
+
const OVERSIZE_FRACTION = 0.25;
|
|
45
|
+
const OVERSIZE_MIN = 10;
|
|
46
|
+
|
|
47
|
+
// A symmetric weighted graph over contiguous integer node ids. `adj[i]` maps a
|
|
48
|
+
// neighbour id to the (accumulated) undirected edge weight; aggregated levels may
|
|
49
|
+
// carry self-loops (`adj[i]` containing `i`). `k[i]` is the weighted degree
|
|
50
|
+
// (Σ adj[i][*], self-loops included); `twoM` is Σ k = twice the total edge weight.
|
|
51
|
+
interface WeightedGraph {
|
|
52
|
+
n: number;
|
|
53
|
+
adj: Map<number, number>[];
|
|
54
|
+
k: number[];
|
|
55
|
+
twoM: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build the undirected weighted adjacency over the (already slug-sorted) node set.
|
|
59
|
+
// Dangling edges and edges whose endpoints are not modules are skipped; self-loops
|
|
60
|
+
// are dropped (module edges never self-loop, but guard anyway). A directed module
|
|
61
|
+
// pair a→b and its mirror b→a both fold into the same undirected weight.
|
|
62
|
+
export function buildAdjacency(slugs: string[], edges: Edge[]): WeightedGraph {
|
|
63
|
+
const n = slugs.length;
|
|
64
|
+
const idx = new Map(slugs.map((s, i) => [s, i]));
|
|
65
|
+
const adj: Map<number, number>[] = Array.from({ length: n }, () => new Map<number, number>());
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
if (e.dangling) continue;
|
|
68
|
+
const a = idx.get(e.from);
|
|
69
|
+
const b = idx.get(e.to);
|
|
70
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
71
|
+
adj[a]!.set(b, (adj[a]!.get(b) ?? 0) + e.weight);
|
|
72
|
+
adj[b]!.set(a, (adj[b]!.get(a) ?? 0) + e.weight);
|
|
73
|
+
}
|
|
74
|
+
const k = adj.map((m) => {
|
|
75
|
+
let s = 0;
|
|
76
|
+
for (const w of m.values()) s += w;
|
|
77
|
+
return s;
|
|
78
|
+
});
|
|
79
|
+
const twoM = k.reduce((a, b) => a + b, 0);
|
|
80
|
+
return { n, adj, k, twoM };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Relabel community ids to a dense 0..c-1 range in node order (the community of
|
|
84
|
+
// node 0 becomes 0, the next unseen community becomes 1, …). Deterministic and
|
|
85
|
+
// only cosmetic — the final reindex overrides these values.
|
|
86
|
+
function canonicalize(comm: number[]): { comm: number[]; count: number } {
|
|
87
|
+
const remap = new Map<number, number>();
|
|
88
|
+
const out = new Array<number>(comm.length);
|
|
89
|
+
for (let i = 0; i < comm.length; i++) {
|
|
90
|
+
let id = remap.get(comm[i]!);
|
|
91
|
+
if (id === undefined) {
|
|
92
|
+
id = remap.size;
|
|
93
|
+
remap.set(comm[i]!, id);
|
|
94
|
+
}
|
|
95
|
+
out[i] = id;
|
|
96
|
+
}
|
|
97
|
+
return { comm: out, count: remap.size };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// One local-move level: every node starts in its own community, then nodes are
|
|
101
|
+
// swept in fixed index order and each moves to the neighbour community with the
|
|
102
|
+
// largest modularity gain (ties → lowest community index; a node stays put unless
|
|
103
|
+
// a neighbour STRICTLY beats keeping it where it is). Repeats until a sweep moves
|
|
104
|
+
// nothing. Returns dense community labels for this level.
|
|
105
|
+
function localMove(g: WeightedGraph): { comm: number[]; count: number } {
|
|
106
|
+
const { n, adj, k, twoM } = g;
|
|
107
|
+
const comm = Array.from({ length: n }, (_, i) => i);
|
|
108
|
+
if (twoM === 0) return canonicalize(comm); // no edges → every node isolated
|
|
109
|
+
const commTot = k.slice(); // Σtot per community; each node starts alone
|
|
110
|
+
|
|
111
|
+
let moved = true;
|
|
112
|
+
let sweeps = 0;
|
|
113
|
+
while (moved && sweeps < MAX_SWEEPS) {
|
|
114
|
+
moved = false;
|
|
115
|
+
sweeps++;
|
|
116
|
+
for (let i = 0; i < n; i++) {
|
|
117
|
+
const cOld = comm[i]!;
|
|
118
|
+
commTot[cOld]! -= k[i]!; // remove i from its community before scoring moves
|
|
119
|
+
|
|
120
|
+
// Weight from i into each neighbouring community (self-loops excluded — they
|
|
121
|
+
// travel with the node and never link it to another community).
|
|
122
|
+
const nb = new Map<number, number>();
|
|
123
|
+
for (const [j, wij] of adj[i]!) {
|
|
124
|
+
if (j === i) continue;
|
|
125
|
+
const cj = comm[j]!;
|
|
126
|
+
nb.set(cj, (nb.get(cj) ?? 0) + wij);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Gain of joining community c (relative to i isolated):
|
|
130
|
+
// score(c) = w(i→c) − γ · k[i] · Σtot(c) / 2m
|
|
131
|
+
// The incumbent is rejoining cOld; in the first sweep cOld is empty, so its
|
|
132
|
+
// score is 0 (the isolated baseline).
|
|
133
|
+
let bestC = cOld;
|
|
134
|
+
let bestScore = (nb.get(cOld) ?? 0) - (GAMMA * k[i]! * commTot[cOld]!) / twoM;
|
|
135
|
+
for (const c of [...nb.keys()].sort((a, b) => a - b)) {
|
|
136
|
+
if (c === cOld) continue;
|
|
137
|
+
const score = nb.get(c)! - (GAMMA * k[i]! * commTot[c]!) / twoM;
|
|
138
|
+
if (score > bestScore + EPS) {
|
|
139
|
+
bestScore = score;
|
|
140
|
+
bestC = c;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
commTot[bestC]! += k[i]!;
|
|
145
|
+
if (bestC !== cOld) {
|
|
146
|
+
comm[i] = bestC;
|
|
147
|
+
moved = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return canonicalize(comm);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Collapse each community into a super-node, summing inter-community weights and
|
|
155
|
+
// folding intra-community weight into a self-loop, so modularity is preserved
|
|
156
|
+
// across levels (2m is invariant).
|
|
157
|
+
function aggregate(g: WeightedGraph, comm: number[], count: number): WeightedGraph {
|
|
158
|
+
const adj: Map<number, number>[] = Array.from({ length: count }, () => new Map<number, number>());
|
|
159
|
+
for (let i = 0; i < g.n; i++) {
|
|
160
|
+
const ci = comm[i]!;
|
|
161
|
+
for (const [j, wij] of g.adj[i]!) {
|
|
162
|
+
const cj = comm[j]!;
|
|
163
|
+
adj[ci]!.set(cj, (adj[ci]!.get(cj) ?? 0) + wij);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const k = adj.map((m) => {
|
|
167
|
+
let s = 0;
|
|
168
|
+
for (const w of m.values()) s += w;
|
|
169
|
+
return s;
|
|
170
|
+
});
|
|
171
|
+
const twoM = k.reduce((a, b) => a + b, 0);
|
|
172
|
+
return { n: count, adj, k, twoM };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Full multi-level Louvain: local-move, then aggregate and repeat until a level
|
|
176
|
+
// merges nothing. Returns a community label per BASE node.
|
|
177
|
+
function louvain(g: WeightedGraph): number[] {
|
|
178
|
+
if (g.n === 0) return [];
|
|
179
|
+
let level = g;
|
|
180
|
+
const mapping = Array.from({ length: g.n }, (_, i) => i); // base node → current level node
|
|
181
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
182
|
+
const { comm, count } = localMove(level);
|
|
183
|
+
for (let i = 0; i < mapping.length; i++) mapping[i] = comm[mapping[i]!]!;
|
|
184
|
+
if (count === level.n) break; // nothing merged → converged
|
|
185
|
+
level = aggregate(level, comm, count);
|
|
186
|
+
}
|
|
187
|
+
return canonicalize(mapping).comm;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Group node ids by their community label (labels are dense 0..c-1, so slot i is
|
|
191
|
+
// community i), preserving ascending node order within each community.
|
|
192
|
+
function groupByLabel(labels: number[]): number[][] {
|
|
193
|
+
const groups: number[][] = [];
|
|
194
|
+
for (let i = 0; i < labels.length; i++) {
|
|
195
|
+
(groups[labels[i]!] ??= []).push(i);
|
|
196
|
+
}
|
|
197
|
+
return groups.filter((g) => g && g.length > 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Re-run Louvain on the induced subgraph of a community's members (edges leaving
|
|
201
|
+
// the community are dropped), returning the sub-communities as base-node id lists.
|
|
202
|
+
function louvainInduced(g: WeightedGraph, members: number[]): number[][] {
|
|
203
|
+
const m = members.length;
|
|
204
|
+
const local = new Map<number, number>();
|
|
205
|
+
members.forEach((b, li) => local.set(b, li));
|
|
206
|
+
const adj: Map<number, number>[] = Array.from({ length: m }, () => new Map<number, number>());
|
|
207
|
+
for (let li = 0; li < m; li++) {
|
|
208
|
+
for (const [nb, w] of g.adj[members[li]!]!) {
|
|
209
|
+
const lj = local.get(nb);
|
|
210
|
+
if (lj === undefined) continue; // edge exits the community → excluded
|
|
211
|
+
adj[li]!.set(lj, w);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const k = adj.map((mp) => {
|
|
215
|
+
let s = 0;
|
|
216
|
+
for (const w of mp.values()) s += w;
|
|
217
|
+
return s;
|
|
218
|
+
});
|
|
219
|
+
const twoM = k.reduce((a, b) => a + b, 0);
|
|
220
|
+
const labels = louvain({ n: m, adj, k, twoM });
|
|
221
|
+
return groupByLabel(labels).map((grp) => grp.map((li) => members[li]!));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Split any oversized community (see OVERSIZE_* constants). Non-recursive: a split
|
|
225
|
+
// community's parts are kept as-is even if a part is itself large — one pass is
|
|
226
|
+
// enough for navigation and keeps the result predictable.
|
|
227
|
+
function splitOversized(groups: number[][], g: WeightedGraph, n: number): number[][] {
|
|
228
|
+
const out: number[][] = [];
|
|
229
|
+
for (const grp of groups) {
|
|
230
|
+
if (grp.length > OVERSIZE_FRACTION * n && grp.length >= OVERSIZE_MIN) {
|
|
231
|
+
const sub = louvainInduced(g, grp);
|
|
232
|
+
if (sub.length > 1) {
|
|
233
|
+
out.push(...sub);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
out.push(grp);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Compare two sorted slug lists: larger size first, then lexicographically by the
|
|
243
|
+
// first differing slug (via byStr). The size-descending, member-lexicographic
|
|
244
|
+
// order is what makes ids reproducible for an unchanged partition.
|
|
245
|
+
function compareCommunities(a: string[], b: string[]): number {
|
|
246
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
247
|
+
for (let i = 0; i < a.length; i++) {
|
|
248
|
+
const c = byStr(a[i]!, b[i]!);
|
|
249
|
+
if (c) return c;
|
|
250
|
+
}
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Assign final ids, reusing previous ids where a new community clearly descends
|
|
255
|
+
// from an old one so a small edit does not renumber everything.
|
|
256
|
+
//
|
|
257
|
+
// Reconciliation rule (deterministic): greedily match new→previous by largest
|
|
258
|
+
// member-set intersection (ties: larger intersection, then the new community's
|
|
259
|
+
// size-desc index, then the lower previous id). A matched new community keeps its
|
|
260
|
+
// previous id when that id is still in range [0, n) and unclaimed; otherwise it is
|
|
261
|
+
// demoted to "new". Remaining communities fill the free ids in canonical order.
|
|
262
|
+
// This preserves ids across edits AND keeps the id set a contiguous 0..n-1 (a
|
|
263
|
+
// previous id ≥ n — from a build that had more communities — cannot be reused
|
|
264
|
+
// verbatim without breaking contiguity, so such a community takes a free id).
|
|
265
|
+
function assignIds(ordered: string[][], previous?: Record<string, string[]>): number[] {
|
|
266
|
+
const n = ordered.length;
|
|
267
|
+
const ids = new Array<number>(n).fill(-1);
|
|
268
|
+
if (!previous || Object.keys(previous).length === 0) {
|
|
269
|
+
for (let i = 0; i < n; i++) ids[i] = i; // cold build: size-desc canonical ids
|
|
270
|
+
return ids;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const prevSets = Object.entries(previous).map(([id, members]) => ({
|
|
274
|
+
id: Number(id),
|
|
275
|
+
set: new Set(members),
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
// All overlapping (new, previous) pairs, best-match-first and fully ordered.
|
|
279
|
+
const pairs: { ni: number; prevId: number; inter: number }[] = [];
|
|
280
|
+
ordered.forEach((comm, ni) => {
|
|
281
|
+
for (const prev of prevSets) {
|
|
282
|
+
let inter = 0;
|
|
283
|
+
for (const s of comm) if (prev.set.has(s)) inter++;
|
|
284
|
+
if (inter > 0) pairs.push({ ni, prevId: prev.id, inter });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
pairs.sort((a, b) => b.inter - a.inter || a.ni - b.ni || a.prevId - b.prevId);
|
|
288
|
+
|
|
289
|
+
const matched = new Map<number, number>(); // new community index → reused previous id
|
|
290
|
+
const usedPrev = new Set<number>();
|
|
291
|
+
for (const p of pairs) {
|
|
292
|
+
if (matched.has(p.ni) || usedPrev.has(p.prevId)) continue;
|
|
293
|
+
matched.set(p.ni, p.prevId);
|
|
294
|
+
usedPrev.add(p.prevId);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Keep a reused id only if it stays inside a contiguous 0..n-1 and is unclaimed.
|
|
298
|
+
const taken = new Set<number>();
|
|
299
|
+
for (let ni = 0; ni < n; ni++) {
|
|
300
|
+
const pid = matched.get(ni);
|
|
301
|
+
if (pid !== undefined && pid >= 0 && pid < n && !taken.has(pid)) {
|
|
302
|
+
ids[ni] = pid;
|
|
303
|
+
taken.add(pid);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Fill the gaps with the remaining ids in canonical order.
|
|
307
|
+
const free: number[] = [];
|
|
308
|
+
for (let id = 0; id < n; id++) if (!taken.has(id)) free.push(id);
|
|
309
|
+
let fi = 0;
|
|
310
|
+
for (let ni = 0; ni < n; ni++) if (ids[ni] === -1) ids[ni] = free[fi++]!;
|
|
311
|
+
return ids;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Raw Louvain partition (no oversized split, no reindex) as sorted slug lists —
|
|
315
|
+
// exposed for tests that need to observe the pre-split clustering.
|
|
316
|
+
export function louvainCommunities(modules: ModuleNode[], edges: Edge[]): string[][] {
|
|
317
|
+
if (modules.length === 0) return [];
|
|
318
|
+
const slugs = modules.map((m) => m.slug).sort(byStr);
|
|
319
|
+
const labels = louvain(buildAdjacency(slugs, edges));
|
|
320
|
+
return groupByLabel(labels).map((grp) => grp.map((i) => slugs[i]!).sort(byStr));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Cluster modules into navigation communities. Returns slug → community id (0-based;
|
|
324
|
+
// id 0 is the largest community for a cold build). `previous` (the last manifest's
|
|
325
|
+
// communities map) reconciles ids so an unrelated edit does not renumber them.
|
|
326
|
+
export function detectCommunities(
|
|
327
|
+
modules: ModuleNode[],
|
|
328
|
+
edges: Edge[],
|
|
329
|
+
previous?: Record<string, string[]>,
|
|
330
|
+
): Map<string, number> {
|
|
331
|
+
const out = new Map<string, number>();
|
|
332
|
+
if (modules.length === 0) return out;
|
|
333
|
+
|
|
334
|
+
const slugs = modules.map((m) => m.slug).sort(byStr);
|
|
335
|
+
const g = buildAdjacency(slugs, edges);
|
|
336
|
+
const labels = louvain(g);
|
|
337
|
+
const split = splitOversized(groupByLabel(labels), g, slugs.length);
|
|
338
|
+
const communities = split.map((grp) => grp.map((i) => slugs[i]!).sort(byStr));
|
|
339
|
+
communities.sort(compareCommunities); // size-desc, member-lex → stable reindex
|
|
340
|
+
const ids = assignIds(communities, previous);
|
|
341
|
+
communities.forEach((comm, ni) => {
|
|
342
|
+
for (const s of comm) out.set(s, ids[ni]!);
|
|
343
|
+
});
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Cyclomatic-complexity estimates from branch-token counting over symbol line
|
|
2
|
+
// spans (language-generic: if/for/while/case/catch families plus &&, ||, and
|
|
3
|
+
// ternaries), and the risk ranking nobody else in the space ships: complexity
|
|
4
|
+
// × git churn — files that are BOTH hard to reason about AND constantly
|
|
5
|
+
// changed are where defects concentrate.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { RepoScan } from "./scan.js";
|
|
8
|
+
import { readText } from "./walk.js";
|
|
9
|
+
import { byStr } from "./sort.js";
|
|
10
|
+
|
|
11
|
+
const BRANCH_RE =
|
|
12
|
+
/\b(if|elif|elsif|else\s+if|for|foreach|while|until|unless|case|when|match|catch|rescue|except)\b|&&|\|\||(?<![?:])\?(?![?.:])/g;
|
|
13
|
+
|
|
14
|
+
export function complexityOfSource(source: string): number {
|
|
15
|
+
return 1 + (source.match(BRANCH_RE) ?? []).length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SymbolComplexity {
|
|
19
|
+
file: string;
|
|
20
|
+
name: string;
|
|
21
|
+
line: number;
|
|
22
|
+
endLine?: number;
|
|
23
|
+
complexity: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Per-symbol complexity for one file (or the whole repo when rel is omitted),
|
|
27
|
+
// sorted most-complex first. Symbols without an endLine (regex tier) fall back
|
|
28
|
+
// to a single-line estimate and naturally rank low — labeled by the absent
|
|
29
|
+
// endLine rather than silently guessed.
|
|
30
|
+
export function symbolComplexity(scan: RepoScan, rel?: string, top = 50): SymbolComplexity[] {
|
|
31
|
+
const out: SymbolComplexity[] = [];
|
|
32
|
+
for (const f of scan.files) {
|
|
33
|
+
if (f.kind !== "code") continue;
|
|
34
|
+
if (rel && f.rel !== rel) continue;
|
|
35
|
+
if (!f.symbols.length) continue;
|
|
36
|
+
const lines = readText(join(scan.root, f.rel)).split("\n");
|
|
37
|
+
for (const s of f.symbols) {
|
|
38
|
+
if (s.kind === "reexport" || s.kind === "reexport-all") continue;
|
|
39
|
+
const end = s.endLine ?? s.line;
|
|
40
|
+
const body = lines.slice(s.line - 1, end).join("\n");
|
|
41
|
+
const entry: SymbolComplexity = { file: f.rel, name: s.name, line: s.line, complexity: complexityOfSource(body) };
|
|
42
|
+
if (s.endLine !== undefined) entry.endLine = s.endLine;
|
|
43
|
+
out.push(entry);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
out.sort((a, b) => b.complexity - a.complexity || byStr(a.file, b.file) || a.line - b.line);
|
|
47
|
+
return out.slice(0, top);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RiskHotspot {
|
|
51
|
+
file: string;
|
|
52
|
+
complexity: number; // whole-file branch count + 1
|
|
53
|
+
commits: number;
|
|
54
|
+
// (commits + 1) × complexity — churn amplifies complexity; a complex but
|
|
55
|
+
// frozen file ranks below a complex file under constant change.
|
|
56
|
+
score: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function riskHotspots(scan: RepoScan, churn: Map<string, number>, top = 20): RiskHotspot[] {
|
|
60
|
+
const out: RiskHotspot[] = scan.files
|
|
61
|
+
.filter((f) => f.kind === "code")
|
|
62
|
+
.map((f) => {
|
|
63
|
+
const complexity = complexityOfSource(readText(join(scan.root, f.rel)));
|
|
64
|
+
const commits = churn.get(f.rel) ?? 0;
|
|
65
|
+
return { file: f.rel, complexity, commits, score: (commits + 1) * complexity };
|
|
66
|
+
});
|
|
67
|
+
out.sort((a, b) => b.score - a.score || byStr(a.file, b.file));
|
|
68
|
+
return out.slice(0, top);
|
|
69
|
+
}
|
package/src/coupling.ts
ADDED
|
Binary file
|
package/src/deadcode.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Dead-code candidates over the static index, in two honestly-labeled tiers:
|
|
2
|
+
// "unreferenced" — an exported definition no call site binds to AND no file's
|
|
3
|
+
// identifier/mention pass references (highest confidence); "uncalled" — files
|
|
4
|
+
// reference the name (re-export, type position, config string) but no call
|
|
5
|
+
// site binds. Strictly more principled than name-blacklist heuristics: test
|
|
6
|
+
// files and entrypoint-looking files are excluded as ROOTS (their exports are
|
|
7
|
+
// consumed externally), not by substring matching.
|
|
8
|
+
import type { CodeSymbol } from "./types.js";
|
|
9
|
+
import type { RepoScan } from "./scan.js";
|
|
10
|
+
import { buildCallerIndex } from "./callers.js";
|
|
11
|
+
import { computeSymbolRefs } from "./render/symbols-json.js";
|
|
12
|
+
import { isTestPath } from "./tests-map.js";
|
|
13
|
+
import { byStr } from "./sort.js";
|
|
14
|
+
|
|
15
|
+
const REFERENCE_KINDS = new Set(["reexport", "reexport-all", "default"]);
|
|
16
|
+
// Files whose exports are consumed from OUTSIDE the repo (bins, entrypoints,
|
|
17
|
+
// public API barrels) — their symbols are never dead-code candidates.
|
|
18
|
+
const ENTRYPOINT_RE = /(^|\/)(index|main|cli|app|server|engine)\.[a-z]+$/;
|
|
19
|
+
|
|
20
|
+
export interface DeadSymbol {
|
|
21
|
+
name: string;
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
kind: string;
|
|
25
|
+
tier: "unreferenced" | "uncalled";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findDeadCode(scan: RepoScan): DeadSymbol[] {
|
|
29
|
+
const callers = buildCallerIndex(scan);
|
|
30
|
+
const refs = computeSymbolRefs(scan);
|
|
31
|
+
const out: DeadSymbol[] = [];
|
|
32
|
+
const consider = (s: CodeSymbol): boolean =>
|
|
33
|
+
s.exported && !REFERENCE_KINDS.has(s.kind) && !isTestPath(s.file) && !ENTRYPOINT_RE.test(s.file);
|
|
34
|
+
|
|
35
|
+
for (const f of scan.files) {
|
|
36
|
+
for (const s of f.symbols) {
|
|
37
|
+
if (!consider(s)) continue;
|
|
38
|
+
const entry = callers.get(s.name) ?? callers.get(`${s.name}@${s.file}`);
|
|
39
|
+
const hasCallers = !!entry && entry.def.file === s.file && entry.callers.length > 0;
|
|
40
|
+
if (hasCallers) continue;
|
|
41
|
+
const referenced = (refs.get(s.name)?.size ?? 0) > 0;
|
|
42
|
+
out.push({ name: s.name, file: s.file, line: s.line, kind: s.kind, tier: referenced ? "uncalled" : "unreferenced" });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => byStr(a.tier, b.tier) || byStr(a.file, b.file) || a.line - b.line);
|
|
46
|
+
}
|
package/src/edit.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Symbolic editing over AST line spans (Serena-parity, static edition):
|
|
2
|
+
// replace a symbol's body, insert after/before a symbol — the range math and
|
|
3
|
+
// blank-line normalization mirror the market leader's contract, but resolved
|
|
4
|
+
// from the deterministic index instead of a live language server. Line-span
|
|
5
|
+
// granularity: the caller supplies the replacement body verbatim, including
|
|
6
|
+
// its indentation (same contract as Serena's replace_symbol_body).
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { CodeSymbol } from "./types.js";
|
|
10
|
+
import type { RepoScan } from "./scan.js";
|
|
11
|
+
import { findSymbol } from "./query.js";
|
|
12
|
+
|
|
13
|
+
// Symbol kinds that conventionally sit separated from neighbours by a blank
|
|
14
|
+
// line — insertions next to them keep at least one.
|
|
15
|
+
const SEPARATED_KINDS = new Set(["function", "method", "class", "interface", "struct", "trait", "enum", "def"]);
|
|
16
|
+
|
|
17
|
+
export interface EditResult {
|
|
18
|
+
file: string;
|
|
19
|
+
startLine: number; // 1-based, inclusive — the lines the edit touched
|
|
20
|
+
endLine: number;
|
|
21
|
+
lines: number; // lines written
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve a name path to exactly ONE symbol or throw with the candidate list
|
|
25
|
+
// (the agent then disambiguates with a more specific path — same contract as
|
|
26
|
+
// Serena's find_unique).
|
|
27
|
+
export function resolveUniqueSymbol(scan: RepoScan, namePath: string, file?: string): CodeSymbol {
|
|
28
|
+
let matches = findSymbol(scan, namePath);
|
|
29
|
+
if (file) matches = matches.filter((m) => m.file === file);
|
|
30
|
+
if (matches.length === 1) return matches[0]!;
|
|
31
|
+
if (matches.length === 0) {
|
|
32
|
+
const near = findSymbol(scan, namePath, { substring: true, maxResults: 5 })
|
|
33
|
+
.map((m) => `${m.file}:${m.line} ${m.parent ? m.parent + "/" : ""}${m.name}`)
|
|
34
|
+
.join(", ");
|
|
35
|
+
throw new Error(`no symbol matches "${namePath}"${file ? ` in ${file}` : ""}${near ? ` — near matches: ${near}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
const list = matches.map((m) => `${m.file}:${m.line}`).join(", ");
|
|
38
|
+
throw new Error(`"${namePath}" is ambiguous (${matches.length} matches: ${list}) — qualify with \`file\` or a Parent/name path`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readLines(abs: string): string[] {
|
|
42
|
+
return readFileSync(abs, "utf8").split("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Replace the symbol's whole declaration (lines start..endLine) with `body`.
|
|
46
|
+
// The body is taken verbatim after trimming outer blank lines — supply it
|
|
47
|
+
// fully indented for its context.
|
|
48
|
+
export function replaceSymbolBody(scan: RepoScan, namePath: string, body: string, file?: string): EditResult {
|
|
49
|
+
const sym = resolveUniqueSymbol(scan, namePath, file);
|
|
50
|
+
const end = sym.endLine ?? sym.line;
|
|
51
|
+
const abs = join(scan.root, sym.file);
|
|
52
|
+
const lines = readLines(abs);
|
|
53
|
+
const newLines = body.replace(/^\n+|\n+$/g, "").split("\n");
|
|
54
|
+
lines.splice(sym.line - 1, end - sym.line + 1, ...newLines);
|
|
55
|
+
writeFileSync(abs, lines.join("\n"));
|
|
56
|
+
return { file: sym.file, startLine: sym.line, endLine: sym.line + newLines.length - 1, lines: newLines.length };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function insertAt(
|
|
60
|
+
scan: RepoScan,
|
|
61
|
+
sym: CodeSymbol,
|
|
62
|
+
body: string,
|
|
63
|
+
index: number, // 0-based line index to insert at
|
|
64
|
+
blankBefore: boolean,
|
|
65
|
+
blankAfter: boolean,
|
|
66
|
+
): EditResult {
|
|
67
|
+
const abs = join(scan.root, sym.file);
|
|
68
|
+
const lines = readLines(abs);
|
|
69
|
+
const minGap = SEPARATED_KINDS.has(sym.kind) ? 1 : 0;
|
|
70
|
+
const newLines = body.replace(/^\n+|\n+$/g, "").split("\n");
|
|
71
|
+
const block: string[] = [];
|
|
72
|
+
if (blankBefore && minGap && lines[index - 1]?.trim() !== "") block.push("");
|
|
73
|
+
block.push(...newLines);
|
|
74
|
+
if (blankAfter && minGap && lines[index]?.trim() !== "") block.push("");
|
|
75
|
+
lines.splice(index, 0, ...block);
|
|
76
|
+
writeFileSync(abs, lines.join("\n"));
|
|
77
|
+
return { file: sym.file, startLine: index + 1, endLine: index + block.length, lines: block.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Insert `body` on the line after the symbol's declaration ends, keeping at
|
|
81
|
+
// least one blank line of separation for definition-like kinds.
|
|
82
|
+
export function insertAfterSymbol(scan: RepoScan, namePath: string, body: string, file?: string): EditResult {
|
|
83
|
+
const sym = resolveUniqueSymbol(scan, namePath, file);
|
|
84
|
+
const end = sym.endLine ?? sym.line;
|
|
85
|
+
return insertAt(scan, sym, body, end, true, true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Insert `body` on the line where the symbol's declaration starts, pushing the
|
|
89
|
+
// symbol down, with the same separation rule.
|
|
90
|
+
export function insertBeforeSymbol(scan: RepoScan, namePath: string, body: string, file?: string): EditResult {
|
|
91
|
+
const sym = resolveUniqueSymbol(scan, namePath, file);
|
|
92
|
+
return insertAt(scan, sym, body, sym.line - 1, true, true);
|
|
93
|
+
}
|