/** * openCypher read subset — hand-written recursive-descent parser + executor. * * Issue #1552 PR3. This module is the thin, deletable Cypher layer over the * structured store API (`searchGraph` / `traverse`). It compiles a strict * read-only subset of openCypher to those primitives — there is NO SQL * string assembly from user input anywhere; the structured API already * parameterizes every bind (rule 51). * * ## Supported grammar (strict subset) * * ``` * query := MATCH pattern [WHERE where_clause] RETURN return_list [LIMIT int] * pattern := node_pattern (rel_pattern node_pattern)* * node_pattern := '(' [var] [':' label] ['{' prop_map '}'] ')' * prop_map := key ':' literal (',' key ':' literal)* * rel_pattern := ('<-'? '--' bracket '--' '->'?) * | ('<-' bracket '--') // incoming: <-[...]- (also <-[...]--) * | ('--' bracket '->') // outgoing: -[...]-> (also --[...]->) * | ('<-'? '--' bracket '--' '->'?) // canonical form * bracket := '[' [':' type ('|' ':' type)*] ['*' range] ']' * range := int ('..' int)? | '..' int * where_clause := comparison ((AND | OR) comparison)* * comparison := var '.' key op literal * op := '=' | '<>' | '!=' | '>' | '<' | '>=' | '<=' * return_list := return_item (',' return_item)* * return_item := var | var '.' key * literal := string | number | 'true' | 'false' | 'null' * ``` * * Direction (resolved from the dashes/arrows around the bracket): * - `-[...]->` → outgoing (follow src→dst edges) * - `<-[...]-` → incoming (follow dst→src edges) * - `-[...]-` → both * * Variable-length hops: * - `-[:CALLS*1..3]->` → between 1 and 3 CALLS hops (inclusive) * - `-[:CALLS*2]->` → exactly 2 hops * - `-[:CALLS..3]->` → 1..3 hops (default min = 1) * - `-[:CALLS*]->` → REJECTED (unbounded — see rejection table) * * ## Compile target * * Single-node patterns compile to `searchGraph({ label })`. Fixed-length * relationship patterns compile to `traverse({ start, direction, edgeTypes, * maxDepth })`, filtering the returned hits to the relationship's depth * range. VARIABLE-length patterns (`*M..N` / `*N`) compile to the path- * enumerating primitive `traversePaths` (issue #1650) so an exact `*N` * honors concrete length-N paths; endpoints are filtered by PATH LENGTH * and deduped by node id. Property filters in node patterns * (`{name: "foo"}`) and WHERE conditions are applied in JS as post-filters * on the bound nodes. * * Variable-length patterns enumerate concrete relationship-simple paths via * `traversePaths` (issue #1650). Each path is cycle-safe under RELATIONSHIP * UNIQUENESS (a single path never reuses an edge), capped at `maxHops` and a * total-path cap. `*M..N` returns a node when a path of length in `[M, N]` * reaches it; exact `*N` (N > 1) thus includes a node reachable at BOTH a * shorter and a length-N path (the length-N path qualifies). The result is * deduped by node id, so `*1..N` ("reachable within N hops") is unchanged * from the prior BFS behavior — only exact `*N` (N > 1) gains paths the * shortest-depth BFS dropped. If enumeration hits the store's maxPaths cap, * the success result carries `truncated: true` so callers can detect a * partial endpoint set instead of silently dropping reachable nodes. * * ## Read-only by construction * * The parser only recognizes the tokens `MATCH`, `WHERE`, `RETURN`, * `LIMIT`, `AND`, `OR`, `true`, `false`, `null`. Every write/mutation * clause token (`CREATE`, `MERGE`, `SET`, `DELETE`, `DETACH`, `REMOVE`, * `DROP`, `CALL`, `YIELD`, `UNION`, `WITH`, `ORDER`, `BY`, `SKIP`, * `OPTIONAL`, `EXPLAIN`, `PROFILE`, `USE`, `FOREACH`, `LOAD`, * `CONSTRAINT`, `INDEX`) is rejected with a clear error naming the * supported grammar (rule 51). The module has no code path that writes * to the store. * * ## Rejection table (each has a dedicated test) * * - `CREATE (n:Function)` → unsupported clause (read-only) * - `MATCH (n) DELETE n` → unsupported clause * - `MATCH (n) SET n.x = 1` → unsupported clause * - `MATCH (a)-[:CALLS*]->(b) ...` → unbounded `*` (must specify range) * - `MATCH (a:NotALabel) ...` → unknown label (lists valid options) * - `MATCH (a) RETURN *` → `RETURN *` not in subset * - `MATCH (a)-[:CALLS]->(b) RETURN a, c` → unbound variable `c` * - `MATCH (a:Function {name: 123}) ...` → wrong-type literal matches * nothing (standard Cypher; * NOT a parse error — a numeric * `name` never equals a string) * - `MATCH (a:Function WHERE ...` → missing `)` / missing RETURN * - `MATCH (a:Function)` (no RETURN) → missing RETURN * * ## Scale caveat * * The subset is aimed at interactive exploration over indexed graphs. The * start-node resolution uses `searchGraph` (capped at 1000 rows by the * store); the inline `name`/`filePath` property filter and a supported * single-conjunction first-variable WHERE equality term are pushed down * to the index BEFORE the cap, so an exact-name lookup is found even when * the matching node sorts after the cap on a large graph. Values * containing LIKE metacharacters (`%`/`_`) and multi-group (OR) WHERE * clauses are NOT pushed down — they fall back to the capped scan, so on * graphs with more than 1000 nodes of the starting label such queries can * still false-negative; use the inline literal form for guaranteed * Relationship expansion uses `traverse` (fixed hops) or `traversePaths` * (variable length, issue #1650); both are cycle-safe and depth/length * capped. See the compile-target note for the path-length semantics of * variable-length `*N`. */ import type { GraphStore, SearchHit, TraverseHit, TraversePathHit, } from "../graph-store.js"; import { MAX_TRAVERSE_PATHS_HOPS } from "../graph-store.js"; // ────────────────────────────────────────────────────────────────────────── // Label universe — the documented schema's node labels. // // The store persists `nodes.label` as the symbol `kind` (lowercase: // `function`, `class`, `method`, `interface`, `enum`, `type`, `module`). // The remaining labels (`Project`, `Package`, `Folder`, `File`, `Route`, // `Resource`) are part of the documented schema universe but are not // emitted by the current ingest pipeline; queries against them simply // return zero rows. We ACCEPT the full documented universe so the grammar // matches the issue's stated 12+ label list, and REJECT everything outside // it with a clear error (rejection table). // ────────────────────────────────────────────────────────────────────────── /** * PascalCase Cypher label → the lowercase value stored in `nodes.label`. * Labels whose DB form is not produced by ingest still map to a sensible * storage key so the query is structurally valid (returns empty). */ const CYPHER_LABEL_TO_DB_LABEL: Record = { Project: "project", Package: "package", Folder: "folder", File: "file", Module: "module", Class: "class", Function: "function", Method: "method", Interface: "interface", Enum: "enum", Type: "type", Route: "route", Resource: "resource", }; /** Sorted list of accepted Cypher labels — used in rejection messages. */ const VALID_CYPHER_LABELS: readonly string[] = Object.keys( CYPHER_LABEL_TO_DB_LABEL, ).sort(); // ────────────────────────────────────────────────────────────────────────── // Public result types. // ────────────────────────────────────────────────────────────────────────── /** * A value projected by RETURN. Strings, numbers, booleans, or null. Whole * nodes are returned as {@link CypherNodeValue} so callers can read every * field without re-querying. */ export type CypherScalar = string | number | boolean | null; /** A whole-node value (RETURN `var` with no property). */ export interface CypherNodeValue { nodeId: string; qualifiedName: string; name: string; label: string; filePath: string; } export type CypherValue = CypherScalar | CypherNodeValue; /** One result row — a map from RETURN-item column name to its value. */ export type CypherRow = Record; /** Failure codes. Distinct from the store codes — Cypher has its own. */ export type CypherFailureCode = | "parse_error" // generic grammar failure | "unsupported_clause" // CREATE / SET / DELETE / unbounded `*` / RETURN * | "unknown_label" // label not in the documented universe | "unbound_variable" // RETURN / WHERE references a var not in MATCH | "invalid_query" // structurally parsed but semantically bad (bad range, etc.) | "store_closed" | "db_locked" | "db_corrupt" | "db_error"; export interface CypherFailure { ok: false; code: CypherFailureCode; /** Human-readable explanation including the supported grammar hint. */ message: string; /** * Present only for `unknown_label`: the accepted label list, so callers * can render a completion menu without re-deriving it. */ validLabels?: readonly string[]; } export interface CypherSuccess { ok: true; /** Column names in RETURN order; each row has these keys. */ columns: string[]; rows: CypherRow[]; /** * Present and `true` ONLY when a variable-length expansion hit the * `traversePaths` maxPaths cap — the rows are a PARTIAL endpoint set and * some reachable nodes may be omitted. Callers that must know the result * is complete should treat `truncated: true` as unreliable. Absent means * the enumeration completed (issue #1650). */ truncated?: boolean; } export type CypherResult = CypherSuccess | CypherFailure; // ────────────────────────────────────────────────────────────────────────── // AST types. // ────────────────────────────────────────────────────────────────────────── interface NodePattern { /** Variable name; undefined for an anonymous node `( )`. */ varName?: string; /** PascalCase label as written (`Function`, `Class`, ...). */ label?: string; /** Inline property filters from `{key: value, ...}`. */ properties: Array<{ key: string; value: CypherScalar }>; } type RelDirection = "outgoing" | "incoming" | "both"; interface RelPattern { direction: RelDirection; /** Edge types; empty means "any type". */ types: string[]; /** Inclusive minimum hop count (default 1). */ minHops: number; /** Inclusive maximum hop count. Equal to minHops when `*N` form used. */ maxHops: number; /** True when a `*` range was parsed (variable-length). Drives the path-enumerating compile target (issue #1650). */ isVarLength: boolean; } interface Comparison { varName: string; key: string; op: "=" | "<>" | "!=" | ">" | "<" | ">=" | "<="; value: CypherScalar; } type WhereTerm = Comparison; interface WhereClause { /** Flat OR-of-AND-of-terms. We support AND+OR; no precedence gymnastics. */ orGroups: WhereTerm[][]; } type ReturnItem = | { kind: "var"; varName: string } | { kind: "prop"; varName: string; key: string }; interface MatchClause { nodes: NodePattern[]; rels: RelPattern[]; // length === nodes.length - 1 } interface CypherAst { match: MatchClause; where?: WhereClause; return: ReturnItem[]; limit?: number; } // ────────────────────────────────────────────────────────────────────────── // Tokenizer. // ────────────────────────────────────────────────────────────────────────── type TokenType = | "WORD" // identifier OR keyword (disambiguated in parser) | "STRING" | "NUMBER" | "LPAREN" | "RPAREN" | "LBRACK" | "RBRACK" | "LBRACE" | "RBRACE" | "COLON" | "PIPE" | "COMMA" | "DOT" | "DOTDOT" | "STAR" | "ARROW_R" // -> | "ARROW_L" // <- | "DASHDASH" // -- | "DASH" // - (only meaningful before a number for negation) | "EQ" // = | "NE" // <> or != | "GT" | "LT" | "GE" | "LE" | "EOF"; interface Token { type: TokenType; /** Raw source text of the token (for identifiers, strings, numbers). */ text: string; /** 0-based offset in the source, for error messages. */ pos: number; } const KEYWORDS: Record = { match: true, where: true, return: true, limit: true, and: true, or: true, true: true, false: true, null: true, }; /** * Tokens whose presence ANYWHERE in the query unambiguously signals a * write/mutation intent or a clause outside the read subset. They are * rejected with `unsupported_clause` carrying a tailored message. */ const WRITE_OR_OUTSIDE_CLAUSES: Record = { create: "CREATE is a write clause; this Cypher layer is read-only (MATCH/WHERE/RETURN/LIMIT only).", merge: "MERGE is a write clause; this Cypher layer is read-only.", set: "SET is a write clause; this Cypher layer is read-only.", delete: "DELETE is a write clause; this Cypher layer is read-only.", detach: "DETACH DELETE is a write clause; this Cypher layer is read-only.", remove: "REMOVE is a write clause; this Cypher layer is read-only.", drop: "DROP is a write clause; this Cypher layer is read-only.", call: "CALL { ... } subqueries are outside the read subset.", yield: "YIELD is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).", union: "UNION combinator is outside the read subset.", with: "WITH is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).", order: "ORDER BY is outside the read subset (use LIMIT, or post-sort in the caller).", by: "ORDER BY is outside the read subset.", skip: "SKIP is outside the read subset (use LIMIT).", optional: "OPTIONAL MATCH is outside the read subset.", explain: "EXPLAIN is outside the read subset.", profile: "PROFILE is outside the read subset.", use: "USE (graph routing) is outside the read subset.", foreach: "FOREACH is a write clause; this Cypher layer is read-only.", load: "LOAD CSV / LOAD FROM is outside the read subset.", constraint: "CONSTRAINT is a DDL clause; this Cypher layer is read-only.", index: "INDEX is a DDL clause; this Cypher layer is read-only.", graph: "GRAPH is outside the read subset.", unwind: "UNWIND is outside the read subset.", distinct: "DISTINCT is outside the read subset (this layer does not dedupe).", exists: "EXISTS is outside the read subset (use property comparisons).", in: "IN is outside the read subset (use a property comparison).", is_: "IS NULL / IS NOT NULL is outside the read subset.", not: "NOT is outside the read subset (use positive comparisons or <>).", starts: "STARTS WITH is outside the read subset.", ends: "ENDS WITH is outside the read subset.", contains: "CONTAINS is outside the read subset.", regex: "=~ regex is outside the read subset.", count: "COUNT(...) aggregation is outside the read subset.", sum: "SUM(...) aggregation is outside the read subset.", min: "MIN(...) aggregation is outside the read subset.", max: "MAX(...) aggregation is outside the read subset.", avg: "AVG(...) aggregation is outside the read subset.", collect: "COLLECT(...) aggregation is outside the read subset.", case: "CASE expressions are outside the read subset.", when: "CASE expressions are outside the read subset.", then: "CASE expressions are outside the read subset.", else: "CASE expressions are outside the read subset.", end: "CASE ... END is outside the read subset.", as: "AS aliasing is outside the read subset (RETURN items project under their literal text).", reduce: "REDUCE is outside the read subset.", shortestpath: "shortestPath() is outside the read subset.", all: "ALL(...) pattern predicate is outside the read subset.", any: "ANY(...) pattern predicate is outside the read subset.", none: "NONE(...) pattern predicate is outside the read subset.", single: "SINGLE(...) pattern predicate is outside the read subset.", size: "SIZE() is outside the read subset.", }; class Tokenizer { private readonly src: string; private i = 0; private readonly tokens: Token[] = []; constructor(src: string) { this.src = src; } tokenize(): Token[] { while (this.i < this.src.length) { const ch = this.src[this.i]!; // Whitespace. if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { this.i += 1; continue; } // Line comments `//` (Cypher) and block comments `/* */`. if (ch === "/" && this.src[this.i + 1] === "/") { const nl = this.src.indexOf("\n", this.i); this.i = nl === -1 ? this.src.length : nl + 1; continue; } if (ch === "/" && this.src[this.i + 1] === "*") { const end = this.src.indexOf("*/", this.i + 2); if (end === -1) { throw parseError( this.i, "Unterminated block comment. Supported grammar: MATCH/WHERE/RETURN/LIMIT.", ); } this.i = end + 2; continue; } const start = this.i; // Strings — double or single quoted. Backslash escapes are preserved // literally (the subset doesn't need them); the closing quote is the // next unescaped matching quote. if (ch === '"' || ch === "'") { this.readString(ch, start); continue; } // Numbers — digits, or `.`-leading fractional. A leading `-` is // emitted as a DASH token so the parser can negate a NUMBER in the // literal position (the tokenizer never looks that far ahead). if (isDigit(ch) || (ch === "." && isDigit(this.src[this.i + 1]!))) { this.readNumber(start); continue; } // Identifiers / keywords — [A-Za-z_][A-Za-z0-9_]* if (isIdentStart(ch)) { this.readWord(start); continue; } // Punctuation & operators — maximal munch. const two = this.src.slice(this.i, this.i + 2); const three = this.src.slice(this.i, this.i + 3); if (three === "-->") { this.push("ARROW_R", three, start); continue; } if (three === "<--") { this.push("ARROW_L", three, start); continue; } if (two === "->") { this.push("ARROW_R", two, start); continue; } if (two === "<-") { this.push("ARROW_L", two, start); continue; } if (two === "--") { this.push("DASHDASH", two, start); continue; } if (two === "..") { this.push("DOTDOT", two, start); continue; } if (two === "<>" || two === "!=") { this.push("NE", two, start); continue; } if (two === ">=") { this.push("GE", two, start); continue; } if (two === "<=") { this.push("LE", two, start); continue; } switch (ch) { case "(": this.push("LPAREN", ch, start); break; case ")": this.push("RPAREN", ch, start); break; case "[": this.push("LBRACK", ch, start); break; case "]": this.push("RBRACK", ch, start); break; case "{": this.push("LBRACE", ch, start); break; case "}": this.push("RBRACE", ch, start); break; case ":": this.push("COLON", ch, start); break; case "|": this.push("PIPE", ch, start); break; case ",": this.push("COMMA", ch, start); break; case ".": this.push("DOT", ch, start); break; case "*": this.push("STAR", ch, start); break; case "-": this.push("DASH", ch, start); break; case "=": this.push("EQ", ch, start); break; case ">": this.push("GT", ch, start); break; case "<": this.push("LT", ch, start); break; default: throw parseError( start, `Unexpected character ${JSON.stringify(ch)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`, ); } } this.push("EOF", "", this.i); return this.tokens; } private push(type: TokenType, text: string, pos: number): void { this.tokens.push({ type, text, pos }); this.i = pos + text.length; } private readString(quote: string, start: number): void { let j = start + 1; const chars: string[] = []; while (j < this.src.length) { const c = this.src[j]!; if (c === "\\") { // Preserve the escape sequence verbatim — the subset doesn't // interpret \n / \t / \" / \\; it stores and matches the raw two // characters. This is documented and tested. chars.push(c); chars.push(this.src[j + 1] ?? ""); j += 2; continue; } if (c === quote) { const text = chars.join(""); this.tokens.push({ type: "STRING", text, pos: start }); this.i = j + 1; return; } chars.push(c); j += 1; } throw parseError(start, "Unterminated string literal."); } private readNumber(start: number): void { let j = start; while (j < this.src.length && isDigit(this.src[j]!)) j += 1; if (this.src[j] === "." && isDigit(this.src[j + 1]!)) { j += 1; while (j < this.src.length && isDigit(this.src[j]!)) j += 1; } // Exponent (e.g. 1e3). Rare in property filters but cheap to accept. if (this.src[j] === "e" || this.src[j] === "E") { j += 1; if (this.src[j] === "+" || this.src[j] === "-") j += 1; while (j < this.src.length && isDigit(this.src[j]!)) j += 1; } const text = this.src.slice(start, j); this.tokens.push({ type: "NUMBER", text, pos: start }); this.i = j; } private readWord(start: number): void { let j = start; while (j < this.src.length && isIdentPart(this.src[j]!)) j += 1; const text = this.src.slice(start, j); this.tokens.push({ type: "WORD", text, pos: start }); this.i = j; } } function isDigit(c: string): boolean { return c >= "0" && c <= "9"; } function isIdentStart(c: string): boolean { return ( (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_" ); } function isIdentPart(c: string): boolean { return isIdentStart(c) || isDigit(c); } // ────────────────────────────────────────────────────────────────────────── // Parser. // ────────────────────────────────────────────────────────────────────────── class Parser { private readonly tokens: Token[]; private i = 0; constructor(tokens: Token[]) { this.tokens = tokens; } parse(): CypherAst { // Cypher queries in the subset MUST start with MATCH. Any other // leading token — including a write clause like CREATE — is rejected // here with the supported-grammar hint. this.expectKeyword("match"); const match = this.parseMatch(); let where: WhereClause | undefined; if (this.consumeKeyword("where")) { where = this.parseWhere(); } this.expectKeyword("return"); const returnItems = this.parseReturn(); let limit: number | undefined; if (this.consumeKeyword("limit")) { limit = this.parseNonNegativeInt("LIMIT"); } // Anything after LIMIT (other than EOF) is outside the subset. const tok = this.peek(); if (tok.type !== "EOF") { throw this.unsupportedAfter(tok); } this.validateAst(match, where, returnItems); return { match, where, return: returnItems, limit }; } // ── MATCH / pattern ──────────────────────────────────────────────────── private parseMatch(): MatchClause { const nodes: NodePattern[] = [this.parseNode()]; const rels: RelPattern[] = []; // A pattern continues while the next token begins a relationship. while (this.startsWithRelationship()) { const rel = this.parseRelationship(); nodes.push(this.parseNode()); rels.push(rel); } return { nodes, rels }; } private startsWithRelationship(): boolean { const t = this.peek(); // A relationship starts with a left-side dash run: `<-` (ARROW_L), // `--` (DASHDASH), or `-` (DASH). A bare `<` (LT) is NOT a valid // relationship start — incoming uses `<-`, tokenized as ARROW_L. return ( t.type === "ARROW_L" || t.type === "DASHDASH" || t.type === "DASH" ); } private parseNode(): NodePattern { this.expect("LPAREN", "a node pattern `(var:Label {p: \"v\"})`"); let varName: string | undefined; const first = this.peek(); if (first.type === "WORD" && !this.isKeyword(first.text)) { varName = first.text; this.advance(); } let label: string | undefined; if (this.peek().type === "COLON") { this.advance(); const lt = this.expect("WORD", "a label after `:`"); label = lt.text; } const properties: Array<{ key: string; value: CypherScalar }> = []; if (this.peek().type === "LBRACE") { this.advance(); // Empty `{}` is allowed. if (this.peek().type !== "RBRACE") { properties.push(this.parseProp()); while (this.peek().type === "COMMA") { this.advance(); properties.push(this.parseProp()); } } this.expect("RBRACE", "closing `}` of the property map"); } this.expect("RPAREN", "closing `)` of the node pattern"); return { varName, label, properties }; } private parseProp(): { key: string; value: CypherScalar } { const keyTok = this.expect("WORD", "a property key"); this.expect("COLON", "`:` between property key and value"); const value = this.parseLiteral(); return { key: keyTok.text, value }; } /** * Parse ` [bracket] `. Direction is resolved from * the two dash runs: `-[...]->` outgoing, `<-[...]-` incoming, * `-[...]-` / `--[...]--` both. The bracket is required. */ private parseRelationship(): RelPattern { const leftDir = this.consumeLeftDashRun(); // The bracket `[...]` is REQUIRED in the subset — bare `-->` without // a bracket is rejected because the grammar documents the bracketed // form and we want one shape to test. if (this.peek().type !== "LBRACK") { throw parseError( this.peek().pos, "Relationships must use a bracketed form, e.g. `-[:CALLS]->` or `-[:CALLS*1..3]->`. Bare arrows without `[...]` are not in the subset.", ); } this.advance(); // consume [ const types: string[] = []; if (this.peek().type === "COLON") { this.advance(); types.push(this.expect("WORD", "an edge type after `:[`").text); while (this.peek().type === "PIPE") { this.advance(); // openCypher accepts both `:A|:B` and `:A|B`; the colon after `|` // is optional. Consume it if present, else read the type directly. if (this.peek().type === "COLON") this.advance(); types.push(this.expect("WORD", "an edge type after `|`").text); } } let minHops = 1; let maxHops = 1; let isVarLength = false; if (this.peek().type === "STAR") { this.advance(); const range = this.parseRange(); minHops = range.min; maxHops = range.max; isVarLength = true; } this.expect("RBRACK", "closing `]` of the relationship bracket"); const rightDir = this.consumeRightDashRun(); if (leftDir === "none" && rightDir === "none") { throw parseError( this.peek().pos, "Relationships require dashes around the bracket, e.g. `-[:CALLS]->`, `<-[:CALLS]-`, or `-[:CALLS]-`. Bare `[...]` between nodes is not valid.", ); } const direction = resolveDirection(leftDir, rightDir); if (maxHops < minHops) { throw parseError( this.peek().pos, `Relationship range max (${maxHops}) is less than min (${minHops}).`, ); } return { direction, types, minHops, maxHops, isVarLength }; } private parseRange(): { min: number; max: number } { // Forms accepted: // N → exactly N (min=max=N) // N..M → min=N, max=M // ..M → min=1, max=M (default min) // Forms REJECTED: // (empty) → bare `*` — unbounded → unsupported_clause // N.. → unbounded max → unsupported_clause // .. → unbounded max → unsupported_clause const tok = this.peek(); const hasLower = tok.type === "NUMBER"; let min = 1; let max = Infinity; if (hasLower) { const n = this.parseNonNegativeInt("the minimum hop count"); min = n; max = n; // `*N` form: exactly N } if (this.peek().type === "DOTDOT") { this.advance(); // After `..`, a NUMBER is required (we don't accept unbounded max). if (this.peek().type === "NUMBER") { max = this.parseNonNegativeInt("the maximum hop count"); } else { throw parseError( this.peek().pos, "Unbounded variable-length `*..` is not supported. Specify a maximum, e.g. `*1..3`. The read subset rejects unbounded traversal.", "unsupported_clause", ); } } else if (!hasLower) { // Bare `*` with nothing after it. throw parseError( tok.pos, "Unbounded variable-length `*` is not supported. Specify a range, e.g. `*1..3` or `*2`. The read subset rejects unbounded traversal.", "unsupported_clause", ); } if (min < 0 || max < 0) { throw parseError(tok.pos, "Hop counts must be non-negative integers."); } return { min, max }; } // ── dash-run consumption ─────────────────────────────────────────────── /** * Left-side dash-run result. `"incoming"` = `<-` arrow; `"dash"` = bare * `-`/`--` (direction-neutral — the OTHER side's arrow wins, or undirected * if neither side has an arrow); `"none"` = nothing present. */ private consumeLeftDashRun(): "incoming" | "dash" | "none" { const t = this.peek(); if (t.type === "ARROW_L") { this.advance(); return "incoming"; } if (t.type === "DASHDASH" || t.type === "DASH") { this.advance(); return "dash"; } return "none"; } /** * Right-side dash-run result. `"outgoing"` = `->`/`-->` arrow; `"dash"` * = bare `-`/`--` (direction-neutral); `"none"` = nothing present. */ private consumeRightDashRun(): "outgoing" | "dash" | "none" { const t = this.peek(); if (t.type === "ARROW_R") { this.advance(); return "outgoing"; } if (t.type === "DASHDASH" || t.type === "DASH") { this.advance(); return "dash"; } return "none"; } // ── WHERE ────────────────────────────────────────────────────────────── private parseWhere(): WhereClause { // Flat OR-of-AND: split on OR, each side is AND-joined terms. const orGroups: WhereTerm[][] = []; orGroups.push(this.parseAndGroup()); while (this.consumeKeyword("or")) { orGroups.push(this.parseAndGroup()); } return { orGroups }; } private parseAndGroup(): WhereTerm[] { const terms: WhereTerm[] = [this.parseComparison()]; while (this.consumeKeyword("and")) { terms.push(this.parseComparison()); } return terms; } private parseComparison(): Comparison { const varTok = this.expect( "WORD", "a variable in WHERE (e.g. `a.name = \"foo\"`)", ); if (this.isKeyword(varTok.text)) { throw parseError( varTok.pos, `Expected a variable name in WHERE but found keyword ${JSON.stringify(varTok.text)}.`, ); } this.expect("DOT", "`.` between variable and property in WHERE"); const keyTok = this.expect("WORD", "a property name after `var.`"); const op = this.parseOp(); const value = this.parseLiteral(); return { varName: varTok.text, key: keyTok.text, op, value }; } private parseOp(): Comparison["op"] { const t = this.peek(); switch (t.type) { case "EQ": this.advance(); return "="; case "NE": this.advance(); return "<>"; case "GT": this.advance(); return ">"; case "LT": this.advance(); return "<"; case "GE": this.advance(); return ">="; case "LE": this.advance(); return "<="; default: throw parseError( t.pos, "Expected a comparison operator (=, <>, !=, >, <, >=, <=) in WHERE.", ); } } // ── RETURN ───────────────────────────────────────────────────────────── private parseReturn(): ReturnItem[] { // Reject `RETURN *` explicitly — it's outside the subset. if (this.peek().type === "STAR") { throw parseError( this.peek().pos, "`RETURN *` is outside the read subset. List the items to project explicitly, e.g. `RETURN a, b.name`.", "unsupported_clause", ); } const items: ReturnItem[] = [this.parseReturnItem()]; while (this.peek().type === "COMMA") { this.advance(); items.push(this.parseReturnItem()); } if (items.length === 0) { // parseReturnItem always produces one; defensive. throw parseError(this.peek().pos, "RETURN requires at least one item."); } return items; } private parseReturnItem(): ReturnItem { const varTok = this.expect( "WORD", "a variable (or var.prop) in RETURN", ); if (this.isKeyword(varTok.text)) { throw parseError( varTok.pos, `Expected a variable name in RETURN but found keyword ${JSON.stringify(varTok.text)}.`, ); } if (this.peek().type === "DOT") { this.advance(); const keyTok = this.expect("WORD", "a property name after `var.`"); return { kind: "prop", varName: varTok.text, key: keyTok.text }; } return { kind: "var", varName: varTok.text }; } // ── numeric helpers ──────────────────────────────────────────────────── private parseNonNegativeInt(label: string): number { const tok = this.peek(); if (tok.type === "NUMBER") { const n = Number(tok.text); if (!Number.isInteger(n) || n < 0) { throw parseError( tok.pos, `${label} must be a non-negative integer; got ${JSON.stringify(tok.text)}.`, ); } this.advance(); return n; } throw parseError(tok.pos, `Expected a non-negative integer for ${label}.`); } // ── literals ─────────────────────────────────────────────────────────── private parseLiteral(): CypherScalar { const tok = this.peek(); // Optional leading `-` for negative numbers. let negate = false; if (tok.type === "DASH") { negate = true; this.advance(); } const t = negate ? this.peek() : tok; if (t.type === "STRING") { if (negate) { throw parseError(t.pos, "Cannot negate a string literal."); } this.advance(); return t.text; } if (t.type === "NUMBER") { this.advance(); const n = Number(t.text); return negate ? -n : n; } if (t.type === "WORD") { if (t.text.toLowerCase() === "true") { if (negate) { throw parseError(t.pos, "Cannot negate `true`."); } this.advance(); return true; } if (t.text.toLowerCase() === "false") { if (negate) { throw parseError(t.pos, "Cannot negate `false`."); } this.advance(); return false; } if (t.text.toLowerCase() === "null") { if (negate) { throw parseError(t.pos, "Cannot negate `null`."); } this.advance(); return null; } // Any other WORD here is a bare identifier where a literal was // expected — almost certainly a typo or an unsupported construct. // If it's a write/outside keyword, surface that message. const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()]; if (outside) { throw parseError(t.pos, outside); } } throw parseError( tok.pos, "Expected a literal value (string, number, true, false, or null).", ); } // ── AST validation ───────────────────────────────────────────────────── private validateAst( match: MatchClause, where: WhereClause | undefined, returnItems: ReturnItem[], ): void { // Collect bound variable names (anonymous nodes contribute nothing). const bound = new Set(); for (const n of match.nodes) { if (n.varName) bound.add(n.varName); } // Validate labels. for (const n of match.nodes) { if (n.label !== undefined && !(n.label in CYPHER_LABEL_TO_DB_LABEL)) { throw parseError( 0, `Unknown label ${JSON.stringify(n.label)}. Valid labels: ${VALID_CYPHER_LABELS.join(", ")}.`, "unknown_label", [...VALID_CYPHER_LABELS], ); } } // Validate WHERE variable references. if (where) { for (const group of where.orGroups) { for (const term of group) { if (!bound.has(term.varName)) { throw parseError( 0, `WHERE references variable ${JSON.stringify(term.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`, "unbound_variable", ); } } } } // Validate RETURN variable references. for (const item of returnItems) { if (!bound.has(item.varName)) { throw parseError( 0, `RETURN references variable ${JSON.stringify(item.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`, "unbound_variable", ); } } } // ── token helpers ────────────────────────────────────────────────────── private peek(): Token { return this.tokens[this.i]!; } private advance(): Token { const t = this.tokens[this.i]!; if (this.i < this.tokens.length - 1) this.i += 1; return t; } private expect(type: TokenType, what: string): Token { const t = this.peek(); if (t.type !== type) { throw parseError( t.pos, `Expected ${what} but found ${describeToken(t)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`, ); } return this.advance(); } private expectKeyword(kw: string): Token { const t = this.peek(); if (t.type === "WORD" && t.text.toLowerCase() === kw) { return this.advance(); } // Helpful: if it's a write/outside clause, lead with that message. if (t.type === "WORD") { const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()]; if (outside) { throw parseError(t.pos, outside, "unsupported_clause"); } } throw parseError( t.pos, `Expected keyword ${kw.toUpperCase()} but found ${describeToken(t)}. Queries must start with MATCH and use only MATCH/WHERE/RETURN/LIMIT.`, "parse_error", ); } private consumeKeyword(kw: string): boolean { const t = this.peek(); if (t.type === "WORD" && t.text.toLowerCase() === kw) { this.advance(); return true; } return false; } private isKeyword(text: string): boolean { return KEYWORDS[text.toLowerCase()] === true; } private unsupportedAfter(tok: Token): CypherParseError { const outside = WRITE_OR_OUTSIDE_CLAUSES[tok.text.toLowerCase()]; if (outside) { return parseError(tok.pos, outside, "unsupported_clause"); } return parseError( tok.pos, `Unexpected token ${describeToken(tok)} after the query. Only MATCH/WHERE/RETURN/LIMIT are supported.`, "parse_error", ); } } function resolveDirection( left: "incoming" | "dash" | "none", right: "outgoing" | "dash" | "none", ): RelDirection { // Arrows on each side carry direction; bare dashes are direction-neutral. // -[...]-> : left="dash", right="outgoing" → outgoing // <-[...]- : left="incoming", right="dash" → incoming // -[...]- : left="dash", right="dash" → both (undirected) // <-[...]-> : left="incoming", right="outgoing" → conflict (reject) const leftArrow = left === "incoming"; const rightArrow = right === "outgoing"; if (leftArrow && rightArrow) { throw parseError( 0, "Conflicting relationship direction (e.g. `<-[...]->`). Use a consistent direction.", ); } if (leftArrow) return "incoming"; if (rightArrow) return "outgoing"; return "both"; } // ────────────────────────────────────────────────────────────────────────── // Errors. // ────────────────────────────────────────────────────────────────────────── class CypherParseError extends Error { readonly code: CypherFailureCode; readonly pos: number; readonly validLabels?: readonly string[]; constructor( pos: number, message: string, code: CypherFailureCode = "parse_error", validLabels?: readonly string[], ) { super(message); this.name = "CypherParseError"; this.pos = pos; this.code = code; this.validLabels = validLabels; } } function parseError( pos: number, message: string, code: CypherFailureCode = "parse_error", validLabels?: readonly string[], ): CypherParseError { return new CypherParseError(pos, message, code, validLabels); } function describeToken(t: Token): string { if (t.type === "EOF") return "end of input"; if (t.type === "WORD") return `identifier ${JSON.stringify(t.text)}`; if (t.type === "STRING") return `string ${JSON.stringify(t.text)}`; if (t.type === "NUMBER") return `number ${t.text}`; return JSON.stringify(t.text); } // ────────────────────────────────────────────────────────────────────────── // Public parse API. // ────────────────────────────────────────────────────────────────────────── export type CypherParseResult = | { ok: true; ast: CypherAst } | CypherFailure; /** * Parse a Cypher query string into an AST without executing it. Use this * to validate query shape (e.g. at a tool boundary) before opening a * store. The AST is an opaque internal type; callers should treat it as * a handle to pass to {@link executeAst}. */ export function parseCypher(query: string): CypherParseResult { if (typeof query !== "string") { return { ok: false, code: "invalid_query", message: "Cypher query must be a string.", }; } if (query.length === 0 || query.trim().length === 0) { return { ok: false, code: "parse_error", message: "Empty query. Supported grammar: MATCH (a:Label {p: \"v\"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.", }; } try { const tokens = new Tokenizer(query).tokenize(); const ast = new Parser(tokens).parse(); return { ok: true, ast }; } catch (e) { if (e instanceof CypherParseError) { return { ok: false, code: e.code, message: e.message, ...(e.validLabels ? { validLabels: e.validLabels } : {}), }; } return { ok: false, code: "parse_error", message: e instanceof Error ? e.message : String(e), }; } } // ────────────────────────────────────────────────────────────────────────── // Executor — compiles the AST to searchGraph / traverse calls. // ────────────────────────────────────────────────────────────────────────── /** Property-access aliases — both camelCase and snake_case work. */ const PROP_ALIASES: Record = { name: "name", qualifiedname: "qualifiedName", qualified_name: "qualifiedName", label: "label", kind: "label", filepath: "filePath", file_path: "filePath", nodeid: "nodeId", node_id: "nodeId", id: "nodeId", }; function nodeToValue(hit: SearchHit | TraverseHit | TraversePathHit): CypherNodeValue { return { nodeId: hit.nodeId, qualifiedName: hit.qualifiedName, name: hit.name, label: hit.label, filePath: hit.filePath, }; } function readProperty(node: CypherNodeValue, key: string): CypherScalar { const alias = PROP_ALIASES[key.toLowerCase()]; if (!alias) { // Unknown property → return null rather than throw, so a query like // `RETURN a.confidence` on a node (which has no confidence) returns // null instead of failing the whole query. Matches Cypher semantics // for missing properties. return null; } return node[alias]; } function compareValues(a: CypherScalar, op: Comparison["op"], b: CypherScalar): boolean { // Type-coercion rules (deliberately simple, documented): // - number op number → numeric compare // - string op string → lexical compare // - bool op bool → for = / <> only // - null involved → false for ordering ops; =/<> follow SQL three-valued // logic (null = anything → unknown → false here; null <> anything → false) // - mixed types for ordering → false (no coercion) if (a === null || b === null) { if (op === "=" || op === "<>" || op === "!=") { // SQL: NULL = x and NULL <> x are both UNKNOWN → false. return false; } return false; } if (op === "=") return a === b; if (op === "<>" || op === "!=") return a !== b; if (typeof a !== typeof b) return false; if (typeof a === "boolean") { // Booleans only support = / <> (handled above). return false; } if (op === ">") return (a as number | string) > (b as number | string); if (op === "<") return (a as number | string) < (b as number | string); if (op === ">=") return (a as number | string) >= (b as number | string); if (op === "<=") return (a as number | string) <= (b as number | string); return false; } /** * A binding tracks both the named variables (for WHERE / RETURN) AND the * most-recently-resolved node by position (`lastNode`). The positional * cursor is what makes anonymous nodes work: `MATCH ()-[:CALLS]->(b)` * starts from every node (anonymous first node) and traverses forward, * and `MATCH (a)-[:CALLS]->()-[:CALLS]->(c)` flows through the anonymous * middle node without dropping the path (cursor Bugbot: 'Anonymous nodes * break path expansion'). */ interface Binding { varByName: Map; lastNode: CypherNodeValue; } /** * Push ONE `name` / `filePath` equality filter down to the structured * `searchGraph` filters so the candidate set is narrowed by the index * BEFORE the 1000-row cap applies. A specific name like `"runServer"` * narrows from the whole graph to a handful of rows, so a low-degree * node with an exact name match is no longer truncated out (cursor * Bugbot: 'Start search truncates before filters'). * * Only LITERAL values are pushed: a `%`/`_` in the value would act as a * LIKE wildcard under searchGraph's `LIKE ... COLLATE NOCASE`, ballooning * the candidate set (e.g. `"foo_bar"` matching `fooXbar`). Such values * fall back to the capped label scan + exact post-filter instead * (chatgpt-codex-connector: 'Escape LIKE wildcards before start-node * pushdown'). The first writer wins (inline properties take priority * over a WHERE term) so two constraints on the same field don't clobber * each other. The post-filter ({@link matchesNodePattern} / * {@link matchesWhere}) always enforces exact case-sensitive equality, * so this narrowing never loses a valid row. */ function pushFilterToSearch( query: { namePattern?: string; filePattern?: string }, key: string, value: CypherScalar, ): void { if (typeof value !== "string") return; // Skip LIKE metacharacters so the pushed pattern stays literal-exact. if (value.includes("%") || value.includes("_")) return; const k = key.toLowerCase(); if (k === "name" && query.namePattern === undefined) { query.namePattern = value; } else if ( (k === "filepath" || k === "file_path") && query.filePattern === undefined ) { query.filePattern = value; } } /** * Execute a parsed AST against a store. Exposed so callers that already * hold an AST (e.g. a cached plan) can skip re-parsing. */ export function executeAst(store: GraphStore, ast: CypherAst): CypherResult { // 1. Resolve the FIRST node pattern via searchGraph. The label + any // inline `name`/`filePath` property filters are pushed down so the // candidate set is narrowed by the index before the 1000-row cap; // a supported first-variable WHERE equality term (`f.name = "x"`) is // pushed down too when WHERE is a single conjunction (no top-level // OR), so `MATCH (f) WHERE f.name = "rare"` is narrowed before the // cap rather than after (chatgpt-codex-connector: 'Push down first- // node WHERE filters before capping'). Remaining inline properties + // the full WHERE are applied as JS post-filters. A closed store // surfaces as `{ ok: false, code: "store_closed" }` from searchGraph // itself (we don't reach into the private `closed` flag). const firstNode = ast.match.nodes[0]!; const searchQuery: { label?: string; namePattern?: string; filePattern?: string; limit: number; } = { limit: 1000 }; if (firstNode.label !== undefined) { searchQuery.label = CYPHER_LABEL_TO_DB_LABEL[firstNode.label]!; } for (const { key, value } of firstNode.properties) { pushFilterToSearch(searchQuery, key, value); } // Single OR-group ⇒ pure conjunction ⇒ every term is a necessary // condition, so pushing a first-var `=` term down only narrows. With OR // (multiple groups) we push nothing — a term true on one branch is not // necessary overall, and pushing it would drop the other branch's rows. if (ast.where && ast.where.orGroups.length === 1 && firstNode.varName) { for (const term of ast.where.orGroups[0]!) { if (term.varName === firstNode.varName && term.op === "=") { pushFilterToSearch(searchQuery, term.key, term.value); } } } const search = store.searchGraph(searchQuery); if (!search.ok) { return storeFailureToCypher(search); } let bindings: Binding[] = search.hits .map((hit) => nodeToValue(hit)) .filter((node) => matchesNodePattern(node, firstNode)) .map((node) => { const varByName = new Map(); if (firstNode.varName) varByName.set(firstNode.varName, node); return { varByName, lastNode: node }; }); // OR-across every variable-length expansion: any traversePaths cap hit // makes the final result a partial endpoint set (issue #1650). let truncated = false; // 2. Walk the remaining (rel, node) pairs, expanding each binding via // traverse. The traverse start is the binding's POSITIONAL lastNode, // not a named variable — so anonymous nodes anywhere in the path // still pass the cursor forward. for (let idx = 0; idx < ast.match.rels.length; idx += 1) { const rel = ast.match.rels[idx]!; const nodePattern = ast.match.nodes[idx + 1]!; const nextBindings: Binding[] = []; // Surface an oversized variable-length depth as a query-level failure // instead of letting the store cap silently drop results: the store // rejects maxHops > MAX_TRAVERSE_PATHS_HOPS, but this loop's invalid_query // skip would otherwise hide it and return an empty success // (cursor Bugbot: 'Hop cap yields silent drops'; chatgpt-codex- // connector P2: 'Propagate oversized variable-length'). if (rel.isVarLength && rel.maxHops > MAX_TRAVERSE_PATHS_HOPS) { const range = rel.minHops === rel.maxHops ? `*${rel.maxHops}` : `*${rel.minHops}..${rel.maxHops}`; return { ok: false, code: "invalid_query", message: `Variable-length ${range} exceeds the maximum supported traversal depth (${MAX_TRAVERSE_PATHS_HOPS}). Narrow the range.`, }; } for (const binding of bindings) { // Collect candidate endpoint nodes for THIS binding + rel. const candidates: CypherNodeValue[] = []; if (rel.isVarLength) { // Variable-length (`*M..N` / `*N`) compiles to the path-enumerating // primitive (issue #1650) so an exact `*N` honors concrete length-N // paths, not just BFS shortest-depth reachability. A node reachable // at both a shorter and a length-N path is now returned for the // length-N path, fixing the dropped-endpoint bug. const tp = store.traversePaths({ start: binding.lastNode.nodeId, direction: rel.direction, ...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}), // Push minHops into the store so its maxPaths cap counts only // in-range paths, not the shorter prefixes an exact *N must walk // (cursor Bugbot: 'Path cap ignores hop minimum'). minHops: Math.max(1, rel.minHops), maxHops: rel.maxHops, }); if (!tp.ok) { // unknown_start can happen if the node vanished between search // and traverse -- skip this binding rather than fail the whole // query. Genuine db errors propagate. if ( tp.code === "unknown_start" || tp.code === "ambiguous_start" || tp.code === "invalid_query" ) { continue; } return storeFailureToCypher(tp); } // Path enumeration may have stopped at the maxPaths cap -- surface // that so callers can detect an incomplete result instead of // silently omitting reachable nodes (cursor Bugbot: 'Ignores path // enumeration truncation'). if (tp.truncated) truncated = true; // A `*0..N` bound includes the trivial length-0 path (the start // node itself) -- traversePaths only yields length >= 1 paths. if (rel.minHops === 0) candidates.push(binding.lastNode); // traversePaths already restricts emitted paths to // [max(1, minHops), maxHops], so no length filter is needed here. for (const hit of tp.hits) { candidates.push(nodeToValue(hit)); } } else { // Fixed-length single hop (no `*`): BFS traverse is exact for // direct neighbors -- the original compile target, unchanged. const t = store.traverse({ start: binding.lastNode.nodeId, direction: rel.direction, ...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}), maxDepth: rel.maxHops, }); if (!t.ok) { if ( t.code === "unknown_start" || t.code === "ambiguous_start" || t.code === "invalid_query" ) { continue; } return storeFailureToCypher(t); } // Depth filter: traverse's depth is inclusive; the relationship's // minHops/maxHops are inclusive bounds (depth in [minHops, maxHops]). for (const hit of t.hits) { if (hit.depth < rel.minHops || hit.depth > rel.maxHops) continue; candidates.push(nodeToValue(hit)); } } // Shared: dedupe by node id (one binding per distinct endpoint), // apply the target node pattern, then bind. Deduping by node id // preserves the read-subset's reachability contract for `*1..N` // (one row per reachable node), even though var-length now // enumerates paths internally (issue #1650). const seen = new Set(); for (const node of candidates) { if (seen.has(node.nodeId)) continue; seen.add(node.nodeId); if (!matchesNodePattern(node, nodePattern)) continue; const varByName = new Map(binding.varByName); if (nodePattern.varName) { // If the var was already bound (re-binding in a path), require // it to be the SAME node (Cypher equality semantics). Skip // otherwise. const existing = varByName.get(nodePattern.varName); if (existing && existing.nodeId !== node.nodeId) continue; varByName.set(nodePattern.varName, node); } nextBindings.push({ varByName, lastNode: node }); } } bindings = nextBindings; if (bindings.length === 0) break; } // 3. Apply WHERE (resolves variables by name — anonymous nodes have none). if (ast.where) { bindings = bindings.filter((b) => matchesWhere(b.varByName, ast.where!)); } // 4. Apply LIMIT. if (ast.limit !== undefined) { bindings = bindings.slice(0, ast.limit); } // 5. Project RETURN (resolves variables by name). const columns = ast.return.map((item) => item.kind === "var" ? item.varName : `${item.varName}.${item.key}`, ); const rows: CypherRow[] = bindings.map((b) => { const row: CypherRow = {}; ast.return.forEach((item) => { const col = item.kind === "var" ? item.varName : `${item.varName}.${item.key}`; const node = b.varByName.get(item.varName); if (!node) { row[col] = null; return; } row[col] = item.kind === "var" ? node : readProperty(node, item.key); }); return row; }); return truncated ? { ok: true, columns, rows, truncated: true } : { ok: true, columns, rows }; } function matchesNodePattern(node: CypherNodeValue, pattern: NodePattern): boolean { // Enforce the parsed `:Label`. The FIRST node's label is already pushed // into searchGraph's `label` filter, so this is a no-op for it; for // relationship TARGET nodes this is the only place the parsed label is // enforced (cursor Bugbot: 'Relationship node labels not enforced' — // without this, `MATCH (a:Function)-[:CALLS]->(b:Type)` returned // Function nodes for `b`). `pattern.label` is validated at parse time, // so the mapped db label always exists when set. if (pattern.label !== undefined) { const dbLabel = CYPHER_LABEL_TO_DB_LABEL[pattern.label]; if (dbLabel !== undefined && node.label !== dbLabel) return false; } for (const { key, value } of pattern.properties) { const actual = readProperty(node, key); if (!compareValues(actual, "=", value)) return false; } return true; } function matchesWhere( varByName: Map, where: WhereClause, ): boolean { // OR-of-AND. return where.orGroups.some((group) => group.every((term) => { const node = varByName.get(term.varName); if (!node) return false; const actual = readProperty(node, term.key); return compareValues(actual, term.op, term.value); }), ); } function storeFailureToCypher(f: { ok: false; code: string; }): CypherFailure { // Map store failure codes to Cypher failure codes (1:1 for the shared // suffix; store_closed is checked up-front in executeAst). switch (f.code) { case "db_locked": return { ok: false, code: "db_locked", message: "Database is locked." }; case "db_corrupt": return { ok: false, code: "db_corrupt", message: "Database is corrupt." }; case "store_closed": return { ok: false, code: "store_closed", message: "The graph store is closed." }; default: return { ok: false, code: "db_error", message: `Database error: ${f.code}`, }; } } // ────────────────────────────────────────────────────────────────────────── // Public execute API. // ────────────────────────────────────────────────────────────────────────── /** * Parse and execute a Cypher query against a store. Convenience wrapper * around {@link parseCypher} + {@link executeAst}. * * @example * const r = executeCypher(store, 'MATCH (f:Function {name: "foo"})-[:CALLS*1..2]->(g) WHERE g.label = "function" RETURN f.name, g.qualifiedName LIMIT 5'); * if (r.ok) for (const row of r.rows) console.log(row); */ export function executeCypher(store: GraphStore, query: string): CypherResult { const parsed = parseCypher(query); if (!parsed.ok) return parsed; return executeAst(store, parsed.ast); } // ────────────────────────────────────────────────────────────────────────── // Exports for tests / type narrowing. // ────────────────────────────────────────────────────────────────────────── export { CYPHER_LABEL_TO_DB_LABEL, VALID_CYPHER_LABELS, type CypherAst, type CypherParseError, };