@remnic/coding-graph 9.3.759
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 +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,1420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-store tests — ordered steps 2–3 of issue #1552 PR1.
|
|
3
|
+
*
|
|
4
|
+
* Cover:
|
|
5
|
+
* - upsertFileBatch: ingest counts (exact node + edge counts from hand-
|
|
6
|
+
* written fixture IR)
|
|
7
|
+
* - idempotency: re-ingesting the same IR yields identical state
|
|
8
|
+
* - dangling-edge handling: cross-file edges whose destination was
|
|
9
|
+
* deleted are DROPPED (per the PR1 policy documented in graph-schema)
|
|
10
|
+
* and surfaced via droppedDanglingEdges
|
|
11
|
+
* - node-id determinism: nodeIdFor is pure (sorted key material sha256)
|
|
12
|
+
* - write queue serialization (rule 40)
|
|
13
|
+
* - tagged failure shape on a closed store (rule 34)
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
openBetterSqlite3,
|
|
23
|
+
type BetterSqlite3Database,
|
|
24
|
+
} from "@remnic/core/runtime/better-sqlite";
|
|
25
|
+
import { GraphStore, nodeIdFor } from "./graph-store.js";
|
|
26
|
+
import type { StoreFileIR, SymbolIR } from "./graph-store.js";
|
|
27
|
+
|
|
28
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Fixture IR — synthetic code only (public-repo policy).
|
|
30
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function sym(
|
|
33
|
+
qualifiedName: string,
|
|
34
|
+
name: string,
|
|
35
|
+
startByte: number,
|
|
36
|
+
endByte: number,
|
|
37
|
+
kind: SymbolIR["kind"] = "function",
|
|
38
|
+
): SymbolIR {
|
|
39
|
+
return { qualifiedName, name, span: { startByte, endByte }, kind };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fileA: StoreFileIR = {
|
|
43
|
+
path: "src/a.ts",
|
|
44
|
+
language: "typescript",
|
|
45
|
+
contentHash: "h-a",
|
|
46
|
+
symbols: [
|
|
47
|
+
sym("a.greet", "greet", 0, 100),
|
|
48
|
+
sym("a.farewell", "farewell", 100, 200),
|
|
49
|
+
],
|
|
50
|
+
edges: [
|
|
51
|
+
{
|
|
52
|
+
srcQualifiedName: "a.greet",
|
|
53
|
+
dstQualifiedName: "a.farewell",
|
|
54
|
+
type: "CALLS",
|
|
55
|
+
confidence: 0.95,
|
|
56
|
+
provenance: "heuristic",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const fileB: StoreFileIR = {
|
|
62
|
+
path: "src/b.ts",
|
|
63
|
+
language: "typescript",
|
|
64
|
+
contentHash: "h-b",
|
|
65
|
+
symbols: [
|
|
66
|
+
sym("b.run", "run", 0, 50),
|
|
67
|
+
sym("b.helper", "helper", 50, 150),
|
|
68
|
+
],
|
|
69
|
+
edges: [
|
|
70
|
+
{
|
|
71
|
+
srcQualifiedName: "b.run",
|
|
72
|
+
dstQualifiedName: "b.helper",
|
|
73
|
+
type: "CALLS",
|
|
74
|
+
confidence: 0.9,
|
|
75
|
+
provenance: "heuristic",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// Cross-file edge — destination lives in fileA.
|
|
79
|
+
srcQualifiedName: "b.run",
|
|
80
|
+
dstQualifiedName: "a.greet",
|
|
81
|
+
type: "CALLS",
|
|
82
|
+
confidence: 0.6,
|
|
83
|
+
provenance: "heuristic",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
async function tempStore(): Promise<{ store: GraphStore; dir: string }> {
|
|
89
|
+
const dir = await mkdtemp(path.join(tmpdir(), "coding-graph-store-"));
|
|
90
|
+
const store = await GraphStore.open({
|
|
91
|
+
dbPath: path.join(dir, "graph.sqlite"),
|
|
92
|
+
});
|
|
93
|
+
return { store, dir };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("upsertFileBatch: ingest two-file fixture yields exact node + edge counts", async () => {
|
|
97
|
+
const { store, dir } = await tempStore();
|
|
98
|
+
try {
|
|
99
|
+
assert.equal(store.schemaVersion(), 1);
|
|
100
|
+
|
|
101
|
+
const result = await store.upsertFileBatch([fileA, fileB]);
|
|
102
|
+
assert.equal(result.ok, true);
|
|
103
|
+
if (!result.ok) throw new Error("expected ok result");
|
|
104
|
+
assert.equal(result.results.length, 2);
|
|
105
|
+
|
|
106
|
+
const a = result.results.find((r) => r.path === "src/a.ts");
|
|
107
|
+
const b = result.results.find((r) => r.path === "src/b.ts");
|
|
108
|
+
assert.ok(a, "expected result for fileA");
|
|
109
|
+
assert.ok(b, "expected result for fileB");
|
|
110
|
+
assert.equal(a?.nodeCount, 2, "fileA: 2 symbols");
|
|
111
|
+
assert.equal(a?.edgeCount, 1, "fileA: 1 intra-file edge");
|
|
112
|
+
assert.equal(b?.nodeCount, 2, "fileB: 2 symbols");
|
|
113
|
+
assert.equal(b?.edgeCount, 2, "fileB: 1 intra + 1 cross-file edge");
|
|
114
|
+
assert.equal(a?.droppedDanglingEdges, 0, "no dangling edges on first ingest");
|
|
115
|
+
assert.equal(b?.droppedDanglingEdges, 0);
|
|
116
|
+
|
|
117
|
+
// Re-open and verify the rows persisted.
|
|
118
|
+
await store.close();
|
|
119
|
+
const reopened = await GraphStore.open({
|
|
120
|
+
dbPath: path.join(dir, "graph.sqlite"),
|
|
121
|
+
});
|
|
122
|
+
assert.equal(reopened.schemaVersion(), 1);
|
|
123
|
+
await reopened.close();
|
|
124
|
+
} finally {
|
|
125
|
+
await rm(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("idempotency: re-ingesting the same IR is a no-op (counts and ids unchanged)", async () => {
|
|
130
|
+
const { store, dir } = await tempStore();
|
|
131
|
+
try {
|
|
132
|
+
const first = await store.upsertFileBatch([fileA, fileB]);
|
|
133
|
+
assert.equal(first.ok, true);
|
|
134
|
+
if (!first.ok) throw new Error("expected ok result");
|
|
135
|
+
|
|
136
|
+
// Compute the expected ids deterministically.
|
|
137
|
+
const expectedA1 = nodeIdFor({
|
|
138
|
+
qualifiedName: "a.greet",
|
|
139
|
+
filePath: "src/a.ts",
|
|
140
|
+
label: "function",
|
|
141
|
+
});
|
|
142
|
+
const expectedB1 = nodeIdFor({
|
|
143
|
+
qualifiedName: "b.run",
|
|
144
|
+
filePath: "src/b.ts",
|
|
145
|
+
label: "function",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Second ingest — same IR — must not change row counts nor ids.
|
|
149
|
+
const second = await store.upsertFileBatch([fileA, fileB]);
|
|
150
|
+
assert.equal(second.ok, true);
|
|
151
|
+
if (!second.ok) throw new Error("expected ok result");
|
|
152
|
+
for (const r of second.results) {
|
|
153
|
+
assert.equal(r.nodeCount, 0, "no new nodes when ids collide on PK");
|
|
154
|
+
assert.equal(r.edgeCount, 0, "no new edges when (src,dst,type) collides");
|
|
155
|
+
assert.equal(r.droppedDanglingEdges, 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The rows still hash to the same deterministic ids.
|
|
159
|
+
assert.equal(expectedA1.length, 64, "sha256 hex digest length");
|
|
160
|
+
assert.equal(expectedB1.length, 64);
|
|
161
|
+
assert.notEqual(expectedA1, expectedB1, "different symbols → different ids");
|
|
162
|
+
} finally {
|
|
163
|
+
await store.close();
|
|
164
|
+
await rm(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("dangling-edge policy: cross-file edges whose dst is dropped are counted, not kept", async () => {
|
|
169
|
+
const { store, dir } = await tempStore();
|
|
170
|
+
try {
|
|
171
|
+
// Initial batch — cross-file edge b.run → a.greet is fine.
|
|
172
|
+
const r1 = await store.upsertFileBatch([fileA, fileB]);
|
|
173
|
+
assert.equal(r1.ok, true);
|
|
174
|
+
if (!r1.ok) throw new Error("expected ok result");
|
|
175
|
+
|
|
176
|
+
// Re-ingest fileA only with a stripped symbol set: a.greet is gone, so
|
|
177
|
+
// the cross-file edge from b.run → a.greet becomes dangling.
|
|
178
|
+
const fileAReduced: StoreFileIR = {
|
|
179
|
+
path: "src/a.ts",
|
|
180
|
+
language: "typescript",
|
|
181
|
+
contentHash: "h-a-2",
|
|
182
|
+
symbols: [sym("a.farewell", "farewell", 100, 200)],
|
|
183
|
+
// No edges — keeps the test focused on the dangling count.
|
|
184
|
+
edges: [],
|
|
185
|
+
};
|
|
186
|
+
const r2 = await store.upsertFileBatch([fileAReduced]);
|
|
187
|
+
assert.equal(r2.ok, true);
|
|
188
|
+
if (!r2.ok) throw new Error("expected ok result");
|
|
189
|
+
assert.equal(r2.results.length, 1);
|
|
190
|
+
const a = r2.results[0]!;
|
|
191
|
+
assert.equal(a.path, "src/a.ts");
|
|
192
|
+
assert.equal(a.droppedDanglingEdges, 1, "one cross-file edge pointed at a.greet");
|
|
193
|
+
} finally {
|
|
194
|
+
await store.close();
|
|
195
|
+
await rm(dir, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("dangling-edge count is batch-order-independent when both ends are pruned (chatgpt-codex-connector P2: 'Count dangling edges against the whole batch')", async () => {
|
|
200
|
+
// Two files share one cross-file edge x.src → y.dst. A re-ingest
|
|
201
|
+
// batch that prunes BOTH ends must report 0 dangling edges (the
|
|
202
|
+
// edge is cascade-deleted, not dangling) regardless of which file
|
|
203
|
+
// the loop visits first. The pre-fix per-file src exclusion made
|
|
204
|
+
// this 1 in one order and 0 in the other.
|
|
205
|
+
const fileXfull: StoreFileIR = {
|
|
206
|
+
path: "src/x.ts",
|
|
207
|
+
language: "typescript",
|
|
208
|
+
contentHash: "h-x",
|
|
209
|
+
symbols: [sym("x.src", "src", 0, 50)],
|
|
210
|
+
edges: [
|
|
211
|
+
{
|
|
212
|
+
srcQualifiedName: "x.src",
|
|
213
|
+
dstQualifiedName: "y.dst",
|
|
214
|
+
type: "CALLS",
|
|
215
|
+
confidence: 0.9,
|
|
216
|
+
provenance: "heuristic",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
const fileYfull: StoreFileIR = {
|
|
221
|
+
path: "src/y.ts",
|
|
222
|
+
language: "typescript",
|
|
223
|
+
contentHash: "h-y",
|
|
224
|
+
symbols: [sym("y.dst", "dst", 0, 50)],
|
|
225
|
+
};
|
|
226
|
+
const fileXempty: StoreFileIR = {
|
|
227
|
+
path: "src/x.ts",
|
|
228
|
+
language: "typescript",
|
|
229
|
+
contentHash: "h-x-2",
|
|
230
|
+
symbols: [],
|
|
231
|
+
edges: [],
|
|
232
|
+
};
|
|
233
|
+
const fileYempty: StoreFileIR = {
|
|
234
|
+
path: "src/y.ts",
|
|
235
|
+
language: "typescript",
|
|
236
|
+
contentHash: "h-y-2",
|
|
237
|
+
symbols: [],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
async function droppedTotal(order: StoreFileIR[]): Promise<number> {
|
|
241
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-order-"));
|
|
242
|
+
const store = await GraphStore.open({ dbPath: path.join(dir, "store.db") });
|
|
243
|
+
try {
|
|
244
|
+
await store.upsertFileBatch([fileXfull, fileYfull]);
|
|
245
|
+
const pruned = await store.upsertFileBatch(order);
|
|
246
|
+
assert.equal(pruned.ok, true);
|
|
247
|
+
if (!pruned.ok) throw new Error("expected ok result");
|
|
248
|
+
return pruned.results.reduce((sum, r) => sum + r.droppedDanglingEdges, 0);
|
|
249
|
+
} finally {
|
|
250
|
+
await store.close();
|
|
251
|
+
await rm(dir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Both ends pruned in one batch — order must not change the total,
|
|
256
|
+
// and the total must be 0 (the edge is cascade-deleted, not dangling).
|
|
257
|
+
const totalXfirst = await droppedTotal([fileXempty, fileYempty]);
|
|
258
|
+
const totalYfirst = await droppedTotal([fileYempty, fileXempty]);
|
|
259
|
+
assert.equal(
|
|
260
|
+
totalXfirst,
|
|
261
|
+
totalYfirst,
|
|
262
|
+
"droppedDanglingEdges total must be identical regardless of file order in the batch",
|
|
263
|
+
);
|
|
264
|
+
assert.equal(
|
|
265
|
+
totalXfirst,
|
|
266
|
+
0,
|
|
267
|
+
"an edge whose both ends are pruned in the batch is cascade-deleted, not dangling",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Control: pruning only the dst (src survives in a separate batch)
|
|
271
|
+
// is a genuine dangling edge and must still be reported as 1.
|
|
272
|
+
const dirCtl = await mkdtemp(path.join(tmpdir(), "cg-order-ctl-"));
|
|
273
|
+
const storeCtl = await GraphStore.open({ dbPath: path.join(dirCtl, "store.db") });
|
|
274
|
+
try {
|
|
275
|
+
await storeCtl.upsertFileBatch([fileXfull, fileYfull]);
|
|
276
|
+
const onlyDstPruned = await storeCtl.upsertFileBatch([fileYempty]);
|
|
277
|
+
assert.equal(onlyDstPruned.ok, true);
|
|
278
|
+
if (!onlyDstPruned.ok) throw new Error("expected ok result");
|
|
279
|
+
assert.equal(
|
|
280
|
+
onlyDstPruned.results.reduce((s, r) => s + r.droppedDanglingEdges, 0),
|
|
281
|
+
1,
|
|
282
|
+
"a one-ended prune (src survives) is a genuine dangling edge and is still counted",
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
await storeCtl.close();
|
|
286
|
+
await rm(dirCtl, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("fts_index: maps FTS rowid → node id so PR2 can JOIN hits back to nodes", async () => {
|
|
291
|
+
const { store, dir } = await tempStore();
|
|
292
|
+
let peekDb: BetterSqlite3Database | undefined;
|
|
293
|
+
try {
|
|
294
|
+
const r = await store.upsertFileBatch([fileA]);
|
|
295
|
+
assert.equal(r.ok, true);
|
|
296
|
+
if (!r.ok) throw new Error("expected ok");
|
|
297
|
+
|
|
298
|
+
// Re-open the same file with a raw connection (after the store
|
|
299
|
+
// closed its transaction) to peek at the fts_index mapping.
|
|
300
|
+
await store.close();
|
|
301
|
+
peekDb = openBetterSqlite3(path.join(dir, "graph.sqlite"));
|
|
302
|
+
|
|
303
|
+
// Hit nodes_fts with the symbol name and JOIN to fts_index to
|
|
304
|
+
// recover the node id — exactly the read path PR2 will use.
|
|
305
|
+
type FtsHit = { node_id: string };
|
|
306
|
+
const hits: FtsHit[] = peekDb
|
|
307
|
+
.prepare(
|
|
308
|
+
`SELECT fts_index.node_id AS node_id
|
|
309
|
+
FROM nodes_fts
|
|
310
|
+
JOIN fts_index ON fts_index.fts_rowid = nodes_fts.rowid
|
|
311
|
+
WHERE nodes_fts MATCH ?`,
|
|
312
|
+
)
|
|
313
|
+
.all("greet") as FtsHit[];
|
|
314
|
+
assert.equal(hits.length, 1, "one MATCH hit for 'greet'");
|
|
315
|
+
const expected = nodeIdFor({
|
|
316
|
+
qualifiedName: "a.greet",
|
|
317
|
+
filePath: "src/a.ts",
|
|
318
|
+
label: "function",
|
|
319
|
+
});
|
|
320
|
+
assert.equal(hits[0]?.node_id, expected);
|
|
321
|
+
|
|
322
|
+
// The forward mapping (node id → fts_rowid) is the deterministic
|
|
323
|
+
// hash slice used by the write path. Verify it round-trips.
|
|
324
|
+
type IndexRow = { fts_rowid: number };
|
|
325
|
+
const fwd: IndexRow[] = peekDb
|
|
326
|
+
.prepare(
|
|
327
|
+
"SELECT fts_rowid FROM fts_index WHERE node_id = ?",
|
|
328
|
+
)
|
|
329
|
+
.all(expected) as IndexRow[];
|
|
330
|
+
assert.equal(fwd.length, 1);
|
|
331
|
+
} finally {
|
|
332
|
+
peekDb?.close();
|
|
333
|
+
await rm(dir, { recursive: true, force: true });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("fts_index: prunes the mapping when a node is removed by re-ingest", async () => {
|
|
338
|
+
const { store, dir } = await tempStore();
|
|
339
|
+
let peekDb: BetterSqlite3Database | undefined;
|
|
340
|
+
try {
|
|
341
|
+
const r1 = await store.upsertFileBatch([fileA]);
|
|
342
|
+
assert.equal(r1.ok, true);
|
|
343
|
+
if (!r1.ok) throw new Error("expected ok");
|
|
344
|
+
|
|
345
|
+
// Re-ingest fileA with an empty symbol set — every node is
|
|
346
|
+
// pruned, so every fts_index row must follow.
|
|
347
|
+
const empty: StoreFileIR = {
|
|
348
|
+
path: "src/a.ts",
|
|
349
|
+
language: "typescript",
|
|
350
|
+
contentHash: "h-a-empty",
|
|
351
|
+
symbols: [],
|
|
352
|
+
edges: [],
|
|
353
|
+
};
|
|
354
|
+
const r2 = await store.upsertFileBatch([empty]);
|
|
355
|
+
assert.equal(r2.ok, true);
|
|
356
|
+
if (!r2.ok) throw new Error("expected ok");
|
|
357
|
+
|
|
358
|
+
await store.close();
|
|
359
|
+
peekDb = openBetterSqlite3(path.join(dir, "graph.sqlite"));
|
|
360
|
+
|
|
361
|
+
type CountRow = { c: number };
|
|
362
|
+
const ftsCount: CountRow[] = peekDb
|
|
363
|
+
.prepare("SELECT COUNT(*) AS c FROM nodes_fts")
|
|
364
|
+
.all() as CountRow[];
|
|
365
|
+
const idxCount: CountRow[] = peekDb
|
|
366
|
+
.prepare("SELECT COUNT(*) AS c FROM fts_index")
|
|
367
|
+
.all() as CountRow[];
|
|
368
|
+
assert.equal(ftsCount[0]?.c, 0, "no FTS rows survive the prune");
|
|
369
|
+
assert.equal(idxCount[0]?.c, 0, "no fts_index rows survive the prune");
|
|
370
|
+
} finally {
|
|
371
|
+
peekDb?.close();
|
|
372
|
+
await rm(dir, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("nodeIdFor is pure and order-stable (rule 23/38)", () => {
|
|
377
|
+
const a1 = nodeIdFor({
|
|
378
|
+
qualifiedName: "mod.fn",
|
|
379
|
+
filePath: "x.ts",
|
|
380
|
+
label: "function",
|
|
381
|
+
});
|
|
382
|
+
const a2 = nodeIdFor({
|
|
383
|
+
qualifiedName: "mod.fn",
|
|
384
|
+
filePath: "x.ts",
|
|
385
|
+
label: "function",
|
|
386
|
+
});
|
|
387
|
+
assert.equal(a1, a2, "same input → same id");
|
|
388
|
+
|
|
389
|
+
// Different label → different id.
|
|
390
|
+
const b = nodeIdFor({
|
|
391
|
+
qualifiedName: "mod.fn",
|
|
392
|
+
filePath: "x.ts",
|
|
393
|
+
label: "method",
|
|
394
|
+
});
|
|
395
|
+
assert.notEqual(a1, b);
|
|
396
|
+
|
|
397
|
+
// Different qualified name → different id.
|
|
398
|
+
const c = nodeIdFor({
|
|
399
|
+
qualifiedName: "mod.other",
|
|
400
|
+
filePath: "x.ts",
|
|
401
|
+
label: "function",
|
|
402
|
+
});
|
|
403
|
+
assert.notEqual(a1, c);
|
|
404
|
+
|
|
405
|
+
// Different file path → different id.
|
|
406
|
+
const d = nodeIdFor({
|
|
407
|
+
qualifiedName: "mod.fn",
|
|
408
|
+
filePath: "y.ts",
|
|
409
|
+
label: "function",
|
|
410
|
+
});
|
|
411
|
+
assert.notEqual(a1, d);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("nodeIdFor uses sha256 (64 hex chars)", () => {
|
|
415
|
+
const id = nodeIdFor({
|
|
416
|
+
qualifiedName: "x",
|
|
417
|
+
filePath: "y",
|
|
418
|
+
label: "z",
|
|
419
|
+
});
|
|
420
|
+
assert.equal(id.length, 64);
|
|
421
|
+
assert.match(id, /^[0-9a-f]{64}$/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("write queue serializes concurrent upserts (rule 40)", async () => {
|
|
425
|
+
const { store, dir } = await tempStore();
|
|
426
|
+
try {
|
|
427
|
+
// Fire N batches in parallel — the queue must execute them one after
|
|
428
|
+
// the other without raising and without losing rows.
|
|
429
|
+
const N = 8;
|
|
430
|
+
const irs: StoreFileIR[] = Array.from({ length: N }, (_, i) => ({
|
|
431
|
+
path: `src/file-${i}.ts`,
|
|
432
|
+
language: "typescript",
|
|
433
|
+
contentHash: `h-${i}`,
|
|
434
|
+
symbols: [sym(`f-${i}.run`, "run", 0, 10)],
|
|
435
|
+
edges: [],
|
|
436
|
+
}));
|
|
437
|
+
const settled = await Promise.all(
|
|
438
|
+
irs.map((ir) => store.upsertFileBatch([ir])),
|
|
439
|
+
);
|
|
440
|
+
for (const r of settled) {
|
|
441
|
+
assert.equal(r.ok, true, "every concurrent upsert must succeed");
|
|
442
|
+
if (r.ok) {
|
|
443
|
+
assert.equal(r.results[0]?.nodeCount, 1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// drain() resolves cleanly after all queued writes settle.
|
|
448
|
+
await store.drain();
|
|
449
|
+
} finally {
|
|
450
|
+
await store.close();
|
|
451
|
+
await rm(dir, { recursive: true, force: true });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("closed store returns tagged failure, never throws (rule 34)", async () => {
|
|
456
|
+
const { store, dir } = await tempStore();
|
|
457
|
+
await store.close();
|
|
458
|
+
try {
|
|
459
|
+
const result = await store.upsertFileBatch([fileA]);
|
|
460
|
+
assert.equal(result.ok, false);
|
|
461
|
+
if (result.ok) throw new Error("expected failure");
|
|
462
|
+
assert.equal(
|
|
463
|
+
result.code,
|
|
464
|
+
"store_closed",
|
|
465
|
+
`closed store should return "store_closed" lifecycle failure; got ${result.code}`,
|
|
466
|
+
);
|
|
467
|
+
} finally {
|
|
468
|
+
await rm(dir, { recursive: true, force: true });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
473
|
+
// Rule 22 — read primitives distinguish backend failure from empty.
|
|
474
|
+
// Before the fix, readMeta / readFileHashes / readCoChanges caught every
|
|
475
|
+
// error and returned the empty value (null / new Map() / []), so a
|
|
476
|
+
// SQLITE_BUSY or a closed store was indistinguishable from "key absent" /
|
|
477
|
+
// "empty index" / "no co-change edges". The reindex executor's prune +
|
|
478
|
+
// head-advance decisions depend on these reads, so conflating error with
|
|
479
|
+
// empty could skip pruning while advancing head, or prune against a
|
|
480
|
+
// falsely-empty set (cursor Bugbot HIGH: 'readFileHashes conflates error
|
|
481
|
+
// with empty'; 'readCoChanges swallows store errors'; 'readMeta conflates
|
|
482
|
+
// absent key with db failure').
|
|
483
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
test("readMeta: closed store returns tagged failure, not null (rule 22)", async () => {
|
|
486
|
+
const { store, dir } = await tempStore();
|
|
487
|
+
await store.close();
|
|
488
|
+
try {
|
|
489
|
+
const r = store.readMeta("any_key");
|
|
490
|
+
assert.equal(r.ok, false);
|
|
491
|
+
if (r.ok) throw new Error("expected failure");
|
|
492
|
+
assert.equal(r.code, "store_closed");
|
|
493
|
+
} finally {
|
|
494
|
+
await rm(dir, { recursive: true, force: true });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("readMeta: absent key returns { ok: true; value: null } — distinct from failure (rule 22)", async () => {
|
|
499
|
+
const { store, dir } = await tempStore();
|
|
500
|
+
try {
|
|
501
|
+
const r = store.readMeta("absent_key");
|
|
502
|
+
assert.equal(r.ok, true);
|
|
503
|
+
if (!r.ok) throw new Error("expected ok");
|
|
504
|
+
assert.equal(r.value, null);
|
|
505
|
+
} finally {
|
|
506
|
+
await rm(dir, { recursive: true, force: true });
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("readFileHashes: closed store returns tagged failure, not an empty Map (rule 22)", async () => {
|
|
511
|
+
const { store, dir } = await tempStore();
|
|
512
|
+
await store.close();
|
|
513
|
+
try {
|
|
514
|
+
const r = store.readFileHashes();
|
|
515
|
+
assert.equal(r.ok, false);
|
|
516
|
+
if (r.ok) throw new Error("expected failure");
|
|
517
|
+
assert.equal(r.code, "store_closed");
|
|
518
|
+
} finally {
|
|
519
|
+
await rm(dir, { recursive: true, force: true });
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("readFileHashes: empty index returns { ok: true; hashes: <empty> } — distinct from failure (rule 22)", async () => {
|
|
524
|
+
const { store, dir } = await tempStore();
|
|
525
|
+
try {
|
|
526
|
+
const r = store.readFileHashes();
|
|
527
|
+
assert.equal(r.ok, true);
|
|
528
|
+
if (!r.ok) throw new Error("expected ok");
|
|
529
|
+
assert.equal(r.hashes.size, 0);
|
|
530
|
+
} finally {
|
|
531
|
+
await rm(dir, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("readCoChanges: closed store returns tagged failure, not [] (rule 22)", async () => {
|
|
536
|
+
const { store, dir } = await tempStore();
|
|
537
|
+
await store.close();
|
|
538
|
+
try {
|
|
539
|
+
const r = store.readCoChanges("src/a.ts");
|
|
540
|
+
assert.equal(r.ok, false);
|
|
541
|
+
if (r.ok) throw new Error("expected failure");
|
|
542
|
+
assert.equal(r.code, "store_closed");
|
|
543
|
+
} finally {
|
|
544
|
+
await rm(dir, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("readCoChanges: no edges returns { ok: true; edges: [] } — distinct from failure (rule 22)", async () => {
|
|
549
|
+
const { store, dir } = await tempStore();
|
|
550
|
+
try {
|
|
551
|
+
const r = store.readCoChanges("src/a.ts");
|
|
552
|
+
assert.equal(r.ok, true);
|
|
553
|
+
if (!r.ok) throw new Error("expected ok");
|
|
554
|
+
assert.equal(r.edges.length, 0);
|
|
555
|
+
} finally {
|
|
556
|
+
await rm(dir, { recursive: true, force: true });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("node-id ingestion: file-level delete cascades prior nodes + owned edges", async () => {
|
|
561
|
+
const { store, dir } = await tempStore();
|
|
562
|
+
try {
|
|
563
|
+
const r1 = await store.upsertFileBatch([fileA]);
|
|
564
|
+
assert.equal(r1.ok, true);
|
|
565
|
+
if (!r1.ok) throw new Error("expected ok");
|
|
566
|
+
|
|
567
|
+
// Re-ingest fileA with empty symbols — deletes both nodes.
|
|
568
|
+
const fileAEmpty: StoreFileIR = {
|
|
569
|
+
path: "src/a.ts",
|
|
570
|
+
language: "typescript",
|
|
571
|
+
contentHash: "h-a-empty",
|
|
572
|
+
symbols: [],
|
|
573
|
+
edges: [],
|
|
574
|
+
};
|
|
575
|
+
const r2 = await store.upsertFileBatch([fileAEmpty]);
|
|
576
|
+
assert.equal(r2.ok, true);
|
|
577
|
+
if (!r2.ok) throw new Error("expected ok");
|
|
578
|
+
assert.equal(r2.results[0]?.nodeCount, 0);
|
|
579
|
+
assert.equal(r2.results[0]?.edgeCount, 0);
|
|
580
|
+
|
|
581
|
+
// Re-ingesting fileB should now see a cross-file edge b.run → a.greet
|
|
582
|
+
// as a fresh insert (the prior dangling drop is gone since a.greet no
|
|
583
|
+
// longer exists to receive the edge; the edge is skipped at insert time
|
|
584
|
+
// because the dst cannot be resolved).
|
|
585
|
+
const r3 = await store.upsertFileBatch([fileB]);
|
|
586
|
+
assert.equal(r3.ok, true);
|
|
587
|
+
if (!r3.ok) throw new Error("expected ok");
|
|
588
|
+
// fileB has 2 symbols + 2 edges declared, but the cross-file edge's
|
|
589
|
+
// dst (a.greet) is missing → 1 edge kept (intra-file).
|
|
590
|
+
assert.equal(r3.results[0]?.nodeCount, 2);
|
|
591
|
+
assert.equal(r3.results[0]?.edgeCount, 1);
|
|
592
|
+
} finally {
|
|
593
|
+
await store.close();
|
|
594
|
+
await rm(dir, { recursive: true, force: true });
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("absolute dbPath is required", async () => {
|
|
599
|
+
// Path.resolve would also produce absolute; we need a deliberately
|
|
600
|
+
// relative path so the validator throws — the previous version of this
|
|
601
|
+
// test composed off mkdtemp, which always returned absolute, so the
|
|
602
|
+
// assertion could never fire.
|
|
603
|
+
const relative = `relative-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`;
|
|
604
|
+
await assert.rejects(
|
|
605
|
+
() =>
|
|
606
|
+
GraphStore.open({
|
|
607
|
+
dbPath: relative,
|
|
608
|
+
}),
|
|
609
|
+
/dbPath must be absolute/,
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("edge src validation: cross-file edge src is rejected, not cross-owned (chatgpt-codex-connector P2)", async () => {
|
|
614
|
+
const { store, dir } = await tempStore();
|
|
615
|
+
try {
|
|
616
|
+
// Seed fileB first so b.run exists in the DB.
|
|
617
|
+
const r1 = await store.upsertFileBatch([fileB]);
|
|
618
|
+
assert.equal(r1.ok, true);
|
|
619
|
+
if (!r1.ok) throw new Error("expected ok");
|
|
620
|
+
|
|
621
|
+
// Ingest fileA with an edge whose srcQualifiedName is "b.run" —
|
|
622
|
+
// a symbol that lives in fileB, NOT fileA. The edge must be
|
|
623
|
+
// DROPPED: a FileIR may only assert edges whose src is a symbol
|
|
624
|
+
// in the same file. Without this guard the edge would be silently
|
|
625
|
+
// cross-owned by fileB and survive fileA re-ingests.
|
|
626
|
+
const fileAMalformedEdge: StoreFileIR = {
|
|
627
|
+
path: "src/a.ts",
|
|
628
|
+
language: "typescript",
|
|
629
|
+
contentHash: "h-a-malformed",
|
|
630
|
+
symbols: [sym("a.greet", "greet", 0, 100)],
|
|
631
|
+
edges: [
|
|
632
|
+
{
|
|
633
|
+
srcQualifiedName: "b.run",
|
|
634
|
+
dstQualifiedName: "a.greet",
|
|
635
|
+
type: "CALLS",
|
|
636
|
+
confidence: 0.5,
|
|
637
|
+
provenance: "heuristic",
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
};
|
|
641
|
+
const r2 = await store.upsertFileBatch([fileAMalformedEdge]);
|
|
642
|
+
assert.equal(r2.ok, true);
|
|
643
|
+
if (!r2.ok) throw new Error("expected ok");
|
|
644
|
+
assert.equal(
|
|
645
|
+
r2.results[0]?.edgeCount,
|
|
646
|
+
0,
|
|
647
|
+
"cross-file edge src must be rejected — no edges inserted",
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
// Verify no edge exists in the DB at all for this assertion.
|
|
651
|
+
// The store exposes no public query API yet, so we go through
|
|
652
|
+
// the re-ingest path: re-ingest fileA with no edges and confirm
|
|
653
|
+
// no cross-owned edge was left behind.
|
|
654
|
+
const fileAClean: StoreFileIR = {
|
|
655
|
+
path: "src/a.ts",
|
|
656
|
+
language: "typescript",
|
|
657
|
+
contentHash: "h-a-clean",
|
|
658
|
+
symbols: [sym("a.greet", "greet", 0, 100)],
|
|
659
|
+
edges: [],
|
|
660
|
+
};
|
|
661
|
+
const r3 = await store.upsertFileBatch([fileAClean]);
|
|
662
|
+
assert.equal(r3.ok, true);
|
|
663
|
+
if (!r3.ok) throw new Error("expected ok");
|
|
664
|
+
// If the cross-owned edge had been created, re-ingesting fileA
|
|
665
|
+
// (which only deletes edges whose src is in fileA) would leave
|
|
666
|
+
// it behind. But since the edge was never created, there is
|
|
667
|
+
// nothing to clean up — edgeCount is 0.
|
|
668
|
+
assert.equal(r3.results[0]?.edgeCount, 0);
|
|
669
|
+
} finally {
|
|
670
|
+
await store.close();
|
|
671
|
+
await rm(dir, { recursive: true, force: true });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("duplicate paths in one batch throw (cursor Bugbot: 'Duplicate paths corrupt edge pass')", async () => {
|
|
676
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-dup-"));
|
|
677
|
+
const store = await GraphStore.open({
|
|
678
|
+
dbPath: path.join(dir, "store.db"),
|
|
679
|
+
});
|
|
680
|
+
try {
|
|
681
|
+
const fileA1: StoreFileIR = {
|
|
682
|
+
path: "src/a.ts",
|
|
683
|
+
language: "typescript",
|
|
684
|
+
contentHash: "h-a-1",
|
|
685
|
+
symbols: [sym("a.greet", "greet", 0, 100)],
|
|
686
|
+
};
|
|
687
|
+
const fileA2: StoreFileIR = {
|
|
688
|
+
path: "src/a.ts",
|
|
689
|
+
language: "typescript",
|
|
690
|
+
contentHash: "h-a-2",
|
|
691
|
+
symbols: [sym("a.farewell", "farewell", 0, 100)],
|
|
692
|
+
};
|
|
693
|
+
await assert.rejects(
|
|
694
|
+
store.upsertFileBatch([fileA1, fileA2]),
|
|
695
|
+
/duplicate path 'src\/a\.ts' in batch/,
|
|
696
|
+
"duplicate paths in one batch must throw, not silently corrupt the edge pass",
|
|
697
|
+
);
|
|
698
|
+
} finally {
|
|
699
|
+
await store.close();
|
|
700
|
+
await rm(dir, { recursive: true, force: true });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("null/missing symbols is rejected as a contract violation, not silently pruned (chatgpt-codex-connector P2: 'Reject missing symbols instead of pruning the file')", async () => {
|
|
705
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-nullsym-"));
|
|
706
|
+
const store = await GraphStore.open({
|
|
707
|
+
dbPath: path.join(dir, "store.db"),
|
|
708
|
+
});
|
|
709
|
+
try {
|
|
710
|
+
// Seed the file with valid symbols so there is real state to wipe.
|
|
711
|
+
const seeded = await store.upsertFileBatch([fileA]);
|
|
712
|
+
assert.equal(seeded.ok, true, "seed ingest succeeds");
|
|
713
|
+
assert.equal(
|
|
714
|
+
seeded.ok && seeded.results[0]!.nodeCount,
|
|
715
|
+
2,
|
|
716
|
+
"fileA seeded with 2 symbol nodes",
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// Deliberately violate the FileIR contract (symbols is required,
|
|
720
|
+
// non-optional). The type system forbids null; this cast seeds
|
|
721
|
+
// exactly the malformed payload a JSON-deserializing or buggy
|
|
722
|
+
// caller would send, to prove the runtime guard catches it.
|
|
723
|
+
const nullSymbolsIr = { ...fileA, symbols: null } as unknown as StoreFileIR;
|
|
724
|
+
|
|
725
|
+
// The guard throws BEFORE the transaction opens, so no prune can
|
|
726
|
+
// run and the existing nodes survive. The pre-fix `?? []` made
|
|
727
|
+
// this return ok and delete both seeded nodes.
|
|
728
|
+
await assert.rejects(
|
|
729
|
+
store.upsertFileBatch([nullSymbolsIr]),
|
|
730
|
+
/symbols must be an array .* received null/,
|
|
731
|
+
"a null symbols field must be rejected, not treated as an empty assertion set",
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// The guard throws before the transaction opens, so the seeded
|
|
735
|
+
// nodes must still be present. nodeCount counts NEW inserts, so a
|
|
736
|
+
// no-op re-ingest (nodeCount 0) is the proof: had the null-symbols
|
|
737
|
+
// call wiped them (the pre-fix `?? []` bug), this re-ingest would
|
|
738
|
+
// re-insert both and report 2.
|
|
739
|
+
const reingested = await store.upsertFileBatch([fileA]);
|
|
740
|
+
assert.equal(reingested.ok, true, "store accepts a valid ingest after the rejection");
|
|
741
|
+
assert.equal(
|
|
742
|
+
reingested.ok && reingested.results[0]!.nodeCount,
|
|
743
|
+
0,
|
|
744
|
+
"fileA re-ingest is a no-op — seeded nodes survived the rejected null-symbols attempt (no wipe)",
|
|
745
|
+
);
|
|
746
|
+
} finally {
|
|
747
|
+
await store.close();
|
|
748
|
+
await rm(dir, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("duplicate (src,dst,type) edges keep first metadata, do not inflate edgeCount (cursor Bugbot #28876d4c)", async () => {
|
|
753
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-dupedge-"));
|
|
754
|
+
const store = await GraphStore.open({
|
|
755
|
+
dbPath: path.join(dir, "store.db"),
|
|
756
|
+
});
|
|
757
|
+
try {
|
|
758
|
+
const fileDup: StoreFileIR = {
|
|
759
|
+
path: "src/dup.ts",
|
|
760
|
+
language: "typescript",
|
|
761
|
+
contentHash: "h-dup",
|
|
762
|
+
symbols: [sym("dup.caller", "caller", 0, 50), sym("dup.callee", "callee", 50, 100)],
|
|
763
|
+
edges: [
|
|
764
|
+
{
|
|
765
|
+
srcQualifiedName: "dup.caller",
|
|
766
|
+
dstQualifiedName: "dup.callee",
|
|
767
|
+
type: "CALLS",
|
|
768
|
+
confidence: 0.95,
|
|
769
|
+
provenance: "heuristic",
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
// Same (src, dst, type) — differing metadata. First edge wins;
|
|
773
|
+
// this duplicate must NOT inflate edgeCount to 2.
|
|
774
|
+
srcQualifiedName: "dup.caller",
|
|
775
|
+
dstQualifiedName: "dup.callee",
|
|
776
|
+
type: "CALLS",
|
|
777
|
+
confidence: 0.4,
|
|
778
|
+
provenance: "lsp",
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
};
|
|
782
|
+
const r = await store.upsertFileBatch([fileDup]);
|
|
783
|
+
assert.equal(r.ok, true);
|
|
784
|
+
if (!r.ok) throw new Error("expected ok");
|
|
785
|
+
assert.equal(
|
|
786
|
+
r.results[0]?.edgeCount,
|
|
787
|
+
1,
|
|
788
|
+
"duplicate edge triples must dedupe to a single insert — edgeCount must not be inflated",
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
// Re-ingest the same file to prove the persisted edge kept the
|
|
792
|
+
// FIRST duplicate's metadata (confidence 0.95): a no-op re-upsert
|
|
793
|
+
// (changes=0) means the stored row matches the first edge exactly.
|
|
794
|
+
const r2 = await store.upsertFileBatch([fileDup]);
|
|
795
|
+
assert.equal(r2.ok, true);
|
|
796
|
+
if (!r2.ok) throw new Error("expected ok");
|
|
797
|
+
assert.equal(
|
|
798
|
+
r2.results[0]?.edgeCount,
|
|
799
|
+
0,
|
|
800
|
+
"re-ingest with identical first-edge metadata must be a no-op (changes=0)",
|
|
801
|
+
);
|
|
802
|
+
} finally {
|
|
803
|
+
await store.close();
|
|
804
|
+
await rm(dir, { recursive: true, force: true });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("ambiguous local qualified_name drops edges, does not silently pick one node (chatgpt-codex-connector P2)", async () => {
|
|
809
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-ambig-"));
|
|
810
|
+
const store = await GraphStore.open({
|
|
811
|
+
dbPath: path.join(dir, "store.db"),
|
|
812
|
+
});
|
|
813
|
+
try {
|
|
814
|
+
// Two symbols share qualifiedName "ambig.Foo" but have different
|
|
815
|
+
// kinds (type vs function) → different node ids, same qualified_name.
|
|
816
|
+
// Node identity is (qualifiedName, filePath, label=kind).
|
|
817
|
+
const fileAmbig: StoreFileIR = {
|
|
818
|
+
path: "src/ambig.ts",
|
|
819
|
+
language: "typescript",
|
|
820
|
+
contentHash: "h-ambig",
|
|
821
|
+
symbols: [
|
|
822
|
+
sym("ambig.Foo", "Foo", 0, 100, "type"),
|
|
823
|
+
sym("ambig.Foo", "Foo", 100, 200, "function"),
|
|
824
|
+
sym("ambig.bar", "bar", 200, 300, "function"),
|
|
825
|
+
],
|
|
826
|
+
edges: [
|
|
827
|
+
{
|
|
828
|
+
// src "ambig.Foo" is ambiguous (two nodes) — must be dropped,
|
|
829
|
+
// NOT silently attached to whichever the unordered query left last.
|
|
830
|
+
srcQualifiedName: "ambig.Foo",
|
|
831
|
+
dstQualifiedName: "ambig.bar",
|
|
832
|
+
type: "CALLS",
|
|
833
|
+
confidence: 0.9,
|
|
834
|
+
provenance: "heuristic",
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
const r = await store.upsertFileBatch([fileAmbig]);
|
|
839
|
+
assert.equal(r.ok, true);
|
|
840
|
+
if (!r.ok) throw new Error("expected ok");
|
|
841
|
+
assert.equal(
|
|
842
|
+
r.results[0]?.edgeCount,
|
|
843
|
+
0,
|
|
844
|
+
"edge whose src qualified_name is ambiguous (two nodes share it) must be dropped, not silently resolved",
|
|
845
|
+
);
|
|
846
|
+
} finally {
|
|
847
|
+
await store.close();
|
|
848
|
+
await rm(dir, { recursive: true, force: true });
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("out-of-range edge confidence is rejected at the storage boundary (chatgpt-codex-connector P2)", async () => {
|
|
853
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-conf-"));
|
|
854
|
+
const store = await GraphStore.open({
|
|
855
|
+
dbPath: path.join(dir, "store.db"),
|
|
856
|
+
});
|
|
857
|
+
try {
|
|
858
|
+
for (const badConfidence of [-0.1, 1.5, -1, 2, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
859
|
+
const file: StoreFileIR = {
|
|
860
|
+
path: "src/conf.ts",
|
|
861
|
+
language: "typescript",
|
|
862
|
+
contentHash: "h-conf",
|
|
863
|
+
symbols: [sym("conf.caller", "caller", 0, 50), sym("conf.callee", "callee", 50, 100)],
|
|
864
|
+
edges: [
|
|
865
|
+
{
|
|
866
|
+
srcQualifiedName: "conf.caller",
|
|
867
|
+
dstQualifiedName: "conf.callee",
|
|
868
|
+
type: "CALLS",
|
|
869
|
+
confidence: badConfidence,
|
|
870
|
+
provenance: "heuristic",
|
|
871
|
+
},
|
|
872
|
+
],
|
|
873
|
+
};
|
|
874
|
+
await assert.rejects(
|
|
875
|
+
store.upsertFileBatch([file]),
|
|
876
|
+
/out of range \[0, 1\]/,
|
|
877
|
+
`confidence ${badConfidence} must be rejected at the storage boundary`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
} finally {
|
|
881
|
+
await store.close();
|
|
882
|
+
await rm(dir, { recursive: true, force: true });
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("omitted edges field preserves prior edges, explicit [] wipes them (cursor Bugbot: 'Omitted edges field wipes stored edges')", async () => {
|
|
887
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-noedges-"));
|
|
888
|
+
const store = await GraphStore.open({
|
|
889
|
+
dbPath: path.join(dir, "store.db"),
|
|
890
|
+
});
|
|
891
|
+
try {
|
|
892
|
+
// Initial ingest with edges.
|
|
893
|
+
const r1 = await store.upsertFileBatch([fileA]);
|
|
894
|
+
assert.equal(r1.ok, true);
|
|
895
|
+
if (!r1.ok) throw new Error("expected ok");
|
|
896
|
+
assert.equal(r1.results[0]?.edgeCount, 1, "initial ingest stores 1 edge");
|
|
897
|
+
|
|
898
|
+
// Re-upsert the same file WITHOUT an edges field — a bare core
|
|
899
|
+
// ParseResult.ir has no edges. Prior edges MUST be preserved.
|
|
900
|
+
const noEdges: StoreFileIR = {
|
|
901
|
+
path: "src/a.ts",
|
|
902
|
+
language: "typescript",
|
|
903
|
+
contentHash: "h-a-noedges",
|
|
904
|
+
symbols: [sym("a.greet", "greet", 0, 100), sym("a.farewell", "farewell", 100, 200)],
|
|
905
|
+
};
|
|
906
|
+
const r2 = await store.upsertFileBatch([noEdges]);
|
|
907
|
+
assert.equal(r2.ok, true);
|
|
908
|
+
if (!r2.ok) throw new Error("expected ok");
|
|
909
|
+
assert.equal(
|
|
910
|
+
r2.results[0]?.edgeCount,
|
|
911
|
+
0,
|
|
912
|
+
"re-upsert without edges field must not insert or delete edges",
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Re-ingest fileB (which has a cross-file edge to a.greet). If the
|
|
916
|
+
// edge from fileA was preserved, fileB's re-ingest sees it still
|
|
917
|
+
// there (no dangling drop). If it was wiped, fileB's edge target
|
|
918
|
+
// is gone but the edge re-inserts fine since a.greet still exists.
|
|
919
|
+
// The definitive proof: re-upsert fileA with explicit [] edges —
|
|
920
|
+
// this MUST wipe the prior edge.
|
|
921
|
+
const emptyEdges: StoreFileIR = {
|
|
922
|
+
path: "src/a.ts",
|
|
923
|
+
language: "typescript",
|
|
924
|
+
contentHash: "h-a-empty-edges",
|
|
925
|
+
symbols: [sym("a.greet", "greet", 0, 100), sym("a.farewell", "farewell", 100, 200)],
|
|
926
|
+
edges: [],
|
|
927
|
+
};
|
|
928
|
+
const r3 = await store.upsertFileBatch([emptyEdges]);
|
|
929
|
+
assert.equal(r3.ok, true);
|
|
930
|
+
if (!r3.ok) throw new Error("expected ok");
|
|
931
|
+
// The prior edge (a.greet → a.farewell) is now stale and deleted.
|
|
932
|
+
assert.equal(
|
|
933
|
+
r3.results[0]?.droppedDanglingEdges,
|
|
934
|
+
0,
|
|
935
|
+
"explicit [] edges does not produce dangling edges (edges are src-owned)",
|
|
936
|
+
);
|
|
937
|
+
} finally {
|
|
938
|
+
await store.close();
|
|
939
|
+
await rm(dir, { recursive: true, force: true });
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("close blocks new writes before draining so a concurrent upsert is rejected (chatgpt-codex-connector P2)", async () => {
|
|
944
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-closing-"));
|
|
945
|
+
const store = await GraphStore.open({
|
|
946
|
+
dbPath: path.join(dir, "store.db"),
|
|
947
|
+
});
|
|
948
|
+
try {
|
|
949
|
+
// Schedule a write (pending on the queue).
|
|
950
|
+
const firstWrite = store.upsertFileBatch([fileA]);
|
|
951
|
+
// Start close() WITHOUT awaiting firstWrite. close() sets the
|
|
952
|
+
// closing flag synchronously before awaiting drain, so a write
|
|
953
|
+
// issued while drain is pending must be rejected, not scheduled
|
|
954
|
+
// onto a queue whose tail drain() already snapshotted.
|
|
955
|
+
const closeP = store.close();
|
|
956
|
+
const rejected = await store.upsertFileBatch([fileB]);
|
|
957
|
+
assert.equal(rejected.ok, false);
|
|
958
|
+
if (rejected.ok) throw new Error("expected rejected");
|
|
959
|
+
assert.equal(
|
|
960
|
+
rejected.code,
|
|
961
|
+
"store_closed",
|
|
962
|
+
"upsert during close-drain must return store_closed, not run against a closing DB",
|
|
963
|
+
);
|
|
964
|
+
// Let the pending write + close finish.
|
|
965
|
+
await firstWrite;
|
|
966
|
+
await closeP;
|
|
967
|
+
} finally {
|
|
968
|
+
await rm(dir, { recursive: true, force: true });
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("a second close() during an in-progress drain awaits it, not resolves early (chatgpt-codex-connector P2: 'Wait for an in-progress close')", async () => {
|
|
973
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-close2-"));
|
|
974
|
+
const store = await GraphStore.open({
|
|
975
|
+
dbPath: path.join(dir, "store.db"),
|
|
976
|
+
});
|
|
977
|
+
try {
|
|
978
|
+
// Schedule a write but do NOT await it, so close()'s drain has a
|
|
979
|
+
// pending write to wait on.
|
|
980
|
+
const firstWrite = store.upsertFileBatch([fileA]);
|
|
981
|
+
// closeA runs first: it sets `closing` synchronously then awaits
|
|
982
|
+
// the drain. closeB is issued on the SAME synchronous tick, while
|
|
983
|
+
// closeA is still mid-drain (no microtasks have flushed yet).
|
|
984
|
+
const closeA = store.close();
|
|
985
|
+
const closeB = store.close();
|
|
986
|
+
|
|
987
|
+
// Discriminator: the pre-fix early `return` resolved closeB
|
|
988
|
+
// synchronously (it returned a settled promise), so a .then
|
|
989
|
+
// callback fires on the very next microtask. The fix returns the
|
|
990
|
+
// shared pending drain promise, so closeB stays pending until the
|
|
991
|
+
// drain finishes. `await Promise.resolve()` advances exactly one
|
|
992
|
+
// microtask — enough for closeB's .then to fire if it was already
|
|
993
|
+
// settled, not enough if it is still waiting on the drain.
|
|
994
|
+
let earlyResolved = false;
|
|
995
|
+
closeB.then(() => {
|
|
996
|
+
earlyResolved = true;
|
|
997
|
+
});
|
|
998
|
+
await Promise.resolve();
|
|
999
|
+
assert.equal(
|
|
1000
|
+
earlyResolved,
|
|
1001
|
+
false,
|
|
1002
|
+
"a second close() issued while the first is draining must await the shared drain promise, not resolve before it",
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
// Both closes now settle together once the drain (and firstWrite)
|
|
1006
|
+
// complete.
|
|
1007
|
+
await closeA;
|
|
1008
|
+
await closeB;
|
|
1009
|
+
await firstWrite;
|
|
1010
|
+
} finally {
|
|
1011
|
+
await rm(dir, { recursive: true, force: true });
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test("invalid symbol spans are rejected before storing nodes (chatgpt-codex-connector P2: 'Reject invalid symbol spans before storing nodes')", async () => {
|
|
1016
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-badspan-"));
|
|
1017
|
+
const store = await GraphStore.open({
|
|
1018
|
+
dbPath: path.join(dir, "store.db"),
|
|
1019
|
+
});
|
|
1020
|
+
try {
|
|
1021
|
+
// Seed valid state so we can prove the guard throws BEFORE the
|
|
1022
|
+
// transaction opens — a wipe would clear these nodes.
|
|
1023
|
+
const seeded = await store.upsertFileBatch([fileA]);
|
|
1024
|
+
assert.equal(seeded.ok, true, "seed ingest succeeds");
|
|
1025
|
+
assert.equal(
|
|
1026
|
+
seeded.ok && seeded.results[0]!.nodeCount,
|
|
1027
|
+
2,
|
|
1028
|
+
"fileA seeded with 2 symbol nodes",
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
// Each case targets a distinct branch of assertValidSymbolSpan.
|
|
1032
|
+
// The `sym()` helper builds a well-formed symbol; the non-integer
|
|
1033
|
+
// and missing-span cases bypass it to seed the malformed payload a
|
|
1034
|
+
// JSON-deserializing or buggy caller would emit.
|
|
1035
|
+
const cases: Array<{ label: string; sym: unknown; re: RegExp }> = [
|
|
1036
|
+
{ label: "startByte > endByte", sym: sym("a.bad", "bad", 100, 10), re: /startByte > endByte/ },
|
|
1037
|
+
{ label: "negative startByte", sym: sym("a.bad", "bad", -1, 10), re: /negative span/ },
|
|
1038
|
+
{ label: "negative endByte", sym: sym("a.bad", "bad", 0, -5), re: /negative span/ },
|
|
1039
|
+
{ label: "non-integer startByte", sym: { qualifiedName: "a.bad", name: "bad", kind: "function", span: { startByte: 1.5, endByte: 10 } }, re: /non-integer span/ },
|
|
1040
|
+
{ label: "NaN endByte", sym: { qualifiedName: "a.bad", name: "bad", kind: "function", span: { startByte: 0, endByte: Number.NaN } }, re: /non-integer span/ },
|
|
1041
|
+
{ label: "missing span object", sym: { qualifiedName: "a.bad", name: "bad", kind: "function" }, re: /no span/ },
|
|
1042
|
+
];
|
|
1043
|
+
for (const { label, sym: badSym, re } of cases) {
|
|
1044
|
+
const ir = { ...fileA, symbols: [badSym] } as unknown as StoreFileIR;
|
|
1045
|
+
await assert.rejects(
|
|
1046
|
+
store.upsertFileBatch([ir]),
|
|
1047
|
+
re,
|
|
1048
|
+
`${label}: invalid span must be rejected at the store boundary`,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Guard throws before the transaction opens, so every rejected
|
|
1053
|
+
// attempt left the seeded nodes intact — a re-ingest is a no-op
|
|
1054
|
+
// (nodeCount 0). The pre-fix path would have bound the bad span and
|
|
1055
|
+
// reported nodeCount 1.
|
|
1056
|
+
const reingested = await store.upsertFileBatch([fileA]);
|
|
1057
|
+
assert.equal(reingested.ok, true, "store accepts a valid ingest after the rejections");
|
|
1058
|
+
assert.equal(
|
|
1059
|
+
reingested.ok && reingested.results[0]!.nodeCount,
|
|
1060
|
+
0,
|
|
1061
|
+
"fileA re-ingest is a no-op — seeded nodes survived every rejected bad-span attempt",
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// Boundary check: a zero-length half-open span [5, 5) is VALID
|
|
1065
|
+
// (startByte <= endByte), so it must be accepted, not rejected.
|
|
1066
|
+
const zeroSpan = await store.upsertFileBatch([
|
|
1067
|
+
{ ...fileA, symbols: [sym("a.zero", "zero", 5, 5)] },
|
|
1068
|
+
]);
|
|
1069
|
+
assert.equal(zeroSpan.ok, true, "a zero-length span [5, 5) is a legal half-open span");
|
|
1070
|
+
} finally {
|
|
1071
|
+
await store.close();
|
|
1072
|
+
await rm(dir, { recursive: true, force: true });
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("non-canonical file paths are rejected before persisting (chatgpt-codex-connector P2: 'Reject non-canonical file paths before persisting')", async () => {
|
|
1077
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-badpath-"));
|
|
1078
|
+
const store = await GraphStore.open({
|
|
1079
|
+
dbPath: path.join(dir, "store.db"),
|
|
1080
|
+
});
|
|
1081
|
+
try {
|
|
1082
|
+
const seeded = await store.upsertFileBatch([fileA]);
|
|
1083
|
+
assert.equal(seeded.ok, true, "seed ingest succeeds");
|
|
1084
|
+
|
|
1085
|
+
// Each case is a distinct aliasing hazard: the same repo file
|
|
1086
|
+
// rendered two ways would hash to two files rows + node ids and
|
|
1087
|
+
// leave duplicates the canonical ingest cannot match or prune.
|
|
1088
|
+
const cases: Array<{ label: string; path: string; re: RegExp }> = [
|
|
1089
|
+
{ label: "backslash separator", path: "src\\a.ts", re: /forward slashes/ },
|
|
1090
|
+
{ label: "leading ./", path: "./src/a.ts", re: /must be canonical/ },
|
|
1091
|
+
{ label: "leading ../", path: "../src/a.ts", re: /must be canonical/ },
|
|
1092
|
+
{ label: "absolute posix", path: "/src/a.ts", re: /must be repo-relative/ },
|
|
1093
|
+
{ label: ".. segment", path: "src/../a.ts", re: /must be canonical/ },
|
|
1094
|
+
{ label: "empty string", path: "", re: /non-empty string/ },
|
|
1095
|
+
];
|
|
1096
|
+
for (const { label, path: badPath, re } of cases) {
|
|
1097
|
+
const ir = { ...fileA, path: badPath } as StoreFileIR;
|
|
1098
|
+
await assert.rejects(
|
|
1099
|
+
store.upsertFileBatch([ir]),
|
|
1100
|
+
re,
|
|
1101
|
+
`${label}: non-canonical path must be rejected at the store boundary`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Canonical re-ingest is a no-op — the seeded file survived every
|
|
1106
|
+
// rejected non-canonical attempt (the guard throws before the
|
|
1107
|
+
// files row is touched).
|
|
1108
|
+
const reingested = await store.upsertFileBatch([fileA]);
|
|
1109
|
+
assert.equal(reingested.ok, true, "store accepts a canonical ingest after the rejections");
|
|
1110
|
+
assert.equal(
|
|
1111
|
+
reingested.ok && reingested.results[0]!.nodeCount,
|
|
1112
|
+
0,
|
|
1113
|
+
"fileA re-ingest is a no-op — the canonical seeded row survived every rejected non-canonical attempt",
|
|
1114
|
+
);
|
|
1115
|
+
} finally {
|
|
1116
|
+
await store.close();
|
|
1117
|
+
await rm(dir, { recursive: true, force: true });
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1123
|
+
// upsertEdges (issue #1554 codegraph ingest_traces persistence path)
|
|
1124
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1125
|
+
|
|
1126
|
+
test("upsertEdges: persists a standalone HTTP_CALLS edge between indexed nodes (issue #1554 P1 persistence)", async () => {
|
|
1127
|
+
const { store, dir } = await tempStore();
|
|
1128
|
+
try {
|
|
1129
|
+
// Seed two files so the nodes the trace edge references exist.
|
|
1130
|
+
const seeded = await store.upsertFileBatch([fileA, fileB]);
|
|
1131
|
+
assert.equal(seeded.ok, true);
|
|
1132
|
+
|
|
1133
|
+
// Persist a runtime HTTP call observation as a standalone edge.
|
|
1134
|
+
const result = await store.upsertEdges([
|
|
1135
|
+
{
|
|
1136
|
+
srcQualifiedName: "a.greet",
|
|
1137
|
+
dstQualifiedName: "b.run",
|
|
1138
|
+
type: "HTTP_CALLS",
|
|
1139
|
+
confidence: 0.7,
|
|
1140
|
+
provenance: "trace",
|
|
1141
|
+
},
|
|
1142
|
+
]);
|
|
1143
|
+
assert.equal(result.ok, true);
|
|
1144
|
+
if (!result.ok) throw new Error("expected ok");
|
|
1145
|
+
assert.equal(result.persisted, 1, "one edge persisted");
|
|
1146
|
+
assert.equal(result.skipped, 0, "no skipped edges");
|
|
1147
|
+
|
|
1148
|
+
// Re-open the store and verify the edge survived on disk (real
|
|
1149
|
+
// persistence, not an in-memory stub — P1 acceptance criterion).
|
|
1150
|
+
await store.close();
|
|
1151
|
+
const reopened = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
|
|
1152
|
+
const stats = reopened.schemaStats();
|
|
1153
|
+
assert.equal(stats.ok, true);
|
|
1154
|
+
if (!stats.ok) throw new Error("expected stats ok");
|
|
1155
|
+
assert.equal(
|
|
1156
|
+
(stats.stats.edgesByType["HTTP_CALLS"] ?? 0),
|
|
1157
|
+
1,
|
|
1158
|
+
"HTTP_CALLS edge persisted across reopen",
|
|
1159
|
+
);
|
|
1160
|
+
await reopened.close();
|
|
1161
|
+
} finally {
|
|
1162
|
+
await rm(dir, { recursive: true, force: true });
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("upsertEdges: skips edges whose endpoint does not resolve to an indexed node (dangling policy)", async () => {
|
|
1167
|
+
const { store, dir } = await tempStore();
|
|
1168
|
+
try {
|
|
1169
|
+
await store.upsertFileBatch([fileA]);
|
|
1170
|
+
|
|
1171
|
+
// 'b.run' is not indexed (only fileA was ingested) → edge is skipped.
|
|
1172
|
+
const result = await store.upsertEdges([
|
|
1173
|
+
{
|
|
1174
|
+
srcQualifiedName: "a.greet",
|
|
1175
|
+
dstQualifiedName: "b.run",
|
|
1176
|
+
type: "HTTP_CALLS",
|
|
1177
|
+
confidence: 0.7,
|
|
1178
|
+
provenance: "trace",
|
|
1179
|
+
},
|
|
1180
|
+
]);
|
|
1181
|
+
assert.equal(result.ok, true);
|
|
1182
|
+
if (!result.ok) throw new Error("expected ok");
|
|
1183
|
+
assert.equal(result.persisted, 0, "dangling dst → not persisted");
|
|
1184
|
+
assert.equal(result.skipped, 1, "dangling edge counted as skipped");
|
|
1185
|
+
} finally {
|
|
1186
|
+
await store.close();
|
|
1187
|
+
await rm(dir, { recursive: true, force: true });
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("upsertEdges: upgrades confidence on an existing (src,dst,type) edge (idempotent upsert)", async () => {
|
|
1192
|
+
const { store, dir } = await tempStore();
|
|
1193
|
+
try {
|
|
1194
|
+
await store.upsertFileBatch([fileA, fileB]);
|
|
1195
|
+
|
|
1196
|
+
// First trace: confidence 0.5.
|
|
1197
|
+
const first = await store.upsertEdges([
|
|
1198
|
+
{ srcQualifiedName: "a.greet", dstQualifiedName: "b.run", type: "HTTP_CALLS", confidence: 0.5, provenance: "trace" },
|
|
1199
|
+
]);
|
|
1200
|
+
assert.equal(first.ok && first.persisted, 1);
|
|
1201
|
+
|
|
1202
|
+
// Second trace, same endpoints: confidence upgraded to 0.9.
|
|
1203
|
+
const second = await store.upsertEdges([
|
|
1204
|
+
{ srcQualifiedName: "a.greet", dstQualifiedName: "b.run", type: "HTTP_CALLS", confidence: 0.9, provenance: "trace" },
|
|
1205
|
+
]);
|
|
1206
|
+
assert.equal(second.ok && second.persisted, 1, "upsert updates the existing edge row");
|
|
1207
|
+
|
|
1208
|
+
// Reopen + verify there is exactly ONE HTTP_CALLS edge (not two).
|
|
1209
|
+
await store.close();
|
|
1210
|
+
const reopened = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
|
|
1211
|
+
const stats = reopened.schemaStats();
|
|
1212
|
+
assert.equal(stats.ok && (stats.stats.edgesByType["HTTP_CALLS"] ?? 0), 1, "no duplicate edge rows");
|
|
1213
|
+
await reopened.close();
|
|
1214
|
+
} finally {
|
|
1215
|
+
await rm(dir, { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test("upsertEdges: rejects invalid provenance / out-of-range confidence at the boundary (rule 51)", async () => {
|
|
1220
|
+
const { store, dir } = await tempStore();
|
|
1221
|
+
try {
|
|
1222
|
+
await store.upsertFileBatch([fileA]);
|
|
1223
|
+
|
|
1224
|
+
// Validation errors are fail-loud (matching upsertFileBatch's policy:
|
|
1225
|
+
// a bad edge is a contract violation, not a tagged failure).
|
|
1226
|
+
await assert.rejects(
|
|
1227
|
+
store.upsertEdges([
|
|
1228
|
+
{ srcQualifiedName: "a.greet", dstQualifiedName: "a.farewell", type: "HTTP_CALLS", confidence: 0.5, provenance: "bogus" as never },
|
|
1229
|
+
]),
|
|
1230
|
+
/provenance/,
|
|
1231
|
+
);
|
|
1232
|
+
await assert.rejects(
|
|
1233
|
+
store.upsertEdges([
|
|
1234
|
+
{ srcQualifiedName: "a.greet", dstQualifiedName: "a.farewell", type: "HTTP_CALLS", confidence: 1.5, provenance: "trace" },
|
|
1235
|
+
]),
|
|
1236
|
+
/out of range/,
|
|
1237
|
+
);
|
|
1238
|
+
} finally {
|
|
1239
|
+
await store.close();
|
|
1240
|
+
await rm(dir, { recursive: true, force: true });
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1246
|
+
// upsertEdges: node-id-keyed endpoints (issue #1677 — duplicate qualified-name
|
|
1247
|
+
// support). A SIMILAR_TO edge between two symbols that share a qualified name
|
|
1248
|
+
// across two files is persisted by node id, NOT dropped as ambiguous.
|
|
1249
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1250
|
+
|
|
1251
|
+
test("upsertEdges: srcNodeId/dstNodeId persist an edge between same-qualified-name nodes (issue #1677)", async () => {
|
|
1252
|
+
const { store, dir } = await tempStore();
|
|
1253
|
+
try {
|
|
1254
|
+
// Two files, each declaring a symbol with the SAME qualified name
|
|
1255
|
+
// "dup.Foo" but a different file path → distinct deterministic node ids
|
|
1256
|
+
// (identity is (qualifiedName, filePath, label)). The qualified-name
|
|
1257
|
+
// fallback would be AMBIGUOUS here and drop the edge.
|
|
1258
|
+
const fileOne: StoreFileIR = {
|
|
1259
|
+
path: "src/one.ts",
|
|
1260
|
+
language: "typescript",
|
|
1261
|
+
contentHash: "h-one",
|
|
1262
|
+
symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
|
|
1263
|
+
};
|
|
1264
|
+
const fileTwo: StoreFileIR = {
|
|
1265
|
+
path: "src/two.ts",
|
|
1266
|
+
language: "typescript",
|
|
1267
|
+
contentHash: "h-two",
|
|
1268
|
+
symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
|
|
1269
|
+
};
|
|
1270
|
+
await store.upsertFileBatch([fileOne, fileTwo]);
|
|
1271
|
+
|
|
1272
|
+
const idOne = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/one.ts", label: "function" });
|
|
1273
|
+
const idTwo = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/two.ts", label: "function" });
|
|
1274
|
+
assert.notEqual(idOne, idTwo, "two same-qname symbols in different files have distinct node ids");
|
|
1275
|
+
|
|
1276
|
+
// Edge keyed by node ids — the SIMILAR_TO pipeline shape.
|
|
1277
|
+
const result = await store.upsertEdges([
|
|
1278
|
+
{
|
|
1279
|
+
srcQualifiedName: "dup.Foo",
|
|
1280
|
+
dstQualifiedName: "dup.Foo",
|
|
1281
|
+
srcNodeId: idOne,
|
|
1282
|
+
dstNodeId: idTwo,
|
|
1283
|
+
type: "SIMILAR_TO",
|
|
1284
|
+
confidence: 0.93,
|
|
1285
|
+
provenance: "semantic",
|
|
1286
|
+
},
|
|
1287
|
+
]);
|
|
1288
|
+
assert.equal(result.ok, true);
|
|
1289
|
+
if (!result.ok) throw new Error("expected ok");
|
|
1290
|
+
assert.equal(result.persisted, 1, "node-id-keyed edge persisted despite duplicate qualified name");
|
|
1291
|
+
assert.equal(result.skipped, 0, "edge must NOT be dropped as ambiguous");
|
|
1292
|
+
|
|
1293
|
+
// The edge is traversable from either node by id.
|
|
1294
|
+
const t = store.traverse({ start: idOne, edgeTypes: ["SIMILAR_TO"], maxDepth: 1 });
|
|
1295
|
+
assert.equal(t.ok, true);
|
|
1296
|
+
if (t.ok) {
|
|
1297
|
+
assert.ok(
|
|
1298
|
+
t.hits.some((h) => h.nodeId === idTwo),
|
|
1299
|
+
"SIMILAR_TO edge reaches the exact same-qname node by id",
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
} finally {
|
|
1303
|
+
await store.close();
|
|
1304
|
+
await rm(dir, { recursive: true, force: true });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test("upsertEdges: a node id that does not reference an indexed node is skipped (issue #1677)", async () => {
|
|
1309
|
+
const { store, dir } = await tempStore();
|
|
1310
|
+
try {
|
|
1311
|
+
await store.upsertFileBatch([fileA]);
|
|
1312
|
+
|
|
1313
|
+
// srcNodeId resolves (a.greet exists) but dstNodeId points at a node
|
|
1314
|
+
// that was never ingested → dangling, counted as skipped (consistent
|
|
1315
|
+
// with the qname dangling-edge policy).
|
|
1316
|
+
const bogusDst = nodeIdFor({ qualifiedName: "ghost", filePath: "src/ghost.ts", label: "function" });
|
|
1317
|
+
const result = await store.upsertEdges([
|
|
1318
|
+
{
|
|
1319
|
+
srcQualifiedName: "a.greet",
|
|
1320
|
+
dstQualifiedName: "ghost",
|
|
1321
|
+
srcNodeId: nodeIdFor({ qualifiedName: "a.greet", filePath: "src/a.ts", label: "function" }),
|
|
1322
|
+
dstNodeId: bogusDst,
|
|
1323
|
+
type: "SIMILAR_TO",
|
|
1324
|
+
confidence: 0.9,
|
|
1325
|
+
provenance: "semantic",
|
|
1326
|
+
},
|
|
1327
|
+
]);
|
|
1328
|
+
assert.equal(result.ok, true);
|
|
1329
|
+
if (!result.ok) throw new Error("expected ok");
|
|
1330
|
+
assert.equal(result.persisted, 0, "dangling dst node id → not persisted");
|
|
1331
|
+
assert.equal(result.skipped, 1, "dangling node id counted as skipped");
|
|
1332
|
+
} finally {
|
|
1333
|
+
await store.close();
|
|
1334
|
+
await rm(dir, { recursive: true, force: true });
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("upsertEdges: a node id whose qualified_name does not match is skipped (issue #1677, chatgpt-codex-connector P2)", async () => {
|
|
1339
|
+
const { store, dir } = await tempStore();
|
|
1340
|
+
try {
|
|
1341
|
+
// Two files, same qname "dup.Foo" → two distinct node ids.
|
|
1342
|
+
const fileOne: StoreFileIR = {
|
|
1343
|
+
path: "src/one.ts",
|
|
1344
|
+
language: "typescript",
|
|
1345
|
+
contentHash: "h-one",
|
|
1346
|
+
symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
|
|
1347
|
+
};
|
|
1348
|
+
const fileTwo: StoreFileIR = {
|
|
1349
|
+
path: "src/two.ts",
|
|
1350
|
+
language: "typescript",
|
|
1351
|
+
contentHash: "h-two",
|
|
1352
|
+
symbols: [sym("dup.Foo", "Foo", 0, 100, "function")],
|
|
1353
|
+
};
|
|
1354
|
+
await store.upsertFileBatch([fileOne, fileTwo]);
|
|
1355
|
+
const idOne = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/one.ts", label: "function" });
|
|
1356
|
+
const idTwo = nodeIdFor({ qualifiedName: "dup.Foo", filePath: "src/two.ts", label: "function" });
|
|
1357
|
+
|
|
1358
|
+
// idOne exists and resolves "dup.Foo" — this edge is fine.
|
|
1359
|
+
const ok = await store.upsertEdges([
|
|
1360
|
+
{
|
|
1361
|
+
srcQualifiedName: "dup.Foo",
|
|
1362
|
+
dstQualifiedName: "dup.Foo",
|
|
1363
|
+
srcNodeId: idOne,
|
|
1364
|
+
dstNodeId: idTwo,
|
|
1365
|
+
type: "SIMILAR_TO",
|
|
1366
|
+
confidence: 0.9,
|
|
1367
|
+
provenance: "semantic",
|
|
1368
|
+
},
|
|
1369
|
+
]);
|
|
1370
|
+
assert.equal(ok.ok && ok.persisted, 1, "correctly-paired id+qname persists");
|
|
1371
|
+
|
|
1372
|
+
// idOne is a REAL node id, but the edge claims its qname is "wrong.qname"
|
|
1373
|
+
// — the id/qname pair is inconsistent and MUST be skipped rather than
|
|
1374
|
+
// silently writing an edge from node idOne under a misleading name
|
|
1375
|
+
// (chatgpt-codex-connector P2: id/qname consistency at the boundary).
|
|
1376
|
+
const bad = await store.upsertEdges([
|
|
1377
|
+
{
|
|
1378
|
+
srcQualifiedName: "wrong.qname",
|
|
1379
|
+
dstQualifiedName: "dup.Foo",
|
|
1380
|
+
srcNodeId: idOne,
|
|
1381
|
+
dstNodeId: idTwo,
|
|
1382
|
+
type: "SIMILAR_TO",
|
|
1383
|
+
confidence: 0.9,
|
|
1384
|
+
provenance: "semantic",
|
|
1385
|
+
},
|
|
1386
|
+
]);
|
|
1387
|
+
assert.equal(bad.ok, true);
|
|
1388
|
+
if (!bad.ok) throw new Error("expected ok");
|
|
1389
|
+
assert.equal(bad.persisted, 0, "mismatched id+qname → not persisted");
|
|
1390
|
+
assert.equal(bad.skipped, 1, "mismatched id+qname counted as skipped");
|
|
1391
|
+
} finally {
|
|
1392
|
+
await store.close();
|
|
1393
|
+
await rm(dir, { recursive: true, force: true });
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("upsertEdges: edges without node ids still use the qname path (issue #1677 back-compat)", async () => {
|
|
1398
|
+
const { store, dir } = await tempStore();
|
|
1399
|
+
try {
|
|
1400
|
+
// The existing trace/HTTP_CALLS path passes no node ids — it MUST keep
|
|
1401
|
+
// resolving by qualified name exactly as before.
|
|
1402
|
+
await store.upsertFileBatch([fileA]);
|
|
1403
|
+
const result = await store.upsertEdges([
|
|
1404
|
+
{
|
|
1405
|
+
srcQualifiedName: "a.greet",
|
|
1406
|
+
dstQualifiedName: "a.farewell",
|
|
1407
|
+
type: "CALLS",
|
|
1408
|
+
confidence: 0.8,
|
|
1409
|
+
provenance: "heuristic",
|
|
1410
|
+
},
|
|
1411
|
+
]);
|
|
1412
|
+
assert.equal(result.ok, true);
|
|
1413
|
+
if (!result.ok) throw new Error("expected ok");
|
|
1414
|
+
assert.equal(result.persisted, 1, "qname-only edge still persists via the fallback path");
|
|
1415
|
+
assert.equal(result.skipped, 0);
|
|
1416
|
+
} finally {
|
|
1417
|
+
await store.close();
|
|
1418
|
+
await rm(dir, { recursive: true, force: true });
|
|
1419
|
+
}
|
|
1420
|
+
});
|