@maxgfr/codeindex 2.17.1 → 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/src/delta.ts ADDED
@@ -0,0 +1,417 @@
1
+ // `delta`: map a git diff onto the link-graph — changed files → enclosing
2
+ // symbols → blast radius → a risk-scored, reasons-first review panel.
3
+ //
4
+ // The scoring is deterministic and mechanical; judging whether a risky change is
5
+ // CORRECT is not this engine's job. Reasons matter more than the number: every
6
+ // point of the score is explained by one reason string carrying its numbers, so
7
+ // a reviewer can disagree with a signal instead of with an opaque total.
8
+ import type { Graph, ModuleNode, SymbolIndex } from "./types.js";
9
+ import type { DiffFile, DiffSpec, Hunk } from "./git.js";
10
+ import { isGitWorktree, resolveBaseRef, diffFiles, diffHunks, untrackedFiles } from "./git.js";
11
+ import { byStr } from "./sort.js";
12
+ import { have, sh } from "./util.js";
13
+ import { impactOf } from "./traverse.js";
14
+
15
+ export interface DeltaOptions {
16
+ base?: string;
17
+ staged?: boolean;
18
+ depth?: number; // blast-radius hops, default 2
19
+ }
20
+
21
+ export interface ChangedSymbol {
22
+ name: string;
23
+ kind: string;
24
+ exported: boolean;
25
+ line: number;
26
+ endLine?: number;
27
+ parent?: string;
28
+ approx?: boolean; // attributed by nearest-def fallback, not exact enclosure
29
+ }
30
+
31
+ export interface DeltaChange {
32
+ path: string;
33
+ status: DiffFile["status"];
34
+ oldPath?: string;
35
+ binary?: boolean;
36
+ linesAdded?: number;
37
+ linesDeleted?: number;
38
+ module?: string;
39
+ hunks: { start: number; end: number }[];
40
+ symbols: ChangedSymbol[];
41
+ }
42
+
43
+ export interface DeltaModule {
44
+ slug: string;
45
+ path: string;
46
+ score: number; // 0–100
47
+ bucket: "HIGH" | "MEDIUM" | "LOW";
48
+ reasons: string[]; // one per fired signal, fixed order, numbers included
49
+ changedFiles: string[];
50
+ changedSymbols: { total: number; exported: number };
51
+ impact: { directFiles: number; transitiveFiles: number; modules: string[] };
52
+ tests: { status: "covered" | "gap" | "n/a"; files: string[] };
53
+ open: string[]; // best changed files to open first
54
+ }
55
+
56
+ export interface DeltaResult {
57
+ base: { ref: string; mergeBase: string; staged: boolean };
58
+ indexCommit?: string;
59
+ depth: number;
60
+ changes: DeltaChange[];
61
+ modules: DeltaModule[];
62
+ dangling: { from: string; spec: string; reason: string }[];
63
+ deleted: string[];
64
+ unindexed: string[];
65
+ notes: string[];
66
+ }
67
+
68
+ export type DeltaError = { error: string };
69
+
70
+ // The fixed weight table — exported so consumers and tests pin every signal
71
+ // exactly rather than inferring it from totals.
72
+ export const RISK_WEIGHTS = {
73
+ exportedChange: 25, // an exported symbol changed: consumers may break
74
+ hubHigh: 20, // pagerank percentile ≥ .90
75
+ hubMed: 10, // pagerank percentile ≥ .75
76
+ blastHigh: 20, // ≥ 20 dependent files or ≥ 5 dependent modules
77
+ blastMed: 10, // ≥ 5 dependent files
78
+ testGap: 20, // a testable module with no covering test
79
+ surprise: 10, // the module sits on a surprising cross-community edge
80
+ dangling: 15, // a changed file carries a dangling import
81
+ } as const;
82
+
83
+ const HIGH_MIN = 60;
84
+ const MEDIUM_MIN = 30;
85
+ const OPEN_CAP = 3;
86
+ export const DEFAULT_DELTA_DEPTH = 2;
87
+
88
+ interface NamedDef {
89
+ name: string;
90
+ file: string;
91
+ line: number;
92
+ endLine?: number;
93
+ kind: string;
94
+ exported: boolean;
95
+ parent?: string;
96
+ }
97
+
98
+ // Every symbol whose range encloses a changed hunk, innermost first. When no def
99
+ // encloses the hunk and the file's defs carry no endLine (regex-extracted
100
+ // languages), the nearest def at or above the hunk is taken and flagged
101
+ // `approx` — never silently presented as exact.
102
+ export function symbolsInHunks(defs: NamedDef[], hunks: Hunk[]): ChangedSymbol[] {
103
+ const out: ChangedSymbol[] = [];
104
+ const seen = new Set<string>();
105
+ const push = (d: NamedDef, approx: boolean): void => {
106
+ const key = `${d.name}:${d.line}`;
107
+ if (seen.has(key)) return;
108
+ seen.add(key);
109
+ out.push({
110
+ name: d.name,
111
+ kind: d.kind,
112
+ exported: d.exported,
113
+ line: d.line,
114
+ ...(d.endLine !== undefined ? { endLine: d.endLine } : {}),
115
+ ...(d.parent !== undefined ? { parent: d.parent } : {}),
116
+ ...(approx ? { approx: true } : {}),
117
+ });
118
+ };
119
+ const span = (d: NamedDef): number => (d.endLine ?? d.line) - d.line;
120
+ for (const h of hunks) {
121
+ const enclosing = defs.filter((d) => d.line <= h.end && (d.endLine ?? d.line) >= h.start);
122
+ if (enclosing.length) {
123
+ enclosing.sort((a, b) => span(a) - span(b) || b.line - a.line || byStr(a.name, b.name));
124
+ for (const d of enclosing) push(d, false);
125
+ } else {
126
+ const above = defs.filter((d) => d.line <= h.start && d.endLine === undefined);
127
+ const near = above[above.length - 1]; // defs are line-sorted
128
+ if (near) push(near, true);
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ // Percentile by STRICTLY-SMALLER count so ties never rank anyone above anyone:
135
+ // with all values equal the percentile is 0, not an artifact of sort order.
136
+ function percentile(values: number[], mine: number): number {
137
+ if (values.length <= 1) return 0;
138
+ let smaller = 0;
139
+ for (const v of values) if (v < mine) smaller++;
140
+ return smaller / (values.length - 1);
141
+ }
142
+
143
+ // The pure core: graph + symbols + parsed diff → the full result. No git, no
144
+ // filesystem — unit-testable with synthetic inputs.
145
+ export function computeDelta(
146
+ graph: Graph,
147
+ symbols: SymbolIndex | undefined,
148
+ diff: { files: DiffFile[]; hunks: Map<string, Hunk[]>; base: DeltaResult["base"]; notes?: string[] },
149
+ depth: number = DEFAULT_DELTA_DEPTH,
150
+ ): DeltaResult {
151
+ const notes = [...(diff.notes ?? [])];
152
+ if (!symbols) notes.push("symbol index missing — symbol-level attribution disabled");
153
+
154
+ const fileByRel = new Map(graph.files.map((f) => [f.rel, f]));
155
+
156
+ const defsByFile = new Map<string, NamedDef[]>();
157
+ if (symbols) {
158
+ for (const [name, entries] of Object.entries(symbols.defs)) {
159
+ for (const d of entries) {
160
+ let arr = defsByFile.get(d.file);
161
+ if (!arr) defsByFile.set(d.file, (arr = []));
162
+ arr.push({ name, ...d });
163
+ }
164
+ }
165
+ for (const arr of defsByFile.values()) arr.sort((a, b) => a.line - b.line || byStr(a.name, b.name));
166
+ }
167
+
168
+ const deleted: string[] = [];
169
+ const unindexed: string[] = [];
170
+ const changes: DeltaChange[] = [];
171
+ for (const df of [...diff.files].sort((a, b) => byStr(a.path, b.path))) {
172
+ const carry = {
173
+ ...(df.oldPath !== undefined ? { oldPath: df.oldPath } : {}),
174
+ ...(df.binary ? { binary: true } : {}),
175
+ ...(df.linesAdded !== undefined ? { linesAdded: df.linesAdded } : {}),
176
+ ...(df.linesDeleted !== undefined ? { linesDeleted: df.linesDeleted } : {}),
177
+ };
178
+ if (df.status === "deleted") {
179
+ deleted.push(df.path);
180
+ changes.push({ path: df.path, status: df.status, ...carry, hunks: [], symbols: [] });
181
+ continue;
182
+ }
183
+ const node = fileByRel.get(df.path);
184
+ if (!node) {
185
+ unindexed.push(df.path);
186
+ continue;
187
+ }
188
+ // A file added whole (including untracked) has no hunks in the diff: treat
189
+ // the entire file as changed so its symbols are attributed.
190
+ let hunks = diff.hunks.get(df.path) ?? [];
191
+ if (!hunks.length && df.status === "added" && !df.binary) hunks = [{ start: 1, end: Math.max(node.lines, 1) }];
192
+ const syms = df.binary ? [] : symbolsInHunks(defsByFile.get(df.path) ?? [], hunks);
193
+ changes.push({
194
+ path: df.path,
195
+ status: df.status,
196
+ ...carry,
197
+ module: node.module,
198
+ hunks: hunks.map((h) => ({ start: h.start, end: h.end })),
199
+ symbols: syms,
200
+ });
201
+ }
202
+
203
+ // Dangling imports leaving any changed indexed file — broken references the
204
+ // diff either introduced or now sits on top of.
205
+ const changedRels = new Set(changes.filter((c) => c.status !== "deleted").map((c) => c.path));
206
+ const dangling = graph.fileEdges
207
+ .filter((e) => e.dangling && (e.kind === "import" || e.kind === "doc-link") && changedRels.has(e.from))
208
+ .map((e) => ({ from: e.from, spec: e.to, reason: e.reason ?? "unknown" }))
209
+ .sort((a, b) => byStr(a.from, b.from) || byStr(a.spec, b.spec));
210
+
211
+ // Group by module and score.
212
+ const byModule = new Map<string, DeltaChange[]>();
213
+ for (const c of changes) {
214
+ if (c.status === "deleted" || !c.module) continue;
215
+ let arr = byModule.get(c.module);
216
+ if (!arr) byModule.set(c.module, (arr = []));
217
+ arr.push(c);
218
+ }
219
+
220
+ const nonTestCode = new Set<string>();
221
+ for (const f of graph.files) {
222
+ if (f.fileKind === "code" && !f.testFile) nonTestCode.add(f.module);
223
+ }
224
+ const pagerankKnown = graph.modules.some((m) => m.pagerank !== undefined);
225
+ const metricOf = (m: ModuleNode): number => (pagerankKnown ? (m.pagerank ?? 0) : m.degIn + m.degOut);
226
+ const metricValues = graph.modules.map(metricOf);
227
+ const metricName = pagerankKnown ? "pagerank" : "degree";
228
+
229
+ const modules: DeltaModule[] = [];
230
+ for (const slug of [...byModule.keys()].sort(byStr)) {
231
+ const m = graph.modules.find((x) => x.slug === slug);
232
+ if (!m) continue;
233
+ const moduleChanges = byModule.get(slug)!;
234
+ const reasons: string[] = [];
235
+ let score = 0;
236
+
237
+ // 1. Exported API changed.
238
+ const exportedNames = [...new Set(moduleChanges.flatMap((c) => c.symbols.filter((s) => s.exported).map((s) => s.name)))].sort(byStr);
239
+ if (exportedNames.length) {
240
+ score += RISK_WEIGHTS.exportedChange;
241
+ const shown = exportedNames.slice(0, 3).join(", ") + (exportedNames.length > 3 ? ", …" : "");
242
+ reasons.push(exportedNames.length === 1 ? `exported symbol ${shown} changed` : `exported symbols ${shown} changed`);
243
+ }
244
+
245
+ // 2. Structural importance of the touched module.
246
+ const pct = percentile(metricValues, metricOf(m));
247
+ if (pct >= 0.9) {
248
+ score += RISK_WEIGHTS.hubHigh;
249
+ reasons.push(`${metricName} p${Math.round(pct * 100)} hub`);
250
+ } else if (pct >= 0.75) {
251
+ score += RISK_WEIGHTS.hubMed;
252
+ reasons.push(`${metricName} p${Math.round(pct * 100)} hub`);
253
+ }
254
+
255
+ // 3. Blast radius — union of the reverse closure of each changed file.
256
+ const depthByRel = new Map<string, number>();
257
+ const impModules = new Set<string>();
258
+ for (const c of moduleChanges) {
259
+ const imp = impactOf(graph, c.path, depth);
260
+ if (!imp) continue;
261
+ for (const f of imp.files) {
262
+ const prev = depthByRel.get(f.rel);
263
+ if (prev === undefined || f.depth < prev) depthByRel.set(f.rel, f.depth);
264
+ }
265
+ for (const im of imp.modules) if (im !== slug) impModules.add(im);
266
+ }
267
+ const transitiveFiles = depthByRel.size;
268
+ const directFiles = [...depthByRel.values()].filter((d) => d === 1).length;
269
+ const impact = { directFiles, transitiveFiles, modules: [...impModules].sort(byStr) };
270
+ if (transitiveFiles >= 20 || impact.modules.length >= 5) {
271
+ score += RISK_WEIGHTS.blastHigh;
272
+ reasons.push(`${transitiveFiles} dependent files across ${impact.modules.length} modules (depth ${depth})`);
273
+ } else if (transitiveFiles >= 5) {
274
+ score += RISK_WEIGHTS.blastMed;
275
+ reasons.push(`${transitiveFiles} dependent files across ${impact.modules.length} modules (depth ${depth})`);
276
+ }
277
+
278
+ // 4. Test gap — only for modules that should have tests at all.
279
+ const testable = m.tier <= 1 && m.symbols > 0 && nonTestCode.has(slug);
280
+ const coveredBy = m.testedBy ?? [];
281
+ const tests: DeltaModule["tests"] = testable
282
+ ? coveredBy.length
283
+ ? { status: "covered", files: coveredBy }
284
+ : { status: "gap", files: [] }
285
+ : { status: "n/a", files: [] };
286
+ if (tests.status === "gap") {
287
+ score += RISK_WEIGHTS.testGap;
288
+ reasons.push("no test covers this module");
289
+ }
290
+
291
+ // 5. Surprising cross-community coupling incident to the module.
292
+ const sup = (graph.surprises ?? []).find((s) => s.from === slug || s.to === slug);
293
+ if (sup) {
294
+ score += RISK_WEIGHTS.surprise;
295
+ reasons.push(`cross-community edge to ${sup.from === slug ? sup.to : sup.from} (surprising)`);
296
+ }
297
+
298
+ // 6. Dangling imports from this module's changed files.
299
+ const moduleDangling = dangling.filter((d) => moduleChanges.some((c) => c.path === d.from));
300
+ if (moduleDangling.length) {
301
+ score += RISK_WEIGHTS.dangling;
302
+ const first = moduleDangling[0]!;
303
+ const more = moduleDangling.length > 1 ? ` (+${moduleDangling.length - 1} more)` : "";
304
+ reasons.push(`dangling import "${first.spec}" in ${first.from}${more}`);
305
+ }
306
+
307
+ score = Math.min(100, score);
308
+ const changedFiles = moduleChanges.map((c) => c.path).sort(byStr);
309
+ const allSyms = moduleChanges.flatMap((c) => c.symbols);
310
+ const open = moduleChanges
311
+ .slice()
312
+ .sort(
313
+ (a, b) =>
314
+ b.symbols.filter((s) => s.exported).length - a.symbols.filter((s) => s.exported).length ||
315
+ b.symbols.length - a.symbols.length ||
316
+ byStr(a.path, b.path),
317
+ )
318
+ .slice(0, OPEN_CAP)
319
+ .map((c) => c.path);
320
+
321
+ modules.push({
322
+ slug,
323
+ path: m.path,
324
+ score,
325
+ bucket: score >= HIGH_MIN ? "HIGH" : score >= MEDIUM_MIN ? "MEDIUM" : "LOW",
326
+ reasons,
327
+ changedFiles,
328
+ changedSymbols: {
329
+ total: new Set(allSyms.map((s) => `${s.name}:${s.line}`)).size,
330
+ exported: new Set(allSyms.filter((s) => s.exported).map((s) => `${s.name}:${s.line}`)).size,
331
+ },
332
+ impact,
333
+ tests,
334
+ open,
335
+ });
336
+ }
337
+ modules.sort((a, b) => b.score - a.score || byStr(a.slug, b.slug));
338
+
339
+ return {
340
+ base: diff.base,
341
+ ...(graph.commit !== undefined ? { indexCommit: graph.commit } : {}),
342
+ depth,
343
+ changes,
344
+ modules,
345
+ dangling,
346
+ deleted: deleted.sort(byStr),
347
+ unindexed: unindexed.sort(byStr),
348
+ notes,
349
+ };
350
+ }
351
+
352
+ // Git plumbing → computeDelta, against a graph the caller already built. The
353
+ // caller owns index freshness: a consumer serving a PERSISTED graph must gate
354
+ // on its own staleness oracle first, because symbol line-mapping is only
355
+ // correct against an index built from the same bytes, and a confidently wrong
356
+ // attribution is worse than "rebuild first".
357
+ export function deltaFor(
358
+ repo: string,
359
+ graph: Graph,
360
+ symbols: SymbolIndex | undefined,
361
+ opts: DeltaOptions = {},
362
+ ): DeltaResult | DeltaError {
363
+ if (!have("git")) return { error: "git is required for delta and was not found on PATH" };
364
+ if (!isGitWorktree(repo)) return { error: `delta needs a git worktree — ${repo} is not inside one` };
365
+
366
+ const notes: string[] = [];
367
+ let base: DeltaResult["base"];
368
+ if (opts.staged) {
369
+ const head = sh("git", ["-C", repo, "rev-parse", "HEAD"]);
370
+ if (!head.ok) return { error: "cannot resolve HEAD — empty repository?" };
371
+ base = { ref: "HEAD", mergeBase: head.stdout.trim(), staged: true };
372
+ } else {
373
+ const r = resolveBaseRef(repo, opts.base);
374
+ if ("error" in r) return { error: r.error };
375
+ if (r.note) notes.push(r.note);
376
+ base = { ref: r.ref, mergeBase: r.mergeBase, staged: false };
377
+ }
378
+
379
+ const spec: DiffSpec = opts.staged ? { staged: true } : { mergeBase: base.mergeBase };
380
+ const files = diffFiles(repo, spec);
381
+ if (!opts.staged) {
382
+ const known = new Set(files.map((f) => f.path));
383
+ for (const u of untrackedFiles(repo)) {
384
+ if (!known.has(u)) files.push({ path: u, status: "added" });
385
+ }
386
+ }
387
+
388
+ return computeDelta(graph, symbols, { files, hunks: diffHunks(repo, spec), base, notes }, opts.depth ?? DEFAULT_DELTA_DEPTH);
389
+ }
390
+
391
+ // The human panel. Stdout-only by design: delta output is ephemeral per-worktree
392
+ // state — machine consumers take the JSON.
393
+ export function formatDeltaPanel(res: DeltaResult): string {
394
+ const mb = res.base.mergeBase.slice(0, 7);
395
+ const vs = `${res.base.staged ? "staged vs " : ""}${res.base.ref}`;
396
+ if (!res.changes.length && !res.unindexed.length) {
397
+ return `codeindex: no changes vs ${vs} (merge-base ${mb})\n`;
398
+ }
399
+ const changedCount = res.changes.length + res.unindexed.length;
400
+ const lines = [
401
+ `codeindex: delta vs ${vs} (merge-base ${mb}) — ${changedCount} changed file(s), ` +
402
+ `${res.modules.length} module(s)${res.indexCommit ? `, index @ ${res.indexCommit}` : ""}`,
403
+ ];
404
+ for (const n of res.notes) lines.push(` note: ${n}`);
405
+ for (const m of res.modules) {
406
+ lines.push(` ${m.bucket.padEnd(6)} ${m.slug} score ${m.score}${m.reasons.length ? ` — ${m.reasons.join("; ")}` : ""}`);
407
+ const tests =
408
+ m.tests.status === "gap" ? "GAP" : m.tests.status === "covered" ? `covered (${m.tests.files.length})` : "n/a";
409
+ lines.push(` open: ${m.open.join(", ") || "—"} · tests: ${tests}`);
410
+ }
411
+ if (res.dangling.length) {
412
+ lines.push(` dangling: ${res.dangling.map((d) => `${d.spec} (from ${d.from})`).join(" · ")}`);
413
+ }
414
+ if (res.deleted.length) lines.push(` deleted: ${res.deleted.join(", ")}`);
415
+ if (res.unindexed.length) lines.push(` unindexed: ${res.unindexed.join(", ")}`);
416
+ return lines.join("\n") + "\n";
417
+ }
package/src/derived.ts CHANGED
@@ -80,6 +80,23 @@ export function importPairsFor(scan: RepoScan): Set<string> {
80
80
  return c.importPairs;
81
81
  }
82
82
 
83
+ // Seed the import-pair cache with a set buildGraph already resolved.
84
+ //
85
+ // buildGraph resolves every import in the repo to draw its `import` edges and
86
+ // keeps the resolved pairs to suppress redundant `use` edges — the exact set
87
+ // importPairsFor computes. Without this, the pipeline resolved every import
88
+ // twice: once in buildGraph, once again the first time anything asked for the
89
+ // caller index / references / dead code.
90
+ //
91
+ // Guarded on resolve-context identity: buildGraph is a public export a consumer
92
+ // may call with its own context, and pairs resolved under a foreign context must
93
+ // never masquerade as this scan's. Never overwrites an existing entry.
94
+ export function publishImportPairs(scan: RepoScan, ctx: ResolveContext, pairs: Set<string>): void {
95
+ const c = caches.get(scan);
96
+ if (!c || c.resolveCtx !== ctx || c.importPairs) return;
97
+ c.importPairs = pairs;
98
+ }
99
+
83
100
  export function uniqueDefsFor(scan: RepoScan): Map<string, string> {
84
101
  const c = cacheFor(scan);
85
102
  return (c.uniqueDefs ??= uniqueSymbolDefs(scan));