@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,1107 @@
1
+ /**
2
+ * openCypher read-subset accept/reject suites — issue #1552 PR3 done-when.
3
+ *
4
+ * Two suites, both prove-fail-before (a deliberate break of the grammar
5
+ * or the rejection table on a scratch branch fails the suite):
6
+ *
7
+ * ACCEPT: every documented form parses AND executes against a fixture
8
+ * graph, returning the exact expected rows.
9
+ * REJECT: every entry in the rejection table produces a clear tagged
10
+ * failure with the right code + a message naming the supported
11
+ * grammar.
12
+ *
13
+ * Fixture IR is synthetic (public-repo policy). The graph is small enough
14
+ * that the executor's searchGraph-then-traverse compilation target is
15
+ * deterministic — we assert exact sets, not "at least one hit".
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import { mkdtemp, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+
23
+ import {
24
+ GraphStore,
25
+ type EdgeIR,
26
+ type StoreFileIR,
27
+ type SymbolIR,
28
+ } from "../graph-store.js";
29
+ import {
30
+ CYPHER_LABEL_TO_DB_LABEL,
31
+ executeCypher,
32
+ parseCypher,
33
+ VALID_CYPHER_LABELS,
34
+ type CypherNodeValue,
35
+ } from "./query-parser.js";
36
+
37
+ // ──────────────────────────────────────────────────────────────────────────
38
+ // Fixture helpers — synthetic IR.
39
+ // ──────────────────────────────────────────────────────────────────────────
40
+
41
+ function sym(
42
+ qualifiedName: string,
43
+ name: string,
44
+ startByte: number,
45
+ endByte: number,
46
+ kind: SymbolIR["kind"] = "function",
47
+ ): SymbolIR {
48
+ return { qualifiedName, name, span: { startByte, endByte }, kind };
49
+ }
50
+
51
+ function edge(
52
+ srcQualifiedName: string,
53
+ dstQualifiedName: string,
54
+ type = "CALLS",
55
+ confidence = 0.9,
56
+ provenance: EdgeIR["provenance"] = "heuristic",
57
+ ): EdgeIR {
58
+ return { srcQualifiedName, dstQualifiedName, type, confidence, provenance };
59
+ }
60
+
61
+ /**
62
+ * The fixture graph (all in one file so node identity is unambiguous):
63
+ *
64
+ * app.bootstrap (function) ──CALLS──> app.runServer (function)
65
+ * app.runServer (function) ──CALLS──> app.handleRequest (function)
66
+ * app.handleRequest (function) ──CALLS──> db.query (function)
67
+ * app.handleRequest (function) ──USES_TYPE──> db.QueryResult (type)
68
+ * app.runServer (function) ──IMPORTS──> lib.configHelper (function)
69
+ *
70
+ * util.format (function) — isolated, zero inbound edges → dead code.
71
+ */
72
+ const fixtureFile: StoreFileIR = {
73
+ path: "src/app.ts",
74
+ language: "typescript",
75
+ contentHash: "h-fixture",
76
+ symbols: [
77
+ sym("app.bootstrap", "bootstrap", 0, 50, "function"),
78
+ sym("app.runServer", "runServer", 50, 200, "function"),
79
+ sym("app.handleRequest", "handleRequest", 200, 400, "function"),
80
+ sym("db.query", "query", 400, 500, "function"),
81
+ sym("db.QueryResult", "QueryResult", 500, 600, "type"),
82
+ sym("lib.configHelper", "configHelper", 600, 700, "function"),
83
+ sym("util.format", "format", 700, 800, "function"),
84
+ ],
85
+ edges: [
86
+ edge("app.bootstrap", "app.runServer"),
87
+ edge("app.runServer", "app.handleRequest"),
88
+ edge("app.handleRequest", "db.query"),
89
+ edge("app.handleRequest", "db.QueryResult", "USES_TYPE"),
90
+ edge("app.runServer", "lib.configHelper", "IMPORTS"),
91
+ ],
92
+ };
93
+
94
+ async function tempStoreWithFixture(): Promise<{
95
+ store: GraphStore;
96
+ dir: string;
97
+ }> {
98
+ const dir = await mkdtemp(path.join(tmpdir(), "cypher-pr3-"));
99
+ const store = await GraphStore.open({
100
+ dbPath: path.join(dir, "graph.sqlite"),
101
+ repoRoot: dir,
102
+ });
103
+ const r = await store.upsertFileBatch([fixtureFile]);
104
+ assert.equal(r.ok, true, "fixture ingest must succeed");
105
+ return { store, dir };
106
+ }
107
+
108
+ async function dispose(store: GraphStore, dir: string): Promise<void> {
109
+ await store.close();
110
+ await rm(dir, { recursive: true, force: true });
111
+ }
112
+
113
+ /** Convenience: execute + assert ok, returning the rows. */
114
+ async function run(
115
+ store: GraphStore,
116
+ query: string,
117
+ ): Promise<{ columns: string[]; rows: Record<string, unknown>[] }> {
118
+ const r = executeCypher(store, query);
119
+ if (!r.ok) {
120
+ assert.fail(`Query unexpectedly failed: ${r.code}: ${r.message}\n query: ${query}`);
121
+ }
122
+ return { columns: r.columns, rows: r.rows };
123
+ }
124
+
125
+ /** Convenience: assert a query fails with the given code. */
126
+ function reject(
127
+ store: GraphStore,
128
+ query: string,
129
+ expectedCode: string,
130
+ ): void {
131
+ const r = executeCypher(store, query);
132
+ assert.equal(r.ok, false, `Query unexpectedly succeeded: ${query}`);
133
+ if (r.ok) return;
134
+ assert.equal(
135
+ r.code,
136
+ expectedCode,
137
+ `Wrong failure code for ${JSON.stringify(query)}: expected ${expectedCode}, got ${r.code} (${r.message})`,
138
+ );
139
+ }
140
+
141
+ /** Parse-only reject (no store needed). */
142
+ function rejectParse(query: string, expectedCode: string): void {
143
+ const r = parseCypher(query);
144
+ assert.equal(r.ok, false, `Query unexpectedly parsed: ${query}`);
145
+ if (r.ok) return;
146
+ assert.equal(
147
+ r.code,
148
+ expectedCode,
149
+ `Wrong parse code for ${JSON.stringify(query)}: expected ${expectedCode}, got ${r.code} (${r.message})`,
150
+ );
151
+ }
152
+
153
+ // ──────────────────────────────────────────────────────────────────────────
154
+ // ACCEPT — single-node MATCH (compiles to searchGraph).
155
+ // ──────────────────────────────────────────────────────────────────────────
156
+
157
+ test("ACCEPT: MATCH (f:Function) RETURN f — returns every function node", async () => {
158
+ const { store, dir } = await tempStoreWithFixture();
159
+ try {
160
+ const { columns, rows } = await run(store, "MATCH (f:Function) RETURN f");
161
+ assert.deepEqual(columns, ["f"]);
162
+ // bootstrap, runServer, handleRequest, query, configHelper, format = 6.
163
+ assert.equal(rows.length, 6);
164
+ const names = rows
165
+ .map((r) => (r.f as CypherNodeValue).name)
166
+ .sort();
167
+ assert.deepEqual(names, [
168
+ "bootstrap",
169
+ "configHelper",
170
+ "format",
171
+ "handleRequest",
172
+ "query",
173
+ "runServer",
174
+ ]);
175
+ } finally {
176
+ await dispose(store, dir);
177
+ }
178
+ });
179
+
180
+ test("ACCEPT: label maps PascalCase → lowercase kind (Type → type)", async () => {
181
+ const { store, dir } = await tempStoreWithFixture();
182
+ try {
183
+ const { rows } = await run(store, "MATCH (t:Type) RETURN t.qualifiedName");
184
+ assert.equal(rows.length, 1);
185
+ assert.equal(rows[0]!["t.qualifiedName"], "db.QueryResult");
186
+ } finally {
187
+ await dispose(store, dir);
188
+ }
189
+ });
190
+
191
+ test("ACCEPT: no label returns every node (cap'd by LIMIT)", async () => {
192
+ const { store, dir } = await tempStoreWithFixture();
193
+ try {
194
+ const { rows } = await run(store, "MATCH (n) RETURN n LIMIT 3");
195
+ assert.equal(rows.length, 3);
196
+ } finally {
197
+ await dispose(store, dir);
198
+ }
199
+ });
200
+
201
+ test("ACCEPT: inline property filter {name: \"query\"} narrows by exact name", async () => {
202
+ const { store, dir } = await tempStoreWithFixture();
203
+ try {
204
+ const { rows } = await run(
205
+ store,
206
+ 'MATCH (f:Function {name: "query"}) RETURN f.qualifiedName',
207
+ );
208
+ assert.equal(rows.length, 1);
209
+ assert.equal(rows[0]!["f.qualifiedName"], "db.query");
210
+ } finally {
211
+ await dispose(store, dir);
212
+ }
213
+ });
214
+
215
+ // ──────────────────────────────────────────────────────────────────────────
216
+ // ACCEPT — WHERE clause.
217
+ // ──────────────────────────────────────────────────────────────────────────
218
+
219
+ test("ACCEPT: WHERE = string", async () => {
220
+ const { store, dir } = await tempStoreWithFixture();
221
+ try {
222
+ const { rows } = await run(
223
+ store,
224
+ 'MATCH (f:Function) WHERE f.name = "runServer" RETURN f.qualifiedName',
225
+ );
226
+ assert.equal(rows.length, 1);
227
+ assert.equal(rows[0]!["f.qualifiedName"], "app.runServer");
228
+ } finally {
229
+ await dispose(store, dir);
230
+ }
231
+ });
232
+
233
+ test("ACCEPT: WHERE <> filters out", async () => {
234
+ const { store, dir } = await tempStoreWithFixture();
235
+ try {
236
+ const { rows } = await run(
237
+ store,
238
+ 'MATCH (f:Function) WHERE f.name <> "runServer" RETURN f.name',
239
+ );
240
+ assert.equal(rows.length, 5);
241
+ assert.ok(!rows.some((r) => r["f.name"] === "runServer"));
242
+ } finally {
243
+ await dispose(store, dir);
244
+ }
245
+ });
246
+
247
+ test("ACCEPT: WHERE OR-of-AND precedence", async () => {
248
+ const { store, dir } = await tempStoreWithFixture();
249
+ try {
250
+ // name = bootstrap OR name = query → 2 rows (OR splits the group).
251
+ const { rows } = await run(
252
+ store,
253
+ 'MATCH (f:Function) WHERE f.name = "bootstrap" OR f.name = "query" RETURN f.name',
254
+ );
255
+ const names = rows.map((r) => r["f.name"]).sort();
256
+ assert.deepEqual(names, ["bootstrap", "query"]);
257
+ } finally {
258
+ await dispose(store, dir);
259
+ }
260
+ });
261
+
262
+ test("ACCEPT: WHERE AND combines within a group", async () => {
263
+ const { store, dir } = await tempStoreWithFixture();
264
+ try {
265
+ // label = function AND name = query → exactly db.query.
266
+ const { rows } = await run(
267
+ store,
268
+ 'MATCH (n) WHERE n.label = "function" AND n.name = "query" RETURN n.qualifiedName',
269
+ );
270
+ assert.equal(rows.length, 1);
271
+ assert.equal(rows[0]!["n.qualifiedName"], "db.query");
272
+ } finally {
273
+ await dispose(store, dir);
274
+ }
275
+ });
276
+
277
+ test("ACCEPT: WHERE on label property (kind alias)", async () => {
278
+ const { store, dir } = await tempStoreWithFixture();
279
+ try {
280
+ const { rows } = await run(
281
+ store,
282
+ 'MATCH (n) WHERE n.kind = "type" RETURN n.qualifiedName',
283
+ );
284
+ assert.equal(rows.length, 1);
285
+ assert.equal(rows[0]!["n.qualifiedName"], "db.QueryResult");
286
+ } finally {
287
+ await dispose(store, dir);
288
+ }
289
+ });
290
+
291
+ test("ACCEPT: WHERE on snake_case alias file_path", async () => {
292
+ const { store, dir } = await tempStoreWithFixture();
293
+ try {
294
+ const { rows } = await run(
295
+ store,
296
+ 'MATCH (f:Function) WHERE f.file_path = "src/app.ts" RETURN f.name LIMIT 2',
297
+ );
298
+ assert.equal(rows.length, 2);
299
+ } finally {
300
+ await dispose(store, dir);
301
+ }
302
+ });
303
+
304
+ // ──────────────────────────────────────────────────────────────────────────
305
+ // ACCEPT — single-edge relationships (compile to traverse depth==1).
306
+ // ──────────────────────────────────────────────────────────────────────────
307
+
308
+ test("ACCEPT: (a)-[:CALLS]->(b) — direct outgoing CALLS edges", async () => {
309
+ const { store, dir } = await tempStoreWithFixture();
310
+ try {
311
+ const { columns, rows } = await run(
312
+ store,
313
+ "MATCH (a:Function)-[:CALLS]->(b:Function) RETURN a.name, b.name",
314
+ );
315
+ assert.deepEqual(columns, ["a.name", "b.name"]);
316
+ // bootstrap→runServer, runServer→handleRequest, handleRequest→query.
317
+ const pairs = rows
318
+ .map((r) => `${r["a.name"]}→${r["b.name"]}`)
319
+ .sort();
320
+ assert.deepEqual(pairs, [
321
+ "bootstrap→runServer",
322
+ "handleRequest→query",
323
+ "runServer→handleRequest",
324
+ ]);
325
+ } finally {
326
+ await dispose(store, dir);
327
+ }
328
+ });
329
+
330
+ test("ACCEPT: incoming direction <-[:CALLS]- finds what the source calls", async () => {
331
+ // `(target)<-[:CALLS]-(src)` means src CALLS target. With src=handleRequest,
332
+ // target = whatever handleRequest calls = db.query.
333
+ const { store, dir } = await tempStoreWithFixture();
334
+ try {
335
+ const { rows } = await run(
336
+ store,
337
+ 'MATCH (target:Function)<-[:CALLS]-(src:Function {name: "handleRequest"}) RETURN target.name',
338
+ );
339
+ assert.equal(rows.length, 1);
340
+ assert.equal(rows[0]!["target.name"], "query");
341
+ } finally {
342
+ await dispose(store, dir);
343
+ }
344
+ });
345
+
346
+ test("ACCEPT: undirected -[:CALLS]- matches both directions", async () => {
347
+ const { store, dir } = await tempStoreWithFixture();
348
+ try {
349
+ const { rows } = await run(
350
+ store,
351
+ 'MATCH (a:Function)-[:CALLS]-(b:Function {name: "query"}) RETURN a.name',
352
+ );
353
+ // query has one inbound CALLS (from handleRequest); no outbound.
354
+ const names = rows.map((r) => r["a.name"]).sort();
355
+ assert.deepEqual(names, ["handleRequest"]);
356
+ } finally {
357
+ await dispose(store, dir);
358
+ }
359
+ });
360
+
361
+ test("ACCEPT: edge-type alternation -[:CALLS|USES_TYPE]->", async () => {
362
+ const { store, dir } = await tempStoreWithFixture();
363
+ try {
364
+ const { rows } = await run(
365
+ store,
366
+ "MATCH (a:Function)-[:CALLS|USES_TYPE]->(b) RETURN b.qualifiedName",
367
+ );
368
+ const qnames = rows
369
+ .map((r) => r["b.qualifiedName"] as string)
370
+ .sort();
371
+ // CALLS targets: runServer, handleRequest, query.
372
+ // USES_TYPE targets: QueryResult.
373
+ assert.deepEqual(qnames, [
374
+ "app.handleRequest",
375
+ "app.runServer",
376
+ "db.QueryResult",
377
+ "db.query",
378
+ ]);
379
+ } finally {
380
+ await dispose(store, dir);
381
+ }
382
+ });
383
+
384
+ test("ACCEPT: edge types not in the documented set still pass through", async () => {
385
+ // The store has no CHECK on edge type; the Cypher layer documents the
386
+ // 20+ set but accepts any string and returns no rows for unknown types
387
+ // (matching traverse's behavior).
388
+ const { store, dir } = await tempStoreWithFixture();
389
+ try {
390
+ const { rows } = await run(
391
+ store,
392
+ "MATCH (a)-[:NOT_A_REAL_TYPE]->(b) RETURN a.name",
393
+ );
394
+ assert.equal(rows.length, 0);
395
+ } finally {
396
+ await dispose(store, dir);
397
+ }
398
+ });
399
+
400
+ // ──────────────────────────────────────────────────────────────────────────
401
+ // ACCEPT — variable-length paths (compile to traverse depth ∈ [min,max]).
402
+ // ──────────────────────────────────────────────────────────────────────────
403
+
404
+ test("ACCEPT: *1..2 reaches two hops out", async () => {
405
+ const { store, dir } = await tempStoreWithFixture();
406
+ try {
407
+ // From bootstrap, CALLS*1..2 reaches: runServer (1), handleRequest (2).
408
+ const { rows } = await run(
409
+ store,
410
+ 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*1..2]->(b) RETURN b.qualifiedName',
411
+ );
412
+ const qnames = rows
413
+ .map((r) => r["b.qualifiedName"] as string)
414
+ .sort();
415
+ assert.deepEqual(qnames, ["app.handleRequest", "app.runServer"]);
416
+ } finally {
417
+ await dispose(store, dir);
418
+ }
419
+ });
420
+
421
+ test("ACCEPT: *2 exactly two hops (excludes depth 1)", async () => {
422
+ const { store, dir } = await tempStoreWithFixture();
423
+ try {
424
+ const { rows } = await run(
425
+ store,
426
+ 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*2]->(b) RETURN b.qualifiedName',
427
+ );
428
+ assert.equal(rows.length, 1);
429
+ assert.equal(rows[0]!["b.qualifiedName"], "app.handleRequest");
430
+ } finally {
431
+ await dispose(store, dir);
432
+ }
433
+ });
434
+
435
+ test("ACCEPT: *..3 defaults min to 1, includes 1..3 hops", async () => {
436
+ const { store, dir } = await tempStoreWithFixture();
437
+ try {
438
+ const { rows } = await run(
439
+ store,
440
+ 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*..3]->(b) RETURN b.qualifiedName',
441
+ );
442
+ const qnames = rows
443
+ .map((r) => r["b.qualifiedName"] as string)
444
+ .sort();
445
+ assert.deepEqual(qnames, [
446
+ "app.handleRequest",
447
+ "app.runServer",
448
+ "db.query",
449
+ ]);
450
+ } finally {
451
+ await dispose(store, dir);
452
+ }
453
+ });
454
+
455
+ test("ACCEPT: multi-hop path chains (a)-[:CALLS]->(b)-[:CALLS]->(c)", async () => {
456
+ const { store, dir } = await tempStoreWithFixture();
457
+ try {
458
+ const { rows } = await run(
459
+ store,
460
+ "MATCH (a:Function)-[:CALLS]->(b:Function)-[:CALLS]->(c:Function) RETURN a.name, c.name",
461
+ );
462
+ // Two 2-hop chains: bootstrap→runServer→handleRequest, runServer→handleRequest→query.
463
+ const pairs = rows
464
+ .map((r) => `${r["a.name"]}…${r["c.name"]}`)
465
+ .sort();
466
+ assert.deepEqual(pairs, [
467
+ "bootstrap…handleRequest",
468
+ "runServer…query",
469
+ ]);
470
+ } finally {
471
+ await dispose(store, dir);
472
+ }
473
+ });
474
+
475
+ // ──────────────────────────────────────────────────────────────────────────
476
+ // ACCEPT — anonymous nodes (cursor Bugbot: 'Anonymous nodes break path
477
+ // expansion'). An anonymous node `()` participates in the path but is not
478
+ // bound to a variable; the executor tracks the positional cursor so the
479
+ // path flows through it.
480
+ // ──────────────────────────────────────────────────────────────────────────
481
+
482
+ test("ACCEPT: anonymous FIRST node — ()-[:CALLS]->(b) enumerates all starts", async () => {
483
+ const { store, dir } = await tempStoreWithFixture();
484
+ try {
485
+ // Every node with an outgoing CALLS edge is a valid (anon) start.
486
+ // b = the CALLS target. Distinct b targets: runServer, handleRequest, query.
487
+ const { rows } = await run(
488
+ store,
489
+ "MATCH ()-[:CALLS]->(b:Function) RETURN b.qualifiedName",
490
+ );
491
+ const qnames = rows
492
+ .map((r) => r["b.qualifiedName"] as string)
493
+ .sort();
494
+ assert.deepEqual(qnames, [
495
+ "app.handleRequest",
496
+ "app.runServer",
497
+ "db.query",
498
+ ]);
499
+ } finally {
500
+ await dispose(store, dir);
501
+ }
502
+ });
503
+
504
+ test("ACCEPT: anonymous MIDDLE node — path flows through ()", async () => {
505
+ const { store, dir } = await tempStoreWithFixture();
506
+ try {
507
+ // (a)-[:CALLS]->()-[:CALLS]->(c): the middle node is anonymous but
508
+ // the 2-hop path still resolves. Same result as the named-middle
509
+ // chain test above.
510
+ const { rows } = await run(
511
+ store,
512
+ "MATCH (a:Function)-[:CALLS]->()-[:CALLS]->(c:Function) RETURN a.name, c.name",
513
+ );
514
+ const pairs = rows
515
+ .map((r) => `${r["a.name"]}…${r["c.name"]}`)
516
+ .sort();
517
+ assert.deepEqual(pairs, [
518
+ "bootstrap…handleRequest",
519
+ "runServer…query",
520
+ ]);
521
+ } finally {
522
+ await dispose(store, dir);
523
+ }
524
+ });
525
+
526
+ // ──────────────────────────────────────────────────────────────────────────
527
+ // ACCEPT — inline-property pushdown narrows the start set before the cap
528
+ // (cursor Bugbot: 'Start search truncates before filters'). A specific name
529
+ // pushes down to searchGraph's namePattern so a low-degree node is still
530
+ // found even when the graph exceeds the 1000-row cap.
531
+ // ──────────────────────────────────────────────────────────────────────────
532
+
533
+ test("ACCEPT: inline name filter on a low-degree node still resolves", async () => {
534
+ // util.format has zero inbound edges (dead code) → degree 0, ranked last
535
+ // by searchGraph's degree-desc ordering. Without the pushdown a graph
536
+ // with >1000 higher-degree nodes would truncate it out; the pushdown
537
+ // narrows by name first so it is always found.
538
+ const { store, dir } = await tempStoreWithFixture();
539
+ try {
540
+ const { rows } = await run(
541
+ store,
542
+ 'MATCH (f:Function {name: "format"}) RETURN f.qualifiedName',
543
+ );
544
+ assert.equal(rows.length, 1);
545
+ assert.equal(rows[0]!["f.qualifiedName"], "util.format");
546
+ } finally {
547
+ await dispose(store, dir);
548
+ }
549
+ });
550
+
551
+ test("ACCEPT: wrong-type inline literal matches nothing (standard Cypher, not a parse error)", async () => {
552
+ // {name: 123} is valid syntax; a numeric name never equals a string name,
553
+ // so the result is empty (no tagged failure). This is standard Cypher
554
+ // behavior, documented in the module's rejection-table note.
555
+ const { store, dir } = await tempStoreWithFixture();
556
+ try {
557
+ const { rows } = await run(
558
+ store,
559
+ "MATCH (f:Function {name: 123}) RETURN f.name",
560
+ );
561
+ assert.equal(rows.length, 0);
562
+ } finally {
563
+ await dispose(store, dir);
564
+ }
565
+ });
566
+
567
+ // ──────────────────────────────────────────────────────────────────────────
568
+ // ACCEPT — review-round-2 hardening: relationship-target :Label enforcement,
569
+ // WHERE first-variable pushdown, and LIKE-wildcard guard in the pushdown.
570
+ // ──────────────────────────────────────────────────────────────────────────
571
+
572
+ test("ACCEPT: relationship target :Label is enforced (cursor Bugbot: 'Relationship node labels not enforced')", async () => {
573
+ // Fixture: app.handleRequest (Function) -[:USES_TYPE]-> db.QueryResult (Type).
574
+ // Before the fix, matchesNodePattern ignored the parsed :Label on
575
+ // relationship targets, so `...->(b:Function)` WRONGLY returned the Type
576
+ // node for `b`. Now the parsed label is enforced.
577
+ const { store, dir } = await tempStoreWithFixture();
578
+ try {
579
+ const typed = await run(
580
+ store,
581
+ "MATCH (a:Function)-[:USES_TYPE]->(b:Type) RETURN b.qualifiedName",
582
+ );
583
+ assert.deepEqual(typed.rows.map((r) => r["b.qualifiedName"]), [
584
+ "db.QueryResult",
585
+ ]);
586
+ // Requiring the target to be a Function excludes the Type node —
587
+ // before the fix this returned db.QueryResult.
588
+ const noFn = await run(
589
+ store,
590
+ "MATCH (a:Function)-[:USES_TYPE]->(b:Function) RETURN b.qualifiedName",
591
+ );
592
+ assert.equal(noFn.rows.length, 0);
593
+ // Symmetric on CALLS: no Type node is the target of a CALLS edge.
594
+ const noTypeOverCalls = await run(
595
+ store,
596
+ "MATCH (a:Function)-[:CALLS]->(b:Type) RETURN b.qualifiedName",
597
+ );
598
+ assert.equal(noTypeOverCalls.rows.length, 0);
599
+ } finally {
600
+ await dispose(store, dir);
601
+ }
602
+ });
603
+
604
+ test("ACCEPT: WHERE f.name = ... on the first var narrows before the cap (single conjunction pushdown)", async () => {
605
+ // `MATCH (f) WHERE f.name = "x"` pushes the equality down to the index
606
+ // when WHERE is a single conjunction (no top-level OR), so the named
607
+ // node is found before the 1000-row cap. Parity with the inline form.
608
+ const { store, dir } = await tempStoreWithFixture();
609
+ try {
610
+ const whereForm = await run(
611
+ store,
612
+ 'MATCH (f:Function) WHERE f.name = "handleRequest" RETURN f.qualifiedName',
613
+ );
614
+ assert.deepEqual(whereForm.rows.map((r) => r["f.qualifiedName"]), [
615
+ "app.handleRequest",
616
+ ]);
617
+ const inlineForm = await run(
618
+ store,
619
+ 'MATCH (f:Function {name: "handleRequest"}) RETURN f.qualifiedName',
620
+ );
621
+ assert.deepEqual(inlineForm.rows.map((r) => r["f.qualifiedName"]), [
622
+ "app.handleRequest",
623
+ ]);
624
+ // OR (multiple groups) is NOT pushed down — but still resolves
625
+ // correctly via the post-filter, proving the conservative branch.
626
+ const orForm = await run(
627
+ store,
628
+ 'MATCH (f:Function) WHERE f.name = "bootstrap" OR f.name = "query" RETURN f.name',
629
+ );
630
+ assert.deepEqual(
631
+ orForm.rows.map((r) => r["f.name"]).sort(),
632
+ ["bootstrap", "query"],
633
+ );
634
+ } finally {
635
+ await dispose(store, dir);
636
+ }
637
+ });
638
+
639
+ test("ACCEPT: name containing a LIKE metacharacter resolves exactly (pushdown wildcard guard)", async () => {
640
+ // A literal `_` in the value must not be pushed to searchGraph's LIKE
641
+ // matcher (which treats `_` as a single-char wildcard). The value falls
642
+ // back to the capped label scan + exact post-filter and still resolves
643
+ // to the single exact match — not the wildcard neighbour.
644
+ const dir = await mkdtemp(path.join(tmpdir(), "cypher-wild-"));
645
+ const store = await GraphStore.open({
646
+ dbPath: path.join(dir, "graph.sqlite"),
647
+ repoRoot: dir,
648
+ });
649
+ try {
650
+ const file: StoreFileIR = {
651
+ path: "src/w.ts",
652
+ language: "typescript",
653
+ contentHash: "h-wild",
654
+ symbols: [
655
+ sym("w.foo_bar", "foo_bar", 0, 10, "function"),
656
+ sym("w.foozbar", "foozbar", 10, 20, "function"),
657
+ ],
658
+ edges: [],
659
+ };
660
+ const r = await store.upsertFileBatch([file]);
661
+ assert.equal(r.ok, true, "fixture ingest must succeed");
662
+ const { rows } = await run(
663
+ store,
664
+ 'MATCH (f:Function {name: "foo_bar"}) RETURN f.qualifiedName',
665
+ );
666
+ assert.deepEqual(rows.map((row) => row["f.qualifiedName"]), ["w.foo_bar"]);
667
+ } finally {
668
+ await dispose(store, dir);
669
+ }
670
+ });
671
+
672
+ // ──────────────────────────────────────────────────────────────────────────
673
+ // ACCEPT — LIMIT.
674
+ // ──────────────────────────────────────────────────────────────────────────
675
+
676
+ test("ACCEPT: LIMIT caps the row count", async () => {
677
+ const { store, dir } = await tempStoreWithFixture();
678
+ try {
679
+ const { rows } = await run(
680
+ store,
681
+ "MATCH (f:Function) RETURN f.name LIMIT 2",
682
+ );
683
+ assert.equal(rows.length, 2);
684
+ } finally {
685
+ await dispose(store, dir);
686
+ }
687
+ });
688
+
689
+ test("ACCEPT: LIMIT 0 returns zero rows (rule 27 guard)", async () => {
690
+ const { store, dir } = await tempStoreWithFixture();
691
+ try {
692
+ const { rows } = await run(
693
+ store,
694
+ "MATCH (f:Function) RETURN f.name LIMIT 0",
695
+ );
696
+ assert.equal(rows.length, 0);
697
+ } finally {
698
+ await dispose(store, dir);
699
+ }
700
+ });
701
+
702
+ // ──────────────────────────────────────────────────────────────────────────
703
+ // ACCEPT — case-insensitive keywords + comments.
704
+ // ──────────────────────────────────────────────────────────────────────────
705
+
706
+ test("ACCEPT: keywords are case-insensitive", async () => {
707
+ const { store, dir } = await tempStoreWithFixture();
708
+ try {
709
+ const { rows } = await run(
710
+ store,
711
+ 'match (f:Function) where f.name = "query" return f.qualifiedName',
712
+ );
713
+ assert.equal(rows.length, 1);
714
+ assert.equal(rows[0]!["f.qualifiedName"], "db.query");
715
+ } finally {
716
+ await dispose(store, dir);
717
+ }
718
+ });
719
+
720
+ test("ACCEPT: line and block comments are skipped", async () => {
721
+ const { store, dir } = await tempStoreWithFixture();
722
+ try {
723
+ const { rows } = await run(
724
+ store,
725
+ 'MATCH (f:Function) // find query\n/* block */ WHERE f.name = "query" RETURN f.name',
726
+ );
727
+ assert.equal(rows.length, 1);
728
+ assert.equal(rows[0]!["f.name"], "query");
729
+ } finally {
730
+ await dispose(store, dir);
731
+ }
732
+ });
733
+
734
+ // ──────────────────────────────────────────────────────────────────────────
735
+ // REJECT — write / outside clauses.
736
+ // ──────────────────────────────────────────────────────────────────────────
737
+
738
+ test("REJECT: CREATE is rejected as unsupported_clause", () => {
739
+ rejectParse("CREATE (n:Function)", "unsupported_clause");
740
+ });
741
+
742
+ test("REJECT: MERGE is rejected as unsupported_clause", () => {
743
+ rejectParse("MERGE (n:Function)", "unsupported_clause");
744
+ });
745
+
746
+ test("REJECT: SET is rejected as unsupported_clause", () => {
747
+ rejectParse("MATCH (n) SET n.x = 1", "unsupported_clause");
748
+ });
749
+
750
+ test("REJECT: DELETE is rejected as unsupported_clause", () => {
751
+ rejectParse("MATCH (n) DELETE n", "unsupported_clause");
752
+ });
753
+
754
+ test("REJECT: DETACH DELETE is rejected as unsupported_clause", () => {
755
+ rejectParse("MATCH (n) DETACH DELETE n", "unsupported_clause");
756
+ });
757
+
758
+ test("REJECT: REMOVE is rejected as unsupported_clause", () => {
759
+ rejectParse("MATCH (n) REMOVE n.x", "unsupported_clause");
760
+ });
761
+
762
+ test("REJECT: ORDER BY is rejected as unsupported_clause", () => {
763
+ rejectParse("MATCH (n) RETURN n ORDER BY n.name", "unsupported_clause");
764
+ });
765
+
766
+ test("REJECT: WITH is rejected as unsupported_clause", () => {
767
+ rejectParse("MATCH (n) WITH n RETURN n", "unsupported_clause");
768
+ });
769
+
770
+ test("REJECT: UNION is rejected as unsupported_clause", () => {
771
+ rejectParse(
772
+ "MATCH (n) RETURN n UNION MATCH (m) RETURN m",
773
+ "unsupported_clause",
774
+ );
775
+ });
776
+
777
+ test("REJECT: RETURN * is rejected (outside subset)", () => {
778
+ rejectParse("MATCH (n) RETURN *", "unsupported_clause");
779
+ });
780
+
781
+ // ──────────────────────────────────────────────────────────────────────────
782
+ // REJECT — unbounded variable-length.
783
+ // ──────────────────────────────────────────────────────────────────────────
784
+
785
+ test("REJECT: bare * (unbounded) is rejected", () => {
786
+ rejectParse("MATCH (a)-[:CALLS*]->(b) RETURN b", "unsupported_clause");
787
+ });
788
+
789
+ test("REJECT: *.. (unbounded max) is rejected", async () => {
790
+ const { store, dir } = await tempStoreWithFixture();
791
+ try {
792
+ reject(store, "MATCH (a)-[:CALLS*1..]->(b) RETURN b", "unsupported_clause");
793
+ } finally {
794
+ await dispose(store, dir);
795
+ }
796
+ });
797
+
798
+ // ──────────────────────────────────────────────────────────────────────────
799
+ // REJECT — unknown labels list valid options.
800
+ // ──────────────────────────────────────────────────────────────────────────
801
+
802
+ test("REJECT: unknown label produces unknown_label with validLabels", () => {
803
+ const r = parseCypher("MATCH (a:NotALabel) RETURN a");
804
+ assert.equal(r.ok, false);
805
+ if (r.ok) return;
806
+ assert.equal(r.code, "unknown_label");
807
+ assert.ok(r.validLabels, "validLabels must be present on unknown_label");
808
+ // The valid list is exactly the documented universe, sorted.
809
+ assert.deepEqual(r.validLabels, [...VALID_CYPHER_LABELS]);
810
+ // Message names the bad label and the supported grammar.
811
+ assert.match(r.message, /NotALabel/);
812
+ });
813
+
814
+ test("REJECT: lowercase 'function' is NOT a valid Cypher label (case-sensitive labels)", () => {
815
+ // Cypher labels are case-sensitive PascalCase identifiers. The DB
816
+ // stores lowercase kinds; the Cypher layer maps PascalCase→lowercase
817
+ // internally. A user writing `:function` gets unknown_label with the
818
+ // valid (PascalCase) options listed.
819
+ const r = parseCypher("MATCH (a:function) RETURN a");
820
+ assert.equal(r.ok, false);
821
+ if (r.ok) return;
822
+ assert.equal(r.code, "unknown_label");
823
+ });
824
+
825
+ // ──────────────────────────────────────────────────────────────────────────
826
+ // REJECT — structural / unbound variable.
827
+ // ──────────────────────────────────────────────────────────────────────────
828
+
829
+ test("REJECT: RETURN references unbound variable", () => {
830
+ rejectParse("MATCH (a:Function) RETURN a, c", "unbound_variable");
831
+ });
832
+
833
+ test("REJECT: WHERE references unbound variable", () => {
834
+ rejectParse("MATCH (a:Function) WHERE c.name = \"x\" RETURN a", "unbound_variable");
835
+ });
836
+
837
+ test("REJECT: missing RETURN", () => {
838
+ rejectParse("MATCH (a:Function)", "parse_error");
839
+ });
840
+
841
+ test("REJECT: missing closing paren", () => {
842
+ rejectParse("MATCH (a:Function RETURN a", "parse_error");
843
+ });
844
+
845
+ test("REJECT: empty query", () => {
846
+ rejectParse("", "parse_error");
847
+ rejectParse(" ", "parse_error");
848
+ });
849
+
850
+ test("REJECT: non-string query", () => {
851
+ const r = parseCypher(undefined as unknown as string);
852
+ assert.equal(r.ok, false);
853
+ if (r.ok) return;
854
+ assert.equal(r.code, "invalid_query");
855
+ });
856
+
857
+ // ──────────────────────────────────────────────────────────────────────────
858
+ // REJECT — bare-arrows / malformed relationships.
859
+ // ──────────────────────────────────────────────────────────────────────────
860
+
861
+ test("REJECT: bare arrow without bracket", () => {
862
+ rejectParse("MATCH (a)-->(b) RETURN a", "parse_error");
863
+ });
864
+
865
+ test("REJECT: conflicting direction <-[...]->", () => {
866
+ const r = parseCypher("MATCH (a)<-[:CALLS]->(b) RETURN a");
867
+ assert.equal(r.ok, false);
868
+ if (r.ok) return;
869
+ // Either parse_error or unsupported — we accept either; the contract
870
+ // is "rejected with a clear message", not a specific code here.
871
+ assert.ok(["parse_error", "unsupported_clause", "invalid_query"].includes(r.code));
872
+ });
873
+
874
+ // ──────────────────────────────────────────────────────────────────────────
875
+ // REJECT — closed store.
876
+ // ──────────────────────────────────────────────────────────────────────────
877
+
878
+ test("REJECT: closed store returns store_closed", async () => {
879
+ const { store, dir } = await tempStoreWithFixture();
880
+ await dispose(store, dir);
881
+ // store is now closed; executeCypher must surface store_closed from
882
+ // the underlying searchGraph call.
883
+ const r = executeCypher(store, "MATCH (f:Function) RETURN f");
884
+ assert.equal(r.ok, false);
885
+ if (r.ok) return;
886
+ assert.equal(r.code, "store_closed");
887
+ });
888
+
889
+ // ──────────────────────────────────────────────────────────────────────────
890
+ // Module-level invariants.
891
+ // ──────────────────────────────────────────────────────────────────────────
892
+
893
+ test("INVARIANT: every documented label maps to a db label", () => {
894
+ for (const label of VALID_CYPHER_LABELS) {
895
+ assert.ok(
896
+ label in CYPHER_LABEL_TO_DB_LABEL,
897
+ `${label} missing from CYPHER_LABEL_TO_DB_LABEL`,
898
+ );
899
+ }
900
+ });
901
+
902
+
903
+ test("REJECT: variable-length depth above the supported cap fails with invalid_query (not a silent empty result)", async () => {
904
+ const { store, dir } = await tempStoreWithFile(multiPathCypherFile);
905
+ try {
906
+ const r = executeCypher(
907
+ store,
908
+ 'MATCH (a:Function {name: "a"})-[:CALLS*2000]->(b) RETURN b',
909
+ );
910
+ assert.equal(r.ok, false, "*2000 must fail, not silently return an empty success");
911
+ if (r.ok) throw new Error("expected failure");
912
+ assert.equal(r.code, "invalid_query");
913
+ } finally {
914
+ await dispose(store, dir);
915
+ }
916
+ });
917
+
918
+ test("ACCEPT: truncated flag surfaces when variable-length enumeration hits the path cap (cursor Bugbot)", async () => {
919
+ // A complete directed graph on 23 nodes (no self-loops): 506 edges.
920
+ // *1..3 from one node enumerates 22 + 22*22 + 22*22*22 = 11_154 paths,
921
+ // exceeding the DEFAULT_TRAVERSE_PATHS_MAX (10_000) cap. The executor
922
+ // must surface `truncated: true` rather than silently returning a
923
+ // partial endpoint set.
924
+ const N = 23;
925
+ const symbols: SymbolIR[] = [];
926
+ for (let i = 0; i < N; i += 1) {
927
+ symbols.push(sym("cp." + i, String(i), i * 10, i * 10 + 9));
928
+ }
929
+ const edges: EdgeIR[] = [];
930
+ for (let i = 0; i < N; i += 1) {
931
+ for (let j = 0; j < N; j += 1) {
932
+ if (i !== j) edges.push(edge("cp." + i, "cp." + j));
933
+ }
934
+ }
935
+ const completeFile: StoreFileIR = {
936
+ path: "src/complete.ts",
937
+ language: "typescript",
938
+ contentHash: "h-complete",
939
+ symbols,
940
+ edges,
941
+ };
942
+ const { store, dir } = await tempStoreWithFile(completeFile);
943
+ try {
944
+ const r = executeCypher(
945
+ store,
946
+ 'MATCH (a:Function {name: "0"})-[:CALLS*1..3]->(b) RETURN b.qualifiedName',
947
+ );
948
+ assert.equal(r.ok, true, "query should succeed (partial result, not failure)");
949
+ if (!r.ok) throw new Error("expected ok");
950
+ assert.equal(r.truncated, true, "truncated must be true when the path cap is hit");
951
+ assert.ok(r.rows.length > 0, "partial endpoint set is still returned");
952
+ } finally {
953
+ await dispose(store, dir);
954
+ }
955
+ });
956
+
957
+ test("INVARIANT: the 13 documented labels are all present", () => {
958
+ // Project, Package, Folder, File, Module, Class, Function, Method,
959
+ // Interface, Enum, Type, Route, Resource (issue #1552 body).
960
+ const expected = [
961
+ "Class",
962
+ "Enum",
963
+ "File",
964
+ "Folder",
965
+ "Function",
966
+ "Interface",
967
+ "Method",
968
+ "Module",
969
+ "Package",
970
+ "Project",
971
+ "Resource",
972
+ "Route",
973
+ "Type",
974
+ ];
975
+ assert.deepEqual([...VALID_CYPHER_LABELS].sort(), expected);
976
+ });
977
+
978
+
979
+ // ──────────────────────────────────────────────────────────────────────────
980
+ // ACCEPT — variable-length path enumeration (issue #1650).
981
+ // Exact `*N` (N > 1) must include nodes reachable at BOTH a shorter and a
982
+ // length-N path. The fixture has two paths to b (a->b at len 1, a->c->b at
983
+ // len 2) and a diamond to d (a->b->d, a->c->d, both len 2).
984
+ //
985
+ // a -> b
986
+ // a -> c
987
+ // c -> b
988
+ // b -> d
989
+ // c -> d
990
+ // ──────────────────────────────────────────────────────────────────────────
991
+
992
+ const multiPathCypherFile: StoreFileIR = {
993
+ path: "src/mp.ts",
994
+ language: "typescript",
995
+ contentHash: "h-mp",
996
+ symbols: [
997
+ sym("mp.a", "a", 0, 10),
998
+ sym("mp.b", "b", 10, 20),
999
+ sym("mp.c", "c", 20, 30),
1000
+ sym("mp.d", "d", 30, 40),
1001
+ ],
1002
+ edges: [
1003
+ edge("mp.a", "mp.b"),
1004
+ edge("mp.a", "mp.c"),
1005
+ edge("mp.c", "mp.b"),
1006
+ edge("mp.b", "mp.d"),
1007
+ edge("mp.c", "mp.d"),
1008
+ ],
1009
+ };
1010
+
1011
+ async function tempStoreWithFile(
1012
+ file: StoreFileIR,
1013
+ ): Promise<{ store: GraphStore; dir: string }> {
1014
+ const dir = await mkdtemp(path.join(tmpdir(), "cypher-1650-"));
1015
+ const store = await GraphStore.open({
1016
+ dbPath: path.join(dir, "graph.sqlite"),
1017
+ repoRoot: dir,
1018
+ });
1019
+ const r = await store.upsertFileBatch([file]);
1020
+ assert.equal(r.ok, true, "fixture ingest must succeed");
1021
+ return { store, dir };
1022
+ }
1023
+
1024
+ test("ACCEPT: *N (N>1) includes a node reachable at a shorter AND a length-N path (issue #1650)", async () => {
1025
+ const { store, dir } = await tempStoreWithFile(multiPathCypherFile);
1026
+ try {
1027
+ // b is reachable at len 1 (a->b) AND len 2 (a->c->b). Under the old
1028
+ // BFS compile target, b was visited at depth 1 and dropped by the
1029
+ // depth==2 filter; the length-2 path a->c->b now qualifies it.
1030
+ const { rows } = await run(
1031
+ store,
1032
+ 'MATCH (a:Function {name: "a"})-[:CALLS*2]->(b) RETURN b.qualifiedName',
1033
+ );
1034
+ const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort();
1035
+ assert.deepEqual(qnames, ["mp.b", "mp.d"]);
1036
+ } finally {
1037
+ await dispose(store, dir);
1038
+ }
1039
+ });
1040
+
1041
+ test("ACCEPT: *1..N unchanged — returns reachable nodes, no regression (issue #1650)", async () => {
1042
+ const { store, dir } = await tempStoreWithFile(multiPathCypherFile);
1043
+ try {
1044
+ // *1..2 from a: b (len1), c (len1), d (len2). Endpoint-dedup keeps
1045
+ // this identical to the prior BFS shortest-depth behavior.
1046
+ const { rows } = await run(
1047
+ store,
1048
+ 'MATCH (a:Function {name: "a"})-[:CALLS*1..2]->(b) RETURN b.qualifiedName',
1049
+ );
1050
+ const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort();
1051
+ assert.deepEqual(qnames, ["mp.b", "mp.c", "mp.d"]);
1052
+ } finally {
1053
+ await dispose(store, dir);
1054
+ }
1055
+ });
1056
+
1057
+ test("ACCEPT: diamond *2 reaches the far node via either length-2 path", async () => {
1058
+ const { store, dir } = await tempStoreWithFile(multiPathCypherFile);
1059
+ try {
1060
+ // d is reachable only via length-2 paths (a->b->d, a->c->d). It must
1061
+ // appear in *2 results (deduped to one row).
1062
+ const { rows } = await run(
1063
+ store,
1064
+ 'MATCH (a:Function {name: "a"})-[:CALLS*2]->(b) RETURN b.name',
1065
+ );
1066
+ const names = rows.map((r) => r["b.name"] as string).sort();
1067
+ assert.deepEqual(names, ["b", "d"]);
1068
+ // d appears exactly once (endpoint-dedup), not once per path.
1069
+ const dRows = rows.filter((r) => r["b.name"] === "d");
1070
+ assert.equal(dRows.length, 1);
1071
+ } finally {
1072
+ await dispose(store, dir);
1073
+ }
1074
+ });
1075
+
1076
+ test("ACCEPT: *N on a cyclic graph terminates (cycle safety via relationship-uniqueness)", async () => {
1077
+ // a -> b -> c -> a (cycle) plus a self-edge on b.
1078
+ const cyclicFile: StoreFileIR = {
1079
+ path: "src/cyc.ts",
1080
+ language: "typescript",
1081
+ contentHash: "h-cyc",
1082
+ symbols: [
1083
+ sym("cyc.a", "a", 0, 10),
1084
+ sym("cyc.b", "b", 10, 20),
1085
+ sym("cyc.c", "c", 20, 30),
1086
+ ],
1087
+ edges: [
1088
+ edge("cyc.a", "cyc.b"),
1089
+ edge("cyc.b", "cyc.c"),
1090
+ edge("cyc.c", "cyc.a"),
1091
+ edge("cyc.b", "cyc.b"),
1092
+ ],
1093
+ };
1094
+ const { store, dir } = await tempStoreWithFile(cyclicFile);
1095
+ try {
1096
+ // Must complete (relationship-uniqueness bounds enumeration) and
1097
+ // return only nodes in the cycle.
1098
+ const { rows } = await run(
1099
+ store,
1100
+ 'MATCH (a:Function {name: "a"})-[:CALLS*1..4]->(b) RETURN b.qualifiedName',
1101
+ );
1102
+ const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort();
1103
+ assert.deepEqual(qnames, ["cyc.a", "cyc.b", "cyc.c"]);
1104
+ } finally {
1105
+ await dispose(store, dir);
1106
+ }
1107
+ });