@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,1692 @@
1
+ /**
2
+ * openCypher read subset — hand-written recursive-descent parser + executor.
3
+ *
4
+ * Issue #1552 PR3. This module is the thin, deletable Cypher layer over the
5
+ * structured store API (`searchGraph` / `traverse`). It compiles a strict
6
+ * read-only subset of openCypher to those primitives — there is NO SQL
7
+ * string assembly from user input anywhere; the structured API already
8
+ * parameterizes every bind (rule 51).
9
+ *
10
+ * ## Supported grammar (strict subset)
11
+ *
12
+ * ```
13
+ * query := MATCH pattern [WHERE where_clause] RETURN return_list [LIMIT int]
14
+ * pattern := node_pattern (rel_pattern node_pattern)*
15
+ * node_pattern := '(' [var] [':' label] ['{' prop_map '}'] ')'
16
+ * prop_map := key ':' literal (',' key ':' literal)*
17
+ * rel_pattern := ('<-'? '--' bracket '--' '->'?)
18
+ * | ('<-' bracket '--') // incoming: <-[...]- (also <-[...]--)
19
+ * | ('--' bracket '->') // outgoing: -[...]-> (also --[...]->)
20
+ * | ('<-'? '--' bracket '--' '->'?) // canonical form
21
+ * bracket := '[' [':' type ('|' ':' type)*] ['*' range] ']'
22
+ * range := int ('..' int)? | '..' int
23
+ * where_clause := comparison ((AND | OR) comparison)*
24
+ * comparison := var '.' key op literal
25
+ * op := '=' | '<>' | '!=' | '>' | '<' | '>=' | '<='
26
+ * return_list := return_item (',' return_item)*
27
+ * return_item := var | var '.' key
28
+ * literal := string | number | 'true' | 'false' | 'null'
29
+ * ```
30
+ *
31
+ * Direction (resolved from the dashes/arrows around the bracket):
32
+ * - `-[...]->` → outgoing (follow src→dst edges)
33
+ * - `<-[...]-` → incoming (follow dst→src edges)
34
+ * - `-[...]-` → both
35
+ *
36
+ * Variable-length hops:
37
+ * - `-[:CALLS*1..3]->` → between 1 and 3 CALLS hops (inclusive)
38
+ * - `-[:CALLS*2]->` → exactly 2 hops
39
+ * - `-[:CALLS..3]->` → 1..3 hops (default min = 1)
40
+ * - `-[:CALLS*]->` → REJECTED (unbounded — see rejection table)
41
+ *
42
+ * ## Compile target
43
+ *
44
+ * Single-node patterns compile to `searchGraph({ label })`. Fixed-length
45
+ * relationship patterns compile to `traverse({ start, direction, edgeTypes,
46
+ * maxDepth })`, filtering the returned hits to the relationship's depth
47
+ * range. VARIABLE-length patterns (`*M..N` / `*N`) compile to the path-
48
+ * enumerating primitive `traversePaths` (issue #1650) so an exact `*N`
49
+ * honors concrete length-N paths; endpoints are filtered by PATH LENGTH
50
+ * and deduped by node id. Property filters in node patterns
51
+ * (`{name: "foo"}`) and WHERE conditions are applied in JS as post-filters
52
+ * on the bound nodes.
53
+ *
54
+ * Variable-length patterns enumerate concrete relationship-simple paths via
55
+ * `traversePaths` (issue #1650). Each path is cycle-safe under RELATIONSHIP
56
+ * UNIQUENESS (a single path never reuses an edge), capped at `maxHops` and a
57
+ * total-path cap. `*M..N` returns a node when a path of length in `[M, N]`
58
+ * reaches it; exact `*N` (N > 1) thus includes a node reachable at BOTH a
59
+ * shorter and a length-N path (the length-N path qualifies). The result is
60
+ * deduped by node id, so `*1..N` ("reachable within N hops") is unchanged
61
+ * from the prior BFS behavior — only exact `*N` (N > 1) gains paths the
62
+ * shortest-depth BFS dropped. If enumeration hits the store's maxPaths cap,
63
+ * the success result carries `truncated: true` so callers can detect a
64
+ * partial endpoint set instead of silently dropping reachable nodes.
65
+ *
66
+ * ## Read-only by construction
67
+ *
68
+ * The parser only recognizes the tokens `MATCH`, `WHERE`, `RETURN`,
69
+ * `LIMIT`, `AND`, `OR`, `true`, `false`, `null`. Every write/mutation
70
+ * clause token (`CREATE`, `MERGE`, `SET`, `DELETE`, `DETACH`, `REMOVE`,
71
+ * `DROP`, `CALL`, `YIELD`, `UNION`, `WITH`, `ORDER`, `BY`, `SKIP`,
72
+ * `OPTIONAL`, `EXPLAIN`, `PROFILE`, `USE`, `FOREACH`, `LOAD`,
73
+ * `CONSTRAINT`, `INDEX`) is rejected with a clear error naming the
74
+ * supported grammar (rule 51). The module has no code path that writes
75
+ * to the store.
76
+ *
77
+ * ## Rejection table (each has a dedicated test)
78
+ *
79
+ * - `CREATE (n:Function)` → unsupported clause (read-only)
80
+ * - `MATCH (n) DELETE n` → unsupported clause
81
+ * - `MATCH (n) SET n.x = 1` → unsupported clause
82
+ * - `MATCH (a)-[:CALLS*]->(b) ...` → unbounded `*` (must specify range)
83
+ * - `MATCH (a:NotALabel) ...` → unknown label (lists valid options)
84
+ * - `MATCH (a) RETURN *` → `RETURN *` not in subset
85
+ * - `MATCH (a)-[:CALLS]->(b) RETURN a, c` → unbound variable `c`
86
+ * - `MATCH (a:Function {name: 123}) ...` → wrong-type literal matches
87
+ * nothing (standard Cypher;
88
+ * NOT a parse error — a numeric
89
+ * `name` never equals a string)
90
+ * - `MATCH (a:Function WHERE ...` → missing `)` / missing RETURN
91
+ * - `MATCH (a:Function)` (no RETURN) → missing RETURN
92
+ *
93
+ * ## Scale caveat
94
+ *
95
+ * The subset is aimed at interactive exploration over indexed graphs. The
96
+ * start-node resolution uses `searchGraph` (capped at 1000 rows by the
97
+ * store); the inline `name`/`filePath` property filter and a supported
98
+ * single-conjunction first-variable WHERE equality term are pushed down
99
+ * to the index BEFORE the cap, so an exact-name lookup is found even when
100
+ * the matching node sorts after the cap on a large graph. Values
101
+ * containing LIKE metacharacters (`%`/`_`) and multi-group (OR) WHERE
102
+ * clauses are NOT pushed down — they fall back to the capped scan, so on
103
+ * graphs with more than 1000 nodes of the starting label such queries can
104
+ * still false-negative; use the inline literal form for guaranteed
105
+ * Relationship expansion uses `traverse` (fixed hops) or `traversePaths`
106
+ * (variable length, issue #1650); both are cycle-safe and depth/length
107
+ * capped. See the compile-target note for the path-length semantics of
108
+ * variable-length `*N`.
109
+ */
110
+ import type {
111
+ GraphStore,
112
+ SearchHit,
113
+ TraverseHit,
114
+ TraversePathHit,
115
+ } from "../graph-store.js";
116
+
117
+ import { MAX_TRAVERSE_PATHS_HOPS } from "../graph-store.js";
118
+
119
+ // ──────────────────────────────────────────────────────────────────────────
120
+ // Label universe — the documented schema's node labels.
121
+ //
122
+ // The store persists `nodes.label` as the symbol `kind` (lowercase:
123
+ // `function`, `class`, `method`, `interface`, `enum`, `type`, `module`).
124
+ // The remaining labels (`Project`, `Package`, `Folder`, `File`, `Route`,
125
+ // `Resource`) are part of the documented schema universe but are not
126
+ // emitted by the current ingest pipeline; queries against them simply
127
+ // return zero rows. We ACCEPT the full documented universe so the grammar
128
+ // matches the issue's stated 12+ label list, and REJECT everything outside
129
+ // it with a clear error (rejection table).
130
+ // ──────────────────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * PascalCase Cypher label → the lowercase value stored in `nodes.label`.
134
+ * Labels whose DB form is not produced by ingest still map to a sensible
135
+ * storage key so the query is structurally valid (returns empty).
136
+ */
137
+ const CYPHER_LABEL_TO_DB_LABEL: Record<string, string> = {
138
+ Project: "project",
139
+ Package: "package",
140
+ Folder: "folder",
141
+ File: "file",
142
+ Module: "module",
143
+ Class: "class",
144
+ Function: "function",
145
+ Method: "method",
146
+ Interface: "interface",
147
+ Enum: "enum",
148
+ Type: "type",
149
+ Route: "route",
150
+ Resource: "resource",
151
+ };
152
+
153
+ /** Sorted list of accepted Cypher labels — used in rejection messages. */
154
+ const VALID_CYPHER_LABELS: readonly string[] = Object.keys(
155
+ CYPHER_LABEL_TO_DB_LABEL,
156
+ ).sort();
157
+
158
+ // ──────────────────────────────────────────────────────────────────────────
159
+ // Public result types.
160
+ // ──────────────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * A value projected by RETURN. Strings, numbers, booleans, or null. Whole
164
+ * nodes are returned as {@link CypherNodeValue} so callers can read every
165
+ * field without re-querying.
166
+ */
167
+ export type CypherScalar = string | number | boolean | null;
168
+
169
+ /** A whole-node value (RETURN `var` with no property). */
170
+ export interface CypherNodeValue {
171
+ nodeId: string;
172
+ qualifiedName: string;
173
+ name: string;
174
+ label: string;
175
+ filePath: string;
176
+ }
177
+
178
+ export type CypherValue = CypherScalar | CypherNodeValue;
179
+
180
+ /** One result row — a map from RETURN-item column name to its value. */
181
+ export type CypherRow = Record<string, CypherValue>;
182
+
183
+ /** Failure codes. Distinct from the store codes — Cypher has its own. */
184
+ export type CypherFailureCode =
185
+ | "parse_error" // generic grammar failure
186
+ | "unsupported_clause" // CREATE / SET / DELETE / unbounded `*` / RETURN *
187
+ | "unknown_label" // label not in the documented universe
188
+ | "unbound_variable" // RETURN / WHERE references a var not in MATCH
189
+ | "invalid_query" // structurally parsed but semantically bad (bad range, etc.)
190
+ | "store_closed"
191
+ | "db_locked"
192
+ | "db_corrupt"
193
+ | "db_error";
194
+
195
+ export interface CypherFailure {
196
+ ok: false;
197
+ code: CypherFailureCode;
198
+ /** Human-readable explanation including the supported grammar hint. */
199
+ message: string;
200
+ /**
201
+ * Present only for `unknown_label`: the accepted label list, so callers
202
+ * can render a completion menu without re-deriving it.
203
+ */
204
+ validLabels?: readonly string[];
205
+ }
206
+
207
+ export interface CypherSuccess {
208
+ ok: true;
209
+ /** Column names in RETURN order; each row has these keys. */
210
+ columns: string[];
211
+ rows: CypherRow[];
212
+ /**
213
+ * Present and `true` ONLY when a variable-length expansion hit the
214
+ * `traversePaths` maxPaths cap — the rows are a PARTIAL endpoint set and
215
+ * some reachable nodes may be omitted. Callers that must know the result
216
+ * is complete should treat `truncated: true` as unreliable. Absent means
217
+ * the enumeration completed (issue #1650).
218
+ */
219
+ truncated?: boolean;
220
+ }
221
+
222
+ export type CypherResult = CypherSuccess | CypherFailure;
223
+
224
+ // ──────────────────────────────────────────────────────────────────────────
225
+ // AST types.
226
+ // ──────────────────────────────────────────────────────────────────────────
227
+
228
+ interface NodePattern {
229
+ /** Variable name; undefined for an anonymous node `( )`. */
230
+ varName?: string;
231
+ /** PascalCase label as written (`Function`, `Class`, ...). */
232
+ label?: string;
233
+ /** Inline property filters from `{key: value, ...}`. */
234
+ properties: Array<{ key: string; value: CypherScalar }>;
235
+ }
236
+
237
+ type RelDirection = "outgoing" | "incoming" | "both";
238
+
239
+ interface RelPattern {
240
+ direction: RelDirection;
241
+ /** Edge types; empty means "any type". */
242
+ types: string[];
243
+ /** Inclusive minimum hop count (default 1). */
244
+ minHops: number;
245
+ /** Inclusive maximum hop count. Equal to minHops when `*N` form used. */
246
+ maxHops: number;
247
+ /** True when a `*` range was parsed (variable-length). Drives the path-enumerating compile target (issue #1650). */
248
+ isVarLength: boolean;
249
+ }
250
+
251
+ interface Comparison {
252
+ varName: string;
253
+ key: string;
254
+ op: "=" | "<>" | "!=" | ">" | "<" | ">=" | "<=";
255
+ value: CypherScalar;
256
+ }
257
+
258
+ type WhereTerm = Comparison;
259
+ interface WhereClause {
260
+ /** Flat OR-of-AND-of-terms. We support AND+OR; no precedence gymnastics. */
261
+ orGroups: WhereTerm[][];
262
+ }
263
+
264
+ type ReturnItem =
265
+ | { kind: "var"; varName: string }
266
+ | { kind: "prop"; varName: string; key: string };
267
+
268
+ interface MatchClause {
269
+ nodes: NodePattern[];
270
+ rels: RelPattern[]; // length === nodes.length - 1
271
+ }
272
+
273
+ interface CypherAst {
274
+ match: MatchClause;
275
+ where?: WhereClause;
276
+ return: ReturnItem[];
277
+ limit?: number;
278
+ }
279
+
280
+ // ──────────────────────────────────────────────────────────────────────────
281
+ // Tokenizer.
282
+ // ──────────────────────────────────────────────────────────────────────────
283
+
284
+ type TokenType =
285
+ | "WORD" // identifier OR keyword (disambiguated in parser)
286
+ | "STRING"
287
+ | "NUMBER"
288
+ | "LPAREN"
289
+ | "RPAREN"
290
+ | "LBRACK"
291
+ | "RBRACK"
292
+ | "LBRACE"
293
+ | "RBRACE"
294
+ | "COLON"
295
+ | "PIPE"
296
+ | "COMMA"
297
+ | "DOT"
298
+ | "DOTDOT"
299
+ | "STAR"
300
+ | "ARROW_R" // ->
301
+ | "ARROW_L" // <-
302
+ | "DASHDASH" // --
303
+ | "DASH" // - (only meaningful before a number for negation)
304
+ | "EQ" // =
305
+ | "NE" // <> or !=
306
+ | "GT"
307
+ | "LT"
308
+ | "GE"
309
+ | "LE"
310
+ | "EOF";
311
+
312
+ interface Token {
313
+ type: TokenType;
314
+ /** Raw source text of the token (for identifiers, strings, numbers). */
315
+ text: string;
316
+ /** 0-based offset in the source, for error messages. */
317
+ pos: number;
318
+ }
319
+
320
+ const KEYWORDS: Record<string, true> = {
321
+ match: true,
322
+ where: true,
323
+ return: true,
324
+ limit: true,
325
+ and: true,
326
+ or: true,
327
+ true: true,
328
+ false: true,
329
+ null: true,
330
+ };
331
+
332
+ /**
333
+ * Tokens whose presence ANYWHERE in the query unambiguously signals a
334
+ * write/mutation intent or a clause outside the read subset. They are
335
+ * rejected with `unsupported_clause` carrying a tailored message.
336
+ */
337
+ const WRITE_OR_OUTSIDE_CLAUSES: Record<string, string> = {
338
+ create: "CREATE is a write clause; this Cypher layer is read-only (MATCH/WHERE/RETURN/LIMIT only).",
339
+ merge: "MERGE is a write clause; this Cypher layer is read-only.",
340
+ set: "SET is a write clause; this Cypher layer is read-only.",
341
+ delete: "DELETE is a write clause; this Cypher layer is read-only.",
342
+ detach: "DETACH DELETE is a write clause; this Cypher layer is read-only.",
343
+ remove: "REMOVE is a write clause; this Cypher layer is read-only.",
344
+ drop: "DROP is a write clause; this Cypher layer is read-only.",
345
+ call: "CALL { ... } subqueries are outside the read subset.",
346
+ yield: "YIELD is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).",
347
+ union: "UNION combinator is outside the read subset.",
348
+ with: "WITH is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).",
349
+ order: "ORDER BY is outside the read subset (use LIMIT, or post-sort in the caller).",
350
+ by: "ORDER BY is outside the read subset.",
351
+ skip: "SKIP is outside the read subset (use LIMIT).",
352
+ optional: "OPTIONAL MATCH is outside the read subset.",
353
+ explain: "EXPLAIN is outside the read subset.",
354
+ profile: "PROFILE is outside the read subset.",
355
+ use: "USE (graph routing) is outside the read subset.",
356
+ foreach: "FOREACH is a write clause; this Cypher layer is read-only.",
357
+ load: "LOAD CSV / LOAD FROM is outside the read subset.",
358
+ constraint: "CONSTRAINT is a DDL clause; this Cypher layer is read-only.",
359
+ index: "INDEX is a DDL clause; this Cypher layer is read-only.",
360
+ graph: "GRAPH is outside the read subset.",
361
+ unwind: "UNWIND is outside the read subset.",
362
+ distinct: "DISTINCT is outside the read subset (this layer does not dedupe).",
363
+ exists: "EXISTS is outside the read subset (use property comparisons).",
364
+ in: "IN is outside the read subset (use a property comparison).",
365
+ is_: "IS NULL / IS NOT NULL is outside the read subset.",
366
+ not: "NOT is outside the read subset (use positive comparisons or <>).",
367
+ starts: "STARTS WITH is outside the read subset.",
368
+ ends: "ENDS WITH is outside the read subset.",
369
+ contains: "CONTAINS is outside the read subset.",
370
+ regex: "=~ regex is outside the read subset.",
371
+ count: "COUNT(...) aggregation is outside the read subset.",
372
+ sum: "SUM(...) aggregation is outside the read subset.",
373
+ min: "MIN(...) aggregation is outside the read subset.",
374
+ max: "MAX(...) aggregation is outside the read subset.",
375
+ avg: "AVG(...) aggregation is outside the read subset.",
376
+ collect: "COLLECT(...) aggregation is outside the read subset.",
377
+ case: "CASE expressions are outside the read subset.",
378
+ when: "CASE expressions are outside the read subset.",
379
+ then: "CASE expressions are outside the read subset.",
380
+ else: "CASE expressions are outside the read subset.",
381
+ end: "CASE ... END is outside the read subset.",
382
+ as: "AS aliasing is outside the read subset (RETURN items project under their literal text).",
383
+ reduce: "REDUCE is outside the read subset.",
384
+ shortestpath: "shortestPath() is outside the read subset.",
385
+ all: "ALL(...) pattern predicate is outside the read subset.",
386
+ any: "ANY(...) pattern predicate is outside the read subset.",
387
+ none: "NONE(...) pattern predicate is outside the read subset.",
388
+ single: "SINGLE(...) pattern predicate is outside the read subset.",
389
+ size: "SIZE() is outside the read subset.",
390
+ };
391
+
392
+ class Tokenizer {
393
+ private readonly src: string;
394
+ private i = 0;
395
+ private readonly tokens: Token[] = [];
396
+
397
+ constructor(src: string) {
398
+ this.src = src;
399
+ }
400
+
401
+ tokenize(): Token[] {
402
+ while (this.i < this.src.length) {
403
+ const ch = this.src[this.i]!;
404
+ // Whitespace.
405
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
406
+ this.i += 1;
407
+ continue;
408
+ }
409
+ // Line comments `//` (Cypher) and block comments `/* */`.
410
+ if (ch === "/" && this.src[this.i + 1] === "/") {
411
+ const nl = this.src.indexOf("\n", this.i);
412
+ this.i = nl === -1 ? this.src.length : nl + 1;
413
+ continue;
414
+ }
415
+ if (ch === "/" && this.src[this.i + 1] === "*") {
416
+ const end = this.src.indexOf("*/", this.i + 2);
417
+ if (end === -1) {
418
+ throw parseError(
419
+ this.i,
420
+ "Unterminated block comment. Supported grammar: MATCH/WHERE/RETURN/LIMIT.",
421
+ );
422
+ }
423
+ this.i = end + 2;
424
+ continue;
425
+ }
426
+ const start = this.i;
427
+ // Strings — double or single quoted. Backslash escapes are preserved
428
+ // literally (the subset doesn't need them); the closing quote is the
429
+ // next unescaped matching quote.
430
+ if (ch === '"' || ch === "'") {
431
+ this.readString(ch, start);
432
+ continue;
433
+ }
434
+ // Numbers — digits, or `.`-leading fractional. A leading `-` is
435
+ // emitted as a DASH token so the parser can negate a NUMBER in the
436
+ // literal position (the tokenizer never looks that far ahead).
437
+ if (isDigit(ch) || (ch === "." && isDigit(this.src[this.i + 1]!))) {
438
+ this.readNumber(start);
439
+ continue;
440
+ }
441
+ // Identifiers / keywords — [A-Za-z_][A-Za-z0-9_]*
442
+ if (isIdentStart(ch)) {
443
+ this.readWord(start);
444
+ continue;
445
+ }
446
+ // Punctuation & operators — maximal munch.
447
+ const two = this.src.slice(this.i, this.i + 2);
448
+ const three = this.src.slice(this.i, this.i + 3);
449
+ if (three === "-->") {
450
+ this.push("ARROW_R", three, start);
451
+ continue;
452
+ }
453
+ if (three === "<--") {
454
+ this.push("ARROW_L", three, start);
455
+ continue;
456
+ }
457
+ if (two === "->") {
458
+ this.push("ARROW_R", two, start);
459
+ continue;
460
+ }
461
+ if (two === "<-") {
462
+ this.push("ARROW_L", two, start);
463
+ continue;
464
+ }
465
+ if (two === "--") {
466
+ this.push("DASHDASH", two, start);
467
+ continue;
468
+ }
469
+ if (two === "..") {
470
+ this.push("DOTDOT", two, start);
471
+ continue;
472
+ }
473
+ if (two === "<>" || two === "!=") {
474
+ this.push("NE", two, start);
475
+ continue;
476
+ }
477
+ if (two === ">=") {
478
+ this.push("GE", two, start);
479
+ continue;
480
+ }
481
+ if (two === "<=") {
482
+ this.push("LE", two, start);
483
+ continue;
484
+ }
485
+ switch (ch) {
486
+ case "(":
487
+ this.push("LPAREN", ch, start);
488
+ break;
489
+ case ")":
490
+ this.push("RPAREN", ch, start);
491
+ break;
492
+ case "[":
493
+ this.push("LBRACK", ch, start);
494
+ break;
495
+ case "]":
496
+ this.push("RBRACK", ch, start);
497
+ break;
498
+ case "{":
499
+ this.push("LBRACE", ch, start);
500
+ break;
501
+ case "}":
502
+ this.push("RBRACE", ch, start);
503
+ break;
504
+ case ":":
505
+ this.push("COLON", ch, start);
506
+ break;
507
+ case "|":
508
+ this.push("PIPE", ch, start);
509
+ break;
510
+ case ",":
511
+ this.push("COMMA", ch, start);
512
+ break;
513
+ case ".":
514
+ this.push("DOT", ch, start);
515
+ break;
516
+ case "*":
517
+ this.push("STAR", ch, start);
518
+ break;
519
+ case "-":
520
+ this.push("DASH", ch, start);
521
+ break;
522
+ case "=":
523
+ this.push("EQ", ch, start);
524
+ break;
525
+ case ">":
526
+ this.push("GT", ch, start);
527
+ break;
528
+ case "<":
529
+ this.push("LT", ch, start);
530
+ break;
531
+ default:
532
+ throw parseError(
533
+ start,
534
+ `Unexpected character ${JSON.stringify(ch)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`,
535
+ );
536
+ }
537
+ }
538
+ this.push("EOF", "", this.i);
539
+ return this.tokens;
540
+ }
541
+
542
+ private push(type: TokenType, text: string, pos: number): void {
543
+ this.tokens.push({ type, text, pos });
544
+ this.i = pos + text.length;
545
+ }
546
+
547
+ private readString(quote: string, start: number): void {
548
+ let j = start + 1;
549
+ const chars: string[] = [];
550
+ while (j < this.src.length) {
551
+ const c = this.src[j]!;
552
+ if (c === "\\") {
553
+ // Preserve the escape sequence verbatim — the subset doesn't
554
+ // interpret \n / \t / \" / \\; it stores and matches the raw two
555
+ // characters. This is documented and tested.
556
+ chars.push(c);
557
+ chars.push(this.src[j + 1] ?? "");
558
+ j += 2;
559
+ continue;
560
+ }
561
+ if (c === quote) {
562
+ const text = chars.join("");
563
+ this.tokens.push({ type: "STRING", text, pos: start });
564
+ this.i = j + 1;
565
+ return;
566
+ }
567
+ chars.push(c);
568
+ j += 1;
569
+ }
570
+ throw parseError(start, "Unterminated string literal.");
571
+ }
572
+
573
+ private readNumber(start: number): void {
574
+ let j = start;
575
+ while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
576
+ if (this.src[j] === "." && isDigit(this.src[j + 1]!)) {
577
+ j += 1;
578
+ while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
579
+ }
580
+ // Exponent (e.g. 1e3). Rare in property filters but cheap to accept.
581
+ if (this.src[j] === "e" || this.src[j] === "E") {
582
+ j += 1;
583
+ if (this.src[j] === "+" || this.src[j] === "-") j += 1;
584
+ while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
585
+ }
586
+ const text = this.src.slice(start, j);
587
+ this.tokens.push({ type: "NUMBER", text, pos: start });
588
+ this.i = j;
589
+ }
590
+
591
+ private readWord(start: number): void {
592
+ let j = start;
593
+ while (j < this.src.length && isIdentPart(this.src[j]!)) j += 1;
594
+ const text = this.src.slice(start, j);
595
+ this.tokens.push({ type: "WORD", text, pos: start });
596
+ this.i = j;
597
+ }
598
+ }
599
+
600
+ function isDigit(c: string): boolean {
601
+ return c >= "0" && c <= "9";
602
+ }
603
+ function isIdentStart(c: string): boolean {
604
+ return (
605
+ (c >= "a" && c <= "z") ||
606
+ (c >= "A" && c <= "Z") ||
607
+ c === "_"
608
+ );
609
+ }
610
+ function isIdentPart(c: string): boolean {
611
+ return isIdentStart(c) || isDigit(c);
612
+ }
613
+
614
+ // ──────────────────────────────────────────────────────────────────────────
615
+ // Parser.
616
+ // ──────────────────────────────────────────────────────────────────────────
617
+
618
+ class Parser {
619
+ private readonly tokens: Token[];
620
+ private i = 0;
621
+
622
+ constructor(tokens: Token[]) {
623
+ this.tokens = tokens;
624
+ }
625
+
626
+ parse(): CypherAst {
627
+ // Cypher queries in the subset MUST start with MATCH. Any other
628
+ // leading token — including a write clause like CREATE — is rejected
629
+ // here with the supported-grammar hint.
630
+ this.expectKeyword("match");
631
+ const match = this.parseMatch();
632
+
633
+ let where: WhereClause | undefined;
634
+ if (this.consumeKeyword("where")) {
635
+ where = this.parseWhere();
636
+ }
637
+
638
+ this.expectKeyword("return");
639
+ const returnItems = this.parseReturn();
640
+
641
+ let limit: number | undefined;
642
+ if (this.consumeKeyword("limit")) {
643
+ limit = this.parseNonNegativeInt("LIMIT");
644
+ }
645
+
646
+ // Anything after LIMIT (other than EOF) is outside the subset.
647
+ const tok = this.peek();
648
+ if (tok.type !== "EOF") {
649
+ throw this.unsupportedAfter(tok);
650
+ }
651
+
652
+ this.validateAst(match, where, returnItems);
653
+ return { match, where, return: returnItems, limit };
654
+ }
655
+
656
+ // ── MATCH / pattern ────────────────────────────────────────────────────
657
+
658
+ private parseMatch(): MatchClause {
659
+ const nodes: NodePattern[] = [this.parseNode()];
660
+ const rels: RelPattern[] = [];
661
+ // A pattern continues while the next token begins a relationship.
662
+ while (this.startsWithRelationship()) {
663
+ const rel = this.parseRelationship();
664
+ nodes.push(this.parseNode());
665
+ rels.push(rel);
666
+ }
667
+ return { nodes, rels };
668
+ }
669
+
670
+ private startsWithRelationship(): boolean {
671
+ const t = this.peek();
672
+ // A relationship starts with a left-side dash run: `<-` (ARROW_L),
673
+ // `--` (DASHDASH), or `-` (DASH). A bare `<` (LT) is NOT a valid
674
+ // relationship start — incoming uses `<-`, tokenized as ARROW_L.
675
+ return (
676
+ t.type === "ARROW_L" ||
677
+ t.type === "DASHDASH" ||
678
+ t.type === "DASH"
679
+ );
680
+ }
681
+
682
+ private parseNode(): NodePattern {
683
+ this.expect("LPAREN", "a node pattern `(var:Label {p: \"v\"})`");
684
+ let varName: string | undefined;
685
+ const first = this.peek();
686
+ if (first.type === "WORD" && !this.isKeyword(first.text)) {
687
+ varName = first.text;
688
+ this.advance();
689
+ }
690
+ let label: string | undefined;
691
+ if (this.peek().type === "COLON") {
692
+ this.advance();
693
+ const lt = this.expect("WORD", "a label after `:`");
694
+ label = lt.text;
695
+ }
696
+ const properties: Array<{ key: string; value: CypherScalar }> = [];
697
+ if (this.peek().type === "LBRACE") {
698
+ this.advance();
699
+ // Empty `{}` is allowed.
700
+ if (this.peek().type !== "RBRACE") {
701
+ properties.push(this.parseProp());
702
+ while (this.peek().type === "COMMA") {
703
+ this.advance();
704
+ properties.push(this.parseProp());
705
+ }
706
+ }
707
+ this.expect("RBRACE", "closing `}` of the property map");
708
+ }
709
+ this.expect("RPAREN", "closing `)` of the node pattern");
710
+ return { varName, label, properties };
711
+ }
712
+
713
+ private parseProp(): { key: string; value: CypherScalar } {
714
+ const keyTok = this.expect("WORD", "a property key");
715
+ this.expect("COLON", "`:` between property key and value");
716
+ const value = this.parseLiteral();
717
+ return { key: keyTok.text, value };
718
+ }
719
+
720
+ /**
721
+ * Parse `<leftDash> [bracket] <rightDash>`. Direction is resolved from
722
+ * the two dash runs: `-[...]->` outgoing, `<-[...]-` incoming,
723
+ * `-[...]-` / `--[...]--` both. The bracket is required.
724
+ */
725
+ private parseRelationship(): RelPattern {
726
+ const leftDir = this.consumeLeftDashRun();
727
+ // The bracket `[...]` is REQUIRED in the subset — bare `-->` without
728
+ // a bracket is rejected because the grammar documents the bracketed
729
+ // form and we want one shape to test.
730
+ if (this.peek().type !== "LBRACK") {
731
+ throw parseError(
732
+ this.peek().pos,
733
+ "Relationships must use a bracketed form, e.g. `-[:CALLS]->` or `-[:CALLS*1..3]->`. Bare arrows without `[...]` are not in the subset.",
734
+ );
735
+ }
736
+ this.advance(); // consume [
737
+ const types: string[] = [];
738
+ if (this.peek().type === "COLON") {
739
+ this.advance();
740
+ types.push(this.expect("WORD", "an edge type after `:[`").text);
741
+ while (this.peek().type === "PIPE") {
742
+ this.advance();
743
+ // openCypher accepts both `:A|:B` and `:A|B`; the colon after `|`
744
+ // is optional. Consume it if present, else read the type directly.
745
+ if (this.peek().type === "COLON") this.advance();
746
+ types.push(this.expect("WORD", "an edge type after `|`").text);
747
+ }
748
+ }
749
+ let minHops = 1;
750
+ let maxHops = 1;
751
+ let isVarLength = false;
752
+ if (this.peek().type === "STAR") {
753
+ this.advance();
754
+ const range = this.parseRange();
755
+ minHops = range.min;
756
+ maxHops = range.max;
757
+ isVarLength = true;
758
+ }
759
+ this.expect("RBRACK", "closing `]` of the relationship bracket");
760
+
761
+ const rightDir = this.consumeRightDashRun();
762
+ if (leftDir === "none" && rightDir === "none") {
763
+ throw parseError(
764
+ this.peek().pos,
765
+ "Relationships require dashes around the bracket, e.g. `-[:CALLS]->`, `<-[:CALLS]-`, or `-[:CALLS]-`. Bare `[...]` between nodes is not valid.",
766
+ );
767
+ }
768
+
769
+ const direction = resolveDirection(leftDir, rightDir);
770
+ if (maxHops < minHops) {
771
+ throw parseError(
772
+ this.peek().pos,
773
+ `Relationship range max (${maxHops}) is less than min (${minHops}).`,
774
+ );
775
+ }
776
+ return { direction, types, minHops, maxHops, isVarLength };
777
+ }
778
+
779
+ private parseRange(): { min: number; max: number } {
780
+ // Forms accepted:
781
+ // N → exactly N (min=max=N)
782
+ // N..M → min=N, max=M
783
+ // ..M → min=1, max=M (default min)
784
+ // Forms REJECTED:
785
+ // (empty) → bare `*` — unbounded → unsupported_clause
786
+ // N.. → unbounded max → unsupported_clause
787
+ // .. → unbounded max → unsupported_clause
788
+ const tok = this.peek();
789
+ const hasLower = tok.type === "NUMBER";
790
+ let min = 1;
791
+ let max = Infinity;
792
+ if (hasLower) {
793
+ const n = this.parseNonNegativeInt("the minimum hop count");
794
+ min = n;
795
+ max = n; // `*N` form: exactly N
796
+ }
797
+ if (this.peek().type === "DOTDOT") {
798
+ this.advance();
799
+ // After `..`, a NUMBER is required (we don't accept unbounded max).
800
+ if (this.peek().type === "NUMBER") {
801
+ max = this.parseNonNegativeInt("the maximum hop count");
802
+ } else {
803
+ throw parseError(
804
+ this.peek().pos,
805
+ "Unbounded variable-length `*..` is not supported. Specify a maximum, e.g. `*1..3`. The read subset rejects unbounded traversal.",
806
+ "unsupported_clause",
807
+ );
808
+ }
809
+ } else if (!hasLower) {
810
+ // Bare `*` with nothing after it.
811
+ throw parseError(
812
+ tok.pos,
813
+ "Unbounded variable-length `*` is not supported. Specify a range, e.g. `*1..3` or `*2`. The read subset rejects unbounded traversal.",
814
+ "unsupported_clause",
815
+ );
816
+ }
817
+ if (min < 0 || max < 0) {
818
+ throw parseError(tok.pos, "Hop counts must be non-negative integers.");
819
+ }
820
+ return { min, max };
821
+ }
822
+
823
+ // ── dash-run consumption ───────────────────────────────────────────────
824
+
825
+ /**
826
+ * Left-side dash-run result. `"incoming"` = `<-` arrow; `"dash"` = bare
827
+ * `-`/`--` (direction-neutral — the OTHER side's arrow wins, or undirected
828
+ * if neither side has an arrow); `"none"` = nothing present.
829
+ */
830
+ private consumeLeftDashRun(): "incoming" | "dash" | "none" {
831
+ const t = this.peek();
832
+ if (t.type === "ARROW_L") {
833
+ this.advance();
834
+ return "incoming";
835
+ }
836
+ if (t.type === "DASHDASH" || t.type === "DASH") {
837
+ this.advance();
838
+ return "dash";
839
+ }
840
+ return "none";
841
+ }
842
+
843
+ /**
844
+ * Right-side dash-run result. `"outgoing"` = `->`/`-->` arrow; `"dash"`
845
+ * = bare `-`/`--` (direction-neutral); `"none"` = nothing present.
846
+ */
847
+ private consumeRightDashRun(): "outgoing" | "dash" | "none" {
848
+ const t = this.peek();
849
+ if (t.type === "ARROW_R") {
850
+ this.advance();
851
+ return "outgoing";
852
+ }
853
+ if (t.type === "DASHDASH" || t.type === "DASH") {
854
+ this.advance();
855
+ return "dash";
856
+ }
857
+ return "none";
858
+ }
859
+
860
+ // ── WHERE ──────────────────────────────────────────────────────────────
861
+
862
+ private parseWhere(): WhereClause {
863
+ // Flat OR-of-AND: split on OR, each side is AND-joined terms.
864
+ const orGroups: WhereTerm[][] = [];
865
+ orGroups.push(this.parseAndGroup());
866
+ while (this.consumeKeyword("or")) {
867
+ orGroups.push(this.parseAndGroup());
868
+ }
869
+ return { orGroups };
870
+ }
871
+
872
+ private parseAndGroup(): WhereTerm[] {
873
+ const terms: WhereTerm[] = [this.parseComparison()];
874
+ while (this.consumeKeyword("and")) {
875
+ terms.push(this.parseComparison());
876
+ }
877
+ return terms;
878
+ }
879
+
880
+ private parseComparison(): Comparison {
881
+ const varTok = this.expect(
882
+ "WORD",
883
+ "a variable in WHERE (e.g. `a.name = \"foo\"`)",
884
+ );
885
+ if (this.isKeyword(varTok.text)) {
886
+ throw parseError(
887
+ varTok.pos,
888
+ `Expected a variable name in WHERE but found keyword ${JSON.stringify(varTok.text)}.`,
889
+ );
890
+ }
891
+ this.expect("DOT", "`.` between variable and property in WHERE");
892
+ const keyTok = this.expect("WORD", "a property name after `var.`");
893
+ const op = this.parseOp();
894
+ const value = this.parseLiteral();
895
+ return { varName: varTok.text, key: keyTok.text, op, value };
896
+ }
897
+
898
+ private parseOp(): Comparison["op"] {
899
+ const t = this.peek();
900
+ switch (t.type) {
901
+ case "EQ":
902
+ this.advance();
903
+ return "=";
904
+ case "NE":
905
+ this.advance();
906
+ return "<>";
907
+ case "GT":
908
+ this.advance();
909
+ return ">";
910
+ case "LT":
911
+ this.advance();
912
+ return "<";
913
+ case "GE":
914
+ this.advance();
915
+ return ">=";
916
+ case "LE":
917
+ this.advance();
918
+ return "<=";
919
+ default:
920
+ throw parseError(
921
+ t.pos,
922
+ "Expected a comparison operator (=, <>, !=, >, <, >=, <=) in WHERE.",
923
+ );
924
+ }
925
+ }
926
+
927
+ // ── RETURN ─────────────────────────────────────────────────────────────
928
+
929
+ private parseReturn(): ReturnItem[] {
930
+ // Reject `RETURN *` explicitly — it's outside the subset.
931
+ if (this.peek().type === "STAR") {
932
+ throw parseError(
933
+ this.peek().pos,
934
+ "`RETURN *` is outside the read subset. List the items to project explicitly, e.g. `RETURN a, b.name`.",
935
+ "unsupported_clause",
936
+ );
937
+ }
938
+ const items: ReturnItem[] = [this.parseReturnItem()];
939
+ while (this.peek().type === "COMMA") {
940
+ this.advance();
941
+ items.push(this.parseReturnItem());
942
+ }
943
+ if (items.length === 0) {
944
+ // parseReturnItem always produces one; defensive.
945
+ throw parseError(this.peek().pos, "RETURN requires at least one item.");
946
+ }
947
+ return items;
948
+ }
949
+
950
+ private parseReturnItem(): ReturnItem {
951
+ const varTok = this.expect(
952
+ "WORD",
953
+ "a variable (or var.prop) in RETURN",
954
+ );
955
+ if (this.isKeyword(varTok.text)) {
956
+ throw parseError(
957
+ varTok.pos,
958
+ `Expected a variable name in RETURN but found keyword ${JSON.stringify(varTok.text)}.`,
959
+ );
960
+ }
961
+ if (this.peek().type === "DOT") {
962
+ this.advance();
963
+ const keyTok = this.expect("WORD", "a property name after `var.`");
964
+ return { kind: "prop", varName: varTok.text, key: keyTok.text };
965
+ }
966
+ return { kind: "var", varName: varTok.text };
967
+ }
968
+
969
+ // ── numeric helpers ────────────────────────────────────────────────────
970
+
971
+ private parseNonNegativeInt(label: string): number {
972
+ const tok = this.peek();
973
+ if (tok.type === "NUMBER") {
974
+ const n = Number(tok.text);
975
+ if (!Number.isInteger(n) || n < 0) {
976
+ throw parseError(
977
+ tok.pos,
978
+ `${label} must be a non-negative integer; got ${JSON.stringify(tok.text)}.`,
979
+ );
980
+ }
981
+ this.advance();
982
+ return n;
983
+ }
984
+ throw parseError(tok.pos, `Expected a non-negative integer for ${label}.`);
985
+ }
986
+
987
+ // ── literals ───────────────────────────────────────────────────────────
988
+
989
+ private parseLiteral(): CypherScalar {
990
+ const tok = this.peek();
991
+ // Optional leading `-` for negative numbers.
992
+ let negate = false;
993
+ if (tok.type === "DASH") {
994
+ negate = true;
995
+ this.advance();
996
+ }
997
+ const t = negate ? this.peek() : tok;
998
+ if (t.type === "STRING") {
999
+ if (negate) {
1000
+ throw parseError(t.pos, "Cannot negate a string literal.");
1001
+ }
1002
+ this.advance();
1003
+ return t.text;
1004
+ }
1005
+ if (t.type === "NUMBER") {
1006
+ this.advance();
1007
+ const n = Number(t.text);
1008
+ return negate ? -n : n;
1009
+ }
1010
+ if (t.type === "WORD") {
1011
+ if (t.text.toLowerCase() === "true") {
1012
+ if (negate) {
1013
+ throw parseError(t.pos, "Cannot negate `true`.");
1014
+ }
1015
+ this.advance();
1016
+ return true;
1017
+ }
1018
+ if (t.text.toLowerCase() === "false") {
1019
+ if (negate) {
1020
+ throw parseError(t.pos, "Cannot negate `false`.");
1021
+ }
1022
+ this.advance();
1023
+ return false;
1024
+ }
1025
+ if (t.text.toLowerCase() === "null") {
1026
+ if (negate) {
1027
+ throw parseError(t.pos, "Cannot negate `null`.");
1028
+ }
1029
+ this.advance();
1030
+ return null;
1031
+ }
1032
+ // Any other WORD here is a bare identifier where a literal was
1033
+ // expected — almost certainly a typo or an unsupported construct.
1034
+ // If it's a write/outside keyword, surface that message.
1035
+ const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()];
1036
+ if (outside) {
1037
+ throw parseError(t.pos, outside);
1038
+ }
1039
+ }
1040
+ throw parseError(
1041
+ tok.pos,
1042
+ "Expected a literal value (string, number, true, false, or null).",
1043
+ );
1044
+ }
1045
+
1046
+ // ── AST validation ─────────────────────────────────────────────────────
1047
+
1048
+ private validateAst(
1049
+ match: MatchClause,
1050
+ where: WhereClause | undefined,
1051
+ returnItems: ReturnItem[],
1052
+ ): void {
1053
+ // Collect bound variable names (anonymous nodes contribute nothing).
1054
+ const bound = new Set<string>();
1055
+ for (const n of match.nodes) {
1056
+ if (n.varName) bound.add(n.varName);
1057
+ }
1058
+ // Validate labels.
1059
+ for (const n of match.nodes) {
1060
+ if (n.label !== undefined && !(n.label in CYPHER_LABEL_TO_DB_LABEL)) {
1061
+ throw parseError(
1062
+ 0,
1063
+ `Unknown label ${JSON.stringify(n.label)}. Valid labels: ${VALID_CYPHER_LABELS.join(", ")}.`,
1064
+ "unknown_label",
1065
+ [...VALID_CYPHER_LABELS],
1066
+ );
1067
+ }
1068
+ }
1069
+ // Validate WHERE variable references.
1070
+ if (where) {
1071
+ for (const group of where.orGroups) {
1072
+ for (const term of group) {
1073
+ if (!bound.has(term.varName)) {
1074
+ throw parseError(
1075
+ 0,
1076
+ `WHERE references variable ${JSON.stringify(term.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`,
1077
+ "unbound_variable",
1078
+ );
1079
+ }
1080
+ }
1081
+ }
1082
+ }
1083
+ // Validate RETURN variable references.
1084
+ for (const item of returnItems) {
1085
+ if (!bound.has(item.varName)) {
1086
+ throw parseError(
1087
+ 0,
1088
+ `RETURN references variable ${JSON.stringify(item.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`,
1089
+ "unbound_variable",
1090
+ );
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ // ── token helpers ──────────────────────────────────────────────────────
1096
+
1097
+ private peek(): Token {
1098
+ return this.tokens[this.i]!;
1099
+ }
1100
+
1101
+ private advance(): Token {
1102
+ const t = this.tokens[this.i]!;
1103
+ if (this.i < this.tokens.length - 1) this.i += 1;
1104
+ return t;
1105
+ }
1106
+
1107
+ private expect(type: TokenType, what: string): Token {
1108
+ const t = this.peek();
1109
+ if (t.type !== type) {
1110
+ throw parseError(
1111
+ t.pos,
1112
+ `Expected ${what} but found ${describeToken(t)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`,
1113
+ );
1114
+ }
1115
+ return this.advance();
1116
+ }
1117
+
1118
+ private expectKeyword(kw: string): Token {
1119
+ const t = this.peek();
1120
+ if (t.type === "WORD" && t.text.toLowerCase() === kw) {
1121
+ return this.advance();
1122
+ }
1123
+ // Helpful: if it's a write/outside clause, lead with that message.
1124
+ if (t.type === "WORD") {
1125
+ const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()];
1126
+ if (outside) {
1127
+ throw parseError(t.pos, outside, "unsupported_clause");
1128
+ }
1129
+ }
1130
+ throw parseError(
1131
+ t.pos,
1132
+ `Expected keyword ${kw.toUpperCase()} but found ${describeToken(t)}. Queries must start with MATCH and use only MATCH/WHERE/RETURN/LIMIT.`,
1133
+ "parse_error",
1134
+ );
1135
+ }
1136
+
1137
+ private consumeKeyword(kw: string): boolean {
1138
+ const t = this.peek();
1139
+ if (t.type === "WORD" && t.text.toLowerCase() === kw) {
1140
+ this.advance();
1141
+ return true;
1142
+ }
1143
+ return false;
1144
+ }
1145
+
1146
+ private isKeyword(text: string): boolean {
1147
+ return KEYWORDS[text.toLowerCase()] === true;
1148
+ }
1149
+
1150
+ private unsupportedAfter(tok: Token): CypherParseError {
1151
+ const outside = WRITE_OR_OUTSIDE_CLAUSES[tok.text.toLowerCase()];
1152
+ if (outside) {
1153
+ return parseError(tok.pos, outside, "unsupported_clause");
1154
+ }
1155
+ return parseError(
1156
+ tok.pos,
1157
+ `Unexpected token ${describeToken(tok)} after the query. Only MATCH/WHERE/RETURN/LIMIT are supported.`,
1158
+ "parse_error",
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ function resolveDirection(
1164
+ left: "incoming" | "dash" | "none",
1165
+ right: "outgoing" | "dash" | "none",
1166
+ ): RelDirection {
1167
+ // Arrows on each side carry direction; bare dashes are direction-neutral.
1168
+ // -[...]-> : left="dash", right="outgoing" → outgoing
1169
+ // <-[...]- : left="incoming", right="dash" → incoming
1170
+ // -[...]- : left="dash", right="dash" → both (undirected)
1171
+ // <-[...]-> : left="incoming", right="outgoing" → conflict (reject)
1172
+ const leftArrow = left === "incoming";
1173
+ const rightArrow = right === "outgoing";
1174
+ if (leftArrow && rightArrow) {
1175
+ throw parseError(
1176
+ 0,
1177
+ "Conflicting relationship direction (e.g. `<-[...]->`). Use a consistent direction.",
1178
+ );
1179
+ }
1180
+ if (leftArrow) return "incoming";
1181
+ if (rightArrow) return "outgoing";
1182
+ return "both";
1183
+ }
1184
+
1185
+ // ──────────────────────────────────────────────────────────────────────────
1186
+ // Errors.
1187
+ // ──────────────────────────────────────────────────────────────────────────
1188
+
1189
+ class CypherParseError extends Error {
1190
+ readonly code: CypherFailureCode;
1191
+ readonly pos: number;
1192
+ readonly validLabels?: readonly string[];
1193
+ constructor(
1194
+ pos: number,
1195
+ message: string,
1196
+ code: CypherFailureCode = "parse_error",
1197
+ validLabels?: readonly string[],
1198
+ ) {
1199
+ super(message);
1200
+ this.name = "CypherParseError";
1201
+ this.pos = pos;
1202
+ this.code = code;
1203
+ this.validLabels = validLabels;
1204
+ }
1205
+ }
1206
+
1207
+ function parseError(
1208
+ pos: number,
1209
+ message: string,
1210
+ code: CypherFailureCode = "parse_error",
1211
+ validLabels?: readonly string[],
1212
+ ): CypherParseError {
1213
+ return new CypherParseError(pos, message, code, validLabels);
1214
+ }
1215
+
1216
+ function describeToken(t: Token): string {
1217
+ if (t.type === "EOF") return "end of input";
1218
+ if (t.type === "WORD") return `identifier ${JSON.stringify(t.text)}`;
1219
+ if (t.type === "STRING") return `string ${JSON.stringify(t.text)}`;
1220
+ if (t.type === "NUMBER") return `number ${t.text}`;
1221
+ return JSON.stringify(t.text);
1222
+ }
1223
+
1224
+ // ──────────────────────────────────────────────────────────────────────────
1225
+ // Public parse API.
1226
+ // ──────────────────────────────────────────────────────────────────────────
1227
+
1228
+ export type CypherParseResult =
1229
+ | { ok: true; ast: CypherAst }
1230
+ | CypherFailure;
1231
+
1232
+ /**
1233
+ * Parse a Cypher query string into an AST without executing it. Use this
1234
+ * to validate query shape (e.g. at a tool boundary) before opening a
1235
+ * store. The AST is an opaque internal type; callers should treat it as
1236
+ * a handle to pass to {@link executeAst}.
1237
+ */
1238
+ export function parseCypher(query: string): CypherParseResult {
1239
+ if (typeof query !== "string") {
1240
+ return {
1241
+ ok: false,
1242
+ code: "invalid_query",
1243
+ message: "Cypher query must be a string.",
1244
+ };
1245
+ }
1246
+ if (query.length === 0 || query.trim().length === 0) {
1247
+ return {
1248
+ ok: false,
1249
+ code: "parse_error",
1250
+ message: "Empty query. Supported grammar: MATCH (a:Label {p: \"v\"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.",
1251
+ };
1252
+ }
1253
+ try {
1254
+ const tokens = new Tokenizer(query).tokenize();
1255
+ const ast = new Parser(tokens).parse();
1256
+ return { ok: true, ast };
1257
+ } catch (e) {
1258
+ if (e instanceof CypherParseError) {
1259
+ return {
1260
+ ok: false,
1261
+ code: e.code,
1262
+ message: e.message,
1263
+ ...(e.validLabels ? { validLabels: e.validLabels } : {}),
1264
+ };
1265
+ }
1266
+ return {
1267
+ ok: false,
1268
+ code: "parse_error",
1269
+ message: e instanceof Error ? e.message : String(e),
1270
+ };
1271
+ }
1272
+ }
1273
+
1274
+ // ──────────────────────────────────────────────────────────────────────────
1275
+ // Executor — compiles the AST to searchGraph / traverse calls.
1276
+ // ──────────────────────────────────────────────────────────────────────────
1277
+
1278
+ /** Property-access aliases — both camelCase and snake_case work. */
1279
+ const PROP_ALIASES: Record<string, keyof CypherNodeValue> = {
1280
+ name: "name",
1281
+ qualifiedname: "qualifiedName",
1282
+ qualified_name: "qualifiedName",
1283
+ label: "label",
1284
+ kind: "label",
1285
+ filepath: "filePath",
1286
+ file_path: "filePath",
1287
+ nodeid: "nodeId",
1288
+ node_id: "nodeId",
1289
+ id: "nodeId",
1290
+ };
1291
+
1292
+ function nodeToValue(hit: SearchHit | TraverseHit | TraversePathHit): CypherNodeValue {
1293
+ return {
1294
+ nodeId: hit.nodeId,
1295
+ qualifiedName: hit.qualifiedName,
1296
+ name: hit.name,
1297
+ label: hit.label,
1298
+ filePath: hit.filePath,
1299
+ };
1300
+ }
1301
+
1302
+ function readProperty(node: CypherNodeValue, key: string): CypherScalar {
1303
+ const alias = PROP_ALIASES[key.toLowerCase()];
1304
+ if (!alias) {
1305
+ // Unknown property → return null rather than throw, so a query like
1306
+ // `RETURN a.confidence` on a node (which has no confidence) returns
1307
+ // null instead of failing the whole query. Matches Cypher semantics
1308
+ // for missing properties.
1309
+ return null;
1310
+ }
1311
+ return node[alias];
1312
+ }
1313
+
1314
+ function compareValues(a: CypherScalar, op: Comparison["op"], b: CypherScalar): boolean {
1315
+ // Type-coercion rules (deliberately simple, documented):
1316
+ // - number op number → numeric compare
1317
+ // - string op string → lexical compare
1318
+ // - bool op bool → for = / <> only
1319
+ // - null involved → false for ordering ops; =/<> follow SQL three-valued
1320
+ // logic (null = anything → unknown → false here; null <> anything → false)
1321
+ // - mixed types for ordering → false (no coercion)
1322
+ if (a === null || b === null) {
1323
+ if (op === "=" || op === "<>" || op === "!=") {
1324
+ // SQL: NULL = x and NULL <> x are both UNKNOWN → false.
1325
+ return false;
1326
+ }
1327
+ return false;
1328
+ }
1329
+ if (op === "=") return a === b;
1330
+ if (op === "<>" || op === "!=") return a !== b;
1331
+ if (typeof a !== typeof b) return false;
1332
+ if (typeof a === "boolean") {
1333
+ // Booleans only support = / <> (handled above).
1334
+ return false;
1335
+ }
1336
+ if (op === ">") return (a as number | string) > (b as number | string);
1337
+ if (op === "<") return (a as number | string) < (b as number | string);
1338
+ if (op === ">=") return (a as number | string) >= (b as number | string);
1339
+ if (op === "<=") return (a as number | string) <= (b as number | string);
1340
+ return false;
1341
+ }
1342
+
1343
+ /**
1344
+ * A binding tracks both the named variables (for WHERE / RETURN) AND the
1345
+ * most-recently-resolved node by position (`lastNode`). The positional
1346
+ * cursor is what makes anonymous nodes work: `MATCH ()-[:CALLS]->(b)`
1347
+ * starts from every node (anonymous first node) and traverses forward,
1348
+ * and `MATCH (a)-[:CALLS]->()-[:CALLS]->(c)` flows through the anonymous
1349
+ * middle node without dropping the path (cursor Bugbot: 'Anonymous nodes
1350
+ * break path expansion').
1351
+ */
1352
+ interface Binding {
1353
+ varByName: Map<string, CypherNodeValue>;
1354
+ lastNode: CypherNodeValue;
1355
+ }
1356
+
1357
+ /**
1358
+ * Push ONE `name` / `filePath` equality filter down to the structured
1359
+ * `searchGraph` filters so the candidate set is narrowed by the index
1360
+ * BEFORE the 1000-row cap applies. A specific name like `"runServer"`
1361
+ * narrows from the whole graph to a handful of rows, so a low-degree
1362
+ * node with an exact name match is no longer truncated out (cursor
1363
+ * Bugbot: 'Start search truncates before filters').
1364
+ *
1365
+ * Only LITERAL values are pushed: a `%`/`_` in the value would act as a
1366
+ * LIKE wildcard under searchGraph's `LIKE ... COLLATE NOCASE`, ballooning
1367
+ * the candidate set (e.g. `"foo_bar"` matching `fooXbar`). Such values
1368
+ * fall back to the capped label scan + exact post-filter instead
1369
+ * (chatgpt-codex-connector: 'Escape LIKE wildcards before start-node
1370
+ * pushdown'). The first writer wins (inline properties take priority
1371
+ * over a WHERE term) so two constraints on the same field don't clobber
1372
+ * each other. The post-filter ({@link matchesNodePattern} /
1373
+ * {@link matchesWhere}) always enforces exact case-sensitive equality,
1374
+ * so this narrowing never loses a valid row.
1375
+ */
1376
+ function pushFilterToSearch(
1377
+ query: { namePattern?: string; filePattern?: string },
1378
+ key: string,
1379
+ value: CypherScalar,
1380
+ ): void {
1381
+ if (typeof value !== "string") return;
1382
+ // Skip LIKE metacharacters so the pushed pattern stays literal-exact.
1383
+ if (value.includes("%") || value.includes("_")) return;
1384
+ const k = key.toLowerCase();
1385
+ if (k === "name" && query.namePattern === undefined) {
1386
+ query.namePattern = value;
1387
+ } else if (
1388
+ (k === "filepath" || k === "file_path") &&
1389
+ query.filePattern === undefined
1390
+ ) {
1391
+ query.filePattern = value;
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Execute a parsed AST against a store. Exposed so callers that already
1397
+ * hold an AST (e.g. a cached plan) can skip re-parsing.
1398
+ */
1399
+ export function executeAst(store: GraphStore, ast: CypherAst): CypherResult {
1400
+ // 1. Resolve the FIRST node pattern via searchGraph. The label + any
1401
+ // inline `name`/`filePath` property filters are pushed down so the
1402
+ // candidate set is narrowed by the index before the 1000-row cap;
1403
+ // a supported first-variable WHERE equality term (`f.name = "x"`) is
1404
+ // pushed down too when WHERE is a single conjunction (no top-level
1405
+ // OR), so `MATCH (f) WHERE f.name = "rare"` is narrowed before the
1406
+ // cap rather than after (chatgpt-codex-connector: 'Push down first-
1407
+ // node WHERE filters before capping'). Remaining inline properties +
1408
+ // the full WHERE are applied as JS post-filters. A closed store
1409
+ // surfaces as `{ ok: false, code: "store_closed" }` from searchGraph
1410
+ // itself (we don't reach into the private `closed` flag).
1411
+ const firstNode = ast.match.nodes[0]!;
1412
+ const searchQuery: {
1413
+ label?: string;
1414
+ namePattern?: string;
1415
+ filePattern?: string;
1416
+ limit: number;
1417
+ } = { limit: 1000 };
1418
+ if (firstNode.label !== undefined) {
1419
+ searchQuery.label = CYPHER_LABEL_TO_DB_LABEL[firstNode.label]!;
1420
+ }
1421
+ for (const { key, value } of firstNode.properties) {
1422
+ pushFilterToSearch(searchQuery, key, value);
1423
+ }
1424
+ // Single OR-group ⇒ pure conjunction ⇒ every term is a necessary
1425
+ // condition, so pushing a first-var `=` term down only narrows. With OR
1426
+ // (multiple groups) we push nothing — a term true on one branch is not
1427
+ // necessary overall, and pushing it would drop the other branch's rows.
1428
+ if (ast.where && ast.where.orGroups.length === 1 && firstNode.varName) {
1429
+ for (const term of ast.where.orGroups[0]!) {
1430
+ if (term.varName === firstNode.varName && term.op === "=") {
1431
+ pushFilterToSearch(searchQuery, term.key, term.value);
1432
+ }
1433
+ }
1434
+ }
1435
+ const search = store.searchGraph(searchQuery);
1436
+ if (!search.ok) {
1437
+ return storeFailureToCypher(search);
1438
+ }
1439
+
1440
+ let bindings: Binding[] = search.hits
1441
+ .map((hit) => nodeToValue(hit))
1442
+ .filter((node) => matchesNodePattern(node, firstNode))
1443
+ .map((node) => {
1444
+ const varByName = new Map<string, CypherNodeValue>();
1445
+ if (firstNode.varName) varByName.set(firstNode.varName, node);
1446
+ return { varByName, lastNode: node };
1447
+ });
1448
+
1449
+ // OR-across every variable-length expansion: any traversePaths cap hit
1450
+ // makes the final result a partial endpoint set (issue #1650).
1451
+ let truncated = false;
1452
+
1453
+ // 2. Walk the remaining (rel, node) pairs, expanding each binding via
1454
+ // traverse. The traverse start is the binding's POSITIONAL lastNode,
1455
+ // not a named variable — so anonymous nodes anywhere in the path
1456
+ // still pass the cursor forward.
1457
+ for (let idx = 0; idx < ast.match.rels.length; idx += 1) {
1458
+ const rel = ast.match.rels[idx]!;
1459
+ const nodePattern = ast.match.nodes[idx + 1]!;
1460
+ const nextBindings: Binding[] = [];
1461
+ // Surface an oversized variable-length depth as a query-level failure
1462
+ // instead of letting the store cap silently drop results: the store
1463
+ // rejects maxHops > MAX_TRAVERSE_PATHS_HOPS, but this loop's invalid_query
1464
+ // skip would otherwise hide it and return an empty success
1465
+ // (cursor Bugbot: 'Hop cap yields silent drops'; chatgpt-codex-
1466
+ // connector P2: 'Propagate oversized variable-length').
1467
+ if (rel.isVarLength && rel.maxHops > MAX_TRAVERSE_PATHS_HOPS) {
1468
+ const range =
1469
+ rel.minHops === rel.maxHops
1470
+ ? `*${rel.maxHops}`
1471
+ : `*${rel.minHops}..${rel.maxHops}`;
1472
+ return {
1473
+ ok: false,
1474
+ code: "invalid_query",
1475
+ message: `Variable-length ${range} exceeds the maximum supported traversal depth (${MAX_TRAVERSE_PATHS_HOPS}). Narrow the range.`,
1476
+ };
1477
+ }
1478
+ for (const binding of bindings) {
1479
+ // Collect candidate endpoint nodes for THIS binding + rel.
1480
+ const candidates: CypherNodeValue[] = [];
1481
+ if (rel.isVarLength) {
1482
+ // Variable-length (`*M..N` / `*N`) compiles to the path-enumerating
1483
+ // primitive (issue #1650) so an exact `*N` honors concrete length-N
1484
+ // paths, not just BFS shortest-depth reachability. A node reachable
1485
+ // at both a shorter and a length-N path is now returned for the
1486
+ // length-N path, fixing the dropped-endpoint bug.
1487
+ const tp = store.traversePaths({
1488
+ start: binding.lastNode.nodeId,
1489
+ direction: rel.direction,
1490
+ ...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}),
1491
+ // Push minHops into the store so its maxPaths cap counts only
1492
+ // in-range paths, not the shorter prefixes an exact *N must walk
1493
+ // (cursor Bugbot: 'Path cap ignores hop minimum').
1494
+ minHops: Math.max(1, rel.minHops),
1495
+ maxHops: rel.maxHops,
1496
+ });
1497
+ if (!tp.ok) {
1498
+ // unknown_start can happen if the node vanished between search
1499
+ // and traverse -- skip this binding rather than fail the whole
1500
+ // query. Genuine db errors propagate.
1501
+ if (
1502
+ tp.code === "unknown_start" ||
1503
+ tp.code === "ambiguous_start" ||
1504
+ tp.code === "invalid_query"
1505
+ ) {
1506
+ continue;
1507
+ }
1508
+ return storeFailureToCypher(tp);
1509
+ }
1510
+ // Path enumeration may have stopped at the maxPaths cap -- surface
1511
+ // that so callers can detect an incomplete result instead of
1512
+ // silently omitting reachable nodes (cursor Bugbot: 'Ignores path
1513
+ // enumeration truncation').
1514
+ if (tp.truncated) truncated = true;
1515
+ // A `*0..N` bound includes the trivial length-0 path (the start
1516
+ // node itself) -- traversePaths only yields length >= 1 paths.
1517
+ if (rel.minHops === 0) candidates.push(binding.lastNode);
1518
+ // traversePaths already restricts emitted paths to
1519
+ // [max(1, minHops), maxHops], so no length filter is needed here.
1520
+ for (const hit of tp.hits) {
1521
+ candidates.push(nodeToValue(hit));
1522
+ }
1523
+ } else {
1524
+ // Fixed-length single hop (no `*`): BFS traverse is exact for
1525
+ // direct neighbors -- the original compile target, unchanged.
1526
+ const t = store.traverse({
1527
+ start: binding.lastNode.nodeId,
1528
+ direction: rel.direction,
1529
+ ...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}),
1530
+ maxDepth: rel.maxHops,
1531
+ });
1532
+ if (!t.ok) {
1533
+ if (
1534
+ t.code === "unknown_start" ||
1535
+ t.code === "ambiguous_start" ||
1536
+ t.code === "invalid_query"
1537
+ ) {
1538
+ continue;
1539
+ }
1540
+ return storeFailureToCypher(t);
1541
+ }
1542
+ // Depth filter: traverse's depth is inclusive; the relationship's
1543
+ // minHops/maxHops are inclusive bounds (depth in [minHops, maxHops]).
1544
+ for (const hit of t.hits) {
1545
+ if (hit.depth < rel.minHops || hit.depth > rel.maxHops) continue;
1546
+ candidates.push(nodeToValue(hit));
1547
+ }
1548
+ }
1549
+ // Shared: dedupe by node id (one binding per distinct endpoint),
1550
+ // apply the target node pattern, then bind. Deduping by node id
1551
+ // preserves the read-subset's reachability contract for `*1..N`
1552
+ // (one row per reachable node), even though var-length now
1553
+ // enumerates paths internally (issue #1650).
1554
+ const seen = new Set<string>();
1555
+ for (const node of candidates) {
1556
+ if (seen.has(node.nodeId)) continue;
1557
+ seen.add(node.nodeId);
1558
+ if (!matchesNodePattern(node, nodePattern)) continue;
1559
+ const varByName = new Map(binding.varByName);
1560
+ if (nodePattern.varName) {
1561
+ // If the var was already bound (re-binding in a path), require
1562
+ // it to be the SAME node (Cypher equality semantics). Skip
1563
+ // otherwise.
1564
+ const existing = varByName.get(nodePattern.varName);
1565
+ if (existing && existing.nodeId !== node.nodeId) continue;
1566
+ varByName.set(nodePattern.varName, node);
1567
+ }
1568
+ nextBindings.push({ varByName, lastNode: node });
1569
+ }
1570
+ }
1571
+ bindings = nextBindings;
1572
+ if (bindings.length === 0) break;
1573
+ }
1574
+
1575
+ // 3. Apply WHERE (resolves variables by name — anonymous nodes have none).
1576
+ if (ast.where) {
1577
+ bindings = bindings.filter((b) => matchesWhere(b.varByName, ast.where!));
1578
+ }
1579
+
1580
+ // 4. Apply LIMIT.
1581
+ if (ast.limit !== undefined) {
1582
+ bindings = bindings.slice(0, ast.limit);
1583
+ }
1584
+
1585
+ // 5. Project RETURN (resolves variables by name).
1586
+ const columns = ast.return.map((item) =>
1587
+ item.kind === "var" ? item.varName : `${item.varName}.${item.key}`,
1588
+ );
1589
+ const rows: CypherRow[] = bindings.map((b) => {
1590
+ const row: CypherRow = {};
1591
+ ast.return.forEach((item) => {
1592
+ const col = item.kind === "var" ? item.varName : `${item.varName}.${item.key}`;
1593
+ const node = b.varByName.get(item.varName);
1594
+ if (!node) {
1595
+ row[col] = null;
1596
+ return;
1597
+ }
1598
+ row[col] =
1599
+ item.kind === "var" ? node : readProperty(node, item.key);
1600
+ });
1601
+ return row;
1602
+ });
1603
+
1604
+ return truncated
1605
+ ? { ok: true, columns, rows, truncated: true }
1606
+ : { ok: true, columns, rows };
1607
+ }
1608
+
1609
+ function matchesNodePattern(node: CypherNodeValue, pattern: NodePattern): boolean {
1610
+ // Enforce the parsed `:Label`. The FIRST node's label is already pushed
1611
+ // into searchGraph's `label` filter, so this is a no-op for it; for
1612
+ // relationship TARGET nodes this is the only place the parsed label is
1613
+ // enforced (cursor Bugbot: 'Relationship node labels not enforced' —
1614
+ // without this, `MATCH (a:Function)-[:CALLS]->(b:Type)` returned
1615
+ // Function nodes for `b`). `pattern.label` is validated at parse time,
1616
+ // so the mapped db label always exists when set.
1617
+ if (pattern.label !== undefined) {
1618
+ const dbLabel = CYPHER_LABEL_TO_DB_LABEL[pattern.label];
1619
+ if (dbLabel !== undefined && node.label !== dbLabel) return false;
1620
+ }
1621
+ for (const { key, value } of pattern.properties) {
1622
+ const actual = readProperty(node, key);
1623
+ if (!compareValues(actual, "=", value)) return false;
1624
+ }
1625
+ return true;
1626
+ }
1627
+
1628
+ function matchesWhere(
1629
+ varByName: Map<string, CypherNodeValue>,
1630
+ where: WhereClause,
1631
+ ): boolean {
1632
+ // OR-of-AND.
1633
+ return where.orGroups.some((group) =>
1634
+ group.every((term) => {
1635
+ const node = varByName.get(term.varName);
1636
+ if (!node) return false;
1637
+ const actual = readProperty(node, term.key);
1638
+ return compareValues(actual, term.op, term.value);
1639
+ }),
1640
+ );
1641
+ }
1642
+
1643
+ function storeFailureToCypher(f: {
1644
+ ok: false;
1645
+ code: string;
1646
+ }): CypherFailure {
1647
+ // Map store failure codes to Cypher failure codes (1:1 for the shared
1648
+ // suffix; store_closed is checked up-front in executeAst).
1649
+ switch (f.code) {
1650
+ case "db_locked":
1651
+ return { ok: false, code: "db_locked", message: "Database is locked." };
1652
+ case "db_corrupt":
1653
+ return { ok: false, code: "db_corrupt", message: "Database is corrupt." };
1654
+ case "store_closed":
1655
+ return { ok: false, code: "store_closed", message: "The graph store is closed." };
1656
+ default:
1657
+ return {
1658
+ ok: false,
1659
+ code: "db_error",
1660
+ message: `Database error: ${f.code}`,
1661
+ };
1662
+ }
1663
+ }
1664
+
1665
+ // ──────────────────────────────────────────────────────────────────────────
1666
+ // Public execute API.
1667
+ // ──────────────────────────────────────────────────────────────────────────
1668
+
1669
+ /**
1670
+ * Parse and execute a Cypher query against a store. Convenience wrapper
1671
+ * around {@link parseCypher} + {@link executeAst}.
1672
+ *
1673
+ * @example
1674
+ * const r = executeCypher(store, 'MATCH (f:Function {name: "foo"})-[:CALLS*1..2]->(g) WHERE g.label = "function" RETURN f.name, g.qualifiedName LIMIT 5');
1675
+ * if (r.ok) for (const row of r.rows) console.log(row);
1676
+ */
1677
+ export function executeCypher(store: GraphStore, query: string): CypherResult {
1678
+ const parsed = parseCypher(query);
1679
+ if (!parsed.ok) return parsed;
1680
+ return executeAst(store, parsed.ast);
1681
+ }
1682
+
1683
+ // ──────────────────────────────────────────────────────────────────────────
1684
+ // Exports for tests / type narrowing.
1685
+ // ──────────────────────────────────────────────────────────────────────────
1686
+
1687
+ export {
1688
+ CYPHER_LABEL_TO_DB_LABEL,
1689
+ VALID_CYPHER_LABELS,
1690
+ type CypherAst,
1691
+ type CypherParseError,
1692
+ };