@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/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));
|