Skip to content

[bug] query_graph Cypher: var-length paths reuse edges and repeated node variables aren't unified — a single self-loop fabricates 100-hop paths; heavy expansion crashes server #797

Description

@aitoroses

Summary

While using query_graph to find the longest call chain in an indexed Go/TS repo, I hit three compounding Cypher-engine bugs that together make any path-length/cycle analysis return meaningless results — plus a server crash on a heavy expansion. Reported together because they were found in one session and reinforce each other; happy to split if you prefer.

Environment: codebase-memory-mcp 0.8.1, macOS arm64 (Darwin 25.2.0), single project indexed (~21k nodes, ~88k edges, 21,193 CALLS edges per get_graph_schema).

Bug 1 — Repeated node variables in a pattern are not unified

A pattern that reuses the same variable should only match when both endpoints are the same node. It doesn't — (a)-[:CALLS]->(a) matches every CALLS edge:

MATCH (a)-[:CALLS]->(a) RETURN count(*)   -- 19814
MATCH (a)-[:CALLS]->(b) RETURN count(*)   -- 19814 (identical)

Consequence: self-loop/cycle detection via variable reuse silently returns garbage (it looks like the graph has 19,814 self-loops). Same for RETURN a.qualified_name LIMIT 10 — it just lists sources of arbitrary edges, which is very misleading because the output is plausible.

(Side note: count(*) reports 19,814 while get_graph_schema reports 21,193 CALLS edges — possibly a separate discrepancy.)

Bug 2 — Variable-length expansion reuses the same edge (no edge-uniqueness)

Standard Cypher semantics require relationship uniqueness within a path. This engine allows the same edge to repeat, so a single self-loop edge generates "paths" of arbitrary length.

In my graph there is a node whose only outbound CALLS edge is a self-loop (see "Amplifier" below):

MATCH (a {name: 'NewRelayServer'})-[r:CALLS]->(b)
RETURN b.qualified_name, r.callee, r.confidence
-- exactly 1 row: b = a itself (same qualified_name), callee='mux.HandleFunc', confidence 0.50

With one outbound edge that is a self-loop, no directed path of length ≥ 2 can exist. Yet:

MATCH (a {name: 'NewRelayServer'})-[:CALLS*2..2]->(b)   RETURN DISTINCT b.name  -- matches (itself)
MATCH (a)-[:CALLS*40..40]->(b)  RETURN a.name, b.name LIMIT 1                   -- matches
MATCH (a)-[:CALLS*100..100]->(b) RETURN a.name, b.name LIMIT 1
-- matches: a = b = 'NewRelayServer'the self-loop traversed 100 times

Consequence: "longest path" / reachability-depth queries are dominated by whichever node has a self-loop, and any walk that can reach a self-loop node can pad itself to the hop cap.

Bug 3 — Silent 100-hop cap on variable-length expansion

MATCH (a)-[:CALLS*100..100]->(b) RETURN a.name LIMIT 1  -- match
MATCH (a)-[:CALLS*101..101]->(b) RETURN a.name LIMIT 1  -- empty, "Query returned no results" hint

A cap is reasonable, but it's indistinguishable from a genuine "no such path" answer. Combined with Bug 2, the engine confidently reports the longest chain in the codebase is exactly 100 calls — a pure artifact. An explicit error/warning ("hop limit exceeded") would prevent wrong conclusions.

Crash — heavy variable-length expansion kills the server

This query crashed the server mid-session (MCP error -32000: Connection closed); the immediately following request on the restarted process answered "project not found or not indexed" until a client reconnect:

MATCH (a)-[:CALLS*100..100]->(b) RETURN a.name, b.name LIMIT 20

The same query with LIMIT 1 completes fine. Possibly the same missing execution-budget issue as #601, but this is a hard crash rather than a hang.

Amplifier (parsing/quality, related: #725, #763)

The self-loop edge that makes Bug 2 so visible is itself a resolver artifact: a call to Go stdlib mux.HandleFunc inside NewRelayServer was resolved by the callee_suffix strategy (confidence 0.50) to the enclosing function itself. The same suffix-matching family also flags dozens of thin frontend CRUD wrappers (get, list, create, calling e.g. res.json() / api.get(...)) as self_recursive: true. Strategy counts in this graph: suffix_match 5,163 + import_map_suffix 1,972 + callee_suffix 8 ≈ 34% of CALLS edges. Low-confidence guessed edges routinely creating self-loops is what turns Bug 2 from a theoretical issue into a practical one. Happy to file this separately if useful.

Workarounds I tried that the parser rejects (for context)

  • Path variables: MATCH p = (a)-[:CALLS*5..5]->(b) RETURN length(p)expected token type 66, got 85 at pos 6
  • Node inequality: WHERE a <> b / WHERE a.qualified_name < b.qualified_nameexpected value
  • Relationship inline property map: ()-[r:CALLS {strategy: 'suffix_match'}]->() → parse error
  • Boolean in node inline map: {self_recursive: true} → parse error (works via WHERE f.self_recursive = true)

So there is currently no expressible way to ask for simple (node-unique) paths or to filter out low-confidence edges in a variable-length match.

Expected

  1. Repeated node variables in a pattern unify to the same node (standard Cypher semantics).
  2. Variable-length expansion enforces relationship uniqueness per path.
  3. Hitting the hop cap surfaces an explicit error/warning instead of an empty result.
  4. Heavy expansions degrade (timeout/budget) instead of crashing the process.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcypherCypher query language parser/executor bugspriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.stability/performanceServer crashes, OOM, hangs, high CPU/memory

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions