/** * openCypher read-subset accept/reject suites — issue #1552 PR3 done-when. * * Two suites, both prove-fail-before (a deliberate break of the grammar * or the rejection table on a scratch branch fails the suite): * * ACCEPT: every documented form parses AND executes against a fixture * graph, returning the exact expected rows. * REJECT: every entry in the rejection table produces a clear tagged * failure with the right code + a message naming the supported * grammar. * * Fixture IR is synthetic (public-repo policy). The graph is small enough * that the executor's searchGraph-then-traverse compilation target is * deterministic — we assert exact sets, not "at least one hit". */ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { GraphStore, type EdgeIR, type StoreFileIR, type SymbolIR, } from "../graph-store.js"; import { CYPHER_LABEL_TO_DB_LABEL, executeCypher, parseCypher, VALID_CYPHER_LABELS, type CypherNodeValue, } from "./query-parser.js"; // ────────────────────────────────────────────────────────────────────────── // Fixture helpers — synthetic IR. // ────────────────────────────────────────────────────────────────────────── function sym( qualifiedName: string, name: string, startByte: number, endByte: number, kind: SymbolIR["kind"] = "function", ): SymbolIR { return { qualifiedName, name, span: { startByte, endByte }, kind }; } function edge( srcQualifiedName: string, dstQualifiedName: string, type = "CALLS", confidence = 0.9, provenance: EdgeIR["provenance"] = "heuristic", ): EdgeIR { return { srcQualifiedName, dstQualifiedName, type, confidence, provenance }; } /** * The fixture graph (all in one file so node identity is unambiguous): * * app.bootstrap (function) ──CALLS──> app.runServer (function) * app.runServer (function) ──CALLS──> app.handleRequest (function) * app.handleRequest (function) ──CALLS──> db.query (function) * app.handleRequest (function) ──USES_TYPE──> db.QueryResult (type) * app.runServer (function) ──IMPORTS──> lib.configHelper (function) * * util.format (function) — isolated, zero inbound edges → dead code. */ const fixtureFile: StoreFileIR = { path: "src/app.ts", language: "typescript", contentHash: "h-fixture", symbols: [ sym("app.bootstrap", "bootstrap", 0, 50, "function"), sym("app.runServer", "runServer", 50, 200, "function"), sym("app.handleRequest", "handleRequest", 200, 400, "function"), sym("db.query", "query", 400, 500, "function"), sym("db.QueryResult", "QueryResult", 500, 600, "type"), sym("lib.configHelper", "configHelper", 600, 700, "function"), sym("util.format", "format", 700, 800, "function"), ], edges: [ edge("app.bootstrap", "app.runServer"), edge("app.runServer", "app.handleRequest"), edge("app.handleRequest", "db.query"), edge("app.handleRequest", "db.QueryResult", "USES_TYPE"), edge("app.runServer", "lib.configHelper", "IMPORTS"), ], }; async function tempStoreWithFixture(): Promise<{ store: GraphStore; dir: string; }> { const dir = await mkdtemp(path.join(tmpdir(), "cypher-pr3-")); const store = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite"), repoRoot: dir, }); const r = await store.upsertFileBatch([fixtureFile]); assert.equal(r.ok, true, "fixture ingest must succeed"); return { store, dir }; } async function dispose(store: GraphStore, dir: string): Promise { await store.close(); await rm(dir, { recursive: true, force: true }); } /** Convenience: execute + assert ok, returning the rows. */ async function run( store: GraphStore, query: string, ): Promise<{ columns: string[]; rows: Record[] }> { const r = executeCypher(store, query); if (!r.ok) { assert.fail(`Query unexpectedly failed: ${r.code}: ${r.message}\n query: ${query}`); } return { columns: r.columns, rows: r.rows }; } /** Convenience: assert a query fails with the given code. */ function reject( store: GraphStore, query: string, expectedCode: string, ): void { const r = executeCypher(store, query); assert.equal(r.ok, false, `Query unexpectedly succeeded: ${query}`); if (r.ok) return; assert.equal( r.code, expectedCode, `Wrong failure code for ${JSON.stringify(query)}: expected ${expectedCode}, got ${r.code} (${r.message})`, ); } /** Parse-only reject (no store needed). */ function rejectParse(query: string, expectedCode: string): void { const r = parseCypher(query); assert.equal(r.ok, false, `Query unexpectedly parsed: ${query}`); if (r.ok) return; assert.equal( r.code, expectedCode, `Wrong parse code for ${JSON.stringify(query)}: expected ${expectedCode}, got ${r.code} (${r.message})`, ); } // ────────────────────────────────────────────────────────────────────────── // ACCEPT — single-node MATCH (compiles to searchGraph). // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: MATCH (f:Function) RETURN f — returns every function node", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { columns, rows } = await run(store, "MATCH (f:Function) RETURN f"); assert.deepEqual(columns, ["f"]); // bootstrap, runServer, handleRequest, query, configHelper, format = 6. assert.equal(rows.length, 6); const names = rows .map((r) => (r.f as CypherNodeValue).name) .sort(); assert.deepEqual(names, [ "bootstrap", "configHelper", "format", "handleRequest", "query", "runServer", ]); } finally { await dispose(store, dir); } }); test("ACCEPT: label maps PascalCase → lowercase kind (Type → type)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run(store, "MATCH (t:Type) RETURN t.qualifiedName"); assert.equal(rows.length, 1); assert.equal(rows[0]!["t.qualifiedName"], "db.QueryResult"); } finally { await dispose(store, dir); } }); test("ACCEPT: no label returns every node (cap'd by LIMIT)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run(store, "MATCH (n) RETURN n LIMIT 3"); assert.equal(rows.length, 3); } finally { await dispose(store, dir); } }); test("ACCEPT: inline property filter {name: \"query\"} narrows by exact name", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function {name: "query"}) RETURN f.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["f.qualifiedName"], "db.query"); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — WHERE clause. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: WHERE = string", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function) WHERE f.name = "runServer" RETURN f.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["f.qualifiedName"], "app.runServer"); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE <> filters out", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function) WHERE f.name <> "runServer" RETURN f.name', ); assert.equal(rows.length, 5); assert.ok(!rows.some((r) => r["f.name"] === "runServer")); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE OR-of-AND precedence", async () => { const { store, dir } = await tempStoreWithFixture(); try { // name = bootstrap OR name = query → 2 rows (OR splits the group). const { rows } = await run( store, 'MATCH (f:Function) WHERE f.name = "bootstrap" OR f.name = "query" RETURN f.name', ); const names = rows.map((r) => r["f.name"]).sort(); assert.deepEqual(names, ["bootstrap", "query"]); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE AND combines within a group", async () => { const { store, dir } = await tempStoreWithFixture(); try { // label = function AND name = query → exactly db.query. const { rows } = await run( store, 'MATCH (n) WHERE n.label = "function" AND n.name = "query" RETURN n.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["n.qualifiedName"], "db.query"); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE on label property (kind alias)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (n) WHERE n.kind = "type" RETURN n.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["n.qualifiedName"], "db.QueryResult"); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE on snake_case alias file_path", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function) WHERE f.file_path = "src/app.ts" RETURN f.name LIMIT 2', ); assert.equal(rows.length, 2); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — single-edge relationships (compile to traverse depth==1). // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: (a)-[:CALLS]->(b) — direct outgoing CALLS edges", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { columns, rows } = await run( store, "MATCH (a:Function)-[:CALLS]->(b:Function) RETURN a.name, b.name", ); assert.deepEqual(columns, ["a.name", "b.name"]); // bootstrap→runServer, runServer→handleRequest, handleRequest→query. const pairs = rows .map((r) => `${r["a.name"]}→${r["b.name"]}`) .sort(); assert.deepEqual(pairs, [ "bootstrap→runServer", "handleRequest→query", "runServer→handleRequest", ]); } finally { await dispose(store, dir); } }); test("ACCEPT: incoming direction <-[:CALLS]- finds what the source calls", async () => { // `(target)<-[:CALLS]-(src)` means src CALLS target. With src=handleRequest, // target = whatever handleRequest calls = db.query. const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (target:Function)<-[:CALLS]-(src:Function {name: "handleRequest"}) RETURN target.name', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["target.name"], "query"); } finally { await dispose(store, dir); } }); test("ACCEPT: undirected -[:CALLS]- matches both directions", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (a:Function)-[:CALLS]-(b:Function {name: "query"}) RETURN a.name', ); // query has one inbound CALLS (from handleRequest); no outbound. const names = rows.map((r) => r["a.name"]).sort(); assert.deepEqual(names, ["handleRequest"]); } finally { await dispose(store, dir); } }); test("ACCEPT: edge-type alternation -[:CALLS|USES_TYPE]->", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (a:Function)-[:CALLS|USES_TYPE]->(b) RETURN b.qualifiedName", ); const qnames = rows .map((r) => r["b.qualifiedName"] as string) .sort(); // CALLS targets: runServer, handleRequest, query. // USES_TYPE targets: QueryResult. assert.deepEqual(qnames, [ "app.handleRequest", "app.runServer", "db.QueryResult", "db.query", ]); } finally { await dispose(store, dir); } }); test("ACCEPT: edge types not in the documented set still pass through", async () => { // The store has no CHECK on edge type; the Cypher layer documents the // 20+ set but accepts any string and returns no rows for unknown types // (matching traverse's behavior). const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (a)-[:NOT_A_REAL_TYPE]->(b) RETURN a.name", ); assert.equal(rows.length, 0); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — variable-length paths (compile to traverse depth ∈ [min,max]). // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: *1..2 reaches two hops out", async () => { const { store, dir } = await tempStoreWithFixture(); try { // From bootstrap, CALLS*1..2 reaches: runServer (1), handleRequest (2). const { rows } = await run( store, 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*1..2]->(b) RETURN b.qualifiedName', ); const qnames = rows .map((r) => r["b.qualifiedName"] as string) .sort(); assert.deepEqual(qnames, ["app.handleRequest", "app.runServer"]); } finally { await dispose(store, dir); } }); test("ACCEPT: *2 exactly two hops (excludes depth 1)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*2]->(b) RETURN b.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["b.qualifiedName"], "app.handleRequest"); } finally { await dispose(store, dir); } }); test("ACCEPT: *..3 defaults min to 1, includes 1..3 hops", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (a:Function {name: "bootstrap"})-[:CALLS*..3]->(b) RETURN b.qualifiedName', ); const qnames = rows .map((r) => r["b.qualifiedName"] as string) .sort(); assert.deepEqual(qnames, [ "app.handleRequest", "app.runServer", "db.query", ]); } finally { await dispose(store, dir); } }); test("ACCEPT: multi-hop path chains (a)-[:CALLS]->(b)-[:CALLS]->(c)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (a:Function)-[:CALLS]->(b:Function)-[:CALLS]->(c:Function) RETURN a.name, c.name", ); // Two 2-hop chains: bootstrap→runServer→handleRequest, runServer→handleRequest→query. const pairs = rows .map((r) => `${r["a.name"]}…${r["c.name"]}`) .sort(); assert.deepEqual(pairs, [ "bootstrap…handleRequest", "runServer…query", ]); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — anonymous nodes (cursor Bugbot: 'Anonymous nodes break path // expansion'). An anonymous node `()` participates in the path but is not // bound to a variable; the executor tracks the positional cursor so the // path flows through it. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: anonymous FIRST node — ()-[:CALLS]->(b) enumerates all starts", async () => { const { store, dir } = await tempStoreWithFixture(); try { // Every node with an outgoing CALLS edge is a valid (anon) start. // b = the CALLS target. Distinct b targets: runServer, handleRequest, query. const { rows } = await run( store, "MATCH ()-[:CALLS]->(b:Function) RETURN b.qualifiedName", ); const qnames = rows .map((r) => r["b.qualifiedName"] as string) .sort(); assert.deepEqual(qnames, [ "app.handleRequest", "app.runServer", "db.query", ]); } finally { await dispose(store, dir); } }); test("ACCEPT: anonymous MIDDLE node — path flows through ()", async () => { const { store, dir } = await tempStoreWithFixture(); try { // (a)-[:CALLS]->()-[:CALLS]->(c): the middle node is anonymous but // the 2-hop path still resolves. Same result as the named-middle // chain test above. const { rows } = await run( store, "MATCH (a:Function)-[:CALLS]->()-[:CALLS]->(c:Function) RETURN a.name, c.name", ); const pairs = rows .map((r) => `${r["a.name"]}…${r["c.name"]}`) .sort(); assert.deepEqual(pairs, [ "bootstrap…handleRequest", "runServer…query", ]); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — inline-property pushdown narrows the start set before the cap // (cursor Bugbot: 'Start search truncates before filters'). A specific name // pushes down to searchGraph's namePattern so a low-degree node is still // found even when the graph exceeds the 1000-row cap. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: inline name filter on a low-degree node still resolves", async () => { // util.format has zero inbound edges (dead code) → degree 0, ranked last // by searchGraph's degree-desc ordering. Without the pushdown a graph // with >1000 higher-degree nodes would truncate it out; the pushdown // narrows by name first so it is always found. const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function {name: "format"}) RETURN f.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["f.qualifiedName"], "util.format"); } finally { await dispose(store, dir); } }); test("ACCEPT: wrong-type inline literal matches nothing (standard Cypher, not a parse error)", async () => { // {name: 123} is valid syntax; a numeric name never equals a string name, // so the result is empty (no tagged failure). This is standard Cypher // behavior, documented in the module's rejection-table note. const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (f:Function {name: 123}) RETURN f.name", ); assert.equal(rows.length, 0); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — review-round-2 hardening: relationship-target :Label enforcement, // WHERE first-variable pushdown, and LIKE-wildcard guard in the pushdown. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: relationship target :Label is enforced (cursor Bugbot: 'Relationship node labels not enforced')", async () => { // Fixture: app.handleRequest (Function) -[:USES_TYPE]-> db.QueryResult (Type). // Before the fix, matchesNodePattern ignored the parsed :Label on // relationship targets, so `...->(b:Function)` WRONGLY returned the Type // node for `b`. Now the parsed label is enforced. const { store, dir } = await tempStoreWithFixture(); try { const typed = await run( store, "MATCH (a:Function)-[:USES_TYPE]->(b:Type) RETURN b.qualifiedName", ); assert.deepEqual(typed.rows.map((r) => r["b.qualifiedName"]), [ "db.QueryResult", ]); // Requiring the target to be a Function excludes the Type node — // before the fix this returned db.QueryResult. const noFn = await run( store, "MATCH (a:Function)-[:USES_TYPE]->(b:Function) RETURN b.qualifiedName", ); assert.equal(noFn.rows.length, 0); // Symmetric on CALLS: no Type node is the target of a CALLS edge. const noTypeOverCalls = await run( store, "MATCH (a:Function)-[:CALLS]->(b:Type) RETURN b.qualifiedName", ); assert.equal(noTypeOverCalls.rows.length, 0); } finally { await dispose(store, dir); } }); test("ACCEPT: WHERE f.name = ... on the first var narrows before the cap (single conjunction pushdown)", async () => { // `MATCH (f) WHERE f.name = "x"` pushes the equality down to the index // when WHERE is a single conjunction (no top-level OR), so the named // node is found before the 1000-row cap. Parity with the inline form. const { store, dir } = await tempStoreWithFixture(); try { const whereForm = await run( store, 'MATCH (f:Function) WHERE f.name = "handleRequest" RETURN f.qualifiedName', ); assert.deepEqual(whereForm.rows.map((r) => r["f.qualifiedName"]), [ "app.handleRequest", ]); const inlineForm = await run( store, 'MATCH (f:Function {name: "handleRequest"}) RETURN f.qualifiedName', ); assert.deepEqual(inlineForm.rows.map((r) => r["f.qualifiedName"]), [ "app.handleRequest", ]); // OR (multiple groups) is NOT pushed down — but still resolves // correctly via the post-filter, proving the conservative branch. const orForm = await run( store, 'MATCH (f:Function) WHERE f.name = "bootstrap" OR f.name = "query" RETURN f.name', ); assert.deepEqual( orForm.rows.map((r) => r["f.name"]).sort(), ["bootstrap", "query"], ); } finally { await dispose(store, dir); } }); test("ACCEPT: name containing a LIKE metacharacter resolves exactly (pushdown wildcard guard)", async () => { // A literal `_` in the value must not be pushed to searchGraph's LIKE // matcher (which treats `_` as a single-char wildcard). The value falls // back to the capped label scan + exact post-filter and still resolves // to the single exact match — not the wildcard neighbour. const dir = await mkdtemp(path.join(tmpdir(), "cypher-wild-")); const store = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite"), repoRoot: dir, }); try { const file: StoreFileIR = { path: "src/w.ts", language: "typescript", contentHash: "h-wild", symbols: [ sym("w.foo_bar", "foo_bar", 0, 10, "function"), sym("w.foozbar", "foozbar", 10, 20, "function"), ], edges: [], }; const r = await store.upsertFileBatch([file]); assert.equal(r.ok, true, "fixture ingest must succeed"); const { rows } = await run( store, 'MATCH (f:Function {name: "foo_bar"}) RETURN f.qualifiedName', ); assert.deepEqual(rows.map((row) => row["f.qualifiedName"]), ["w.foo_bar"]); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — LIMIT. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: LIMIT caps the row count", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (f:Function) RETURN f.name LIMIT 2", ); assert.equal(rows.length, 2); } finally { await dispose(store, dir); } }); test("ACCEPT: LIMIT 0 returns zero rows (rule 27 guard)", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, "MATCH (f:Function) RETURN f.name LIMIT 0", ); assert.equal(rows.length, 0); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — case-insensitive keywords + comments. // ────────────────────────────────────────────────────────────────────────── test("ACCEPT: keywords are case-insensitive", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'match (f:Function) where f.name = "query" return f.qualifiedName', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["f.qualifiedName"], "db.query"); } finally { await dispose(store, dir); } }); test("ACCEPT: line and block comments are skipped", async () => { const { store, dir } = await tempStoreWithFixture(); try { const { rows } = await run( store, 'MATCH (f:Function) // find query\n/* block */ WHERE f.name = "query" RETURN f.name', ); assert.equal(rows.length, 1); assert.equal(rows[0]!["f.name"], "query"); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // REJECT — write / outside clauses. // ────────────────────────────────────────────────────────────────────────── test("REJECT: CREATE is rejected as unsupported_clause", () => { rejectParse("CREATE (n:Function)", "unsupported_clause"); }); test("REJECT: MERGE is rejected as unsupported_clause", () => { rejectParse("MERGE (n:Function)", "unsupported_clause"); }); test("REJECT: SET is rejected as unsupported_clause", () => { rejectParse("MATCH (n) SET n.x = 1", "unsupported_clause"); }); test("REJECT: DELETE is rejected as unsupported_clause", () => { rejectParse("MATCH (n) DELETE n", "unsupported_clause"); }); test("REJECT: DETACH DELETE is rejected as unsupported_clause", () => { rejectParse("MATCH (n) DETACH DELETE n", "unsupported_clause"); }); test("REJECT: REMOVE is rejected as unsupported_clause", () => { rejectParse("MATCH (n) REMOVE n.x", "unsupported_clause"); }); test("REJECT: ORDER BY is rejected as unsupported_clause", () => { rejectParse("MATCH (n) RETURN n ORDER BY n.name", "unsupported_clause"); }); test("REJECT: WITH is rejected as unsupported_clause", () => { rejectParse("MATCH (n) WITH n RETURN n", "unsupported_clause"); }); test("REJECT: UNION is rejected as unsupported_clause", () => { rejectParse( "MATCH (n) RETURN n UNION MATCH (m) RETURN m", "unsupported_clause", ); }); test("REJECT: RETURN * is rejected (outside subset)", () => { rejectParse("MATCH (n) RETURN *", "unsupported_clause"); }); // ────────────────────────────────────────────────────────────────────────── // REJECT — unbounded variable-length. // ────────────────────────────────────────────────────────────────────────── test("REJECT: bare * (unbounded) is rejected", () => { rejectParse("MATCH (a)-[:CALLS*]->(b) RETURN b", "unsupported_clause"); }); test("REJECT: *.. (unbounded max) is rejected", async () => { const { store, dir } = await tempStoreWithFixture(); try { reject(store, "MATCH (a)-[:CALLS*1..]->(b) RETURN b", "unsupported_clause"); } finally { await dispose(store, dir); } }); // ────────────────────────────────────────────────────────────────────────── // REJECT — unknown labels list valid options. // ────────────────────────────────────────────────────────────────────────── test("REJECT: unknown label produces unknown_label with validLabels", () => { const r = parseCypher("MATCH (a:NotALabel) RETURN a"); assert.equal(r.ok, false); if (r.ok) return; assert.equal(r.code, "unknown_label"); assert.ok(r.validLabels, "validLabels must be present on unknown_label"); // The valid list is exactly the documented universe, sorted. assert.deepEqual(r.validLabels, [...VALID_CYPHER_LABELS]); // Message names the bad label and the supported grammar. assert.match(r.message, /NotALabel/); }); test("REJECT: lowercase 'function' is NOT a valid Cypher label (case-sensitive labels)", () => { // Cypher labels are case-sensitive PascalCase identifiers. The DB // stores lowercase kinds; the Cypher layer maps PascalCase→lowercase // internally. A user writing `:function` gets unknown_label with the // valid (PascalCase) options listed. const r = parseCypher("MATCH (a:function) RETURN a"); assert.equal(r.ok, false); if (r.ok) return; assert.equal(r.code, "unknown_label"); }); // ────────────────────────────────────────────────────────────────────────── // REJECT — structural / unbound variable. // ────────────────────────────────────────────────────────────────────────── test("REJECT: RETURN references unbound variable", () => { rejectParse("MATCH (a:Function) RETURN a, c", "unbound_variable"); }); test("REJECT: WHERE references unbound variable", () => { rejectParse("MATCH (a:Function) WHERE c.name = \"x\" RETURN a", "unbound_variable"); }); test("REJECT: missing RETURN", () => { rejectParse("MATCH (a:Function)", "parse_error"); }); test("REJECT: missing closing paren", () => { rejectParse("MATCH (a:Function RETURN a", "parse_error"); }); test("REJECT: empty query", () => { rejectParse("", "parse_error"); rejectParse(" ", "parse_error"); }); test("REJECT: non-string query", () => { const r = parseCypher(undefined as unknown as string); assert.equal(r.ok, false); if (r.ok) return; assert.equal(r.code, "invalid_query"); }); // ────────────────────────────────────────────────────────────────────────── // REJECT — bare-arrows / malformed relationships. // ────────────────────────────────────────────────────────────────────────── test("REJECT: bare arrow without bracket", () => { rejectParse("MATCH (a)-->(b) RETURN a", "parse_error"); }); test("REJECT: conflicting direction <-[...]->", () => { const r = parseCypher("MATCH (a)<-[:CALLS]->(b) RETURN a"); assert.equal(r.ok, false); if (r.ok) return; // Either parse_error or unsupported — we accept either; the contract // is "rejected with a clear message", not a specific code here. assert.ok(["parse_error", "unsupported_clause", "invalid_query"].includes(r.code)); }); // ────────────────────────────────────────────────────────────────────────── // REJECT — closed store. // ────────────────────────────────────────────────────────────────────────── test("REJECT: closed store returns store_closed", async () => { const { store, dir } = await tempStoreWithFixture(); await dispose(store, dir); // store is now closed; executeCypher must surface store_closed from // the underlying searchGraph call. const r = executeCypher(store, "MATCH (f:Function) RETURN f"); assert.equal(r.ok, false); if (r.ok) return; assert.equal(r.code, "store_closed"); }); // ────────────────────────────────────────────────────────────────────────── // Module-level invariants. // ────────────────────────────────────────────────────────────────────────── test("INVARIANT: every documented label maps to a db label", () => { for (const label of VALID_CYPHER_LABELS) { assert.ok( label in CYPHER_LABEL_TO_DB_LABEL, `${label} missing from CYPHER_LABEL_TO_DB_LABEL`, ); } }); test("REJECT: variable-length depth above the supported cap fails with invalid_query (not a silent empty result)", async () => { const { store, dir } = await tempStoreWithFile(multiPathCypherFile); try { const r = executeCypher( store, 'MATCH (a:Function {name: "a"})-[:CALLS*2000]->(b) RETURN b', ); assert.equal(r.ok, false, "*2000 must fail, not silently return an empty success"); if (r.ok) throw new Error("expected failure"); assert.equal(r.code, "invalid_query"); } finally { await dispose(store, dir); } }); test("ACCEPT: truncated flag surfaces when variable-length enumeration hits the path cap (cursor Bugbot)", async () => { // A complete directed graph on 23 nodes (no self-loops): 506 edges. // *1..3 from one node enumerates 22 + 22*22 + 22*22*22 = 11_154 paths, // exceeding the DEFAULT_TRAVERSE_PATHS_MAX (10_000) cap. The executor // must surface `truncated: true` rather than silently returning a // partial endpoint set. const N = 23; const symbols: SymbolIR[] = []; for (let i = 0; i < N; i += 1) { symbols.push(sym("cp." + i, String(i), i * 10, i * 10 + 9)); } const edges: EdgeIR[] = []; for (let i = 0; i < N; i += 1) { for (let j = 0; j < N; j += 1) { if (i !== j) edges.push(edge("cp." + i, "cp." + j)); } } const completeFile: StoreFileIR = { path: "src/complete.ts", language: "typescript", contentHash: "h-complete", symbols, edges, }; const { store, dir } = await tempStoreWithFile(completeFile); try { const r = executeCypher( store, 'MATCH (a:Function {name: "0"})-[:CALLS*1..3]->(b) RETURN b.qualifiedName', ); assert.equal(r.ok, true, "query should succeed (partial result, not failure)"); if (!r.ok) throw new Error("expected ok"); assert.equal(r.truncated, true, "truncated must be true when the path cap is hit"); assert.ok(r.rows.length > 0, "partial endpoint set is still returned"); } finally { await dispose(store, dir); } }); test("INVARIANT: the 13 documented labels are all present", () => { // Project, Package, Folder, File, Module, Class, Function, Method, // Interface, Enum, Type, Route, Resource (issue #1552 body). const expected = [ "Class", "Enum", "File", "Folder", "Function", "Interface", "Method", "Module", "Package", "Project", "Resource", "Route", "Type", ]; assert.deepEqual([...VALID_CYPHER_LABELS].sort(), expected); }); // ────────────────────────────────────────────────────────────────────────── // ACCEPT — variable-length path enumeration (issue #1650). // Exact `*N` (N > 1) must include nodes reachable at BOTH a shorter and a // length-N path. The fixture has two paths to b (a->b at len 1, a->c->b at // len 2) and a diamond to d (a->b->d, a->c->d, both len 2). // // a -> b // a -> c // c -> b // b -> d // c -> d // ────────────────────────────────────────────────────────────────────────── const multiPathCypherFile: StoreFileIR = { path: "src/mp.ts", language: "typescript", contentHash: "h-mp", symbols: [ sym("mp.a", "a", 0, 10), sym("mp.b", "b", 10, 20), sym("mp.c", "c", 20, 30), sym("mp.d", "d", 30, 40), ], edges: [ edge("mp.a", "mp.b"), edge("mp.a", "mp.c"), edge("mp.c", "mp.b"), edge("mp.b", "mp.d"), edge("mp.c", "mp.d"), ], }; async function tempStoreWithFile( file: StoreFileIR, ): Promise<{ store: GraphStore; dir: string }> { const dir = await mkdtemp(path.join(tmpdir(), "cypher-1650-")); const store = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite"), repoRoot: dir, }); const r = await store.upsertFileBatch([file]); assert.equal(r.ok, true, "fixture ingest must succeed"); return { store, dir }; } test("ACCEPT: *N (N>1) includes a node reachable at a shorter AND a length-N path (issue #1650)", async () => { const { store, dir } = await tempStoreWithFile(multiPathCypherFile); try { // b is reachable at len 1 (a->b) AND len 2 (a->c->b). Under the old // BFS compile target, b was visited at depth 1 and dropped by the // depth==2 filter; the length-2 path a->c->b now qualifies it. const { rows } = await run( store, 'MATCH (a:Function {name: "a"})-[:CALLS*2]->(b) RETURN b.qualifiedName', ); const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort(); assert.deepEqual(qnames, ["mp.b", "mp.d"]); } finally { await dispose(store, dir); } }); test("ACCEPT: *1..N unchanged — returns reachable nodes, no regression (issue #1650)", async () => { const { store, dir } = await tempStoreWithFile(multiPathCypherFile); try { // *1..2 from a: b (len1), c (len1), d (len2). Endpoint-dedup keeps // this identical to the prior BFS shortest-depth behavior. const { rows } = await run( store, 'MATCH (a:Function {name: "a"})-[:CALLS*1..2]->(b) RETURN b.qualifiedName', ); const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort(); assert.deepEqual(qnames, ["mp.b", "mp.c", "mp.d"]); } finally { await dispose(store, dir); } }); test("ACCEPT: diamond *2 reaches the far node via either length-2 path", async () => { const { store, dir } = await tempStoreWithFile(multiPathCypherFile); try { // d is reachable only via length-2 paths (a->b->d, a->c->d). It must // appear in *2 results (deduped to one row). const { rows } = await run( store, 'MATCH (a:Function {name: "a"})-[:CALLS*2]->(b) RETURN b.name', ); const names = rows.map((r) => r["b.name"] as string).sort(); assert.deepEqual(names, ["b", "d"]); // d appears exactly once (endpoint-dedup), not once per path. const dRows = rows.filter((r) => r["b.name"] === "d"); assert.equal(dRows.length, 1); } finally { await dispose(store, dir); } }); test("ACCEPT: *N on a cyclic graph terminates (cycle safety via relationship-uniqueness)", async () => { // a -> b -> c -> a (cycle) plus a self-edge on b. const cyclicFile: StoreFileIR = { path: "src/cyc.ts", language: "typescript", contentHash: "h-cyc", symbols: [ sym("cyc.a", "a", 0, 10), sym("cyc.b", "b", 10, 20), sym("cyc.c", "c", 20, 30), ], edges: [ edge("cyc.a", "cyc.b"), edge("cyc.b", "cyc.c"), edge("cyc.c", "cyc.a"), edge("cyc.b", "cyc.b"), ], }; const { store, dir } = await tempStoreWithFile(cyclicFile); try { // Must complete (relationship-uniqueness bounds enumeration) and // return only nodes in the cycle. const { rows } = await run( store, 'MATCH (a:Function {name: "a"})-[:CALLS*1..4]->(b) RETURN b.qualifiedName', ); const qnames = rows.map((r) => r["b.qualifiedName"] as string).sort(); assert.deepEqual(qnames, ["cyc.a", "cyc.b", "cyc.c"]); } finally { await dispose(store, dir); } });