@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.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. package/src/workspaces.ts +748 -0
@@ -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
+ }
@@ -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
+ }
Binary file
@@ -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
+ }