@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.
Files changed (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,1879 @@
1
+ /**
2
+ * graph-store PR2 tests — ordered steps 4–5 of issue #1552.
3
+ *
4
+ * Covers the read-side primitives that land in PR2:
5
+ * - traverse(): iterative frontier BFS with cycle / self-edge safety,
6
+ * direction (outgoing/incoming/both), edge-type filter, and the
7
+ * half-open depth-cap boundary (depth == maxDepth INCLUDED,
8
+ * maxDepth+1 NOT — rule 35).
9
+ * - searchGraph(): label / name / file LIKE patterns + degreeMin/Max
10
+ * filters + limit (including the rule-27 `limit: 0` guard).
11
+ * - schemaStats(): aggregate counts over the whole graph.
12
+ * - deadCode(): exclusion constant behavior, including the load-bearing
13
+ * fixture that proves an exported-but-uncalled symbol is NOT reported
14
+ * while a private uncalled symbol IS.
15
+ * - snippetFor(): reads spans from disk via repoRoot; tagged failures
16
+ * for unset root / missing file / ambiguous name.
17
+ *
18
+ * Fixture IR is synthetic (public-repo policy). Tests prove-fail-before
19
+ * by exact-set assertions — a deliberate break of the depth cap or the
20
+ * dead-code exclusion fails the corresponding test.
21
+ */
22
+ import assert from "node:assert/strict";
23
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import path from "node:path";
26
+ import test from "node:test";
27
+
28
+ import { openBetterSqlite3 } from "@remnic/core/runtime/better-sqlite";
29
+
30
+ import {
31
+ DEAD_CODE_EXCLUSION,
32
+ GraphStore,
33
+ type EdgeIR,
34
+ type StoreFileIR,
35
+ type SymbolIR,
36
+ } from "./graph-store.js";
37
+
38
+ // ──────────────────────────────────────────────────────────────────────────
39
+ // Fixture helpers — synthetic IR.
40
+ // ──────────────────────────────────────────────────────────────────────────
41
+
42
+ function sym(
43
+ qualifiedName: string,
44
+ name: string,
45
+ startByte: number,
46
+ endByte: number,
47
+ kind: SymbolIR["kind"] = "function",
48
+ ): SymbolIR {
49
+ return { qualifiedName, name, span: { startByte, endByte }, kind };
50
+ }
51
+
52
+ function exp(name: string, startByte: number, endByte: number) {
53
+ return { name, span: { startByte, endByte } };
54
+ }
55
+
56
+ function route(
57
+ verb: string,
58
+ pathTemplate: string,
59
+ handlerQualifiedName: string,
60
+ startByte: number,
61
+ endByte: number,
62
+ ) {
63
+ return {
64
+ verb,
65
+ pathTemplate,
66
+ handlerQualifiedName,
67
+ span: { startByte, endByte },
68
+ };
69
+ }
70
+
71
+ function edge(
72
+ srcQualifiedName: string,
73
+ dstQualifiedName: string,
74
+ type = "CALLS",
75
+ confidence = 0.9,
76
+ provenance: EdgeIR["provenance"] = "heuristic",
77
+ ): EdgeIR {
78
+ return { srcQualifiedName, dstQualifiedName, type, confidence, provenance };
79
+ }
80
+
81
+ /** Build a temp dir + a GraphStore opened against a fresh DB inside it. */
82
+ async function tempStore(
83
+ options: { repoRoot?: string } = {},
84
+ ): Promise<{ store: GraphStore; dir: string; repoRoot: string }> {
85
+ const dir = await mkdtemp(path.join(tmpdir(), "coding-graph-pr2-"));
86
+ const repoRoot = options.repoRoot ?? dir;
87
+ const store = await GraphStore.open({
88
+ dbPath: path.join(dir, "graph.sqlite"),
89
+ repoRoot,
90
+ });
91
+ return { store, dir, repoRoot };
92
+ }
93
+
94
+ async function dispose(store: GraphStore, dir: string): Promise<void> {
95
+ await store.close();
96
+ await rm(dir, { recursive: true, force: true });
97
+ }
98
+
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+ // Fixture: a chain a → b → c with a back-edge c → a (cycle) and a
101
+ // self-edge on b. Used to prove cycle / self-edge safety.
102
+ // ──────────────────────────────────────────────────────────────────────────
103
+
104
+ const cyclicFile: StoreFileIR = {
105
+ path: "src/cyclic.ts",
106
+ language: "typescript",
107
+ contentHash: "h-cyclic",
108
+ symbols: [
109
+ sym("cyc.a", "a", 0, 10),
110
+ sym("cyc.b", "b", 10, 20),
111
+ sym("cyc.c", "c", 20, 30),
112
+ ],
113
+ edges: [
114
+ edge("cyc.a", "cyc.b"),
115
+ edge("cyc.b", "cyc.c"),
116
+ edge("cyc.c", "cyc.a"), // back-edge: cycle a → b → c → a
117
+ edge("cyc.b", "cyc.b"), // self-edge on b
118
+ ],
119
+ };
120
+
121
+ // ──────────────────────────────────────────────────────────────────────────
122
+ // Fixture: a star graph for degree tests — center has degree 4, leaves
123
+ // have degree 1. Used by searchGraph's degreeMin/Max filters.
124
+ // ──────────────────────────────────────────────────────────────────────────
125
+
126
+ const starFile: StoreFileIR = {
127
+ path: "src/star.ts",
128
+ language: "typescript",
129
+ contentHash: "h-star",
130
+ symbols: [
131
+ sym("star.center", "center", 0, 10),
132
+ sym("star.l1", "l1", 10, 20),
133
+ sym("star.l2", "l2", 20, 30),
134
+ sym("star.l3", "l3", 30, 40),
135
+ sym("star.l4", "l4", 40, 50),
136
+ ],
137
+ edges: [
138
+ edge("star.center", "star.l1"),
139
+ edge("star.center", "star.l2"),
140
+ edge("star.center", "star.l3"),
141
+ edge("star.center", "star.l4"),
142
+ ],
143
+ };
144
+
145
+ // ──────────────────────────────────────────────────────────────────────────
146
+ // Fixture: dead-code scenario. Two files:
147
+ // - lib.ts: exports `publicApi` (uncalled from inside the graph) AND
148
+ // `privateHelper` (also uncalled). Only `publicApi` should be
149
+ // excluded from deadCode() because it carries `is_exported=1`.
150
+ // - entry.ts: imports lib and calls into it, providing the only
151
+ // inbound edge on `caller`. Its path matches an entry-point pattern
152
+ // so its symbols are excluded by the path-based rule.
153
+ // ──────────────────────────────────────────────────────────────────────────
154
+
155
+ const deadCodeLibFile: StoreFileIR = {
156
+ path: "src/lib.ts",
157
+ language: "typescript",
158
+ contentHash: "h-lib",
159
+ symbols: [
160
+ sym("lib.publicApi", "publicApi", 0, 100),
161
+ sym("lib.privateHelper", "privateHelper", 100, 200),
162
+ sym("lib.alive", "alive", 200, 300),
163
+ ],
164
+ exports: [
165
+ exp("publicApi", 0, 100), // publicApi is exported
166
+ exp("alive", 200, 300),
167
+ ],
168
+ routes: [],
169
+ edges: [
170
+ // alive calls privateHelper → privateHelper has inbound edge → NOT dead.
171
+ edge("lib.alive", "lib.privateHelper"),
172
+ // publicApi has NO inbound edges but is exported → NOT dead.
173
+ // privateHelper HAS an inbound edge → not a candidate at all.
174
+ // alive has NO inbound edges and is exported → NOT dead.
175
+ ],
176
+ };
177
+
178
+ const deadCodeEntryFile: StoreFileIR = {
179
+ path: "src/index.ts",
180
+ language: "typescript",
181
+ contentHash: "h-entry",
182
+ symbols: [
183
+ sym("entry.main", "main", 0, 50),
184
+ sym("entry.unused", "unused", 50, 100),
185
+ ],
186
+ // No exports — both symbols are private.
187
+ // Path matches ENTRY_POINT_PATH_PATTERNS, so BOTH are excluded by the
188
+ // path rule even though `entry.unused` is uncalled.
189
+ edges: [],
190
+ };
191
+
192
+ // Separate file proving the route-handler exclusion.
193
+ const routeHandlerFile: StoreFileIR = {
194
+ path: "src/server.ts",
195
+ language: "typescript",
196
+ contentHash: "h-route",
197
+ symbols: [
198
+ sym("route.healthHandler", "healthHandler", 0, 100),
199
+ sym("route.uncalledHelper", "uncalledHelper", 100, 200),
200
+ ],
201
+ routes: [
202
+ route("GET", "/health", "route.healthHandler", 0, 100),
203
+ ],
204
+ // No exports → healthHandler relies on the route-handler flag.
205
+ edges: [],
206
+ };
207
+
208
+ // ──────────────────────────────────────────────────────────────────────────
209
+ // traverse(): BFS depth + cycle + self-edge + direction.
210
+ // ──────────────────────────────────────────────────────────────────────────
211
+
212
+ test("traverse: cycle-safe — back-edge does not loop forever, visited set wins", async () => {
213
+ const { store, dir } = await tempStore();
214
+ try {
215
+ const r = await store.upsertFileBatch([cyclicFile]);
216
+ assert.equal(r.ok, true);
217
+ const out = store.traverse({ start: "cyc.a", maxDepth: 10 });
218
+ assert.equal(out.ok, true);
219
+ if (!out.ok) throw new Error("expected ok");
220
+ // Three distinct nodes visited despite the cycle.
221
+ assert.equal(out.hits.length, 3);
222
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
223
+ assert.deepEqual(qnames, ["cyc.a", "cyc.b", "cyc.c"]);
224
+ } finally {
225
+ await dispose(store, dir);
226
+ }
227
+ });
228
+
229
+ test("traverse: self-edge never re-expands the frontier (cycle safety)", async () => {
230
+ const { store, dir } = await tempStore();
231
+ try {
232
+ const r = await store.upsertFileBatch([cyclicFile]);
233
+ assert.equal(r.ok, true);
234
+ const out = store.traverse({ start: "cyc.b", maxDepth: 5 });
235
+ assert.equal(out.ok, true);
236
+ if (!out.ok) throw new Error("expected ok");
237
+ // Start at b: depth 0 = {b}; depth 1 = {c} (self-edge and a→b
238
+ // both skipped because b is visited); depth 2 = {a} (c→a).
239
+ // Total: 3 nodes.
240
+ assert.equal(out.hits.length, 3);
241
+ } finally {
242
+ await dispose(store, dir);
243
+ }
244
+ });
245
+
246
+ test("traverse: depth cap is half-open — depth==maxDepth INCLUDED, maxDepth+1 NOT (rule 35)", async () => {
247
+ const { store, dir } = await tempStore();
248
+ try {
249
+ const r = await store.upsertFileBatch([cyclicFile]);
250
+ assert.equal(r.ok, true);
251
+
252
+ // maxDepth 0 → just the start.
253
+ const d0 = store.traverse({ start: "cyc.a", maxDepth: 0 });
254
+ assert.equal(d0.ok, true);
255
+ if (!d0.ok) throw new Error("expected ok");
256
+ assert.equal(d0.hits.length, 1);
257
+ assert.equal(d0.hits[0]?.qualifiedName, "cyc.a");
258
+ assert.equal(d0.hits[0]?.depth, 0);
259
+
260
+ // maxDepth 1 → start + direct neighbor (b).
261
+ const d1 = store.traverse({ start: "cyc.a", maxDepth: 1 });
262
+ assert.equal(d1.ok, true);
263
+ if (!d1.ok) throw new Error("expected ok");
264
+ assert.equal(d1.hits.length, 2);
265
+ const d1Qnames = d1.hits.map((h) => h.qualifiedName).sort();
266
+ assert.deepEqual(d1Qnames, ["cyc.a", "cyc.b"]);
267
+
268
+ // maxDepth 2 → a (depth 0) + b (depth 1) + c (depth 2). The
269
+ // back-edge c → a would re-visit a at depth 3 — the cap excludes
270
+ // that (the cycle is irrelevant when the cap binds first).
271
+ const d2 = store.traverse({ start: "cyc.a", maxDepth: 2 });
272
+ assert.equal(d2.ok, true);
273
+ if (!d2.ok) throw new Error("expected ok");
274
+ assert.equal(d2.hits.length, 3);
275
+ const depthByNode = new Map(d2.hits.map((h) => [h.qualifiedName, h.depth]));
276
+ assert.equal(depthByNode.get("cyc.a"), 0);
277
+ assert.equal(depthByNode.get("cyc.b"), 1);
278
+ assert.equal(depthByNode.get("cyc.c"), 2);
279
+
280
+ // Boundary proof: maxDepth 2 hits c at depth 2; c → a (depth 3)
281
+ // is NOT included. If the cap were off-by-one (depth < maxDepth
282
+ // instead of depth <= maxDepth), c would be missing here.
283
+ } finally {
284
+ await dispose(store, dir);
285
+ }
286
+ });
287
+
288
+ test("traverse: direction=incoming follows edges backward", async () => {
289
+ const { store, dir } = await tempStore();
290
+ try {
291
+ const r = await store.upsertFileBatch([starFile]);
292
+ assert.equal(r.ok, true);
293
+
294
+ // From l1, incoming direction: who points at l1? Only center.
295
+ const out = store.traverse({
296
+ start: "star.l1",
297
+ direction: "incoming",
298
+ maxDepth: 5,
299
+ });
300
+ assert.equal(out.ok, true);
301
+ if (!out.ok) throw new Error("expected ok");
302
+ assert.equal(out.hits.length, 2);
303
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
304
+ assert.deepEqual(qnames, ["star.center", "star.l1"]);
305
+ // l1 at depth 0, center at depth 1.
306
+ const byName = new Map(out.hits.map((h) => [h.qualifiedName, h.depth]));
307
+ assert.equal(byName.get("star.l1"), 0);
308
+ assert.equal(byName.get("star.center"), 1);
309
+ } finally {
310
+ await dispose(store, dir);
311
+ }
312
+ });
313
+
314
+ test("traverse: direction=both expands in + out per level", async () => {
315
+ const { store, dir } = await tempStore();
316
+ try {
317
+ const r = await store.upsertFileBatch([starFile]);
318
+ assert.equal(r.ok, true);
319
+
320
+ // From center with direction=both at depth 1: each leaf is
321
+ // reachable outgoing. No incoming edges land on center.
322
+ const out = store.traverse({
323
+ start: "star.center",
324
+ direction: "both",
325
+ maxDepth: 1,
326
+ });
327
+ assert.equal(out.ok, true);
328
+ if (!out.ok) throw new Error("expected ok");
329
+ assert.equal(out.hits.length, 5);
330
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
331
+ assert.deepEqual(qnames, [
332
+ "star.center",
333
+ "star.l1",
334
+ "star.l2",
335
+ "star.l3",
336
+ "star.l4",
337
+ ]);
338
+ } finally {
339
+ await dispose(store, dir);
340
+ }
341
+ });
342
+
343
+ test("traverse: edgeTypes filter restricts the frontier", async () => {
344
+ const { store, dir } = await tempStore();
345
+ try {
346
+ // Two edge types: CALLS and USES_TYPE. Restricting to USES_TYPE
347
+ // should yield zero hops because no USES_TYPE edges exist.
348
+ const mixed: StoreFileIR = {
349
+ path: "src/mixed.ts",
350
+ language: "typescript",
351
+ contentHash: "h-mixed",
352
+ symbols: [sym("m.a", "a", 0, 10), sym("m.b", "b", 10, 20)],
353
+ edges: [
354
+ edge("m.a", "m.b", "CALLS"),
355
+ edge("m.a", "m.b", "USES_TYPE"),
356
+ ],
357
+ };
358
+ const r = await store.upsertFileBatch([mixed]);
359
+ assert.equal(r.ok, true);
360
+
361
+ const onlyUses = store.traverse({
362
+ start: "m.a",
363
+ edgeTypes: ["USES_TYPE"],
364
+ maxDepth: 5,
365
+ });
366
+ assert.equal(onlyUses.ok, true);
367
+ if (!onlyUses.ok) throw new Error("expected ok");
368
+ // USES_TYPE edge a → b exists, so we should reach b.
369
+ assert.equal(onlyUses.hits.length, 2);
370
+
371
+ const onlyAsync = store.traverse({
372
+ start: "m.a",
373
+ edgeTypes: ["ASYNC_CALLS"],
374
+ maxDepth: 5,
375
+ });
376
+ assert.equal(onlyAsync.ok, true);
377
+ if (!onlyAsync.ok) throw new Error("expected ok");
378
+ // No ASYNC_CALLS edges → only the start node.
379
+ assert.equal(onlyAsync.hits.length, 1);
380
+ assert.equal(onlyAsync.hits[0]?.qualifiedName, "m.a");
381
+ } finally {
382
+ await dispose(store, dir);
383
+ }
384
+ });
385
+
386
+ test("traverse: unknown start returns tagged failure (rule 34)", async () => {
387
+ const { store, dir } = await tempStore();
388
+ try {
389
+ const out = store.traverse({ start: "does.not.exist", maxDepth: 3 });
390
+ assert.equal(out.ok, false);
391
+ if (out.ok) throw new Error("expected failure");
392
+ assert.equal(out.code, "unknown_start");
393
+ } finally {
394
+ await dispose(store, dir);
395
+ }
396
+ });
397
+
398
+ test("traverse: invalid maxDepth rejected with code 'invalid_query' (rule 51)", async () => {
399
+ const { store, dir } = await tempStore();
400
+ try {
401
+ const r = await store.upsertFileBatch([cyclicFile]);
402
+ assert.equal(r.ok, true);
403
+
404
+ // Negative, fractional, NaN — all rejected, not silently clamped.
405
+ const cases: [number, string][] = [
406
+ [-1, "negative"],
407
+ [1.5, "fractional"],
408
+ [Number.NaN, "NaN"],
409
+ ];
410
+ for (const [v, label] of cases) {
411
+ const out = store.traverse({ start: "cyc.a", maxDepth: v });
412
+ assert.equal(out.ok, false, `${label} should fail`);
413
+ if (out.ok) throw new Error(`expected failure for ${label}`);
414
+ assert.equal(out.code, "invalid_query", `${label} → invalid_query`);
415
+ }
416
+ } finally {
417
+ await dispose(store, dir);
418
+ }
419
+ });
420
+
421
+ test("traverse: ambiguous start (qualified name in two files) is rejected with 'ambiguous_start'", async () => {
422
+ const { store, dir } = await tempStore();
423
+ try {
424
+ // Same qualified name declared in two files → ambiguous.
425
+ const file1: StoreFileIR = {
426
+ path: "src/f1.ts",
427
+ language: "typescript",
428
+ contentHash: "h1",
429
+ symbols: [sym("dup.name", "name", 0, 10)],
430
+ };
431
+ const file2: StoreFileIR = {
432
+ path: "src/f2.ts",
433
+ language: "typescript",
434
+ contentHash: "h2",
435
+ symbols: [sym("dup.name", "name", 0, 10)],
436
+ };
437
+ const r = await store.upsertFileBatch([file1, file2]);
438
+ assert.equal(r.ok, true);
439
+
440
+ const out = store.traverse({ start: "dup.name", maxDepth: 3 });
441
+ assert.equal(out.ok, false);
442
+ if (out.ok) throw new Error("expected failure");
443
+ assert.equal(out.code, "ambiguous_start");
444
+ } finally {
445
+ await dispose(store, dir);
446
+ }
447
+ });
448
+
449
+ test("traverse: store_closed when the store is closed", async () => {
450
+ const { store, dir } = await tempStore();
451
+ try {
452
+ const r = await store.upsertFileBatch([cyclicFile]);
453
+ assert.equal(r.ok, true);
454
+ await store.close();
455
+ const out = store.traverse({ start: "cyc.a", maxDepth: 3 });
456
+ assert.equal(out.ok, false);
457
+ if (out.ok) throw new Error("expected failure");
458
+ assert.equal(out.code, "store_closed");
459
+ } finally {
460
+ await rm(dir, { recursive: true, force: true });
461
+ }
462
+ });
463
+
464
+ test("traverse: invalid direction rejected with code 'invalid_query' (chatgpt-codex-connector P2)", async () => {
465
+ const { store, dir } = await tempStore();
466
+ try {
467
+ const r = await store.upsertFileBatch([cyclicFile]);
468
+ assert.equal(r.ok, true);
469
+
470
+ // A typo like "outbound" must NOT silently return only the start
471
+ // node — it must surface as invalid_query so the caller learns.
472
+ const out = store.traverse({
473
+ start: "cyc.a",
474
+ maxDepth: 3,
475
+ // @ts-expect-error — deliberately invalid direction at runtime
476
+ direction: "outbound",
477
+ });
478
+ assert.equal(out.ok, false);
479
+ if (out.ok) throw new Error("expected failure");
480
+ assert.equal(out.code, "invalid_query");
481
+ } finally {
482
+ await dispose(store, dir);
483
+ }
484
+ });
485
+
486
+ test("traverse: a 64-hex node id resolves by id only, never conflated with a qualified_name (cursor Bugbot: 'Traverse start conflates id and name')", async () => {
487
+ const { store, dir } = await tempStore();
488
+ try {
489
+ const r = await store.upsertFileBatch([cyclicFile]);
490
+ assert.equal(r.ok, true);
491
+
492
+ // Look up the actual node id for cyc.a via searchGraph.
493
+ const search = store.searchGraph({ namePattern: "a" });
494
+ assert.equal(search.ok, true);
495
+ if (!search.ok) throw new Error("expected ok");
496
+ const cycA = search.hits.find((h) => h.qualifiedName === "cyc.a");
497
+ assert.ok(cycA, "cyc.a should be in the graph");
498
+ // Node ids are 64-char lowercase hex (sha256).
499
+ assert.match(cycA.nodeId, /^[0-9a-f]{64}$/);
500
+
501
+ // Traverse by node id → resolves uniquely to cyc.a, NOT ambiguous.
502
+ const byId = store.traverse({ start: cycA.nodeId, maxDepth: 1 });
503
+ assert.equal(byId.ok, true);
504
+ if (!byId.ok) throw new Error("expected ok");
505
+ assert.equal(byId.hits.length, 2, "cyc.a + its direct neighbor at depth 1");
506
+
507
+ // A 64-hex string that is NOT any node's id → unknown_start, even
508
+ // though the old `WHERE id = ? OR qualified_name = ?` would have
509
+ // returned [] here too. The point is the id path no longer falls
510
+ // through to the qualified_name path.
511
+ const bogusId = "0".repeat(64);
512
+ const miss = store.traverse({ start: bogusId, maxDepth: 3 });
513
+ assert.equal(miss.ok, false);
514
+ if (miss.ok) throw new Error("expected failure");
515
+ assert.equal(miss.code, "unknown_start");
516
+ } finally {
517
+ await dispose(store, dir);
518
+ }
519
+ });
520
+
521
+ test("traverse: malformed edgeTypes (non-array or non-string element) rejected with 'invalid_query' (chatgpt-codex-connector P2)", async () => {
522
+ const { store, dir } = await tempStore();
523
+ try {
524
+ const r = await store.upsertFileBatch([cyclicFile]);
525
+ assert.equal(r.ok, true);
526
+
527
+ // A bare string instead of an array would throw at .map() without
528
+ // this guard. Now it surfaces as invalid_query.
529
+ const asString = store.traverse({
530
+ start: "cyc.a",
531
+ maxDepth: 3,
532
+ // @ts-expect-error — deliberately malformed at runtime
533
+ edgeTypes: "CALLS",
534
+ });
535
+ assert.equal(asString.ok, false);
536
+ if (asString.ok) throw new Error("expected failure");
537
+ assert.equal(asString.code, "invalid_query");
538
+
539
+ // An array with a non-string element is also rejected.
540
+ const withNumber = store.traverse({
541
+ start: "cyc.a",
542
+ maxDepth: 3,
543
+ // @ts-expect-error — deliberately malformed at runtime
544
+ edgeTypes: ["CALLS", 42],
545
+ });
546
+ assert.equal(withNumber.ok, false);
547
+ if (withNumber.ok) throw new Error("expected failure");
548
+ assert.equal(withNumber.code, "invalid_query");
549
+ } finally {
550
+ await dispose(store, dir);
551
+ }
552
+ });
553
+
554
+ // ──────────────────────────────────────────────────────────────────────────
555
+ // traversePaths(): path-enumerating traversal (issue #1650). Unlike
556
+ // traverse()'s BFS (one hit per node at shortest depth), this primitive
557
+ // yields one hit per distinct relationship-simple path, so an exact `*N`
558
+ // (N > 1) honors length-N paths to nodes also reachable at a shorter depth.
559
+ // ──────────────────────────────────────────────────────────────────────────
560
+
561
+ // Fixture: a graph with multiple paths to the same endpoint.
562
+ // a -> b (b reachable at len 1)
563
+ // a -> c
564
+ // c -> b (b ALSO reachable at len 2 via a->c->b)
565
+ // b -> d
566
+ // c -> d (d reachable at len 2 via TWO paths: a->b->d, a->c->d)
567
+ const multiPathFile: StoreFileIR = {
568
+ path: "src/multipath.ts",
569
+ language: "typescript",
570
+ contentHash: "h-multipath",
571
+ symbols: [
572
+ sym("mp.a", "a", 0, 10),
573
+ sym("mp.b", "b", 10, 20),
574
+ sym("mp.c", "c", 20, 30),
575
+ sym("mp.d", "d", 30, 40),
576
+ ],
577
+ edges: [
578
+ edge("mp.a", "mp.b"),
579
+ edge("mp.a", "mp.c"),
580
+ edge("mp.c", "mp.b"),
581
+ edge("mp.b", "mp.d"),
582
+ edge("mp.c", "mp.d"),
583
+ ],
584
+ };
585
+
586
+ test("traversePaths: endpoint reachable at len 1 AND len 2 yields a hit for each path (issue #1650 core)", async () => {
587
+ const { store, dir } = await tempStore();
588
+ try {
589
+ const r = await store.upsertFileBatch([multiPathFile]);
590
+ assert.equal(r.ok, true);
591
+ const out = store.traversePaths({ start: "mp.a", maxHops: 2 });
592
+ assert.equal(out.ok, true);
593
+ if (!out.ok) throw new Error("expected ok");
594
+ assert.equal(out.truncated, false);
595
+ // len1: a->b, a->c ; len2: a->b->d, a->c->b, a->c->d => 5 paths.
596
+ assert.equal(out.hits.length, 5);
597
+ // b is an endpoint at BOTH length 1 (a->b) and length 2 (a->c->b).
598
+ // BFS traverse would visit b only once at depth 1.
599
+ const bHits = out.hits.filter((h) => h.qualifiedName === "mp.b");
600
+ assert.equal(bHits.length, 2, "b must appear once per distinct path");
601
+ const bLengths = bHits.map((h) => h.length).sort();
602
+ assert.deepEqual(bLengths, [1, 2]);
603
+ // The length-2 hit to b must trace a->c->b.
604
+ const bLen2 = bHits.find((h) => h.length === 2)!;
605
+ assert.equal(bLen2.nodeIds.length, 3);
606
+ // The length-2 path to b is a->c->b: middle node is c, and every hop
607
+ // is a CALLS edge (kilo-code-bot: the prior line was a tautology).
608
+ const cHit = out.hits.find((h) => h.qualifiedName === "mp.c" && h.length === 1)!;
609
+ assert.equal(bLen2.nodeIds[1], cHit.nodeId, "length-2 path to b is a->c->b");
610
+ assert.equal(bLen2.nodeIds[0], out.hits[0]!.nodeIds[0], "path starts at the start node");
611
+ assert.deepEqual(bLen2.edgeTypes, ["CALLS", "CALLS"]);
612
+ } finally {
613
+ await dispose(store, dir);
614
+ }
615
+ });
616
+
617
+ test("traversePaths: diamond yields two length-2 paths to the far node", async () => {
618
+ const { store, dir } = await tempStore();
619
+ try {
620
+ const r = await store.upsertFileBatch([multiPathFile]);
621
+ assert.equal(r.ok, true);
622
+ const out = store.traversePaths({ start: "mp.a", maxHops: 2 });
623
+ assert.equal(out.ok, true);
624
+ if (!out.ok) throw new Error("expected ok");
625
+ const dHits = out.hits.filter((h) => h.qualifiedName === "mp.d");
626
+ assert.equal(dHits.length, 2, "d reachable via two distinct len-2 paths");
627
+ assert.ok(dHits.every((h) => h.length === 2));
628
+ // The two paths are a->b->d and a->c->d (distinct middle node).
629
+ const middles = new Set(dHits.map((h) => h.nodeIds[1]));
630
+ assert.equal(middles.size, 2, "two distinct middle nodes (b and c)");
631
+ } finally {
632
+ await dispose(store, dir);
633
+ }
634
+ });
635
+
636
+ test("traversePaths: relationship-uniqueness terminates on a cyclic graph and caps path length", async () => {
637
+ const { store, dir } = await tempStore();
638
+ try {
639
+ const r = await store.upsertFileBatch([cyclicFile]);
640
+ assert.equal(r.ok, true);
641
+ // cyclicFile: a->b->c->a plus a self-edge on b. Relationship-uniqueness
642
+ // must bound enumeration (each edge used once per path) so this completes.
643
+ const out = store.traversePaths({ start: "cyc.a", maxHops: 5 });
644
+ assert.equal(out.ok, true);
645
+ if (!out.ok) throw new Error("expected ok");
646
+ assert.ok(out.hits.length > 0, "should enumerate at least the len-1 path");
647
+ // No path exceeds maxHops.
648
+ assert.ok(out.hits.every((h) => h.length <= 5));
649
+ // Every hit's nodeIds reconstructs to length+1 entries.
650
+ assert.ok(out.hits.every((h) => h.nodeIds.length === h.length + 1));
651
+ // relationship-uniqueness allows revisiting a node via a DISTINCT edge:
652
+ // a->b->c->a is valid (edges a-b, b-c, c-a all different) and reaches a
653
+ // at length 3. Node-uniqueness would block this.
654
+ const backToA = out.hits.find((h) => h.qualifiedName === "cyc.a" && h.length === 3);
655
+ assert.ok(backToA, "a->b->c->a (len 3) must be enumerated under relationship-uniqueness");
656
+ assert.deepEqual(backToA!.nodeIds.length, 4);
657
+ } finally {
658
+ await dispose(store, dir);
659
+ }
660
+ });
661
+
662
+ test("traversePaths: maxPaths cap sets truncated and bounds the hit count", async () => {
663
+ const { store, dir } = await tempStore();
664
+ try {
665
+ const r = await store.upsertFileBatch([multiPathFile]);
666
+ assert.equal(r.ok, true);
667
+ // 5 distinct paths of length <= 2 exist from a; cap at 2.
668
+ const out = store.traversePaths({ start: "mp.a", maxHops: 2, maxPaths: 2 });
669
+ assert.equal(out.ok, true);
670
+ if (!out.ok) throw new Error("expected ok");
671
+ assert.equal(out.truncated, true, "enumeration must flag truncation");
672
+ assert.equal(out.hits.length, 2, "hit count must not exceed the cap");
673
+ } finally {
674
+ await dispose(store, dir);
675
+ }
676
+ });
677
+
678
+ test("traversePaths: minHops gates the cap -- shorter prefixes do not consume maxPaths (cursor Bugbot)", async () => {
679
+ const { store, dir } = await tempStore();
680
+ try {
681
+ const r = await store.upsertFileBatch([multiPathFile]);
682
+ assert.equal(r.ok, true);
683
+ // *2 (minHops 2, maxHops 2) with a tight maxPaths. Three length-2 paths
684
+ // exist (a->b->d, a->c->b, a->c->d); two length-1 paths exist (a->b,
685
+ // a->c) but must NOT count toward the cap. With maxPaths 2 we expect 2
686
+ // LENGTH-2 hits (truncated), and zero length-1 hits.
687
+ const out = store.traversePaths({ start: "mp.a", minHops: 2, maxHops: 2, maxPaths: 2 });
688
+ assert.equal(out.ok, true);
689
+ if (!out.ok) throw new Error("expected ok");
690
+ assert.equal(out.truncated, true, "cap is hit by in-range length-2 paths");
691
+ assert.equal(out.hits.length, 2);
692
+ assert.ok(out.hits.every((h) => h.length === 2), "no shorter prefix consumes the cap");
693
+ } finally {
694
+ await dispose(store, dir);
695
+ }
696
+ });
697
+
698
+ test("traversePaths: minHops emits only in-range paths (no cap)", async () => {
699
+ const { store, dir } = await tempStore();
700
+ try {
701
+ const r = await store.upsertFileBatch([multiPathFile]);
702
+ assert.equal(r.ok, true);
703
+ // minHops 2, maxHops 2, default cap: all three length-2 paths, no length-1.
704
+ const out = store.traversePaths({ start: "mp.a", minHops: 2, maxHops: 2 });
705
+ assert.equal(out.ok, true);
706
+ if (!out.ok) throw new Error("expected ok");
707
+ assert.equal(out.truncated, false);
708
+ assert.equal(out.hits.length, 3);
709
+ assert.ok(out.hits.every((h) => h.length === 2));
710
+ } finally {
711
+ await dispose(store, dir);
712
+ }
713
+ });
714
+
715
+ test("traversePaths: edgeTypes exposes the relationship type per hop (chatgpt-codex-connector P2)", async () => {
716
+ const { store, dir } = await tempStore();
717
+ try {
718
+ const mixed: StoreFileIR = {
719
+ path: "src/mixed2.ts",
720
+ language: "typescript",
721
+ contentHash: "h-mixed2",
722
+ symbols: [sym("mx.a", "a", 0, 10), sym("mx.b", "b", 10, 20)],
723
+ edges: [
724
+ edge("mx.a", "mx.b", "CALLS"),
725
+ edge("mx.a", "mx.b", "USES_TYPE"),
726
+ ],
727
+ };
728
+ const r = await store.upsertFileBatch([mixed]);
729
+ assert.equal(r.ok, true);
730
+ // Two distinct relationships a->b (CALLS, USES_TYPE) yield two distinct
731
+ // relationship-simple paths with identical nodeIds but different edgeTypes.
732
+ const out = store.traversePaths({ start: "mx.a", maxHops: 1 });
733
+ assert.equal(out.ok, true);
734
+ if (!out.ok) throw new Error("expected ok");
735
+ assert.equal(out.hits.length, 2, "two distinct edges -> two distinct paths");
736
+ const typeSets = out.hits.map((h) => h.edgeTypes).sort();
737
+ assert.deepEqual(typeSets, [["CALLS"], ["USES_TYPE"]]);
738
+ // nodeIds are identical; only edgeTypes distinguishes them.
739
+ assert.deepEqual(out.hits[0]!.nodeIds, out.hits[1]!.nodeIds);
740
+ } finally {
741
+ await dispose(store, dir);
742
+ }
743
+ });
744
+
745
+ test("traversePaths: malformed maxPaths rejected with 'invalid_query' (chatgpt-codex-connector P2)", async () => {
746
+ const { store, dir } = await tempStore();
747
+ try {
748
+ const r = await store.upsertFileBatch([multiPathFile]);
749
+ assert.equal(r.ok, true);
750
+ for (const v of [-1, 1.5, Number.NaN, "100" as never, true as never] as const) {
751
+ const out = store.traversePaths({ start: "mp.a", maxHops: 2, maxPaths: v });
752
+ assert.equal(out.ok, false, `maxPaths ${JSON.stringify(v)} should fail`);
753
+ if (out.ok) throw new Error("expected failure");
754
+ assert.equal(out.code, "invalid_query");
755
+ }
756
+ // undefined still defaults (no rejection).
757
+ const ok = store.traversePaths({ start: "mp.a", maxHops: 1, maxPaths: undefined });
758
+ assert.equal(ok.ok, true);
759
+ } finally {
760
+ await dispose(store, dir);
761
+ }
762
+ });
763
+
764
+ test("traversePaths: maxHops above MAX_TRAVERSE_PATHS_HOPS rejected (stack-overflow guard)", async () => {
765
+ const { store, dir } = await tempStore();
766
+ try {
767
+ const r = await store.upsertFileBatch([multiPathFile]);
768
+ assert.equal(r.ok, true);
769
+ // A pathological depth (e.g. *15000) would overflow the recursive DFS;
770
+ // the store rejects it instead of crashing (chatgpt-codex-connector P2).
771
+ const out = store.traversePaths({ start: "mp.a", maxHops: 15000 });
772
+ assert.equal(out.ok, false);
773
+ if (out.ok) throw new Error("expected failure");
774
+ assert.equal(out.code, "invalid_query");
775
+ } finally {
776
+ await dispose(store, dir);
777
+ }
778
+ });
779
+
780
+ test("traversePaths: edgeEndpoints disambiguates antiparallel edges under direction both", async () => {
781
+ const { store, dir } = await tempStore();
782
+ try {
783
+ // A <-> B (both CALLS): two antiparallel edges. From A, direction both,
784
+ // maxHops 1: hop A->B can be via edge (A,B) outgoing OR edge (B,A)
785
+ // incoming -- same nodeIds [A,B], same edgeTypes [CALLS], distinct
786
+ // edgeEndpoints (chatgpt-codex-connector P2: 'Include edge endpoints').
787
+ const antiFile: StoreFileIR = {
788
+ path: "src/anti.ts",
789
+ language: "typescript",
790
+ contentHash: "h-anti",
791
+ symbols: [sym("an.a", "a", 0, 10), sym("an.b", "b", 10, 20)],
792
+ edges: [edge("an.a", "an.b"), edge("an.b", "an.a")],
793
+ };
794
+ const r = await store.upsertFileBatch([antiFile]);
795
+ assert.equal(r.ok, true);
796
+ const out = store.traversePaths({ start: "an.a", direction: "both", maxHops: 1 });
797
+ assert.equal(out.ok, true);
798
+ if (!out.ok) throw new Error("expected ok");
799
+ assert.equal(out.hits.length, 2, "two distinct antiparallel edges -> two paths");
800
+ // Same node sequence + edge type, but distinct per-hop endpoints.
801
+ assert.deepEqual(out.hits[0]!.nodeIds, out.hits[1]!.nodeIds);
802
+ assert.deepEqual(out.hits[0]!.edgeTypes, out.hits[1]!.edgeTypes);
803
+ const ep = out.hits.map((h) => h.edgeEndpoints[0]).sort((x, y) =>
804
+ x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
805
+ assert.equal(ep.length, 2);
806
+ assert.notDeepEqual(ep[0], ep[1], "edge endpoints must differ");
807
+ } finally {
808
+ await dispose(store, dir);
809
+ }
810
+ });
811
+
812
+ test("traversePaths: malformed minHops rejected with 'invalid_query'", async () => {
813
+ const { store, dir } = await tempStore();
814
+ try {
815
+ const r = await store.upsertFileBatch([multiPathFile]);
816
+ assert.equal(r.ok, true);
817
+ for (const v of [0, -1, 1.5, Number.NaN] as const) {
818
+ const out = store.traversePaths({ start: "mp.a", minHops: v, maxHops: 3 });
819
+ assert.equal(out.ok, false, `minHops ${v} should fail`);
820
+ if (out.ok) throw new Error("expected failure");
821
+ assert.equal(out.code, "invalid_query");
822
+ }
823
+ } finally {
824
+ await dispose(store, dir);
825
+ }
826
+ });
827
+
828
+ test("traversePaths: maxHops 0 yields no paths (every path has length >= 1)", async () => {
829
+ const { store, dir } = await tempStore();
830
+ try {
831
+ const r = await store.upsertFileBatch([multiPathFile]);
832
+ assert.equal(r.ok, true);
833
+ const out = store.traversePaths({ start: "mp.a", maxHops: 0 });
834
+ assert.equal(out.ok, true);
835
+ if (!out.ok) throw new Error("expected ok");
836
+ assert.equal(out.hits.length, 0);
837
+ assert.equal(out.truncated, false);
838
+ } finally {
839
+ await dispose(store, dir);
840
+ }
841
+ });
842
+
843
+ test("traversePaths: direction=incoming follows edges backward", async () => {
844
+ const { store, dir } = await tempStore();
845
+ try {
846
+ const r = await store.upsertFileBatch([multiPathFile]);
847
+ assert.equal(r.ok, true);
848
+ // Who points at b within 1 hop? a (a->b) and c (c->b).
849
+ const out = store.traversePaths({
850
+ start: "mp.b",
851
+ direction: "incoming",
852
+ maxHops: 1,
853
+ });
854
+ assert.equal(out.ok, true);
855
+ if (!out.ok) throw new Error("expected ok");
856
+ assert.equal(out.hits.length, 2);
857
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
858
+ assert.deepEqual(qnames, ["mp.a", "mp.c"]);
859
+ } finally {
860
+ await dispose(store, dir);
861
+ }
862
+ });
863
+
864
+ test("traversePaths: direction=both does not double a self-loop (one hit per distinct path)", async () => {
865
+ const { store, dir } = await tempStore();
866
+ try {
867
+ // A single self-loop edge s->s is matched by BOTH the outgoing and
868
+ // incoming SELECTs under direction "both"; without dedup the same
869
+ // relationship-simple path is emitted twice (chatgpt-codex-connector
870
+ // P2: 'Deduplicate self-loop edges for both-direction traversal').
871
+ const selfFile: StoreFileIR = {
872
+ path: "src/self.ts",
873
+ language: "typescript",
874
+ contentHash: "h-self",
875
+ symbols: [sym("sl.s", "s", 0, 10)],
876
+ edges: [edge("sl.s", "sl.s")],
877
+ };
878
+ const r = await store.upsertFileBatch([selfFile]);
879
+ assert.equal(r.ok, true);
880
+ const out = store.traversePaths({ start: "sl.s", direction: "both", maxHops: 1 });
881
+ assert.equal(out.ok, true);
882
+ if (!out.ok) throw new Error("expected ok");
883
+ assert.equal(out.hits.length, 1, "self-loop must yield exactly one path under direction both");
884
+ assert.equal(out.hits[0]?.length, 1);
885
+ assert.equal(out.hits[0]?.qualifiedName, "sl.s");
886
+ } finally {
887
+ await dispose(store, dir);
888
+ }
889
+ });
890
+
891
+ test("traversePaths: edgeTypes filter restricts enumerated paths", async () => {
892
+ const { store, dir } = await tempStore();
893
+ try {
894
+ const r = await store.upsertFileBatch([multiPathFile]);
895
+ assert.equal(r.ok, true);
896
+ const out = store.traversePaths({
897
+ start: "mp.a",
898
+ edgeTypes: ["USES_TYPE"],
899
+ maxHops: 2,
900
+ });
901
+ assert.equal(out.ok, true);
902
+ if (!out.ok) throw new Error("expected ok");
903
+ assert.equal(out.hits.length, 0, "no USES_TYPE edges => no paths");
904
+ } finally {
905
+ await dispose(store, dir);
906
+ }
907
+ });
908
+
909
+ test("traversePaths: unknown start returns tagged failure (rule 34)", async () => {
910
+ const { store, dir } = await tempStore();
911
+ try {
912
+ const out = store.traversePaths({ start: "does.not.exist", maxHops: 3 });
913
+ assert.equal(out.ok, false);
914
+ if (out.ok) throw new Error("expected failure");
915
+ assert.equal(out.code, "unknown_start");
916
+ } finally {
917
+ await dispose(store, dir);
918
+ }
919
+ });
920
+
921
+ test("traversePaths: invalid maxHops rejected with 'invalid_query' (rule 51)", async () => {
922
+ const { store, dir } = await tempStore();
923
+ try {
924
+ const r = await store.upsertFileBatch([multiPathFile]);
925
+ assert.equal(r.ok, true);
926
+ for (const v of [-1, 1.5, Number.NaN] as const) {
927
+ const out = store.traversePaths({ start: "mp.a", maxHops: v });
928
+ assert.equal(out.ok, false);
929
+ if (out.ok) throw new Error("expected failure");
930
+ assert.equal(out.code, "invalid_query");
931
+ }
932
+ } finally {
933
+ await dispose(store, dir);
934
+ }
935
+ });
936
+
937
+ test("traversePaths: invalid direction / edgeTypes / start rejected with 'invalid_query'", async () => {
938
+ const { store, dir } = await tempStore();
939
+ try {
940
+ const r = await store.upsertFileBatch([multiPathFile]);
941
+ assert.equal(r.ok, true);
942
+ const badDir = store.traversePaths({
943
+ start: "mp.a",
944
+ direction: "sideways" as never,
945
+ maxHops: 2,
946
+ });
947
+ assert.equal(badDir.ok, false);
948
+ if (badDir.ok) throw new Error("expected failure");
949
+ assert.equal(badDir.code, "invalid_query");
950
+
951
+ const badTypes = store.traversePaths({
952
+ start: "mp.a",
953
+ edgeTypes: "CALLS" as never,
954
+ maxHops: 2,
955
+ });
956
+ assert.equal(badTypes.ok, false);
957
+ if (badTypes.ok) throw new Error("expected failure");
958
+ assert.equal(badTypes.code, "invalid_query");
959
+
960
+ const badStart = store.traversePaths({ start: "", maxHops: 2 });
961
+ assert.equal(badStart.ok, false);
962
+ if (badStart.ok) throw new Error("expected failure");
963
+ assert.equal(badStart.code, "invalid_query");
964
+ } finally {
965
+ await dispose(store, dir);
966
+ }
967
+ });
968
+
969
+ test("traversePaths: store_closed when the store is closed", async () => {
970
+ const { store, dir } = await tempStore();
971
+ try {
972
+ const r = await store.upsertFileBatch([multiPathFile]);
973
+ assert.equal(r.ok, true);
974
+ } finally {
975
+ await dispose(store, dir);
976
+ }
977
+ // store is now closed after dispose.
978
+ const out = store.traversePaths({ start: "mp.a", maxHops: 2 });
979
+ assert.equal(out.ok, false);
980
+ if (out.ok) throw new Error("expected failure");
981
+ assert.equal(out.code, "store_closed");
982
+ });
983
+
984
+ test("traversePaths: rejects a null/undefined query object with 'invalid_query'", async () => {
985
+ const { store, dir } = await tempStore();
986
+ try {
987
+ const r = await store.upsertFileBatch([multiPathFile]);
988
+ assert.equal(r.ok, true);
989
+ for (const bad of [null, undefined] as const) {
990
+ const out = store.traversePaths(bad as never);
991
+ assert.equal(out.ok, false);
992
+ if (out.ok) throw new Error("expected failure");
993
+ assert.equal(out.code, "invalid_query");
994
+ }
995
+ } finally {
996
+ await dispose(store, dir);
997
+ }
998
+ });
999
+
1000
+ // ──────────────────────────────────────────────────────────────────────────
1001
+ // searchGraph(): label / name / file patterns + degree filters + limit.
1002
+ // ──────────────────────────────────────────────────────────────────────────
1003
+
1004
+ test("searchGraph: empty query returns every node up to the default limit", async () => {
1005
+ const { store, dir } = await tempStore();
1006
+ try {
1007
+ const r = await store.upsertFileBatch([starFile]);
1008
+ assert.equal(r.ok, true);
1009
+
1010
+ const out = store.searchGraph({});
1011
+ assert.equal(out.ok, true);
1012
+ if (!out.ok) throw new Error("expected ok");
1013
+ assert.equal(out.hits.length, 5);
1014
+ } finally {
1015
+ await dispose(store, dir);
1016
+ }
1017
+ });
1018
+
1019
+ test("searchGraph: namePattern uses LIKE % wildcards (case-insensitive)", async () => {
1020
+ const { store, dir } = await tempStore();
1021
+ try {
1022
+ const r = await store.upsertFileBatch([starFile]);
1023
+ assert.equal(r.ok, true);
1024
+
1025
+ const out = store.searchGraph({ namePattern: "l%" });
1026
+ assert.equal(out.ok, true);
1027
+ if (!out.ok) throw new Error("expected ok");
1028
+ // l1, l2, l3, l4 — four matches.
1029
+ assert.equal(out.hits.length, 4);
1030
+ for (const h of out.hits) {
1031
+ assert.ok(h.name.startsWith("l"));
1032
+ }
1033
+
1034
+ // Case-insensitive: CENTER should match `center`.
1035
+ const upper = store.searchGraph({ namePattern: "CENTER" });
1036
+ assert.equal(upper.ok, true);
1037
+ if (!upper.ok) throw new Error("expected ok");
1038
+ assert.equal(upper.hits.length, 1);
1039
+ assert.equal(upper.hits[0]?.name, "center");
1040
+ } finally {
1041
+ await dispose(store, dir);
1042
+ }
1043
+ });
1044
+
1045
+ test("searchGraph: filePattern filters by repo-relative file path", async () => {
1046
+ const { store, dir } = await tempStore();
1047
+ try {
1048
+ const r = await store.upsertFileBatch([starFile, cyclicFile]);
1049
+ assert.equal(r.ok, true);
1050
+
1051
+ const out = store.searchGraph({ filePattern: "src/star%" });
1052
+ assert.equal(out.ok, true);
1053
+ if (!out.ok) throw new Error("expected ok");
1054
+ assert.equal(out.hits.length, 5);
1055
+ for (const h of out.hits) {
1056
+ assert.equal(h.filePath, "src/star.ts");
1057
+ }
1058
+ } finally {
1059
+ await dispose(store, dir);
1060
+ }
1061
+ });
1062
+
1063
+ test("searchGraph: label filter restricts by symbol kind", async () => {
1064
+ const { store, dir } = await tempStore();
1065
+ try {
1066
+ const mixed: StoreFileIR = {
1067
+ path: "src/kinds.ts",
1068
+ language: "typescript",
1069
+ contentHash: "h-kinds",
1070
+ symbols: [
1071
+ sym("k.fn", "fn", 0, 10, "function"),
1072
+ sym("k.cls", "cls", 10, 20, "class"),
1073
+ ],
1074
+ };
1075
+ const r = await store.upsertFileBatch([mixed]);
1076
+ assert.equal(r.ok, true);
1077
+
1078
+ const onlyFn = store.searchGraph({ label: "function" });
1079
+ assert.equal(onlyFn.ok, true);
1080
+ if (!onlyFn.ok) throw new Error("expected ok");
1081
+ assert.equal(onlyFn.hits.length, 1);
1082
+ assert.equal(onlyFn.hits[0]?.label, "function");
1083
+
1084
+ const onlyClass = store.searchGraph({ label: "class" });
1085
+ assert.equal(onlyClass.ok, true);
1086
+ if (!onlyClass.ok) throw new Error("expected ok");
1087
+ assert.equal(onlyClass.hits.length, 1);
1088
+ assert.equal(onlyClass.hits[0]?.label, "class");
1089
+ } finally {
1090
+ await dispose(store, dir);
1091
+ }
1092
+ });
1093
+
1094
+ test("searchGraph: degreeMin/Max filter on in+out edge count", async () => {
1095
+ const { store, dir } = await tempStore();
1096
+ try {
1097
+ const r = await store.upsertFileBatch([starFile]);
1098
+ assert.equal(r.ok, true);
1099
+
1100
+ // center has degree 4 (4 outbound CALLS); leaves have degree 1.
1101
+ // Note: the unique(src,dst,type) constraint means each leaf also
1102
+ // has degree 1 (one inbound edge).
1103
+ const onlyHigh = store.searchGraph({ degreeMin: 4 });
1104
+ assert.equal(onlyHigh.ok, true);
1105
+ if (!onlyHigh.ok) throw new Error("expected ok");
1106
+ assert.equal(onlyHigh.hits.length, 1);
1107
+ assert.equal(onlyHigh.hits[0]?.qualifiedName, "star.center");
1108
+ assert.equal(onlyHigh.hits[0]?.degree, 4);
1109
+
1110
+ const onlyLeaves = store.searchGraph({ degreeMin: 1, degreeMax: 1 });
1111
+ assert.equal(onlyLeaves.ok, true);
1112
+ if (!onlyLeaves.ok) throw new Error("expected ok");
1113
+ assert.equal(onlyLeaves.hits.length, 4);
1114
+ for (const h of onlyLeaves.hits) {
1115
+ assert.equal(h.degree, 1);
1116
+ }
1117
+ } finally {
1118
+ await dispose(store, dir);
1119
+ }
1120
+ });
1121
+
1122
+ test("searchGraph: limit caps the result set, 0 returns empty (rule 27)", async () => {
1123
+ const { store, dir } = await tempStore();
1124
+ try {
1125
+ const r = await store.upsertFileBatch([starFile]);
1126
+ assert.equal(r.ok, true);
1127
+
1128
+ // limit 0 → empty hits, NOT the whole graph (rule 27 guard).
1129
+ const zero = store.searchGraph({ limit: 0 });
1130
+ assert.equal(zero.ok, true);
1131
+ if (!zero.ok) throw new Error("expected ok");
1132
+ assert.equal(zero.hits.length, 0);
1133
+
1134
+ const two = store.searchGraph({ limit: 2 });
1135
+ assert.equal(two.ok, true);
1136
+ if (!two.ok) throw new Error("expected ok");
1137
+ assert.equal(two.hits.length, 2);
1138
+
1139
+ // Above the clamp cap (1000) → still clamped to 5 (the graph size).
1140
+ const big = store.searchGraph({ limit: 100 });
1141
+ assert.equal(big.ok, true);
1142
+ if (!big.ok) throw new Error("expected ok");
1143
+ assert.equal(big.hits.length, 5);
1144
+ } finally {
1145
+ await dispose(store, dir);
1146
+ }
1147
+ });
1148
+
1149
+ test("searchGraph: invalid degree / limit rejected with 'invalid_query'", async () => {
1150
+ const { store, dir } = await tempStore();
1151
+ try {
1152
+ const r = await store.upsertFileBatch([starFile]);
1153
+ assert.equal(r.ok, true);
1154
+
1155
+ const badDegree = store.searchGraph({ degreeMin: -1 });
1156
+ assert.equal(badDegree.ok, false);
1157
+ if (badDegree.ok) throw new Error("expected failure");
1158
+ assert.equal(badDegree.code, "invalid_query");
1159
+
1160
+ const inverted = store.searchGraph({ degreeMin: 5, degreeMax: 1 });
1161
+ assert.equal(inverted.ok, false);
1162
+ if (inverted.ok) throw new Error("expected failure");
1163
+ assert.equal(inverted.code, "invalid_query");
1164
+
1165
+ const fractional = store.searchGraph({ degreeMax: 1.5 });
1166
+ assert.equal(fractional.ok, false);
1167
+ if (fractional.ok) throw new Error("expected failure");
1168
+ assert.equal(fractional.code, "invalid_query");
1169
+ } finally {
1170
+ await dispose(store, dir);
1171
+ }
1172
+ });
1173
+
1174
+ test("searchGraph: non-string label/namePattern/filePattern rejected with 'invalid_query' (chatgpt-codex-connector P2)", async () => {
1175
+ const { store, dir } = await tempStore();
1176
+ try {
1177
+ const r = await store.upsertFileBatch([starFile]);
1178
+ assert.equal(r.ok, true);
1179
+
1180
+ // A non-string pattern (e.g. a number) must NOT be silently
1181
+ // dropped — its .length is undefined, so without this guard the
1182
+ // filter would be skipped and unrelated nodes returned as ok.
1183
+ for (const bad of [
1184
+ { namePattern: 42 },
1185
+ { label: 100 },
1186
+ { filePattern: 0 },
1187
+ ]) {
1188
+ const out = store.searchGraph(bad as unknown as Parameters<
1189
+ typeof store.searchGraph
1190
+ >[0]);
1191
+ assert.equal(out.ok, false);
1192
+ if (out.ok) throw new Error("expected failure");
1193
+ assert.equal(out.code, "invalid_query");
1194
+ }
1195
+ } finally {
1196
+ await dispose(store, dir);
1197
+ }
1198
+ });
1199
+
1200
+ // ──────────────────────────────────────────────────────────────────────────
1201
+ // schemaStats().
1202
+ // ──────────────────────────────────────────────────────────────────────────
1203
+
1204
+ test("schemaStats: aggregate counts over the whole graph", async () => {
1205
+ const { store, dir } = await tempStore();
1206
+ try {
1207
+ const r = await store.upsertFileBatch([starFile, cyclicFile]);
1208
+ assert.equal(r.ok, true);
1209
+
1210
+ const out = store.schemaStats();
1211
+ assert.equal(out.ok, true);
1212
+ if (!out.ok) throw new Error("expected ok");
1213
+ assert.equal(out.stats.files, 2);
1214
+ // starFile has 5 nodes; cyclicFile has 3 → 8 total.
1215
+ assert.equal(out.stats.nodes, 8);
1216
+ // starFile: 4 CALLS edges. cyclicFile: 3 CALLS + 1 self = 4.
1217
+ assert.equal(out.stats.edges, 8);
1218
+ assert.equal(out.stats.nodesByLabel.function, 8);
1219
+ assert.equal(out.stats.edgesByType.CALLS, 8);
1220
+ } finally {
1221
+ await dispose(store, dir);
1222
+ }
1223
+ });
1224
+
1225
+ test("schemaStats: empty graph returns zeros, not null", async () => {
1226
+ const { store, dir } = await tempStore();
1227
+ try {
1228
+ const out = store.schemaStats();
1229
+ assert.equal(out.ok, true);
1230
+ if (!out.ok) throw new Error("expected ok");
1231
+ assert.equal(out.stats.files, 0);
1232
+ assert.equal(out.stats.nodes, 0);
1233
+ assert.equal(out.stats.edges, 0);
1234
+ assert.deepEqual(out.stats.nodesByLabel, {});
1235
+ assert.deepEqual(out.stats.edgesByType, {});
1236
+ } finally {
1237
+ await dispose(store, dir);
1238
+ }
1239
+ });
1240
+
1241
+ // ──────────────────────────────────────────────────────────────────────────
1242
+ // deadCode(): the load-bearing exclusion test.
1243
+ // ──────────────────────────────────────────────────────────────────────────
1244
+
1245
+ test("deadCode: exported-but-uncalled NOT reported; private-uncalled IS reported", async () => {
1246
+ const { store, dir } = await tempStore();
1247
+ try {
1248
+ const r = await store.upsertFileBatch([deadCodeLibFile]);
1249
+ assert.equal(r.ok, true);
1250
+
1251
+ const out = store.deadCode();
1252
+ assert.equal(out.ok, true);
1253
+ if (!out.ok) throw new Error("expected ok");
1254
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
1255
+
1256
+ // publicApi is uncalled but exported → NOT reported.
1257
+ // alive is uncalled but exported → NOT reported.
1258
+ // privateHelper HAS an inbound CALLS edge (alive → privateHelper)
1259
+ // → not a candidate at all.
1260
+ //
1261
+ // Therefore deadCode() should report ZERO hits from this fixture.
1262
+ assert.equal(
1263
+ qnames.length,
1264
+ 0,
1265
+ `expected no dead-code hits, got: ${qnames.join(", ")}`,
1266
+ );
1267
+ } finally {
1268
+ await dispose(store, dir);
1269
+ }
1270
+ });
1271
+
1272
+ test("deadCode: private uncalled symbol IS reported", async () => {
1273
+ const { store, dir } = await tempStore();
1274
+ try {
1275
+ // A file with one private, uncalled function. No exports, no
1276
+ // inbound edges → it should appear in deadCode().
1277
+ const file: StoreFileIR = {
1278
+ path: "src/internal.ts",
1279
+ language: "typescript",
1280
+ contentHash: "h-internal",
1281
+ symbols: [
1282
+ sym("internal.dead", "dead", 0, 50),
1283
+ sym("internal.alive", "alive", 50, 100),
1284
+ ],
1285
+ // No exports → both are private.
1286
+ edges: [],
1287
+ };
1288
+ const r = await store.upsertFileBatch([file]);
1289
+ assert.equal(r.ok, true);
1290
+
1291
+ const out = store.deadCode();
1292
+ assert.equal(out.ok, true);
1293
+ if (!out.ok) throw new Error("expected ok");
1294
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
1295
+ assert.deepEqual(qnames, ["internal.alive", "internal.dead"]);
1296
+ } finally {
1297
+ await dispose(store, dir);
1298
+ }
1299
+ });
1300
+
1301
+ test("deadCode: re-ingest that REMOVES an export re-classifies the symbol as dead", async () => {
1302
+ const { store, dir } = await tempStore();
1303
+ try {
1304
+ // First ingest: publicApi is exported → not dead.
1305
+ const r1 = await store.upsertFileBatch([deadCodeLibFile]);
1306
+ assert.equal(r1.ok, true);
1307
+ const before = store.deadCode();
1308
+ assert.equal(before.ok, true);
1309
+ if (!before.ok) throw new Error("expected ok");
1310
+ assert.equal(
1311
+ before.hits.length,
1312
+ 0,
1313
+ "no dead symbols before removing the export",
1314
+ );
1315
+
1316
+ // Second ingest: drop the exports array. publicApi loses its
1317
+ // is_exported flag → it should now appear in deadCode().
1318
+ const revised: StoreFileIR = {
1319
+ ...deadCodeLibFile,
1320
+ exports: [], // explicitly exports nothing
1321
+ };
1322
+ const r2 = await store.upsertFileBatch([revised]);
1323
+ assert.equal(r2.ok, true);
1324
+ const after = store.deadCode();
1325
+ assert.equal(after.ok, true);
1326
+ if (!after.ok) throw new Error("expected ok");
1327
+ const qnames = after.hits.map((h) => h.qualifiedName).sort();
1328
+ // publicApi and alive both lose their flags → both become dead.
1329
+ // privateHelper still has an inbound edge → not dead.
1330
+ assert.deepEqual(qnames, ["lib.alive", "lib.publicApi"]);
1331
+ } finally {
1332
+ await dispose(store, dir);
1333
+ }
1334
+ });
1335
+
1336
+ test("deadCode: partial re-ingest preserves the omitted flag (cursor Bugbot + chatgpt-codex-connector P2)", async () => {
1337
+ const { store, dir } = await tempStore();
1338
+ try {
1339
+ // First ingest: healthHandler is a route handler → not dead.
1340
+ const r1 = await store.upsertFileBatch([routeHandlerFile]);
1341
+ assert.equal(r1.ok, true);
1342
+ const before = store.deadCode();
1343
+ assert.equal(before.ok, true);
1344
+ if (!before.ok) throw new Error("expected ok");
1345
+ assert.deepEqual(
1346
+ before.hits.map((h) => h.qualifiedName).sort(),
1347
+ ["route.uncalledHelper"],
1348
+ "healthHandler excluded as a route handler",
1349
+ );
1350
+
1351
+ // Second ingest: provide ONLY exports (omitting routes). The
1352
+ // route-handler flag MUST be preserved — the per-field semantics
1353
+ // say an omitted field leaves existing flags untouched. The old
1354
+ // delete-then-insert wiped both columns and re-classified
1355
+ // healthHandler as dead.
1356
+ const revised: StoreFileIR = {
1357
+ path: routeHandlerFile.path,
1358
+ language: routeHandlerFile.language,
1359
+ contentHash: "h-route-v2",
1360
+ symbols: routeHandlerFile.symbols,
1361
+ exports: [], // explicitly no exports; routes OMITTED
1362
+ };
1363
+ const r2 = await store.upsertFileBatch([revised]);
1364
+ assert.equal(r2.ok, true);
1365
+ const after = store.deadCode();
1366
+ assert.equal(after.ok, true);
1367
+ if (!after.ok) throw new Error("expected ok");
1368
+ assert.deepEqual(
1369
+ after.hits.map((h) => h.qualifiedName).sort(),
1370
+ ["route.uncalledHelper"],
1371
+ "healthHandler STILL excluded — routes flag preserved across a partial re-ingest",
1372
+ );
1373
+ } finally {
1374
+ await dispose(store, dir);
1375
+ }
1376
+ });
1377
+
1378
+ test("deadCode: route handlers excluded via the named constant", async () => {
1379
+ const { store, dir } = await tempStore();
1380
+ try {
1381
+ const r = await store.upsertFileBatch([routeHandlerFile]);
1382
+ assert.equal(r.ok, true);
1383
+ const out = store.deadCode();
1384
+ assert.equal(out.ok, true);
1385
+ if (!out.ok) throw new Error("expected ok");
1386
+ const qnames = out.hits.map((h) => h.qualifiedName).sort();
1387
+ // healthHandler is a route handler → excluded.
1388
+ // uncalledHelper is a private uncalled function → dead.
1389
+ assert.deepEqual(qnames, ["route.uncalledHelper"]);
1390
+ } finally {
1391
+ await dispose(store, dir);
1392
+ }
1393
+ });
1394
+
1395
+ test("deadCode: entry-point file paths excluded via the named constant", async () => {
1396
+ const { store, dir } = await tempStore();
1397
+ try {
1398
+ const r = await store.upsertFileBatch([deadCodeEntryFile]);
1399
+ assert.equal(r.ok, true);
1400
+ const out = store.deadCode();
1401
+ assert.equal(out.ok, true);
1402
+ if (!out.ok) throw new Error("expected ok");
1403
+ // src/index.ts matches ENTRY_POINT_PATH_PATTERNS → both symbols
1404
+ // excluded even though they are private and uncalled.
1405
+ assert.equal(out.hits.length, 0);
1406
+ } finally {
1407
+ await dispose(store, dir);
1408
+ }
1409
+ });
1410
+
1411
+ test("deadCode: test files excluded via the named constant", async () => {
1412
+ const { store, dir } = await tempStore();
1413
+ try {
1414
+ const file: StoreFileIR = {
1415
+ path: "src/foo.test.ts",
1416
+ language: "typescript",
1417
+ contentHash: "h-test",
1418
+ symbols: [sym("test.private", "private", 0, 50)],
1419
+ edges: [],
1420
+ };
1421
+ const r = await store.upsertFileBatch([file]);
1422
+ assert.equal(r.ok, true);
1423
+ const out = store.deadCode();
1424
+ assert.equal(out.ok, true);
1425
+ if (!out.ok) throw new Error("expected ok");
1426
+ // Path matches TEST_PATH_PATTERNS → excluded.
1427
+ assert.equal(out.hits.length, 0);
1428
+ } finally {
1429
+ await dispose(store, dir);
1430
+ }
1431
+ });
1432
+
1433
+ test("DEAD_CODE_EXCLUSION: named constant is exported and stable", () => {
1434
+ // The constant is the single source of truth for exclusion criteria
1435
+ // (rule 53 analog). Asserting its shape locks the public surface so
1436
+ // a refactor cannot silently drop an exclusion category.
1437
+ assert.ok(DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES.includes("CALLS"));
1438
+ assert.ok(DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES.includes("USES_TYPE"));
1439
+ assert.ok(
1440
+ DEAD_CODE_EXCLUSION.EXCLUDED_ATTRIBUTE_FLAGS.includes("is_exported"),
1441
+ );
1442
+ assert.ok(
1443
+ DEAD_CODE_EXCLUSION.EXCLUDED_ATTRIBUTE_FLAGS.includes("is_route_handler"),
1444
+ );
1445
+ assert.ok(DEAD_CODE_EXCLUSION.TEST_PATH_PATTERNS.length > 0);
1446
+ assert.ok(DEAD_CODE_EXCLUSION.ENTRY_POINT_PATH_PATTERNS.length > 0);
1447
+ });
1448
+
1449
+ // ──────────────────────────────────────────────────────────────────────────
1450
+ // snippetFor(): read spans from disk.
1451
+ // ──────────────────────────────────────────────────────────────────────────
1452
+
1453
+ test("snippetFor: reads the exact span from disk via repoRoot", async () => {
1454
+ const { store, dir, repoRoot } = await tempStore();
1455
+ try {
1456
+ // Write a real source file under repoRoot so snippetFor can read it.
1457
+ // The span [4, 18) covers "function greet()" in the source.
1458
+ // 0:'a' 1:'=' 2:'1' 3:';' 4:'f' 5:'u' ... → "function greet()".
1459
+ const src = "a=1;function greet(){return 1;}\n";
1460
+ // Find the byte offsets for `function greet()`.
1461
+ const start = src.indexOf("function greet()");
1462
+ const end = start + "function greet()".length;
1463
+ await mkdir(path.dirname(path.join(repoRoot, "src/snippet.ts")), {
1464
+ recursive: true,
1465
+ });
1466
+ await writeFile(path.join(repoRoot, "src/snippet.ts"), src);
1467
+
1468
+ const file: StoreFileIR = {
1469
+ path: "src/snippet.ts",
1470
+ language: "typescript",
1471
+ contentHash: "h-snippet",
1472
+ symbols: [sym("snip.greet", "greet", start, end)],
1473
+ };
1474
+ const r = await store.upsertFileBatch([file]);
1475
+ assert.equal(r.ok, true);
1476
+
1477
+ const out = await store.snippetFor({ qualifiedName: "snip.greet" });
1478
+ assert.equal(out.ok, true);
1479
+ if (!out.ok) throw new Error("expected ok");
1480
+ assert.equal(out.text, "function greet()");
1481
+ assert.equal(out.startByte, start);
1482
+ assert.equal(out.endByte, end);
1483
+ assert.equal(out.filePath, "src/snippet.ts");
1484
+ assert.equal(out.lang, "typescript");
1485
+ assert.ok(path.isAbsolute(out.absolutePath));
1486
+ } finally {
1487
+ await dispose(store, dir);
1488
+ }
1489
+ });
1490
+
1491
+ test("snippetFor: contextLines expands line-aligned", async () => {
1492
+ const { store, dir, repoRoot } = await tempStore();
1493
+ try {
1494
+ const lines = ["line one\n", "line two\n", "line three\n", "line four\n"];
1495
+ const src = lines.join("");
1496
+ // span covers all of "line two" (line index 1).
1497
+ const start = src.indexOf("line two");
1498
+ const end = start + "line two".length;
1499
+ await mkdir(path.dirname(path.join(repoRoot, "src/ctx.ts")), {
1500
+ recursive: true,
1501
+ });
1502
+ await writeFile(path.join(repoRoot, "src/ctx.ts"), src);
1503
+
1504
+ const file: StoreFileIR = {
1505
+ path: "src/ctx.ts",
1506
+ language: "typescript",
1507
+ contentHash: "h-ctx",
1508
+ symbols: [sym("ctx.fn", "fn", start, end)],
1509
+ };
1510
+ const r = await store.upsertFileBatch([file]);
1511
+ assert.equal(r.ok, true);
1512
+
1513
+ // contextLines: 1 → expand one line before + one line after.
1514
+ const out = await store.snippetFor({
1515
+ qualifiedName: "ctx.fn",
1516
+ contextLines: 1,
1517
+ });
1518
+ assert.equal(out.ok, true);
1519
+ if (!out.ok) throw new Error("expected ok");
1520
+ // Expected to include "line one\nline two\nline three\n".
1521
+ assert.equal(out.text, "line one\nline two\nline three\n");
1522
+ } finally {
1523
+ await dispose(store, dir);
1524
+ }
1525
+ });
1526
+
1527
+ test("snippetFor: returns 'repo_root_unset' when repoRoot was not provided", async () => {
1528
+ // tempStore with no repoRoot passed → store carries the temp dir as
1529
+ // repoRoot. Override by opening directly with no repoRoot.
1530
+ const dir = await mkdtemp(path.join(tmpdir(), "coding-graph-pr2-noroot-"));
1531
+ try {
1532
+ const store = await GraphStore.open({
1533
+ dbPath: path.join(dir, "graph.sqlite"),
1534
+ // No repoRoot.
1535
+ });
1536
+ const file: StoreFileIR = {
1537
+ path: "src/x.ts",
1538
+ language: "typescript",
1539
+ contentHash: "h-x",
1540
+ symbols: [sym("x.fn", "fn", 0, 10)],
1541
+ };
1542
+ const r = await store.upsertFileBatch([file]);
1543
+ assert.equal(r.ok, true);
1544
+
1545
+ const out = await store.snippetFor({ qualifiedName: "x.fn" });
1546
+ assert.equal(out.ok, false);
1547
+ if (out.ok) throw new Error("expected failure");
1548
+ assert.equal(out.code, "repo_root_unset");
1549
+ await store.close();
1550
+ } finally {
1551
+ await rm(dir, { recursive: true, force: true });
1552
+ }
1553
+ });
1554
+
1555
+ test("snippetFor: returns 'not_found' for unknown qualifiedName", async () => {
1556
+ const { store, dir } = await tempStore();
1557
+ try {
1558
+ const out = await store.snippetFor({ qualifiedName: "no.such.symbol" });
1559
+ assert.equal(out.ok, false);
1560
+ if (out.ok) throw new Error("expected failure");
1561
+ assert.equal(out.code, "not_found");
1562
+ } finally {
1563
+ await dispose(store, dir);
1564
+ }
1565
+ });
1566
+
1567
+ test("snippetFor: returns 'read_failed' when the on-disk file is missing", async () => {
1568
+ const { store, dir, repoRoot } = await tempStore();
1569
+ try {
1570
+ const file: StoreFileIR = {
1571
+ path: "src/missing.ts",
1572
+ language: "typescript",
1573
+ contentHash: "h-missing",
1574
+ symbols: [sym("m.fn", "fn", 0, 10)],
1575
+ };
1576
+ const r = await store.upsertFileBatch([file]);
1577
+ assert.equal(r.ok, true);
1578
+ // Note: the file is NOT created on disk under repoRoot.
1579
+
1580
+ const out = await store.snippetFor({ qualifiedName: "m.fn" });
1581
+ assert.equal(out.ok, false);
1582
+ if (out.ok) throw new Error("expected failure");
1583
+ assert.equal(out.code, "read_failed");
1584
+ } finally {
1585
+ await dispose(store, dir);
1586
+ }
1587
+ });
1588
+
1589
+ test("snippetFor: 'ambiguous_name' when qualifiedName exists in two files", async () => {
1590
+ const { store, dir } = await tempStore();
1591
+ try {
1592
+ const f1: StoreFileIR = {
1593
+ path: "src/a1.ts",
1594
+ language: "typescript",
1595
+ contentHash: "h1",
1596
+ symbols: [sym("amb.fn", "fn", 0, 10)],
1597
+ };
1598
+ const f2: StoreFileIR = {
1599
+ path: "src/a2.ts",
1600
+ language: "typescript",
1601
+ contentHash: "h2",
1602
+ symbols: [sym("amb.fn", "fn", 0, 10)],
1603
+ };
1604
+ const r = await store.upsertFileBatch([f1, f2]);
1605
+ assert.equal(r.ok, true);
1606
+
1607
+ const out = await store.snippetFor({ qualifiedName: "amb.fn" });
1608
+ assert.equal(out.ok, false);
1609
+ if (out.ok) throw new Error("expected failure");
1610
+ assert.equal(out.code, "ambiguous_name");
1611
+ } finally {
1612
+ await dispose(store, dir);
1613
+ }
1614
+ });
1615
+
1616
+ test("snippetFor: returns 'store_closed' when the store is closed (cursor Bugbot + chatgpt-codex-connector P2)", async () => {
1617
+ const { store, dir, repoRoot } = await tempStore();
1618
+ try {
1619
+ const file: StoreFileIR = {
1620
+ path: "src/closed.ts",
1621
+ language: "typescript",
1622
+ contentHash: "h-closed",
1623
+ symbols: [sym("closed.fn", "fn", 0, 10)],
1624
+ };
1625
+ const r = await store.upsertFileBatch([file]);
1626
+ await mkdir(path.dirname(path.join(repoRoot, "src/closed.ts")), {
1627
+ recursive: true,
1628
+ });
1629
+ await writeFile(path.join(repoRoot, "src/closed.ts"), "function fn() {}\n");
1630
+
1631
+ await store.close();
1632
+ const out = await store.snippetFor({ qualifiedName: "closed.fn" });
1633
+ assert.equal(out.ok, false);
1634
+ if (out.ok) throw new Error("expected failure");
1635
+ // Must be store_closed (the store is closed), NOT repo_root_unset
1636
+ // (repoRoot IS set) — callers need to distinguish the two.
1637
+ assert.equal(out.code, "store_closed");
1638
+ } finally {
1639
+ await rm(dir, { recursive: true, force: true });
1640
+ }
1641
+ });
1642
+
1643
+ // ──────────────────────────────────────────────────────────────────────────
1644
+ // deadCode(): self-edge must not count as inbound usage
1645
+ // (chatgpt-codex-connector P2: 'Ignore self-edges in dead-code
1646
+ // reachability').
1647
+ // ──────────────────────────────────────────────────────────────────────────
1648
+
1649
+ test("deadCode: a private recursive helper whose only edge is a self-call IS reported as dead", async () => {
1650
+ // recurse.ts defines `run` whose only edge is run → run. Nothing
1651
+ // outside reaches it, so it is dead code; the self-call must NOT
1652
+ // count as external inbound usage.
1653
+ const { store, dir } = await tempStore();
1654
+ try {
1655
+ const file: StoreFileIR = {
1656
+ path: "src/recurse.ts",
1657
+ language: "typescript",
1658
+ contentHash: "h-recurse",
1659
+ symbols: [sym("recurse.run", "run", 0, 10)],
1660
+ edges: [edge("recurse.run", "recurse.run")],
1661
+ };
1662
+ const res = await store.upsertFileBatch([file]);
1663
+ if (!res.ok) throw new Error(`expected ok upsert; got ${res.code}`);
1664
+ const dead = store.deadCode();
1665
+ if (!dead.ok) throw new Error(`expected ok deadCode; got ${dead.code}`);
1666
+ const names = dead.hits.map((h) => h.name);
1667
+ assert.ok(
1668
+ names.includes("run"),
1669
+ `self-only-recursive symbol must be reported as dead; got ${JSON.stringify(names)}`,
1670
+ );
1671
+ } finally {
1672
+ await dispose(store, dir);
1673
+ }
1674
+ });
1675
+
1676
+ // ──────────────────────────────────────────────────────────────────────────
1677
+ // upsertFileBatch: malformed (non-array) exports / routes are rejected
1678
+ // at the boundary so they cannot wipe existing attribute flags
1679
+ // (chatgpt-codex-connector P2: 'Validate attribute arrays before
1680
+ // clearing flags').
1681
+ // ──────────────────────────────────────────────────────────────────────────
1682
+
1683
+ test("upsertFileBatch: non-array exports rejected before wiping flags", async () => {
1684
+ const { store, dir } = await tempStore();
1685
+ try {
1686
+ const file = {
1687
+ path: "src/bad.ts",
1688
+ language: "typescript",
1689
+ contentHash: "h-bad",
1690
+ symbols: [sym("bad.fn", "fn", 0, 10)],
1691
+ // A JSON caller bypassing types could pass a bare string; this
1692
+ // is iterable char-by-char and would otherwise wipe flags.
1693
+ exports: "publicApi",
1694
+ edges: [],
1695
+ } as unknown as StoreFileIR;
1696
+ await assert.rejects(
1697
+ store.upsertFileBatch([file]),
1698
+ /exports must be an array when present/,
1699
+ "a non-array exports field must be rejected, not silently wipe is_exported",
1700
+ );
1701
+ } finally {
1702
+ await dispose(store, dir);
1703
+ }
1704
+ });
1705
+
1706
+ test("upsertFileBatch: non-array routes rejected before wiping flags", async () => {
1707
+ const { store, dir } = await tempStore();
1708
+ try {
1709
+ const file = {
1710
+ path: "src/badr.ts",
1711
+ language: "typescript",
1712
+ contentHash: "h-badr",
1713
+ symbols: [sym("badr.fn", "fn", 0, 10)],
1714
+ routes: "GET /x",
1715
+ edges: [],
1716
+ } as unknown as StoreFileIR;
1717
+ await assert.rejects(
1718
+ store.upsertFileBatch([file]),
1719
+ /routes must be an array when present/,
1720
+ "a non-array routes field must be rejected, not silently wipe is_route_handler",
1721
+ );
1722
+ } finally {
1723
+ await dispose(store, dir);
1724
+ }
1725
+ });
1726
+
1727
+ // ──────────────────────────────────────────────────────────────────────────
1728
+ // Read APIs honor the tagged-failure contract: an unexpected SQLite
1729
+ // error returns { ok:false, code:"db_error" } instead of throwing, so
1730
+ // callers that exhaustively switch on result.code never crash
1731
+ // (cursor Bugbot: 'Read APIs rethrow SQLite errors';
1732
+ // chatgpt-codex-connector P2: 'Return tagged failures from read
1733
+ // queries').
1734
+ // ──────────────────────────────────────────────────────────────────────────
1735
+
1736
+ test("read APIs return 'db_error' instead of throwing on an unexpected SQLite failure", async () => {
1737
+ const { store, dir } = await tempStore();
1738
+ const dbPath = path.join(dir, "graph.sqlite");
1739
+ try {
1740
+ const file: StoreFileIR = {
1741
+ path: "src/n.ts",
1742
+ language: "typescript",
1743
+ contentHash: "h-n",
1744
+ symbols: [sym("n.a", "a", 0, 10)],
1745
+ edges: [],
1746
+ };
1747
+ const res = await store.upsertFileBatch([file]);
1748
+ if (!res.ok) throw new Error(`expected ok upsert; got ${res.code}`);
1749
+ // Sabotage: drop the edges table via a second connection so the
1750
+ // next traverse() edges query fails with "no such table" — a
1751
+ // non-lock, non-corrupt error that previously escaped as a throw.
1752
+ const sabotage = openBetterSqlite3(dbPath);
1753
+ sabotage.exec("DROP TABLE edges");
1754
+ sabotage.close();
1755
+ const out = store.traverse({ start: "n.a", maxDepth: 1 });
1756
+ assert.equal(out.ok, false, "traverse must not throw on a DB error");
1757
+ if (!out.ok) {
1758
+ assert.equal(
1759
+ out.code,
1760
+ "db_error",
1761
+ `unexpected SQLite error must map to db_error, not throw; got ${out.code}`,
1762
+ );
1763
+ }
1764
+ } finally {
1765
+ await dispose(store, dir);
1766
+ }
1767
+ });
1768
+
1769
+ test("traverse: non-string start rejected with 'invalid_query' before binding (chatgpt-codex-connector P2)", async () => {
1770
+ const { store, dir } = await tempStore();
1771
+ try {
1772
+ for (const bad of [
1773
+ 42,
1774
+ null,
1775
+ undefined,
1776
+ { name: "x" },
1777
+ [1, 2],
1778
+ "",
1779
+ ] as unknown[]) {
1780
+ const out = store.traverse({
1781
+ start: bad as never,
1782
+ maxDepth: 1,
1783
+ });
1784
+ assert.equal(out.ok, false, `non-string start ${JSON.stringify(bad)} must not succeed`);
1785
+ if (!out.ok) {
1786
+ assert.equal(
1787
+ out.code,
1788
+ "invalid_query",
1789
+ `non-string start must return invalid_query, not ${out.code}`,
1790
+ );
1791
+ }
1792
+ }
1793
+ } finally {
1794
+ await dispose(store, dir);
1795
+ }
1796
+ });
1797
+
1798
+ test("upsertFileBatch: malformed export entry (non-string name) rejected before wiping flags", async () => {
1799
+ const { store, dir } = await tempStore();
1800
+ try {
1801
+ const file = {
1802
+ path: "src/badentry.ts",
1803
+ language: "typescript",
1804
+ contentHash: "h-bade",
1805
+ symbols: [sym("bade.fn", "fn", 0, 10)],
1806
+ exports: [{ name: 42 }],
1807
+ edges: [],
1808
+ } as unknown as StoreFileIR;
1809
+ await assert.rejects(
1810
+ store.upsertFileBatch([file]),
1811
+ /malformed export entry/,
1812
+ "a malformed export entry must be rejected, not silently skipped while the wipe runs",
1813
+ );
1814
+ } finally {
1815
+ await dispose(store, dir);
1816
+ }
1817
+ });
1818
+
1819
+ test("upsertFileBatch: malformed route entry (non-string handlerQualifiedName) rejected", async () => {
1820
+ const { store, dir } = await tempStore();
1821
+ try {
1822
+ const file = {
1823
+ path: "src/badr2.ts",
1824
+ language: "typescript",
1825
+ contentHash: "h-badr2",
1826
+ symbols: [sym("badr2.fn", "fn", 0, 10)],
1827
+ routes: [{ handlerQualifiedName: 99 }],
1828
+ edges: [],
1829
+ } as unknown as StoreFileIR;
1830
+ await assert.rejects(
1831
+ store.upsertFileBatch([file]),
1832
+ /malformed route entry/,
1833
+ "a malformed route entry must be rejected, not silently skipped while the wipe runs",
1834
+ );
1835
+ } finally {
1836
+ await dispose(store, dir);
1837
+ }
1838
+ });
1839
+
1840
+ test("read APIs reject a null/undefined query object with 'invalid_query'", async () => {
1841
+ const { store, dir } = await tempStore();
1842
+ try {
1843
+ for (const bad of [null, undefined, 42, "x"] as unknown[]) {
1844
+ const t = store.traverse(bad as never);
1845
+ assert.equal(t.ok, false, `traverse(nullish) must not succeed`);
1846
+ if (!t.ok) assert.equal(t.code, "invalid_query");
1847
+ const s = store.searchGraph(bad as never);
1848
+ assert.equal(s.ok, false, `searchGraph(nullish) must not succeed`);
1849
+ if (!s.ok) assert.equal(s.code, "invalid_query");
1850
+ const sn = await store.snippetFor(bad as never);
1851
+ assert.equal(sn.ok, false, `snippetFor(nullish) must not succeed`);
1852
+ if (!sn.ok) assert.equal(sn.code, "invalid_query");
1853
+ }
1854
+ } finally {
1855
+ await dispose(store, dir);
1856
+ }
1857
+ });
1858
+
1859
+ test("snippetFor: invalid contextLines (non-integer / negative / string) rejected with 'invalid_query'", async () => {
1860
+ const { store, dir } = await tempStore();
1861
+ try {
1862
+ for (const bad of [1.9, -1, "2", NaN, true] as unknown[]) {
1863
+ const out = await store.snippetFor({
1864
+ qualifiedName: "x.y",
1865
+ contextLines: bad as never,
1866
+ });
1867
+ assert.equal(out.ok, false, `contextLines=${JSON.stringify(bad)} must not succeed`);
1868
+ if (!out.ok) {
1869
+ assert.equal(
1870
+ out.code,
1871
+ "invalid_query",
1872
+ `invalid contextLines must return invalid_query, not ${out.code}`,
1873
+ );
1874
+ }
1875
+ }
1876
+ } finally {
1877
+ await dispose(store, dir);
1878
+ }
1879
+ });