@remnic/coding-graph 9.6.22 → 9.6.24
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/dist/{chunk-5I2DBHOQ.js → chunk-7DI5P62Q.js} +2 -2
- package/dist/{chunk-CPYJACC5.js → chunk-ZLCF3XQK.js} +72 -8
- package/dist/chunk-ZLCF3XQK.js.map +1 -0
- package/dist/cypher/query-parser.js +2 -2
- package/dist/graph-store.d.ts +27 -0
- package/dist/graph-store.js +1 -1
- package/dist/index.js +302 -22
- 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 +166 -9
- package/src/heuristic-resolution.test.ts +596 -0
- package/src/heuristic-resolution.ts +395 -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-7DI5P62Q.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remnic/coding-graph",
|
|
3
|
-
"version": "9.6.
|
|
3
|
+
"version": "9.6.24",
|
|
4
4
|
"description": "Web-tree-sitter powered symbol-extraction engine + SQLite knowledge-graph store for codebase memory (Tier 1: TypeScript, TSX, JavaScript, Python, Go, Rust, Java, C, C++, C#, Ruby, PHP, Kotlin, Swift, Bash). Optional companion of @remnic/core — install only when coding-graph features are needed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"web-tree-sitter": "^0.25.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@remnic/core": "^9.6.
|
|
44
|
+
"@remnic/core": "^9.6.24"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^22.10.0",
|
|
48
48
|
"tsup": "^8.5.1",
|
|
49
49
|
"tsx": "^4.19.0",
|
|
50
50
|
"typescript": "^5.7.0",
|
|
51
|
-
"@remnic/core": "9.6.
|
|
51
|
+
"@remnic/core": "9.6.24"
|
|
52
52
|
},
|
|
53
53
|
"license": "MIT",
|
|
54
54
|
"repository": {
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance-scoped stale-edge deletion (issue #1891).
|
|
3
|
+
*
|
|
4
|
+
* The reindex pipeline re-derives heuristic edges from every fresh parse
|
|
5
|
+
* and asserts them via `StoreFileIR.edges`. Without scoping, that
|
|
6
|
+
* assertion would delete OTHER provenances' edges owned by the same file
|
|
7
|
+
* (trace edges from `ingest_traces`, lsp-upgraded edges) on every
|
|
8
|
+
* re-ingest — destroying state the parse says nothing about (rule 25).
|
|
9
|
+
*
|
|
10
|
+
* Contract under test:
|
|
11
|
+
* - `assertedEdgeProvenances: ["heuristic"]` + `edges: []` deletes stale
|
|
12
|
+
* heuristic edges but PRESERVES trace/lsp edges owned by the file;
|
|
13
|
+
* - absent `assertedEdgeProvenances` keeps the legacy behavior (all
|
|
14
|
+
* stale src-owned edges deleted) so existing callers are unchanged;
|
|
15
|
+
* - `edges` undefined keeps the existing early return (no deletes at
|
|
16
|
+
* all), regardless of the new field.
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
|
|
24
|
+
import { openBetterSqlite3 } from "@remnic/core/runtime/better-sqlite";
|
|
25
|
+
|
|
26
|
+
import { GraphStore, type FileIR } from "./graph-store.js";
|
|
27
|
+
|
|
28
|
+
const span = (startByte: number, endByte: number) => ({ startByte, endByte });
|
|
29
|
+
|
|
30
|
+
function twoSymbolFile(overrides?: Partial<FileIR>): FileIR {
|
|
31
|
+
return {
|
|
32
|
+
path: "main.ts",
|
|
33
|
+
language: "typescript",
|
|
34
|
+
contentHash: "hash-1",
|
|
35
|
+
symbols: [
|
|
36
|
+
{ kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
|
|
37
|
+
{ kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
|
|
38
|
+
],
|
|
39
|
+
imports: [],
|
|
40
|
+
exports: [],
|
|
41
|
+
callSites: [],
|
|
42
|
+
routes: [],
|
|
43
|
+
...overrides,
|
|
44
|
+
} as FileIR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function openTempStore(): Promise<{ store: GraphStore; dir: string; dbPath: string }> {
|
|
48
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-prov-scope-"));
|
|
49
|
+
const dbPath = path.join(dir, "graph.sqlite");
|
|
50
|
+
const store = await GraphStore.open({ dbPath });
|
|
51
|
+
return { store, dir, dbPath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Direct provenance read — the store has no provenance-split surface. */
|
|
55
|
+
function readEdgeProvenances(dbPath: string): string[] {
|
|
56
|
+
const db = openBetterSqlite3(dbPath, { readonly: true });
|
|
57
|
+
try {
|
|
58
|
+
const rows = db.prepare("SELECT provenance FROM edges ORDER BY provenance").all() as Array<{
|
|
59
|
+
provenance: string;
|
|
60
|
+
}>;
|
|
61
|
+
return rows.map((r) => r.provenance);
|
|
62
|
+
} finally {
|
|
63
|
+
db.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("scoped assertion preserves trace edges while deleting stale heuristic edges", async () => {
|
|
68
|
+
const { store, dir } = await openTempStore();
|
|
69
|
+
try {
|
|
70
|
+
// Seed: file with a heuristic edge asserted by ingest.
|
|
71
|
+
const seeded = await store.upsertFileBatch([
|
|
72
|
+
{
|
|
73
|
+
...twoSymbolFile(),
|
|
74
|
+
edges: [
|
|
75
|
+
{
|
|
76
|
+
srcQualifiedName: "greet",
|
|
77
|
+
dstQualifiedName: "format",
|
|
78
|
+
type: "CALLS",
|
|
79
|
+
confidence: 0.9,
|
|
80
|
+
provenance: "heuristic",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
assert.ok(seeded.ok);
|
|
86
|
+
// Add a trace edge owned by the same file via the standalone path.
|
|
87
|
+
const traced = await store.upsertEdges([
|
|
88
|
+
{
|
|
89
|
+
srcQualifiedName: "greet",
|
|
90
|
+
dstQualifiedName: "format",
|
|
91
|
+
type: "HTTP_CALLS",
|
|
92
|
+
confidence: 1,
|
|
93
|
+
provenance: "trace",
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
assert.ok(traced.ok && traced.persisted === 1);
|
|
97
|
+
|
|
98
|
+
let stats = await store.schemaStats();
|
|
99
|
+
assert.ok(stats.ok);
|
|
100
|
+
assert.equal(stats.stats.edges, 2, "seed state: heuristic CALLS + trace HTTP_CALLS");
|
|
101
|
+
|
|
102
|
+
// Re-ingest: fresh parse supports NO heuristic edges (call removed),
|
|
103
|
+
// asserted with provenance scoping.
|
|
104
|
+
const reingested = await store.upsertFileBatch([
|
|
105
|
+
{
|
|
106
|
+
...twoSymbolFile({ contentHash: "hash-2" }),
|
|
107
|
+
edges: [],
|
|
108
|
+
assertedEdgeProvenances: ["heuristic"],
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
assert.ok(reingested.ok);
|
|
112
|
+
|
|
113
|
+
stats = await store.schemaStats();
|
|
114
|
+
assert.ok(stats.ok);
|
|
115
|
+
assert.equal(stats.stats.edges, 1, "heuristic edge deleted, trace edge preserved");
|
|
116
|
+
assert.deepEqual(stats.stats.edgesByType, { HTTP_CALLS: 1 });
|
|
117
|
+
} finally {
|
|
118
|
+
await store.close();
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("scoped re-assertion keeps the still-supported heuristic edge (no churn)", async () => {
|
|
124
|
+
const { store, dir } = await openTempStore();
|
|
125
|
+
try {
|
|
126
|
+
const edge = {
|
|
127
|
+
srcQualifiedName: "greet",
|
|
128
|
+
dstQualifiedName: "format",
|
|
129
|
+
type: "CALLS",
|
|
130
|
+
confidence: 0.9,
|
|
131
|
+
provenance: "heuristic" as const,
|
|
132
|
+
};
|
|
133
|
+
const first = await store.upsertFileBatch([{ ...twoSymbolFile(), edges: [edge] }]);
|
|
134
|
+
assert.ok(first.ok);
|
|
135
|
+
const second = await store.upsertFileBatch([
|
|
136
|
+
{
|
|
137
|
+
...twoSymbolFile({ contentHash: "hash-2" }),
|
|
138
|
+
edges: [edge],
|
|
139
|
+
assertedEdgeProvenances: ["heuristic"],
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
assert.ok(second.ok);
|
|
143
|
+
const stats = await store.schemaStats();
|
|
144
|
+
assert.ok(stats.ok);
|
|
145
|
+
assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 });
|
|
146
|
+
} finally {
|
|
147
|
+
await store.close();
|
|
148
|
+
await rm(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("legacy behavior unchanged: absent assertedEdgeProvenances deletes all stale src-owned edges", async () => {
|
|
153
|
+
const { store, dir } = await openTempStore();
|
|
154
|
+
try {
|
|
155
|
+
const seeded = await store.upsertFileBatch([
|
|
156
|
+
{
|
|
157
|
+
...twoSymbolFile(),
|
|
158
|
+
edges: [
|
|
159
|
+
{
|
|
160
|
+
srcQualifiedName: "greet",
|
|
161
|
+
dstQualifiedName: "format",
|
|
162
|
+
type: "CALLS",
|
|
163
|
+
confidence: 0.9,
|
|
164
|
+
provenance: "heuristic",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
assert.ok(seeded.ok);
|
|
170
|
+
const traced = await store.upsertEdges([
|
|
171
|
+
{
|
|
172
|
+
srcQualifiedName: "greet",
|
|
173
|
+
dstQualifiedName: "format",
|
|
174
|
+
type: "HTTP_CALLS",
|
|
175
|
+
confidence: 1,
|
|
176
|
+
provenance: "trace",
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
assert.ok(traced.ok);
|
|
180
|
+
|
|
181
|
+
// Legacy caller: empty edges array, NO provenance scoping.
|
|
182
|
+
const reingested = await store.upsertFileBatch([
|
|
183
|
+
{ ...twoSymbolFile({ contentHash: "hash-2" }), edges: [] },
|
|
184
|
+
]);
|
|
185
|
+
assert.ok(reingested.ok);
|
|
186
|
+
|
|
187
|
+
const stats = await store.schemaStats();
|
|
188
|
+
assert.ok(stats.ok);
|
|
189
|
+
assert.equal(stats.stats.edges, 0, "legacy: every stale src-owned edge deleted");
|
|
190
|
+
} finally {
|
|
191
|
+
await store.close();
|
|
192
|
+
await rm(dir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("edges undefined still preserves all prior edges (early return)", async () => {
|
|
197
|
+
const { store, dir } = await openTempStore();
|
|
198
|
+
try {
|
|
199
|
+
const seeded = await store.upsertFileBatch([
|
|
200
|
+
{
|
|
201
|
+
...twoSymbolFile(),
|
|
202
|
+
edges: [
|
|
203
|
+
{
|
|
204
|
+
srcQualifiedName: "greet",
|
|
205
|
+
dstQualifiedName: "format",
|
|
206
|
+
type: "CALLS",
|
|
207
|
+
confidence: 0.9,
|
|
208
|
+
provenance: "heuristic",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
assert.ok(seeded.ok);
|
|
214
|
+
|
|
215
|
+
// Bare IR re-upsert (no edges field): must not wipe anything, even
|
|
216
|
+
// with the scoping field present — the early return wins.
|
|
217
|
+
const reingested = await store.upsertFileBatch([
|
|
218
|
+
{ ...twoSymbolFile({ contentHash: "hash-2" }), assertedEdgeProvenances: ["heuristic"] },
|
|
219
|
+
]);
|
|
220
|
+
assert.ok(reingested.ok);
|
|
221
|
+
|
|
222
|
+
const stats = await store.schemaStats();
|
|
223
|
+
assert.ok(stats.ok);
|
|
224
|
+
assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 });
|
|
225
|
+
} finally {
|
|
226
|
+
await store.close();
|
|
227
|
+
await rm(dir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("scoped re-assertion never downgrades an lsp-upgraded row on the same key", async () => {
|
|
232
|
+
const { store, dir, dbPath } = await openTempStore();
|
|
233
|
+
try {
|
|
234
|
+
const heuristicEdge = {
|
|
235
|
+
srcQualifiedName: "greet",
|
|
236
|
+
dstQualifiedName: "format",
|
|
237
|
+
type: "CALLS",
|
|
238
|
+
confidence: 0.9,
|
|
239
|
+
provenance: "heuristic" as const,
|
|
240
|
+
};
|
|
241
|
+
const seeded = await store.upsertFileBatch([{ ...twoSymbolFile(), edges: [heuristicEdge] }]);
|
|
242
|
+
assert.ok(seeded.ok);
|
|
243
|
+
// LSP layer upgrades the same (src, dst, type) row.
|
|
244
|
+
const upgraded = await store.upsertEdges([
|
|
245
|
+
{ ...heuristicEdge, confidence: 1, provenance: "lsp" },
|
|
246
|
+
]);
|
|
247
|
+
assert.ok(upgraded.ok);
|
|
248
|
+
|
|
249
|
+
// Reindex re-derives the same heuristic key with provenance scoping:
|
|
250
|
+
// the assertion keeps the row alive but must NOT overwrite the
|
|
251
|
+
// stronger out-of-scope provenance back to heuristic.
|
|
252
|
+
const reingested = await store.upsertFileBatch([
|
|
253
|
+
{
|
|
254
|
+
...twoSymbolFile({ contentHash: "hash-2" }),
|
|
255
|
+
edges: [heuristicEdge],
|
|
256
|
+
assertedEdgeProvenances: ["heuristic", "lsp"],
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
assert.ok(reingested.ok);
|
|
260
|
+
|
|
261
|
+
const stats = await store.schemaStats();
|
|
262
|
+
assert.ok(stats.ok);
|
|
263
|
+
assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 });
|
|
264
|
+
// Provenance must still be lsp: read it directly from the DB (the
|
|
265
|
+
// store exposes no provenance-split read surface, and an indirect
|
|
266
|
+
// probe would conflate this with the retire-on-vanish behavior).
|
|
267
|
+
assert.deepEqual(readEdgeProvenances(dbPath), ["lsp"]);
|
|
268
|
+
} finally {
|
|
269
|
+
await store.close();
|
|
270
|
+
await rm(dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("an lsp row is retired when the call disappears from the parse (codex review)", async () => {
|
|
275
|
+
const { store, dir } = await openTempStore();
|
|
276
|
+
try {
|
|
277
|
+
const heuristicEdge = {
|
|
278
|
+
srcQualifiedName: "greet",
|
|
279
|
+
dstQualifiedName: "format",
|
|
280
|
+
type: "CALLS",
|
|
281
|
+
confidence: 0.9,
|
|
282
|
+
provenance: "heuristic" as const,
|
|
283
|
+
};
|
|
284
|
+
const seeded = await store.upsertFileBatch([{ ...twoSymbolFile(), edges: [heuristicEdge] }]);
|
|
285
|
+
assert.ok(seeded.ok);
|
|
286
|
+
const upgraded = await store.upsertEdges([
|
|
287
|
+
{ ...heuristicEdge, confidence: 1, provenance: "lsp" },
|
|
288
|
+
]);
|
|
289
|
+
assert.ok(upgraded.ok);
|
|
290
|
+
|
|
291
|
+
// Fresh parse no longer supports the call: scoped assertion includes
|
|
292
|
+
// lsp, so the upgraded row retires with its heuristic ancestor.
|
|
293
|
+
const reingested = await store.upsertFileBatch([
|
|
294
|
+
{
|
|
295
|
+
...twoSymbolFile({ contentHash: "hash-2" }),
|
|
296
|
+
edges: [],
|
|
297
|
+
assertedEdgeProvenances: ["heuristic", "lsp"],
|
|
298
|
+
},
|
|
299
|
+
]);
|
|
300
|
+
assert.ok(reingested.ok);
|
|
301
|
+
const stats = await store.schemaStats();
|
|
302
|
+
assert.ok(stats.ok);
|
|
303
|
+
assert.equal(stats.stats.edges, 0, "vanished call retires the lsp-upgraded row too");
|
|
304
|
+
} finally {
|
|
305
|
+
await store.close();
|
|
306
|
+
await rm(dir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a path-hinted edge binds only inside its declared target file", async () => {
|
|
311
|
+
const { store, dir } = await openTempStore();
|
|
312
|
+
try {
|
|
313
|
+
// Two files each declaring a `greet`; the hinted edge must bind the
|
|
314
|
+
// one in lib/main.ts, never the decoy.
|
|
315
|
+
const seeded = await store.upsertFileBatch([
|
|
316
|
+
twoSymbolFile(),
|
|
317
|
+
{
|
|
318
|
+
...twoSymbolFile({ path: "lib/main.ts", contentHash: "hash-lib" }),
|
|
319
|
+
symbols: [
|
|
320
|
+
{ kind: "function", name: "helper", qualifiedName: "lib.helper", span: span(0, 40) },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
...twoSymbolFile({ path: "util.ts", contentHash: "hash-util" }),
|
|
325
|
+
symbols: [
|
|
326
|
+
{ kind: "function", name: "shout", qualifiedName: "shout", span: span(0, 40) },
|
|
327
|
+
],
|
|
328
|
+
edges: [
|
|
329
|
+
{
|
|
330
|
+
srcQualifiedName: "shout",
|
|
331
|
+
dstQualifiedName: "greet",
|
|
332
|
+
type: "CALLS",
|
|
333
|
+
confidence: 0.8,
|
|
334
|
+
provenance: "heuristic",
|
|
335
|
+
dstPathHint: "main",
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
]);
|
|
340
|
+
assert.ok(seeded.ok);
|
|
341
|
+
const stats = await store.schemaStats();
|
|
342
|
+
assert.ok(stats.ok);
|
|
343
|
+
assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 }, "hint matched main.ts");
|
|
344
|
+
} finally {
|
|
345
|
+
await store.close();
|
|
346
|
+
await rm(dir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a path-hinted edge whose target file is not indexed is dropped, never globally resolved", async () => {
|
|
351
|
+
const { store, dir } = await openTempStore();
|
|
352
|
+
try {
|
|
353
|
+
const seeded = await store.upsertFileBatch([
|
|
354
|
+
// A decoy `greet` exists in an UNRELATED file...
|
|
355
|
+
twoSymbolFile(),
|
|
356
|
+
{
|
|
357
|
+
...twoSymbolFile({ path: "util.ts", contentHash: "hash-util" }),
|
|
358
|
+
symbols: [
|
|
359
|
+
{ kind: "function", name: "shout", qualifiedName: "shout", span: span(0, 40) },
|
|
360
|
+
],
|
|
361
|
+
edges: [
|
|
362
|
+
{
|
|
363
|
+
srcQualifiedName: "shout",
|
|
364
|
+
dstQualifiedName: "greet",
|
|
365
|
+
type: "CALLS",
|
|
366
|
+
confidence: 0.8,
|
|
367
|
+
provenance: "heuristic",
|
|
368
|
+
// ...but the import pointed at ./missing, which is not indexed.
|
|
369
|
+
dstPathHint: "missing",
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
374
|
+
assert.ok(seeded.ok);
|
|
375
|
+
const stats = await store.schemaStats();
|
|
376
|
+
assert.ok(stats.ok);
|
|
377
|
+
assert.equal(stats.stats.edges, 0, "hinted edge dropped instead of binding the decoy");
|
|
378
|
+
} finally {
|
|
379
|
+
await store.close();
|
|
380
|
+
await rm(dir, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("a path hint never matches test/declaration filename variants (codex review)", async () => {
|
|
385
|
+
const { store, dir } = await openTempStore();
|
|
386
|
+
try {
|
|
387
|
+
const seeded = await store.upsertFileBatch([
|
|
388
|
+
// main.test.ts exports a greet too — a module resolver would never
|
|
389
|
+
// load it for "./main", so the hint must ignore it.
|
|
390
|
+
{
|
|
391
|
+
...twoSymbolFile({ path: "main.test.ts", contentHash: "hash-test" }),
|
|
392
|
+
symbols: [
|
|
393
|
+
{ kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 40) },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
twoSymbolFile(),
|
|
397
|
+
{
|
|
398
|
+
...twoSymbolFile({ path: "util.ts", contentHash: "hash-util" }),
|
|
399
|
+
symbols: [
|
|
400
|
+
{ kind: "function", name: "shout", qualifiedName: "shout", span: span(0, 40) },
|
|
401
|
+
],
|
|
402
|
+
edges: [
|
|
403
|
+
{
|
|
404
|
+
srcQualifiedName: "shout",
|
|
405
|
+
dstQualifiedName: "greet",
|
|
406
|
+
type: "CALLS",
|
|
407
|
+
confidence: 0.8,
|
|
408
|
+
provenance: "heuristic",
|
|
409
|
+
dstPathHint: "main",
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
]);
|
|
414
|
+
assert.ok(seeded.ok);
|
|
415
|
+
const stats = await store.schemaStats();
|
|
416
|
+
assert.ok(stats.ok);
|
|
417
|
+
assert.deepEqual(
|
|
418
|
+
stats.stats.edgesByType,
|
|
419
|
+
{ CALLS: 1 },
|
|
420
|
+
"bound main.ts despite the main.test.ts decoy (not ambiguous-dropped)",
|
|
421
|
+
);
|
|
422
|
+
} finally {
|
|
423
|
+
await store.close();
|
|
424
|
+
await rm(dir, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an empty assertedEdgeProvenances array behaves like an absent one (cursor review round 9)", async () => {
|
|
429
|
+
const { store, dir } = await openTempStore();
|
|
430
|
+
try {
|
|
431
|
+
const seeded = await store.upsertFileBatch([
|
|
432
|
+
{
|
|
433
|
+
...twoSymbolFile(),
|
|
434
|
+
edges: [
|
|
435
|
+
{
|
|
436
|
+
srcQualifiedName: "greet",
|
|
437
|
+
dstQualifiedName: "format",
|
|
438
|
+
type: "CALLS",
|
|
439
|
+
confidence: 0.9,
|
|
440
|
+
provenance: "heuristic",
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
445
|
+
assert.ok(seeded.ok);
|
|
446
|
+
const reingested = await store.upsertFileBatch([
|
|
447
|
+
{ ...twoSymbolFile({ contentHash: "hash-2" }), edges: [], assertedEdgeProvenances: [] },
|
|
448
|
+
]);
|
|
449
|
+
assert.ok(reingested.ok);
|
|
450
|
+
const stats = await store.schemaStats();
|
|
451
|
+
assert.ok(stats.ok);
|
|
452
|
+
assert.equal(stats.stats.edges, 0, "empty scope = legacy delete-all-stale, not protect-all");
|
|
453
|
+
} finally {
|
|
454
|
+
await store.close();
|
|
455
|
+
await rm(dir, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
});
|
package/src/engine/emit.ts
CHANGED
|
@@ -185,7 +185,13 @@ function extractImports(root: TSNode, language: Language, lang: CodingGraphLangu
|
|
|
185
185
|
// all captures share the same module.
|
|
186
186
|
const groups = new Map<
|
|
187
187
|
string,
|
|
188
|
-
{
|
|
188
|
+
{
|
|
189
|
+
module: string;
|
|
190
|
+
names: Set<string>;
|
|
191
|
+
bindings: Map<string, string>; // local -> exported (issue #1894 review)
|
|
192
|
+
startByte: number;
|
|
193
|
+
endByte: number;
|
|
194
|
+
}
|
|
189
195
|
>();
|
|
190
196
|
|
|
191
197
|
for (const match of matches) {
|
|
@@ -193,12 +199,24 @@ function extractImports(root: TSNode, language: Language, lang: CodingGraphLangu
|
|
|
193
199
|
let stmtStart = -1;
|
|
194
200
|
let stmtEnd = -1;
|
|
195
201
|
const names: string[] = [];
|
|
202
|
+
// Default/namespace import locals: informational only, never
|
|
203
|
+
// call-bindable (issue #1894 round 12).
|
|
204
|
+
const unboundNames: string[] = [];
|
|
205
|
+
// local -> exported for aliased named imports (issue #1894 review).
|
|
206
|
+
let aliasExported = "";
|
|
207
|
+
let aliasLocal = "";
|
|
196
208
|
|
|
197
209
|
for (const cap of match.captures) {
|
|
198
210
|
if (cap.name === "import.module") {
|
|
199
211
|
moduleText = cleanModuleSpecifier(cap.node.text);
|
|
200
212
|
} else if (cap.name === "import.name") {
|
|
201
213
|
names.push(cap.node.text);
|
|
214
|
+
} else if (cap.name === "import.unboundName") {
|
|
215
|
+
unboundNames.push(cap.node.text);
|
|
216
|
+
} else if (cap.name === "import.aliasExported") {
|
|
217
|
+
aliasExported = cap.node.text;
|
|
218
|
+
} else if (cap.name === "import.aliasLocal") {
|
|
219
|
+
aliasLocal = cap.node.text;
|
|
202
220
|
} else if (cap.name === "__import.stmt") {
|
|
203
221
|
stmtStart = cap.node.startIndex;
|
|
204
222
|
stmtEnd = cap.node.endIndex;
|
|
@@ -217,16 +235,33 @@ function extractImports(root: TSNode, language: Language, lang: CodingGraphLangu
|
|
|
217
235
|
}
|
|
218
236
|
|
|
219
237
|
const key = `${stmtStart}:${moduleText}`;
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
} else {
|
|
224
|
-
groups.set(key, {
|
|
238
|
+
let group = groups.get(key);
|
|
239
|
+
if (!group) {
|
|
240
|
+
group = {
|
|
225
241
|
module: moduleText,
|
|
226
|
-
names: new Set(
|
|
242
|
+
names: new Set<string>(),
|
|
243
|
+
bindings: new Map<string, string>(),
|
|
227
244
|
startByte: stmtStart,
|
|
228
245
|
endByte: stmtEnd,
|
|
229
|
-
}
|
|
246
|
+
};
|
|
247
|
+
groups.set(key, group);
|
|
248
|
+
}
|
|
249
|
+
for (const n of names) {
|
|
250
|
+
group.names.add(n);
|
|
251
|
+
// Non-aliased: local === exported. May be overwritten below when
|
|
252
|
+
// the alias pattern also matched this specifier.
|
|
253
|
+
if (!group.bindings.has(n)) group.bindings.set(n, n);
|
|
254
|
+
}
|
|
255
|
+
for (const n of unboundNames) {
|
|
256
|
+
group.names.add(n);
|
|
257
|
+
}
|
|
258
|
+
if (aliasExported && aliasLocal) {
|
|
259
|
+
group.names.add(aliasExported);
|
|
260
|
+
// The plain-name pattern also matched this specifier and recorded
|
|
261
|
+
// exported->exported; the alias binding replaces it: the LOCAL
|
|
262
|
+
// identifier is what call sites use.
|
|
263
|
+
group.bindings.delete(aliasExported);
|
|
264
|
+
group.bindings.set(aliasLocal, aliasExported);
|
|
230
265
|
}
|
|
231
266
|
}
|
|
232
267
|
|
|
@@ -234,6 +269,9 @@ function extractImports(root: TSNode, language: Language, lang: CodingGraphLangu
|
|
|
234
269
|
.map((g) => ({
|
|
235
270
|
module: g.module,
|
|
236
271
|
importedNames: Array.from(g.names).sort(),
|
|
272
|
+
bindings: Array.from(g.bindings.entries())
|
|
273
|
+
.map(([local, exported]) => ({ exported, local }))
|
|
274
|
+
.sort((a, b) => a.local.localeCompare(b.local)),
|
|
237
275
|
span: { startByte: g.startByte, endByte: g.endByte },
|
|
238
276
|
}))
|
|
239
277
|
.sort((a, b) => a.span.startByte - b.span.startByte || a.module.localeCompare(b.module));
|
|
@@ -318,6 +356,15 @@ function extractCallSites(root: TSNode, language: Language, lang: CodingGraphLan
|
|
|
318
356
|
calleeNameCandidates: [cap.node.text],
|
|
319
357
|
span: { startByte: cap.node.startIndex, endByte: cap.node.endIndex },
|
|
320
358
|
});
|
|
359
|
+
} else if (cap.name === "call.member") {
|
|
360
|
+
// Member/property call (issue #1894 review): the bare property
|
|
361
|
+
// name must never bind a visible symbol — mark it so the
|
|
362
|
+
// heuristic resolver skips it (LSP owns method dispatch).
|
|
363
|
+
callSites.push({
|
|
364
|
+
calleeNameCandidates: [cap.node.text],
|
|
365
|
+
memberAccess: true,
|
|
366
|
+
span: { startByte: cap.node.startIndex, endByte: cap.node.endIndex },
|
|
367
|
+
});
|
|
321
368
|
}
|
|
322
369
|
}
|
|
323
370
|
return callSites.sort(
|