@remnic/coding-graph 9.6.23 → 9.6.25

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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Phase A heuristic edge resolution (issue #1891).
3
+ *
4
+ * The parser engine emits raw call candidates (`FileIR.callSites`); the
5
+ * graph store persists only pre-resolved `EdgeIR[]`. This module is the
6
+ * bridge the docs call "Phase A — heuristic": a PURE function that derives
7
+ * CALLS edge assertions from a freshly parsed batch, using only evidence
8
+ * present in the IR itself:
9
+ *
10
+ * - src: the innermost same-file symbol whose span contains the call
11
+ * site (half-open spans, rule 35). Top-level calls (no enclosing
12
+ * symbol) are skipped and counted — module-level side effects are not
13
+ * a caller symbol.
14
+ * - dst, in candidate order:
15
+ * 1. a same-file symbol VISIBLE from the call site under lexical
16
+ * scoping approximated by span containment: the caller's own
17
+ * nested symbols first, then each enclosing scope's children out
18
+ * to the file level. Innermost match wins (shadowing); a nested
19
+ * symbol under an unrelated parent is not visible (codex P2 on
20
+ * #1894). Confidence {@link HEURISTIC_CONFIDENCE_SAME_FILE}.
21
+ * 2. an import-bound name (`imports[].importedNames`) whose module
22
+ * specifier is RELATIVE ("./", "../") — i.e. resolvable inside
23
+ * the repo. External-package imports (lodash, node:fs) never
24
+ * bind: emitting their bare names would let the store's
25
+ * qualified-name fallback attach them to unrelated in-repo
26
+ * symbols (codex P2 on #1894). Confidence
27
+ * {@link HEURISTIC_CONFIDENCE_IMPORT_BOUND}; the store's
28
+ * cross-file/full-DB resolution finds the exporting file.
29
+ * A bare name with neither anchor (e.g. `console.log`'s `log`) is
30
+ * skipped — emitting it would delegate a guess, not evidence.
31
+ * - Ambiguity is dropped conservatively (same policy as the store's
32
+ * pass-2): a bare name matching two symbols in the SAME scope level
33
+ * resolves to neither; the next candidate is tried.
34
+ *
35
+ * Final id resolution (batch map + full-DB dst fallback + ambiguity
36
+ * drops) stays in `GraphStore.upsertFileEdges`; this module never touches
37
+ * the DB. Every file in the output carries an explicit `edges` array —
38
+ * `[]` asserts "this parse supports no heuristic edges" so stale edges
39
+ * from a prior version of the file are cleaned up. Paired with the
40
+ * store's provenance-scoped stale delete (`assertedEdgeProvenances`),
41
+ * re-derivation never destroys `trace`/`lsp` edges (rule 25).
42
+ */
43
+ import { posix } from "node:path";
44
+
45
+ import type { EdgeIR, FileIR, StoreFileIR } from "./graph-store.js";
46
+
47
+ /**
48
+ * Languages where a method call on the enclosing instance REQUIRES an
49
+ * explicit receiver (this./self.) — a bare identifier call can never mean
50
+ * a sibling method, so class-like scopes are excluded from bare-call
51
+ * resolution. Implicit-this languages (Java, C#, Kotlin, Swift, Ruby,
52
+ * PHP, C++, Go methods via receivers, ...) keep their class scopes.
53
+ */
54
+ const EXPLICIT_RECEIVER_LANGUAGES: ReadonlySet<string> = new Set([
55
+ "typescript",
56
+ "tsx",
57
+ "javascript",
58
+ "python",
59
+ // PHP method dispatch requires $this->/self:: — both arrive as
60
+ // member_call_expression (memberAccess) — so a bare helper() inside a
61
+ // method never means a sibling method (codex review round 12).
62
+ "php",
63
+ ]);
64
+
65
+ /** Confidence for a call resolved to a unique same-file symbol. */
66
+ export const HEURISTIC_CONFIDENCE_SAME_FILE = 0.9;
67
+ /** Confidence for a call resolved through an import binding. */
68
+ export const HEURISTIC_CONFIDENCE_IMPORT_BOUND = 0.8;
69
+
70
+ /**
71
+ * The provenance scope reindex asserts for derived edges: ONLY its own
72
+ * heuristic derivations. Each provenance layer owns its lifecycle
73
+ * (issue #1894 review rounds 3/7/8): `lsp` rows include member-dispatch
74
+ * resolutions Phase A deliberately never re-asserts, so including `lsp`
75
+ * here would retire still-valid method-call edges on every edit of their
76
+ * file. Vanished-call `lsp` rows are the LSP pass's job to retire when it
77
+ * re-derives from the current parse (its edges die with their src nodes on
78
+ * symbol pruning regardless). `trace` (runtime observation) and semantic
79
+ * edges are likewise never this scope's to touch. The store's update guard
80
+ * separately ensures a re-asserted key never DOWNGRADES an lsp row.
81
+ */
82
+ export const HEURISTIC_PROVENANCE_SCOPE = ["heuristic"] as const;
83
+
84
+ /** Per-batch resolution counters — surfaced by callers for observability. */
85
+ export interface HeuristicResolutionStats {
86
+ /** Total call sites examined across the batch. */
87
+ readonly callSites: number;
88
+ /** Call sites that produced an edge assertion. */
89
+ readonly resolved: number;
90
+ /** Call sites whose candidates matched nothing (no evidence). */
91
+ readonly skippedUnresolved: number;
92
+ /** Call sites whose best candidate was ambiguous in-file. */
93
+ readonly skippedAmbiguous: number;
94
+ /** Call sites with no enclosing symbol (module-level calls). */
95
+ readonly skippedNoEnclosingSymbol: number;
96
+ /** Member/property calls — Phase B (LSP) territory, never bare-bound. */
97
+ readonly skippedMemberAccess: number;
98
+ }
99
+
100
+ /** A FileIR enriched with the store-consumable edge assertions. */
101
+ export type ResolvedFileIR = StoreFileIR & { readonly edges: readonly EdgeIR[] };
102
+
103
+ export interface HeuristicResolutionResult {
104
+ readonly files: readonly ResolvedFileIR[];
105
+ readonly stats: HeuristicResolutionStats;
106
+ }
107
+
108
+ /**
109
+ * Derive CALLS edge assertions for a freshly parsed batch. Pure and
110
+ * deterministic: output order follows input order (files, then call
111
+ */
112
+ export function deriveHeuristicEdges(
113
+ batch: readonly FileIR[],
114
+ ): HeuristicResolutionResult {
115
+ let callSites = 0;
116
+ let resolved = 0;
117
+ let skippedUnresolved = 0;
118
+ let skippedAmbiguous = 0;
119
+ let skippedNoEnclosingSymbol = 0;
120
+ let skippedMemberAccess = 0;
121
+
122
+ const files: ResolvedFileIR[] = [];
123
+
124
+ for (const ir of batch) {
125
+ // Lexical-scope approximation from span containment: each symbol's
126
+ // parent is the innermost OTHER symbol strictly containing its span;
127
+ // no parent = file level (empty-string key).
128
+ const parentOf = new Map<string, string>();
129
+ const kindOf = new Map<string, string>();
130
+ for (const sym of ir.symbols) kindOf.set(sym.qualifiedName, sym.kind);
131
+ for (const sym of ir.symbols) {
132
+ let parent: { qualifiedName: string; size: number } | undefined;
133
+ for (const other of ir.symbols) {
134
+ if (other === sym) continue;
135
+ const contains =
136
+ other.span.startByte <= sym.span.startByte &&
137
+ sym.span.endByte <= other.span.endByte;
138
+ const identical =
139
+ other.span.startByte === sym.span.startByte &&
140
+ other.span.endByte === sym.span.endByte;
141
+ if (!contains || identical) continue;
142
+ const size = other.span.endByte - other.span.startByte;
143
+ if (!parent || size < parent.size) {
144
+ parent = { qualifiedName: other.qualifiedName, size };
145
+ }
146
+ }
147
+ parentOf.set(sym.qualifiedName, parent ? parent.qualifiedName : "");
148
+ }
149
+ // scope level (parent qualifiedName or "" = file) → name → matches.
150
+ const scopeByName = new Map<string, Map<string, { qualifiedName: string; count: number }>>();
151
+ for (const sym of ir.symbols) {
152
+ const level = parentOf.get(sym.qualifiedName) ?? "";
153
+ let names = scopeByName.get(level);
154
+ if (!names) {
155
+ names = new Map();
156
+ scopeByName.set(level, names);
157
+ }
158
+ const prior = names.get(sym.name);
159
+ if (prior) {
160
+ prior.count += 1;
161
+ } else {
162
+ names.set(sym.name, { qualifiedName: sym.qualifiedName, count: 1 });
163
+ }
164
+ }
165
+ // name → repo-relative extension-stripped target path. Only relative
166
+ // specifiers bind (an external package's names must never resolve to
167
+ // in-repo symbols); the hint travels on the edge so the store resolves
168
+ // the dst ONLY within the declared target file (#1894 review).
169
+ const importBindings = new Map<
170
+ string,
171
+ Array<{ exported: string; hint: string; enclosing: string | undefined; invisible?: boolean }>
172
+ >();
173
+ for (const imp of ir.imports) {
174
+ // Two relative-import spellings bind (cursor review on #1894):
175
+ // - path style ("./x", "../x") — JS/TS and friends;
176
+ // - Python dot style (".models", "..parent.sub") — one leading dot
177
+ // is the current package, each extra dot goes one level up, and
178
+ // interior dots are path separators.
179
+ let specifier: string | undefined;
180
+ if (imp.module.startsWith("./") || imp.module.startsWith("../")) {
181
+ specifier = imp.module;
182
+ } else if (ir.language === "python" && imp.module.startsWith(".")) {
183
+ const dots = (/^\.+/.exec(imp.module))?.[0].length ?? 1;
184
+ const rest = imp.module.slice(dots).replace(/\./g, "/");
185
+ specifier = `${"../".repeat(dots - 1)}${rest}` || ".";
186
+ }
187
+ if (specifier === undefined) continue;
188
+ const joined = posix.join(posix.dirname(ir.path), specifier);
189
+ // Strip any trailing slash BEFORE extension-stripping: a pure
190
+ // parent-package import (python "from .. import x") joins to
191
+ // "pkg/" and the store's hint patterns match "pkg" /
192
+ // "pkg/__init__.<ext>", never "pkg/" (codex review round 10).
193
+ const hint = joined.replace(/\/+$/, "").replace(/\.[cm]?[jt]sx?$/, "");
194
+ // A normalized hint still starting with "../" escapes the repo root:
195
+ // files.path values are canonical (no ".." segments), so such an
196
+ // edge could never resolve — skip the binding here so the call site
197
+ // is honestly counted as unresolved instead of emitting a
198
+ // guaranteed-dead edge (cursor review on #1894).
199
+ if (hint.startsWith("../")) continue;
200
+ // Alias-aware (codex review on #1894): call sites use the LOCAL
201
+ // identifier; the dst in the target file is the EXPORTED name.
202
+ // Fallback semantics sharpened across rounds 9 and 12: an ABSENT
203
+ // bindings field means a pre-bindings emitter (or hand-built IR) —
204
+ // fall back to importedNames as local===exported. An EXPLICIT empty
205
+ // array is the parser saying "no call-bindable names" (default /
206
+ // namespace imports whose exported symbol Phase A cannot know) and
207
+ // must NOT re-bind through the fallback.
208
+ const bindings =
209
+ imp.bindings ?? imp.importedNames.map((name) => ({ exported: name, local: name }));
210
+ // A function-local import binds only within its enclosing symbol
211
+ // (codex review round 12): find the innermost symbol containing the
212
+ // import's span; undefined = file level (binds everywhere).
213
+ let enclosing: { qualifiedName: string; size: number } | undefined;
214
+ for (const sym of ir.symbols) {
215
+ if (sym.span.startByte > imp.span.startByte || imp.span.endByte > sym.span.endByte) continue;
216
+ const size = sym.span.endByte - sym.span.startByte;
217
+ if (!enclosing || size < enclosing.size) {
218
+ enclosing = { qualifiedName: sym.qualifiedName, size };
219
+ }
220
+ }
221
+ // A local name can be imported in more than one scope (codex review
222
+ // round 13); keep them all and choose the innermost matching one at
223
+ // the call site. Class-body imports are class attributes and are NOT
224
+ // bare-call-visible, so a class-kind enclosing is filtered out at
225
+ // lookup time.
226
+ const enclosingKind =
227
+ enclosing === undefined ? undefined : kindOf.get(enclosing.qualifiedName);
228
+ const visible =
229
+ enclosingKind === undefined ||
230
+ (enclosingKind !== "class" &&
231
+ enclosingKind !== "interface" &&
232
+ enclosingKind !== "enum");
233
+ for (const b of bindings) {
234
+ const list = importBindings.get(b.local) ?? [];
235
+ list.push({
236
+ exported: b.exported,
237
+ hint,
238
+ enclosing: visible ? enclosing?.qualifiedName : undefined,
239
+ // A class-body import is genuinely invisible: store it with a
240
+ // sentinel that can never match an ancestor chain.
241
+ invisible: !visible,
242
+ });
243
+ importBindings.set(b.local, list);
244
+ }
245
+ }
246
+
247
+ const edges: EdgeIR[] = [];
248
+ const seenKeys = new Set<string>();
249
+
250
+ for (const site of ir.callSites) {
251
+ callSites += 1;
252
+
253
+ // Member/property calls (obj.save()) never bind bare names in
254
+ // Phase A: the receiver decides the target, and only Phase B (LSP)
255
+ // can resolve dispatch (codex review on #1894).
256
+ if (site.memberAccess === true) {
257
+ skippedMemberAccess += 1;
258
+ continue;
259
+ }
260
+
261
+ // src: innermost enclosing symbol (smallest containing span).
262
+ let src: { qualifiedName: string; size: number } | undefined;
263
+ for (const sym of ir.symbols) {
264
+ if (sym.span.startByte > site.span.startByte || site.span.endByte > sym.span.endByte) continue;
265
+ const size = sym.span.endByte - sym.span.startByte;
266
+ if (!src || size < src.size) {
267
+ src = { qualifiedName: sym.qualifiedName, size };
268
+ }
269
+ }
270
+ if (!src) {
271
+ skippedNoEnclosingSymbol += 1;
272
+ continue;
273
+ }
274
+
275
+ // Visible scope levels for this call site, innermost first: the
276
+ // caller's own children, then each ancestor's children, ending at
277
+ // the file level. Symbols nested under unrelated parents are never
278
+ // consulted. In EXPLICIT-receiver languages (JS/TS/Python),
279
+ // class-like ancestors are NOT bare-call scopes (codex review
280
+ // round 8): a bare helper() inside a method cannot mean the
281
+ // sibling method C.helper — that call would be
282
+ // this.helper()/self.helper(), i.e. a member access Phase A
283
+ // skips. Implicit-this languages (Java/C#/Kotlin/Swift/Ruby/...)
284
+ // DO allow an unqualified call to target a same-type method, so
285
+ // their class scopes contribute (codex review round 11).
286
+ // Function/method/module ancestors always contribute.
287
+ const excludeClassScopes = EXPLICIT_RECEIVER_LANGUAGES.has(ir.language);
288
+ const scopeLevels: string[] = [src.qualifiedName];
289
+ // Full ancestor chain (class levels included) — used for
290
+ // function-local import visibility, which is lexical containment,
291
+ // not bare-call scoping.
292
+ const ancestorChain = new Set<string>([src.qualifiedName]);
293
+ let cursor: string | undefined = src.qualifiedName;
294
+ while (cursor !== undefined && cursor !== "") {
295
+ cursor = parentOf.get(cursor) ?? "";
296
+ if (cursor !== "") ancestorChain.add(cursor);
297
+ if (excludeClassScopes) {
298
+ const kind = cursor === "" ? undefined : kindOf.get(cursor);
299
+ if (kind === "class" || kind === "interface" || kind === "enum") continue;
300
+ }
301
+ scopeLevels.push(cursor);
302
+ }
303
+
304
+ // dst: first candidate with in-IR evidence. Ambiguity is tracked
305
+ // PER CANDIDATE (cursor review on #1894): an ambiguous same-file
306
+ // match for candidate A must not block candidate B's import
307
+ // binding.
308
+ let dstQualifiedName: string | undefined;
309
+ let dstPathHint: string | undefined;
310
+ let confidence = 0;
311
+ let sawAmbiguous = false;
312
+ for (const candidate of site.calleeNameCandidates) {
313
+ let candidateAmbiguous = false;
314
+ for (const level of scopeLevels) {
315
+ const match = scopeByName.get(level)?.get(candidate);
316
+ if (!match) continue;
317
+ if (match.count === 1) {
318
+ dstQualifiedName = match.qualifiedName;
319
+ confidence = HEURISTIC_CONFIDENCE_SAME_FILE;
320
+ } else {
321
+ candidateAmbiguous = true;
322
+ }
323
+ break; // innermost level with the name decides (shadowing).
324
+ }
325
+ if (dstQualifiedName !== undefined) break;
326
+ if (candidateAmbiguous) {
327
+ sawAmbiguous = true;
328
+ continue;
329
+ }
330
+ const candidates = importBindings.get(candidate);
331
+ if (candidates !== undefined) {
332
+ // Innermost visible binding wins (codex review round 13):
333
+ // a function-local import shadows a same-name file-level one
334
+ // inside that function; class-body imports are never visible.
335
+ let chosen: { exported: string; hint: string } | undefined;
336
+ let chosenDepth = -1;
337
+ for (const c of candidates) {
338
+ if (c.invisible) continue;
339
+ if (c.enclosing === undefined) {
340
+ if (chosenDepth < 0) { chosen = c; chosenDepth = 0; }
341
+ continue;
342
+ }
343
+ if (ancestorChain.has(c.enclosing)) {
344
+ const depth = [...ancestorChain].indexOf(c.enclosing);
345
+ if (chosenDepth < 0 || depth < chosenDepth) { chosen = c; chosenDepth = depth; }
346
+ }
347
+ }
348
+ if (chosen) {
349
+ dstQualifiedName = chosen.exported;
350
+ dstPathHint = chosen.hint;
351
+ confidence = HEURISTIC_CONFIDENCE_IMPORT_BOUND;
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ if (dstQualifiedName === undefined) {
357
+ if (sawAmbiguous) skippedAmbiguous += 1;
358
+ else skippedUnresolved += 1;
359
+ continue;
360
+ }
361
+
362
+ resolved += 1;
363
+ // The hint is part of edge identity (codex review on #1894): two
364
+ // aliased imports of the same exported name from different modules
365
+ // resolve to different nodes at the store, so both must survive.
366
+ const key = `${src.qualifiedName}\u0000${dstQualifiedName}\u0000${dstPathHint ?? ""}`;
367
+ if (seenKeys.has(key)) continue;
368
+ seenKeys.add(key);
369
+ edges.push({
370
+ srcQualifiedName: src.qualifiedName,
371
+ dstQualifiedName,
372
+ type: "CALLS",
373
+ confidence,
374
+ provenance: "heuristic",
375
+ ...(dstPathHint !== undefined
376
+ ? { dstPathHint, dstImporterLanguage: ir.language }
377
+ : {}),
378
+ });
379
+ }
380
+
381
+ files.push({ ...ir, edges });
382
+ }
383
+
384
+ return {
385
+ files,
386
+ stats: {
387
+ callSites,
388
+ resolved,
389
+ skippedUnresolved,
390
+ skippedAmbiguous,
391
+ skippedNoEnclosingSymbol,
392
+ skippedMemberAccess,
393
+ },
394
+ };
395
+ }
@@ -218,6 +218,21 @@ export interface ResolveOptions {
218
218
  readonly applyUpgrades: (
219
219
  upgrades: readonly EdgeUpgrade[],
220
220
  ) => Promise<void>;
221
+ /**
222
+ * Optional stale-edge reconciliation (issue #1895): after applying a
223
+ * file batch's upgrades, the caller retires prior `lsp`-provenance
224
+ * edges owned by that file whose `(src, dst, type)` keys the current
225
+ * batch does NOT assert. When absent, stale lsp edges persist until
226
+ * node pruning — the soft-fail path documented in #1894.
227
+ */
228
+ readonly reconcileLspEdges?: (
229
+ filePath: string,
230
+ assertedEdges: ReadonlyArray<{
231
+ srcQualifiedName: string;
232
+ dstQualifiedName: string;
233
+ type: string;
234
+ }>,
235
+ ) => void;
221
236
  /**
222
237
  * Workspace root for resolving repo-relative file paths to absolute LSP
223
238
  * URIs and normalizing returned URIs back to repo-relative paths.
@@ -286,6 +301,13 @@ export async function executeLspResolution(
286
301
  // Send all definition requests for this file, collecting upgrades.
287
302
  const upgrades: EdgeUpgrade[] = [];
288
303
  let batchFailed = false;
304
+ // Track whether EVERY request in this file batch was processed to a
305
+ // definitive result (resolved or definitively-not-found). A timeout,
306
+ // request_error, or server-crash mid-batch makes the batch
307
+ // non-exhaustive: reconciliation must NOT run because the asserted
308
+ // set would be incomplete, deleting valid lsp edges for call sites
309
+ // that were never queried (cursor High + codex P1 on #1914).
310
+ let batchExhaustive = true;
289
311
 
290
312
  // Open the document with full text before querying definitions (LSP 3.17).
291
313
  // The server needs the content to answer definition requests accurately.
@@ -328,6 +350,10 @@ export async function executeLspResolution(
328
350
  break;
329
351
  }
330
352
  // request_timeout / request_error — count as unresolved, continue.
353
+ // request_timeout / request_error — count as unresolved.
354
+ // The batch is non-exhaustive: this call site's LSP result is
355
+ // indeterminate, so reconciliation for this file is suppressed.
356
+ batchExhaustive = false;
331
357
  unresolved++;
332
358
  continue;
333
359
  }
@@ -362,21 +388,36 @@ export async function executeLspResolution(
362
388
  break;
363
389
  }
364
390
 
365
- // Apply upgrades transactionally per file batch. If the apply throws,
366
- // zero upgrades from this batch persist (rule 25 the applyUpgrades
391
+ // Apply upgrades transactionally per file batch, then reconcile stale
392
+ // lsp edges for this file (issue #1895). If the apply throws, zero
393
+ // upgrades from this batch persist (rule 25 — the applyUpgrades
367
394
  // callback MUST be transactional).
368
- if (upgrades.length > 0) {
369
- try {
395
+ try {
396
+ if (upgrades.length > 0) {
370
397
  await applyUpgrades(upgrades);
371
398
  upgraded += upgrades.length;
372
- } catch {
373
- // The apply failed — degrade but don't crash. Upgrades from this
374
- // batch are lost (the callback's transaction rolled back). Edges
375
- // from already-applied batches survive (they were in separate
376
- // transactions — this is the documented per-batch isolation).
377
- // Count the lost upgrades as unresolved for reporting.
378
- unresolved += upgrades.length;
379
399
  }
400
+ // Reconcile ONLY when the batch was exhaustive (every call site
401
+ // processed to a definitive result). A partial batch's asserted
402
+ // set would be incomplete and retire valid edges for unprocessed
403
+ // call sites (cursor High + codex P1 on #1914).
404
+ if (batchExhaustive && options.reconcileLspEdges) {
405
+ options.reconcileLspEdges(
406
+ filePath,
407
+ upgrades.map((u) => ({
408
+ srcQualifiedName: u.srcQualifiedName,
409
+ dstQualifiedName: u.dstQualifiedName,
410
+ type: u.type,
411
+ })),
412
+ );
413
+ }
414
+ } catch {
415
+ // The apply failed — degrade but don't crash. Upgrades from this
416
+ // batch are lost (the callback's transaction rolled back). Edges
417
+ // from already-applied batches survive (they were in separate
418
+ // transactions — this is the documented per-batch isolation).
419
+ // Count the lost upgrades as unresolved for reporting.
420
+ unresolved += upgrades.length;
380
421
  }
381
422
  }
382
423
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * LSP edge reconciliation tests (issue #1895).
3
+ *
4
+ * When the LSP resolution pass re-derives edges from the CURRENT source,
5
+ * it must retire prior lsp-provenance edges whose (src, dst, type) key it
6
+ * no longer derives. The store's reconcileLspEdges method does this;
7
+ * the LSP executor wires it after each file batch's upgrades are applied.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import test from "node:test";
14
+
15
+ import { GraphStore, type FileIR } from "./graph-store.js";
16
+
17
+ const span = (startByte: number, endByte: number) => ({ startByte, endByte });
18
+
19
+ function fileIR(overrides: Partial<FileIR> & { path: string }): FileIR {
20
+ return {
21
+ language: "typescript",
22
+ contentHash: `h-${overrides.path}`,
23
+ symbols: [],
24
+ imports: [],
25
+ exports: [],
26
+ callSites: [],
27
+ routes: [],
28
+ ...overrides,
29
+ } as FileIR;
30
+ }
31
+
32
+ async function openTempStore(): Promise<{ store: GraphStore; dir: string }> {
33
+ const dir = await mkdtemp(path.join(tmpdir(), "cg-lsp-recon-"));
34
+ const store = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
35
+ return { store, dir };
36
+ }
37
+
38
+ const HEUR = { confidence: 0.9, provenance: "heuristic" as const };
39
+ const LSP = { confidence: 1, provenance: "lsp" as const };
40
+
41
+ test("reconcile retires lsp edges no longer derived; keeps re-derived ones", async () => {
42
+ const { store, dir } = await openTempStore();
43
+ try {
44
+ // Seed: greet calls format (heuristic) + helper (lsp-upgraded).
45
+ await store.upsertFileBatch([
46
+ {
47
+ ...fileIR({
48
+ path: "main.ts",
49
+ symbols: [
50
+ { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
51
+ { kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
52
+ { kind: "function", name: "helper", qualifiedName: "helper", span: span(133, 190) },
53
+ ],
54
+ }),
55
+ edges: [
56
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...HEUR },
57
+ { srcQualifiedName: "greet", dstQualifiedName: "helper", type: "CALLS", ...HEUR },
58
+ ],
59
+ },
60
+ ]);
61
+ // LSP upgraded both.
62
+ await store.upsertEdges([
63
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...LSP },
64
+ { srcQualifiedName: "greet", dstQualifiedName: "helper", type: "CALLS", ...LSP },
65
+ ]);
66
+
67
+ let stats = await store.schemaStats();
68
+ assert.ok(stats.ok);
69
+ assert.equal(stats.stats.edges, 2, "seed: 2 lsp-upgraded CALLS edges");
70
+
71
+ // LSP re-run derives ONLY greet->format (helper call was removed).
72
+ // Reconcile: retire lsp edges for main.ts NOT in the new set.
73
+ const deleted = store.reconcileLspEdges("main.ts", [
74
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS" },
75
+ ]);
76
+ assert.equal(deleted, 1, "retired the stale greet->helper lsp edge");
77
+
78
+ stats = await store.schemaStats();
79
+ assert.ok(stats.ok);
80
+ assert.equal(stats.stats.edges, 1, "only the re-derived edge remains");
81
+ assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 });
82
+ } finally {
83
+ await store.close();
84
+ await rm(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("reconcile preserves heuristic edges and trace edges untouched", async () => {
89
+ const { store, dir } = await openTempStore();
90
+ try {
91
+ await store.upsertFileBatch([
92
+ {
93
+ ...fileIR({
94
+ path: "main.ts",
95
+ symbols: [
96
+ { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
97
+ { kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
98
+ ],
99
+ }),
100
+ edges: [
101
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...HEUR },
102
+ ],
103
+ },
104
+ ]);
105
+ await store.upsertEdges([
106
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...LSP },
107
+ { srcQualifiedName: "greet", dstQualifiedName: "format", type: "HTTP_CALLS", confidence: 1, provenance: "trace" },
108
+ ]);
109
+
110
+ // LSP re-run found nothing for main.ts → reconcile with empty asserted set.
111
+ const deleted = store.reconcileLspEdges("main.ts", []);
112
+ assert.equal(deleted, 1, "retired the lsp CALLS edge");
113
+
114
+ const stats = await store.schemaStats();
115
+ assert.ok(stats.ok);
116
+ assert.deepEqual(stats.stats.edgesByType, { HTTP_CALLS: 1 }, "trace edge untouched");
117
+ } finally {
118
+ await store.close();
119
+ await rm(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test("reconcile with no prior lsp edges is a no-op", async () => {
124
+ const { store, dir } = await openTempStore();
125
+ try {
126
+ await store.upsertFileBatch([
127
+ {
128
+ ...fileIR({
129
+ path: "main.ts",
130
+ symbols: [
131
+ { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
132
+ ],
133
+ }),
134
+ edges: [],
135
+ },
136
+ ]);
137
+ const deleted = store.reconcileLspEdges("main.ts", [
138
+ { srcQualifiedName: "greet", dstQualifiedName: "missing", type: "CALLS" },
139
+ ]);
140
+ assert.equal(deleted, 0);
141
+ } finally {
142
+ await store.close();
143
+ await rm(dir, { recursive: true, force: true });
144
+ }
145
+ });