@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.
- package/README.md +16 -19
- package/dist/{chunk-CPYJACC5.js → chunk-ABDBWCXU.js} +146 -8
- package/dist/chunk-ABDBWCXU.js.map +1 -0
- package/dist/{chunk-5I2DBHOQ.js → chunk-I4R6GAAA.js} +2 -2
- package/dist/cypher/query-parser.js +2 -2
- package/dist/graph-store.d.ts +47 -0
- package/dist/graph-store.js +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +318 -26
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/edge-provenance-scope.test.ts +457 -0
- package/src/engine/emit.ts +55 -8
- package/src/engine/extractors.ts +45 -11
- package/src/graph-store.ts +265 -9
- package/src/heuristic-resolution.test.ts +596 -0
- package/src/heuristic-resolution.ts +395 -0
- package/src/lsp/resolution.ts +52 -11
- package/src/lsp-reconciliation.test.ts +145 -0
- package/src/reindex.test.ts +57 -0
- package/src/reindex.ts +18 -6
- package/dist/chunk-CPYJACC5.js.map +0 -1
- /package/dist/{chunk-5I2DBHOQ.js.map → chunk-I4R6GAAA.js.map} +0 -0
|
@@ -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
|
+
}
|
package/src/lsp/resolution.ts
CHANGED
|
@@ -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
|
|
366
|
-
//
|
|
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
|
-
|
|
369
|
-
|
|
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
|
+
});
|