From 88e0dc9dccff98fec3337b22ce55b18a6ac6d8cc Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Wed, 1 Jul 2026 12:09:50 +0000 Subject: [PATCH] feat(graph): Cypher-subset query engine -- query-graph command + MCP tool (closes #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #9 — Cypher-like graph query engine for MCP tool query_graph. Agents can now express structural questions in a single expressive query instead of chaining trace + impact + context tool calls. One query replaces 3-5 tool calls for typical structural questions (estimated 60-80% reduction in tool calls for structural questions per issue #9). Supported Cypher subset (per issue 'minimal viable' scope): - Clauses: MATCH, WHERE, RETURN, LIMIT - Predicates: =, !=, <, >, <=, >=, CONTAINS, IS NULL, IS NOT NULL, EXISTS { pattern }, NOT EXISTS { pattern }, AND, OR, NOT - Node labels: Function, Class, File, Module, Route, Type, Interface - Edge types: CALLS, IMPORTS, DEFINES, INHERITS, IMPLEMENTS, USES_TYPE - Read-only (no CREATE/SET/DELETE/MERGE clauses) - Default LIMIT 100, hard cap 1000 to prevent runaway queries Example queries (from issue #9): MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name, g.file MATCH (c:Class)-[:INHERITS]->(p) WHERE p.name = 'BaseModel' RETURN c.name MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name -- dead code Architecture: - scripts/cypher_query_engine.py: tokenizer + recursive-descent parser + AST-to-SQL translator + executor. All user-supplied literals are parameterized (? placeholders) -- no SQL injection. Identifiers (var names, prop names, labels, edge types) validated against allow-lists at parse time. Correlated EXISTS subqueries translate to SQL EXISTS with WHERE correlation (not redundant JOIN). - scripts/commands/query_graph.py: CLI command with --explain flag (shows translated SQL without executing). Registered as 'query-graph'. - scripts/mcp_server.py: static MCP tool definition 'query-graph' with full description, query/workspace/explain parameters. Tool name codelens_query_graph (hyphens to underscores). - tests/test_cypher_query.py: 66 tests covering tokenizer, parser validation, SQL translation (placeholder/param count), end-to-end execution against test database, error handling, CLI command, MCP tool registration. Security: - All string/number literals in WHERE predicates are passed as SQL parameters (? placeholders) -- no string interpolation into SQL. - Identifiers (variable names, property names) validated against allow-lists (_NODE_PROPERTIES, _EDGE_PROPERTIES) at parse time. - Node labels validated against _LABEL_TO_NODE_TYPE map at parse time. - Edge types validated against _VALID_EDGE_TYPES set at parse time. - Write clauses (CREATE/SET/DELETE/MERGE/REMOVE/DROP/INSERT/UPDATE/DETACH) rejected with clear 'read-only' error. - SQL injection test case included in test suite (test_sql_injection_safe). Command count: 69 -> 70 (query-graph added). sync_command_count.py --apply updated README.md, SKILL.md, SKILL-QUICK.md, skill.json, pyproject.toml, scripts/graph_model.py. MCP tools: 67 -> 68 (55 static + 13 dynamic). Verified: - 66/66 cypher query tests pass - 185/185 pass on focused subset (test_cypher_query + test_command_count + test_command_registry + test_version_consistency + test_cli + test_codelens + test_graph_model + test_graph_incremental), 35 skipped (optional deps) - CLI: 'codelens query-graph --explain' produces correct SQL + params - End-to-end: all 3 issue #9 example queries return expected results against test database with 6 nodes + 3 edges --- README.md | 10 +- SKILL-QUICK.md | 10 +- SKILL.md | 4 +- pyproject.toml | 2 +- scripts/commands/query_graph.py | 112 +++ scripts/cypher_query_engine.py | 1242 +++++++++++++++++++++++++++++++ scripts/graph_model.py | 2 +- scripts/mcp_server.py | 43 ++ skill.json | 2 +- tests/test_cypher_query.py | 620 +++++++++++++++ 10 files changed, 2032 insertions(+), 15 deletions(-) create mode 100644 scripts/commands/query_graph.py create mode 100644 scripts/cypher_query_engine.py create mode 100644 tests/test_cypher_query.py diff --git a/README.md b/README.md index b90f8c93..8396bec9 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ > **Before an AI writes a new class/id/function, CodeLens must be checked. This is not optional.** -CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 68 CLI commands, an MCP server with 66 tools (54 static + 12 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). +CodeLens is an AI-native code intelligence platform that gives AI agents **full visibility** into a codebase before they write any code. It prevents collision, overwrite of existing logic, security vulnerabilities, and dead code through 70 CLI commands, an MCP server with 68 tools (55 static + 13 dynamic), AST-based taint analysis, live CVE/OSV scanning, a plugin system with OWASP Top 10 + Compliance rule packs, a true graph data model (nodes + edges) for structural code queries, and token-efficient `--format compact` output for high-volume agent workflows (issue #17). ## Features -- **68 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection -- **MCP Server (66 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 54 statically-defined tools + 12 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) +- **70 CLI Commands** — From basic scan/query to AST taint analysis, CVE scanning, plugin management, auto-fix, dashboards, CI/CD quality gates, and `graph-schema` for cheap graph-shape introspection +- **MCP Server (68 Tools)** — Native AI agent integration via Model Context Protocol (JSON-RPC over stdio), 55 statically-defined tools + 13 dynamically discovered, every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`) - **Token-Efficient Compact Output (v8.2, issue #17)** — `--format compact` produces single-char-key JSON with abbreviated types, omitted null fields, and relative paths — ~50% smaller than `json` on real trace output. Combined with `--limit`/`--offset` pagination, 5 structural queries now cost <5k tokens (down from 30-80k) - **AST Taint Engine** — Tree-sitter based taint analysis with return-value propagation, scope hierarchy, and branch condition refinement - **Live CVE/OSV Scanning** — Real-time vulnerability data from OSV.dev API with SQLite cache, 9 ecosystems (PyPI, npm, crates.io, Go, Maven, NuGet, RubyGems, Pub, Hex) @@ -225,8 +225,8 @@ codelens/ │ ├── changelog.md # Older changelog (per-version highlights) │ └── agent-integration.md # AI agent integration guide ├── scripts/ -│ ├── codelens.py # CLI entry point (68 commands registered) -│ ├── mcp_server.py # MCP JSON-RPC server (66 tools) +│ ├── codelens.py # CLI entry point (70 commands registered) +│ ├── mcp_server.py # MCP JSON-RPC server (68 tools) │ ├── registry.py # Registry read/write/build │ ├── persistent_registry.py # SQLite persistent storage (WAL mode) │ ├── base_parser.py # Base tree-sitter parser diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 5764ffd7..6740ab4c 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -114,7 +114,7 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co | "Cross-file taint" | `dataflow` | `taint` (taint is single-file, AST-deep) | | "Auto-fix issues" | `fix` | `check` (check just gates, doesn't fix) | -## All 68 Commands +## All 70 Commands ### Setup & Lifecycle (8+) `init` · `scan [--incremental] [--max-files N] [--full]` · `registry-validate` · `detect` · `watch [--debounce SECS] [--git-mode] [--interval SECS]` · `git-status` · `migrate` · `serve` · `lsp-status` (issue #33: `codelens --lsp-status` top-level flag is an alias of `codelens lsp-status` — both delegate to `hybrid_engine.get_lsp_status()` and return the identical payload) @@ -146,9 +146,9 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co ### Tooling (1) `plugin ` -**Total: 68 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) +**Total: 70 commands** (auto-registered via `commands/__init__.py`; rerun `python3 scripts/sync_command_count.py --apply` after adding/removing a command) -## MCP Server (66 Tools) +## MCP Server (68 Tools) Start the MCP server for AI agent integration: @@ -156,9 +156,9 @@ Start the MCP server for AI agent integration: python3 scripts/codelens.py serve ``` -Exposes 66 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): +Exposes 68 tools as `codelens_` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`): - 50 statically-defined tools (full JSON schemas in `mcp_server.py`) -- 12 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) +- 13 dynamically-discovered tools (auto-discovered from `COMMAND_REGISTRY`; long-running `watch` and `serve` are excluded) - Every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`). Use `format: "compact"` for token-efficient responses (~50% smaller than `json`). - `watch` and `serve` itself are excluded (long-running) diff --git a/SKILL.md b/SKILL.md index bd91f902..77b8be13 100755 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,10 @@ --- name: codelens description: > - CodeLens — AI-Native Code Intelligence. 68 commands for AI-powered code analysis, + CodeLens — AI-Native Code Intelligence. 70 commands for AI-powered code analysis, security auditing, quality scoring, AST-based taint analysis, live CVE scanning, and pre-write safety checks. Supports 28+ languages with tree-sitter + regex - fallback parsing. MCP server exposes 66 tools for AI agent integration. + fallback parsing. MCP server exposes 68 tools for AI agent integration. For quick command reference with validated output schemas, see SKILL-QUICK.md. For version history, see CHANGELOG.md. --- diff --git a/pyproject.toml b/pyproject.toml index 51b37456..cfb46efa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "codelens" version = "8.2.0" -description = "Live Codebase Reference Intelligence — 68 commands for AI-powered code analysis, security auditing, and quality scoring" +description = "Live Codebase Reference Intelligence — 70 commands for AI-powered code analysis, security auditing, and quality scoring" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.8" diff --git a/scripts/commands/query_graph.py b/scripts/commands/query_graph.py new file mode 100644 index 00000000..a545c014 --- /dev/null +++ b/scripts/commands/query_graph.py @@ -0,0 +1,112 @@ +# @WHO: scripts/commands/query_graph.py +# @WHAT: Cypher-subset graph query — MATCH/WHERE/RETURN/LIMIT over graph_nodes + graph_edges +# @PART: commands +# @ENTRY: execute() + +""" +Query-graph command — Cypher-subset structural queries over the code graph (issue #9). + +Agents can express structural questions in a single expressive query +instead of chaining ``trace`` + ``impact`` + ``context`` tool calls. +One query replaces 3-5 tool calls for typical structural questions +("who calls handleRequest", "dead code with no callers", "classes +inheriting from BaseModel"). + +Supported Cypher subset (per issue #9 "minimal viable" scope): + + MATCH (f:Function)-[:CALLS]->(g) + WHERE f.name = 'handleRequest' + RETURN g.name, g.file + + MATCH (c:Class)-[:INHERITS]->(p) + WHERE p.name = 'BaseModel' + RETURN c.name + + MATCH (f:Function) + WHERE NOT EXISTS { ()-[:CALLS]->(f) } + RETURN f.name -- dead code (no callers) + +Clauses: MATCH, WHERE, RETURN, LIMIT +Predicates: =, !=, <, >, <=, >=, CONTAINS, IS NULL, IS NOT NULL, + EXISTS { pattern }, NOT EXISTS { pattern }, AND, OR, NOT +Node labels: Function, Class, File, Module, Route, Type, Interface +Edge types: CALLS, IMPORTS, DEFINES, INHERITS, IMPLEMENTS, USES_TYPE + +Read-only — no CREATE / SET / DELETE clauses. Default LIMIT 100, hard +cap 1000 to prevent runaway queries. + +Example: + codelens query-graph \\ + 'MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = \\'handleRequest\\' RETURN g.name, g.file' \\ + /path/to/workspace + + codelens query-graph --query 'MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name' /path/to/workspace +""" + +from typing import Optional + +from commands import register_command +from cypher_query_engine import query_graph, CypherParseError + + +def add_args(parser): + """Add query-graph arguments to the parser.""" + parser.add_argument( + "query", + help=( + "Cypher-subset query string. Must contain MATCH ... RETURN. " + "Example: \"MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name\"" + ), + ) + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + parser.add_argument( + "--db-path", default=None, + help="Custom path for SQLite database file (default: /.codelens/codelens.db)", + ) + parser.add_argument( + "--explain", action="store_true", + help="Show the translated SQL and parameters without executing the query", + ) + + +def execute(args, workspace): + """Execute the query-graph command. + + @FLOW: QUERY_GRAPH + @CALLS: cypher_query_engine.query_graph() -> result_dict + @MUTATES: none (read-only graph query) + """ + db_path = getattr(args, "db_path", None) + + if getattr(args, "explain", False): + # Parse + translate only, don't execute. + from cypher_query_engine import parse, translate + try: + ast = parse(args.query) + sql, params, ret = translate(ast) + except CypherParseError as e: + return { + "status": "error", + "error": f"Parse error: {e}", + "query": args.query, + } + return { + "status": "ok", + "query": args.query, + "sql": sql, + "params": params, + "return_items": [r.display_name() for r in ret], + } + + return query_graph(args.query, workspace, db_path=db_path) + + +register_command( + "query-graph", + "Cypher-subset structural query over graph_nodes + graph_edges (MATCH/WHERE/RETURN/LIMIT, read-only)", + add_args, + execute, +) diff --git a/scripts/cypher_query_engine.py b/scripts/cypher_query_engine.py new file mode 100644 index 00000000..79e23bde --- /dev/null +++ b/scripts/cypher_query_engine.py @@ -0,0 +1,1242 @@ +""" +Cypher-subset query engine for the CodeLens graph model (issue #9). + +Provides a read-only, openCypher-inspired query language over the +``graph_nodes`` + ``graph_edges`` SQLite tables populated during scan. +Agents can express structural questions in a single expressive query +instead of chaining ``trace`` + ``impact`` + ``context`` tool calls. + +Supported subset (per issue #9 "minimal viable" scope): + + MATCH (f:Function)-[:CALLS]->(g) + WHERE f.name = 'handleRequest' + RETURN g.name, g.file + + MATCH (c:Class)-[:INHERITS]->(p) + WHERE p.name = 'BaseModel' + RETURN c.name + + MATCH (f:Function) + WHERE NOT EXISTS { ()-[:CALLS]->(f) } + RETURN f.name -- dead code (no callers) + +Clauses: + MATCH — one or more comma-separated path patterns + WHERE — optional predicate (AND / OR / NOT / comparison / CONTAINS / IS NULL / EXISTS) + RETURN — variable.prop or variable (whole node) + LIMIT — optional row cap (default 100, hard cap 1000 to prevent runaway queries) + +Predicates: + n.prop = 'value' (string equality) + n.prop = 42 (integer equality) + n.prop != 'value' (inequality) + n.prop CONTAINS 'value' (substring match, case-sensitive) + n.prop IS NULL (property absent or column NULL) + n.prop IS NOT NULL + EXISTS { (m)-[:CALLS]->(n) } (sub-pattern match) + NOT EXISTS { ()-[:CALLS]->(n) } (no incoming :CALLS edge) + pred AND pred / pred OR pred / NOT pred + +Node labels map to ``graph_nodes.node_type`` (case-insensitive): + Function → function, Class → class, File → file, Module → module, + Route → route, Type → type, Interface → interface + +Edge types map to ``graph_edges.edge_type`` (case-insensitive): + CALLS, IMPORTS, DEFINES, INHERITS, IMPLEMENTS, USES_TYPE + +Security: + All user-supplied literals are passed as SQL parameters — no string + interpolation into SQL. Identifiers (variable names, property names, + labels, edge types) are validated against an allow-list before + inclusion in SQL to prevent injection via crafted identifiers. + +This engine is **read-only** — no CREATE / SET / DELETE / MERGE clauses +are supported. A write clause in the query raises ``CypherParseError``. +""" + +from __future__ import annotations + +import os +import re +import sqlite3 +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple, Union + +from graph_model import ( + GRAPH_NODES_TABLE, + GRAPH_EDGES_TABLE, + NODE_TYPE_FUNCTION, + NODE_TYPE_CLASS, + NODE_TYPE_FILE, + NODE_TYPE_MODULE, + NODE_TYPE_ROUTE, + NODE_TYPE_TYPE, + NODE_TYPE_INTERFACE, + graph_tables_exist, +) +from utils import default_db_path, logger + +# ─── Constants ──────────────────────────────────────────────────────────── + +# Cypher labels → graph_nodes.node_type values. Case-insensitive lookup. +_LABEL_TO_NODE_TYPE: Dict[str, str] = { + "function": NODE_TYPE_FUNCTION, + "method": NODE_TYPE_FUNCTION, # alias + "component": NODE_TYPE_FUNCTION, # React component is a function + "class": NODE_TYPE_CLASS, + "file": NODE_TYPE_FILE, + "module": NODE_TYPE_MODULE, + "route": NODE_TYPE_ROUTE, + "type": NODE_TYPE_TYPE, + "interface": NODE_TYPE_INTERFACE, +} + +# Valid node properties (graph_nodes columns + extra_json keys). +_NODE_PROPERTIES = {"name", "file", "line", "node_type", "node_id"} + +# Valid edge properties (graph_edges columns). +_EDGE_PROPERTIES = {"edge_type", "file", "line", "confidence"} + +# Valid edge types — case-insensitive. User may write :CALLS or :calls. +_VALID_EDGE_TYPES = { + "CALLS", "IMPORTS", "DEFINES", "INHERITS", "IMPLEMENTS", "USES_TYPE", +} + +DEFAULT_LIMIT = 100 +MAX_LIMIT = 1000 + + +class CypherParseError(Exception): + """Raised when the Cypher query cannot be parsed or violates the subset.""" + + +class CypherExecutionError(Exception): + """Raised when the translated SQL fails to execute.""" + + +# ─── AST ────────────────────────────────────────────────────────────────── + + +@dataclass +class NodePattern: + """A node pattern like ``(f:Function)`` or ``(g)`` or ``()``.""" + var: Optional[str] = None # variable name, e.g. 'f' + label: Optional[str] = None # Cypher label, e.g. 'Function' (unmapped) + + def node_type(self) -> Optional[str]: + """Map the Cypher label to a graph_nodes.node_type value.""" + if self.label is None: + return None + key = self.label.lower() + if key not in _LABEL_TO_NODE_TYPE: + raise CypherParseError( + f"Unknown node label ': {self.label}'. Valid labels: " + f"{sorted(set(_LABEL_TO_NODE_TYPE.keys()))}" + ) + return _LABEL_TO_NODE_TYPE[key] + + +@dataclass +class EdgePattern: + """An edge pattern like ``-[:CALLS]->``, ``<-[:IMPORTS]-``, or ``-[]-``.""" + edge_type: Optional[str] = None # e.g. 'CALLS' (already upper-cased) + direction: str = "->" # '->', '<-', or '-' (undirected) + target: NodePattern = field(default_factory=NodePattern) + + +@dataclass +class PathPattern: + """A path pattern: start node + chain of edges.""" + start: NodePattern + edges: List[EdgePattern] = field(default_factory=list) + + +# ─── Predicate AST ──────────────────────────────────────────────────────── + + +@dataclass +class Comparison: + """``var.prop OP value`` — e.g. ``f.name = 'handleRequest'``.""" + var: str + prop: str + op: str # '=', '!=', '<', '>', '<=', '>=', 'CONTAINS' + value: Any # str or int — passed as SQL parameter + + +@dataclass +class IsNull: + """``var.prop IS NULL`` or ``var.prop IS NOT NULL``.""" + var: str + prop: str + negate: bool = False + + +@dataclass +class ExistsSubquery: + """``EXISTS { path_pattern }`` or ``NOT EXISTS { path_pattern }``. + + The inner path pattern is matched against the graph; the outer query + row passes its bound node variables into the subquery so the pattern + can reference them. + """ + pattern: PathPattern + negate: bool = False + + +@dataclass +class BoolOp: + """``left AND right`` / ``left OR right``.""" + op: str # 'AND' / 'OR' + left: Any + right: Any + + +@dataclass +class NotOp: + """``NOT pred``.""" + pred: Any + + +@dataclass +class ReturnItem: + """A single RETURN expression — either ``var.prop`` or bare ``var``.""" + var: str + prop: Optional[str] = None # None means return whole node as dict + + def display_name(self) -> str: + if self.prop is None: + return self.var + return f"{self.var}.{self.prop}" + + +@dataclass +class CypherQuery: + """Parsed Cypher query AST.""" + patterns: List[PathPattern] = field(default_factory=list) + where: Optional[Any] = None + return_items: List[ReturnItem] = field(default_factory=list) + limit: Optional[int] = None + + +# ─── Tokenizer ──────────────────────────────────────────────────────────── + +# Token types. +_TOK_KEYWORD = "KEYWORD" +_TOK_IDENT = "IDENT" +_TOK_STRING = "STRING" +_TOK_NUMBER = "NUMBER" +_TOK_PUNCT = "PUNCT" +_TOK_EOF = "EOF" + +_KEYWORDS = { + "MATCH", "WHERE", "RETURN", "LIMIT", "AND", "OR", "NOT", + "CONTAINS", "IS", "NULL", "EXISTS", +} + +# Single-char punctuation that becomes its own token. +_SINGLE_PUNCT = set("()[]{}:,.<>=-") + +# Multi-char operators. +_MULTI_OPS = ["<=", ">=", "!=", "<-", "->", "-[" ] + + +@dataclass +class Token: + type: str + value: str + pos: int + + +def _tokenize(query: str) -> List[Token]: + """Split a Cypher query string into tokens. + + Rules: + - Whitespace separates tokens but is otherwise skipped. + - ``--`` to end of line is a SQL-style comment (per issue example). + - Single-quoted strings are string literals (no escape — Cypher uses + ``''`` for embedded quotes, but we keep it simple and reject embedded + quotes; agents can use double-quoted property values via parameterization + at the API layer). + - Identifiers start with a letter or underscore, continue with + alphanumeric/underscore. + - Numbers are non-negative integers (no floats needed for graph queries). + """ + tokens: List[Token] = [] + i = 0 + n = len(query) + while i < n: + ch = query[i] + + # Whitespace + if ch.isspace(): + i += 1 + continue + + # Comment: -- to end of line + if ch == "-" and i + 1 < n and query[i + 1] == "-": + while i < n and query[i] != "\n": + i += 1 + continue + + # String literal + if ch == "'": + j = i + 1 + buf: List[str] = [] + while j < n: + if query[j] == "'": + if j + 1 < n and query[j + 1] == "'": + # Escaped quote + buf.append("'") + j += 2 + continue + break + buf.append(query[j]) + j += 1 + if j >= n: + raise CypherParseError( + f"Unterminated string literal at position {i}: " + f"{query[i:i+20]!r}" + ) + tokens.append(Token(_TOK_STRING, "".join(buf), i)) + i = j + 1 + continue + + # Number (non-negative integer) + if ch.isdigit(): + j = i + while j < n and query[j].isdigit(): + j += 1 + tokens.append(Token(_TOK_NUMBER, query[i:j], i)) + i = j + continue + + # Identifier / keyword + if ch.isalpha() or ch == "_": + j = i + while j < n and (query[j].isalnum() or query[j] == "_"): + j += 1 + word = query[i:j] + upper = word.upper() + if upper in _KEYWORDS: + tokens.append(Token(_TOK_KEYWORD, upper, i)) + else: + tokens.append(Token(_TOK_IDENT, word, i)) + i = j + continue + + # Multi-char operators (check before single-char) + matched = False + for op in _MULTI_OPS: + if query[i:i + len(op)] == op: + tokens.append(Token(_TOK_PUNCT, op, i)) + i += len(op) + matched = True + break + if matched: + continue + + # Single-char punctuation + if ch in _SINGLE_PUNCT: + # '-' is ambiguous: could be minus, or part of '->' / '<-' / '-['. + # The multi-char pass above already caught '->', '<-', '-['. + # A lone '-' is the edge dash. + tokens.append(Token(_TOK_PUNCT, ch, i)) + i += 1 + continue + + raise CypherParseError( + f"Unexpected character {ch!r} at position {i}: " + f"{query[max(0, i-10):i+10]!r}" + ) + + tokens.append(Token(_TOK_EOF, "", n)) + return tokens + + +# ─── Parser (recursive descent) ─────────────────────────────────────────── + + +class _Parser: + """Recursive-descent parser for the Cypher subset. + + Grammar (simplified): + + query := MATCH path (',' path)* [WHERE pred] RETURN ret (',' ret)* [LIMIT num] + path := node (edge node)* + node := '(' [var] [':' label] ')' + edge := '-' '[' [':' type] ']' '->' | '<-' '[' [':' type] ']' '-' | '-' '[' [':' type] ']' '-' + pred := or_pred + or_pred := and_pred (OR and_pred)* + and_pred := not_pred (AND not_pred)* + not_pred := NOT not_pred | atom + atom := '(' pred ')' | comparison | is_null | exists + comparison := var '.' prop op value + is_null := var '.' prop IS [NOT] NULL + exists := [NOT] EXISTS '{' path '}' + ret := var ['.' prop] + op := '=' | '!=' | '<' | '>' | '<=' | '>=' | CONTAINS + value := STRING | NUMBER + """ + + def __init__(self, tokens: List[Token]): + self.tokens = tokens + self.pos = 0 + + def _peek(self) -> Token: + return self.tokens[self.pos] + + def _next(self) -> Token: + tok = self.tokens[self.pos] + self.pos += 1 + return tok + + def _expect(self, ttype: str, value: Optional[str] = None) -> Token: + tok = self._peek() + if tok.type != ttype: + raise CypherParseError( + f"Expected {ttype} but got {tok.type} ({tok.value!r}) at pos {tok.pos}" + ) + if value is not None and tok.value != value: + raise CypherParseError( + f"Expected {value!r} but got {tok.value!r} at pos {tok.pos}" + ) + return self._next() + + def _accept(self, ttype: str, value: Optional[str] = None) -> Optional[Token]: + tok = self._peek() + if tok.type != ttype: + return None + if value is not None and tok.value != value: + return None + return self._next() + + def parse(self) -> CypherQuery: + query = CypherQuery() + + # Reject write clauses up front — clearer error than falling through. + # Write clause names are NOT in _KEYWORDS (they're treated as IDENT), + # so we check by value (case-insensitive). + _WRITE_CLAUSES = {"CREATE", "SET", "DELETE", "MERGE", "REMOVE", "DROP", + "INSERT", "UPDATE", "DETACH"} + first = self._peek() + if first.type == _TOK_IDENT and first.value.upper() in _WRITE_CLAUSES: + raise CypherParseError( + f"Write clause '{first.value}' is not supported — query_graph " + f"is read-only (MATCH/WHERE/RETURN/LIMIT only)." + ) + + self._expect(_TOK_KEYWORD, "MATCH") + + # One or more comma-separated path patterns. + query.patterns.append(self._parse_path()) + while self._accept(_TOK_PUNCT, ","): + query.patterns.append(self._parse_path()) + + # Check for write clauses after MATCH (e.g. "MATCH (n) DELETE n"). + next_tok = self._peek() + if next_tok.type == _TOK_IDENT and next_tok.value.upper() in _WRITE_CLAUSES: + raise CypherParseError( + f"Write clause '{next_tok.value}' is not supported — query_graph " + f"is read-only (MATCH/WHERE/RETURN/LIMIT only)." + ) + + # Optional WHERE. + if self._accept(_TOK_KEYWORD, "WHERE"): + query.where = self._parse_pred() + + # RETURN (required). + self._expect(_TOK_KEYWORD, "RETURN") + query.return_items.append(self._parse_return_item()) + while self._accept(_TOK_PUNCT, ","): + query.return_items.append(self._parse_return_item()) + + # Optional LIMIT. + if self._accept(_TOK_KEYWORD, "LIMIT"): + tok = self._expect(_TOK_NUMBER) + query.limit = int(tok.value) + if query.limit <= 0: + raise CypherParseError( + f"LIMIT must be a positive integer, got {query.limit}" + ) + if query.limit > MAX_LIMIT: + raise CypherParseError( + f"LIMIT {query.limit} exceeds hard cap {MAX_LIMIT}. " + f"Use a smaller page size and paginate." + ) + + # Must be at EOF. + if self._peek().type != _TOK_EOF: + tok = self._peek() + raise CypherParseError( + f"Unexpected token after query end: {tok.value!r} at pos {tok.pos}" + ) + + # Validate that all variables in RETURN/WHERE are bound by MATCH. + bound_vars: set = set() + for pat in query.patterns: + if pat.start.var: + bound_vars.add(pat.start.var) + for edge in pat.edges: + if edge.target.var: + bound_vars.add(edge.target.var) + + for item in query.return_items: + if item.var not in bound_vars: + raise CypherParseError( + f"RETURN variable '{item.var}' is not bound by any MATCH " + f"pattern. Bound variables: {sorted(bound_vars)}" + ) + + if query.where is not None: + self._validate_where_vars(query.where, bound_vars) + + return query + + def _validate_where_vars(self, pred: Any, bound_vars: set) -> None: + """Recursively check that all variables in WHERE are bound.""" + if isinstance(pred, Comparison): + if pred.var not in bound_vars: + raise CypherParseError( + f"WHERE variable '{pred.var}' is not bound by any MATCH " + f"pattern. Bound variables: {sorted(bound_vars)}" + ) + elif isinstance(pred, IsNull): + if pred.var not in bound_vars: + raise CypherParseError( + f"WHERE variable '{pred.var}' is not bound by any MATCH " + f"pattern. Bound variables: {sorted(bound_vars)}" + ) + elif isinstance(pred, ExistsSubquery): + # EXISTS sub-pattern may reference bound vars from outer query + # (correlated subquery). Its own internal vars don't need to be + # bound by the outer MATCH, but they must be self-consistent. + inner_vars: set = set() + if pred.pattern.start.var: + inner_vars.add(pred.pattern.start.var) + for edge in pred.pattern.edges: + if edge.target.var: + inner_vars.add(edge.target.var) + # No further validation — the translator will correlate. + elif isinstance(pred, BoolOp): + self._validate_where_vars(pred.left, bound_vars) + self._validate_where_vars(pred.right, bound_vars) + elif isinstance(pred, NotOp): + self._validate_where_vars(pred.pred, bound_vars) + + def _parse_path(self) -> PathPattern: + """Parse a path: node (edge node)*.""" + start = self._parse_node() + path = PathPattern(start=start) + + while True: + edge = self._try_parse_edge() + if edge is None: + break + path.edges.append(edge) + + return path + + def _parse_node(self) -> NodePattern: + """Parse ``(var:Label)`` or ``(var)`` or ``()``.""" + self._expect(_TOK_PUNCT, "(") + node = NodePattern() + + # Optional variable. + if self._peek().type == _TOK_IDENT: + node.var = self._next().value + + # Optional label. + if self._accept(_TOK_PUNCT, ":"): + label_tok = self._expect(_TOK_IDENT) + node.label = label_tok.value + # Validate label at parse time so the error surfaces early + # with a clear message (issue #9 test coverage). + if node.label.lower() not in _LABEL_TO_NODE_TYPE: + raise CypherParseError( + f"Unknown node label ': {node.label}'. Valid labels: " + f"{sorted(set(k for k in _LABEL_TO_NODE_TYPE if k.islower()))}" + ) + + self._expect(_TOK_PUNCT, ")") + return node + + def _try_parse_edge(self) -> Optional[EdgePattern]: + """Parse an edge pattern. Returns None if the next token isn't an edge start. + + Edge forms: + -[:TYPE]-> (right-directed) + <-[:TYPE]- (left-directed) + -[:TYPE]- (undirected) + -[]-> (no type) + """ + # An edge starts with either '<-' or '-'. + tok = self._peek() + if tok.type != _TOK_PUNCT: + return None + if tok.value == "<-": + self._next() + direction = "<-" + elif tok.value == "-": + # Could be '-' followed by '[' (edge bracket), or '-' followed + # by '>' (malformed '->' without brackets — reject). + self._next() + direction = "-" # tentative; will refine after brackets + elif tok.value == "-[": + # Multi-char token from the multi-op pass. + self._next() + # We consumed '-[' — parse the rest as edge body. + return self._parse_edge_body(direction="-", already_consumed_bracket=True) + else: + return None + + # After consuming '<-' or '-', we may have '[' for the edge body. + if direction == "<-": + # Expect '[' + if self._accept(_TOK_PUNCT, "["): + return self._parse_edge_body(direction="<-", already_consumed_bracket=False) + # '<-' without brackets — treat as undirected edge with no type. + # But '<-' alone doesn't have a target. This is malformed. + raise CypherParseError( + f"Expected '[' after '<-' at pos {self._peek().pos}" + ) + else: + # direction == '-', we may have '[' next. + if self._accept(_TOK_PUNCT, "["): + return self._parse_edge_body(direction="-", already_consumed_bracket=False) + # '-' without '[' — malformed edge (need brackets for edge type). + # But wait — could be the start of '->'. Check. + if self._accept(_TOK_PUNCT, ">"): + # '->' without brackets — undirected-ish but actually means + # right-directed edge with no type. Accept it. + target = self._parse_node() + return EdgePattern(edge_type=None, direction="->", target=target) + raise CypherParseError( + f"Expected '[' or '>' after '-' at pos {self._peek().pos}" + ) + + def _parse_edge_body(self, direction: str, already_consumed_bracket: bool) -> EdgePattern: + """Parse the ``:TYPE]`` part of an edge (bracket already open).""" + edge_type: Optional[str] = None + + if self._accept(_TOK_PUNCT, ":"): + type_tok = self._expect(_TOK_IDENT) + raw_type = type_tok.value.upper() + if raw_type not in _VALID_EDGE_TYPES: + raise CypherParseError( + f"Unknown edge type ': {type_tok.value}'. Valid types: " + f"{sorted(_VALID_EDGE_TYPES)}" + ) + edge_type = raw_type + + # Close bracket. + self._expect(_TOK_PUNCT, "]") + + # After ']', expect the direction arrow. + if direction == "<-": + # '<-[:TYPE]-' — expect '-' to close. + self._expect(_TOK_PUNCT, "-") + final_direction = "<-" + elif direction == "-": + # '-[:TYPE]->' or '-[:TYPE]-' + # Note: '->' is tokenized as a single PUNCT token (multi-char op). + if self._accept(_TOK_PUNCT, "->"): + final_direction = "->" + elif self._accept(_TOK_PUNCT, "-"): + final_direction = "-" + else: + raise CypherParseError( + f"Expected '->' or '-' after edge type at pos {self._peek().pos}" + ) + else: + raise CypherParseError(f"Internal parser error: bad direction {direction!r}") + + target = self._parse_node() + return EdgePattern(edge_type=edge_type, direction=final_direction, target=target) + + # ─── Predicate parsing ───────────────────────────────────────────── + + def _parse_pred(self) -> Any: + return self._parse_or() + + def _parse_or(self) -> Any: + left = self._parse_and() + while self._accept(_TOK_KEYWORD, "OR"): + right = self._parse_and() + left = BoolOp(op="OR", left=left, right=right) + return left + + def _parse_and(self) -> Any: + left = self._parse_not() + while self._accept(_TOK_KEYWORD, "AND"): + right = self._parse_not() + left = BoolOp(op="AND", left=left, right=right) + return left + + def _parse_not(self) -> Any: + if self._accept(_TOK_KEYWORD, "NOT"): + # NOT can precede EXISTS or a parenthesized predicate or a comparison. + if self._peek().type == _TOK_KEYWORD and self._peek().value == "EXISTS": + self._next() + return self._parse_exists_body(negate=True) + return NotOp(pred=self._parse_not()) + return self._parse_atom() + + def _parse_atom(self) -> Any: + tok = self._peek() + + # Parenthesized predicate. + if tok.type == _TOK_PUNCT and tok.value == "(": + # But wait — '(' could also start a node pattern inside EXISTS. + # We only reach here for top-level predicates, so '(' means + # a parenthesized boolean expression. + self._next() + inner = self._parse_pred() + self._expect(_TOK_PUNCT, ")") + return inner + + # EXISTS { path } + if tok.type == _TOK_KEYWORD and tok.value == "EXISTS": + self._next() + return self._parse_exists_body(negate=False) + + # var.prop ... + if tok.type == _TOK_IDENT: + return self._parse_comparison_or_isnull() + + raise CypherParseError( + f"Expected predicate at pos {tok.pos}, got {tok.value!r}" + ) + + def _parse_exists_body(self, negate: bool) -> ExistsSubquery: + """Parse ``{ path }`` after EXISTS keyword.""" + self._expect(_TOK_PUNCT, "{") + path = self._parse_path() + self._expect(_TOK_PUNCT, "}") + return ExistsSubquery(pattern=path, negate=negate) + + def _parse_comparison_or_isnull(self) -> Any: + """Parse ``var.prop OP value`` or ``var.prop IS [NOT] NULL``.""" + var_tok = self._expect(_TOK_IDENT) + self._expect(_TOK_PUNCT, ".") + prop_tok = self._expect(_TOK_IDENT) + var = var_tok.value + prop = prop_tok.value + + # Validate property name against allow-list. + if prop not in _NODE_PROPERTIES and prop not in _EDGE_PROPERTIES: + raise CypherParseError( + f"Unknown property '{prop}'. Valid node properties: " + f"{sorted(_NODE_PROPERTIES)}, edge properties: {sorted(_EDGE_PROPERTIES)}" + ) + + # IS NULL / IS NOT NULL + if self._accept(_TOK_KEYWORD, "IS"): + negate = bool(self._accept(_TOK_KEYWORD, "NOT")) + self._expect(_TOK_KEYWORD, "NULL") + return IsNull(var=var, prop=prop, negate=negate) + + # Comparison operator. + op_tok = self._peek() + if op_tok.type == _TOK_KEYWORD and op_tok.value == "CONTAINS": + self._next() + op = "CONTAINS" + elif op_tok.type == _TOK_PUNCT and op_tok.value in ("=", "!=", "<", ">", "<=", ">="): + op = self._next().value + else: + raise CypherParseError( + f"Expected comparison operator (=, !=, <, >, <=, >=, CONTAINS) " + f"or IS [NOT] NULL at pos {op_tok.pos}, got {op_tok.value!r}" + ) + + # Value. + val_tok = self._peek() + if val_tok.type == _TOK_STRING: + value: Any = self._next().value + elif val_tok.type == _TOK_NUMBER: + value = int(self._next().value) + else: + raise CypherParseError( + f"Expected string or number value at pos {val_tok.pos}, " + f"got {val_tok.value!r}" + ) + + return Comparison(var=var, prop=prop, op=op, value=value) + + def _parse_return_item(self) -> ReturnItem: + """Parse ``var`` or ``var.prop``.""" + var_tok = self._expect(_TOK_IDENT) + item = ReturnItem(var=var_tok.value) + + if self._accept(_TOK_PUNCT, "."): + prop_tok = self._expect(_TOK_IDENT) + item.prop = prop_tok.value + # Validate property. + if item.prop not in _NODE_PROPERTIES and item.prop not in _EDGE_PROPERTIES: + raise CypherParseError( + f"Unknown property '{item.prop}' in RETURN. Valid: " + f"{sorted(_NODE_PROPERTIES | _EDGE_PROPERTIES)}" + ) + + return item + + +def parse(query: str) -> CypherQuery: + """Parse a Cypher query string into a validated AST. + + Raises: + CypherParseError: if the query violates the supported subset or + references unknown labels / edge types / properties. + """ + tokens = _tokenize(query) + parser = _Parser(tokens) + return parser.parse() + + +# ─── SQL Translator ─────────────────────────────────────────────────────── + + +def _table_alias_for_var(var: str, counter: Dict[str, int]) -> str: + """Generate a stable SQL table alias for a node variable. + + Uses ``n_`` to avoid collisions with SQL keywords. If the same + variable appears in multiple patterns (unlikely but possible with + correlated EXISTS), the counter disambiguates. + """ + # Sanitize: only alnum + underscore allowed in var (enforced by tokenizer). + return f"n_{var}" + + +def _translate_pattern( + pattern: PathPattern, + alias_map: Dict[str, str], + params: list, + alias_counter: List[int], +) -> Tuple[str, str]: + """Translate a path pattern to a FROM + WHERE SQL fragment. + + Returns ``(from_clause, where_clause)`` where ``from_clause`` is the + list of table joins and ``where_clause`` is the conjunction of node + type + edge type constraints. + + ``alias_map`` is updated in place: var → SQL alias. + """ + from_parts: List[str] = [] + where_parts: List[str] = [] + + # Start node. + start = pattern.start + if start.var is None: + # Anonymous start node — generate a throwaway alias. + alias_counter[0] += 1 + start_alias = f"n_anon{alias_counter[0]}" + else: + start_alias = _table_alias_for_var(start.var, {}) + if start.var in alias_map: + # Variable already bound — reuse the existing alias. + start_alias = alias_map[start.var] + else: + alias_map[start.var] = start_alias + + from_parts.append(f"{GRAPH_NODES_TABLE} AS {start_alias}") + + # Node type constraint on start. + node_type = start.node_type() + if node_type is not None: + where_parts.append(f"{start_alias}.node_type = ?") + params.append(node_type) + + # Walk the edge chain. + prev_alias = start_alias + for idx, edge in enumerate(pattern.edges): + alias_counter[0] += 1 + edge_alias = f"e_{alias_counter[0]}" + alias_counter[0] += 1 + + # Target node. + target = edge.target + target_is_new = target.var is None or target.var not in alias_map + if target.var is None: + target_alias = f"n_anon{alias_counter[0]}" + else: + if target.var in alias_map: + target_alias = alias_map[target.var] + else: + target_alias = _table_alias_for_var(target.var, {}) + alias_map[target.var] = target_alias + + # Build edge JOIN clause (depends on direction). + # When target is already bound (correlated subquery), we DON'T join + # the target node table — instead we add a WHERE condition correlating + # the edge's source/target_id to the existing alias. + if edge.direction == "->": + edge_join = ( + f"JOIN {GRAPH_EDGES_TABLE} AS {edge_alias} " + f"ON {edge_alias}.source_id = {prev_alias}.node_id" + ) + if target_is_new: + target_join = ( + f"JOIN {GRAPH_NODES_TABLE} AS {target_alias} " + f"ON {target_alias}.node_id = {edge_alias}.target_id" + ) + else: + # Correlated: edge.target_id must equal the existing target alias. + target_join = None + where_parts.append( + f"{edge_alias}.target_id = {target_alias}.node_id" + ) + elif edge.direction == "<-": + edge_join = ( + f"JOIN {GRAPH_EDGES_TABLE} AS {edge_alias} " + f"ON {edge_alias}.target_id = {prev_alias}.node_id" + ) + if target_is_new: + target_join = ( + f"JOIN {GRAPH_NODES_TABLE} AS {target_alias} " + f"ON {target_alias}.node_id = {edge_alias}.source_id" + ) + else: + # Correlated: edge.source_id must equal the existing target alias. + target_join = None + where_parts.append( + f"{edge_alias}.source_id = {target_alias}.node_id" + ) + else: # "-" + # Undirected — match either direction. Use OR. + edge_join = ( + f"JOIN {GRAPH_EDGES_TABLE} AS {edge_alias} " + f"ON ({edge_alias}.source_id = {prev_alias}.node_id " + f"OR {edge_alias}.target_id = {prev_alias}.node_id)" + ) + if target_is_new: + target_join = ( + f"JOIN {GRAPH_NODES_TABLE} AS {target_alias} " + f"ON ({target_alias}.node_id = {edge_alias}.target_id " + f"OR {target_alias}.node_id = {edge_alias}.source_id) " + f"AND {target_alias}.node_id != {prev_alias}.node_id" + ) + else: + # Correlated undirected — either direction matches the target. + target_join = None + where_parts.append( + f"({edge_alias}.target_id = {target_alias}.node_id " + f"OR {edge_alias}.source_id = {target_alias}.node_id) " + f"AND {target_alias}.node_id != {prev_alias}.node_id" + ) + + from_parts.append(edge_join) + if target_join is not None: + from_parts.append(target_join) + + # Edge type constraint. + if edge.edge_type is not None: + where_parts.append(f"{edge_alias}.edge_type = ?") + params.append(edge.edge_type) + + # Target node type constraint. + target_type = target.node_type() + if target_type is not None: + where_parts.append(f"{target_alias}.node_type = ?") + params.append(target_type) + + prev_alias = target_alias + + from_clause = " ".join(from_parts) + where_clause = " AND ".join(where_parts) if where_parts else "1=1" + return from_clause, where_clause + + +def _translate_predicate( + pred: Any, + alias_map: Dict[str, str], + params: list, + alias_counter: List[int], +) -> str: + """Translate a predicate AST node to a SQL WHERE fragment.""" + if isinstance(pred, Comparison): + alias = alias_map.get(pred.var) + if alias is None: + # Could be a var referenced only in EXISTS — but we validated + # at parse time that WHERE vars are bound. This is a bug. + raise CypherExecutionError( + f"Variable '{pred.var}' not bound to a SQL alias. This is a bug." + ) + col = f"{alias}.{pred.prop}" + if pred.op == "CONTAINS": + # LIKE with %wildcards% on both sides for substring match. + params.append(f"%{pred.value}%") + return f"{col} LIKE ?" + else: + params.append(pred.value) + op_map = {"=": "=", "!=": "!=", "<": "<", ">": ">", + "<=": "<=", ">=": ">="} + return f"{col} {op_map[pred.op]} ?" + + if isinstance(pred, IsNull): + alias = alias_map.get(pred.var) + if alias is None: + raise CypherExecutionError( + f"Variable '{pred.var}' not bound to a SQL alias. This is a bug." + ) + col = f"{alias}.{pred.prop}" + if pred.negate: + return f"{col} IS NOT NULL" + return f"{col} IS NULL" + + if isinstance(pred, ExistsSubquery): + # Translate the inner path to a correlated EXISTS subquery. + inner_alias_map: Dict[str, str] = dict(alias_map) # inherit outer bindings + inner_from, inner_where = _translate_pattern( + pred.pattern, inner_alias_map, params, alias_counter + ) + sql = f"EXISTS (SELECT 1 FROM {inner_from} WHERE {inner_where})" + if pred.negate: + sql = f"NOT {sql}" + return sql + + if isinstance(pred, BoolOp): + left_sql = _translate_predicate(pred.left, alias_map, params, alias_counter) + right_sql = _translate_predicate(pred.right, alias_map, params, alias_counter) + return f"({left_sql} {pred.op} {right_sql})" + + if isinstance(pred, NotOp): + inner_sql = _translate_predicate(pred.pred, alias_map, params, alias_counter) + return f"(NOT {inner_sql})" + + raise CypherExecutionError(f"Unknown predicate type {type(pred).__name__}") + + +def _translate_return( + return_items: List[ReturnItem], + alias_map: Dict[str, str], +) -> Tuple[str, List[str]]: + """Translate RETURN items to a SELECT column list + display names. + + For ``var.prop`` → ``alias.prop``. + For ``var`` (whole node) → ``alias.node_id, alias.node_type, alias.name, + alias.file, alias.line`` — returned as a dict in the row processor. + """ + cols: List[str] = [] + display_names: List[str] = [] + for item in return_items: + alias = alias_map.get(item.var) + if alias is None: + raise CypherExecutionError( + f"RETURN variable '{item.var}' not bound to a SQL alias. This is a bug." + ) + if item.prop is None: + # Whole node — select all columns. + cols.extend([ + f"{alias}.node_id", + f"{alias}.node_type", + f"{alias}.name", + f"{alias}.file", + f"{alias}.line", + ]) + display_names.append(item.var) + else: + cols.append(f"{alias}.{item.prop}") + display_names.append(item.display_name()) + + select_clause = ", ".join(cols) + return select_clause, display_names + + +def translate(query: CypherQuery) -> Tuple[str, list, List[ReturnItem]]: + """Translate a parsed Cypher query to a parameterized SQL statement. + + Returns ``(sql, params, return_items)``. + + The SQL uses ``?`` placeholders for all user-supplied literals + (string/number values in WHERE). Identifiers (variable names, property + names, labels, edge types) are validated against allow-lists at parse + time and are safe to interpolate into SQL. + + Args: + query: Parsed Cypher query AST from :func:`parse`. + + Returns: + Tuple of (SQL string, parameter list, return items for row processing). + """ + alias_map: Dict[str, str] = {} + params: list = [] + alias_counter: List[int] = [0] + + # Translate all MATCH patterns into FROM + WHERE fragments. + from_clauses: List[str] = [] + where_parts: List[str] = [] + for pat in query.patterns: + from_clause, pat_where = _translate_pattern(pat, alias_map, params, alias_counter) + from_clauses.append(from_clause) + if pat_where and pat_where != "1=1": + where_parts.append(pat_where) + + # Translate WHERE predicate (if any). + if query.where is not None: + pred_sql = _translate_predicate(query.where, alias_map, params, alias_counter) + where_parts.append(pred_sql) + + # Translate RETURN. + select_clause, _ = _translate_return(query.return_items, alias_map) + + # Combine FROM — multiple comma-separated patterns become CROSS JOIN + # (rare in practice; most queries have a single pattern). + from_sql = ", ".join(from_clauses) if len(from_clauses) > 1 else from_clauses[0] + + # Combine WHERE. + where_sql = " AND ".join(where_parts) if where_parts else "1=1" + + # LIMIT. + limit = query.limit if query.limit is not None else DEFAULT_LIMIT + # Append limit as a param so it's type-safe. + params.append(limit) + + sql = f"SELECT {select_clause} FROM {from_sql} WHERE {where_sql} LIMIT ?" + return sql, params, query.return_items + + +# ─── Executor ───────────────────────────────────────────────────────────── + + +def _row_to_result( + row: sqlite3.Row, + return_items: List[ReturnItem], +) -> Dict[str, Any]: + """Convert a SQL row to a result dict keyed by RETURN display names. + + For ``var.prop`` returns → ``{"var.prop": value}``. + For ``var`` (whole node) returns → ``{"var": {node_id, node_type, name, file, line}}``. + """ + result: Dict[str, Any] = {} + col_idx = 0 + for item in return_items: + if item.prop is None: + # Whole node — 5 columns. + result[item.var] = { + "node_id": row[col_idx], + "node_type": row[col_idx + 1], + "name": row[col_idx + 2], + "file": row[col_idx + 3], + "line": row[col_idx + 4], + } + col_idx += 5 + else: + result[item.display_name()] = row[col_idx] + col_idx += 1 + return result + + +def execute( + query: CypherQuery, + db_path: str, +) -> Dict[str, Any]: + """Execute a parsed Cypher query against the graph SQLite database. + + Args: + query: Parsed Cypher query AST. + db_path: Path to the SQLite database (``/.codelens/codelens.db``). + + Returns: + Dict with keys: + - ``status``: "ok" or "error" + - ``rows``: list of result dicts (one per matching row) + - ``row_count``: number of rows returned + - ``truncated``: True if LIMIT was hit (heuristic: row_count == limit) + - ``query``: original query string (for debugging) + - ``sql``: translated SQL (for debugging / EXPLAIN) + - ``elapsed_ms``: execution time in milliseconds + - ``error``: error message (only when status == "error") + """ + import time + + if not os.path.exists(db_path): + return { + "status": "error", + "error": f"Database not found at {db_path}. Run 'codelens scan' first.", + "rows": [], + "row_count": 0, + } + + if not graph_tables_exist(db_path): + return { + "status": "error", + "error": "Graph tables not initialized. Run 'codelens scan' first.", + "rows": [], + "row_count": 0, + } + + try: + sql, params, return_items = translate(query) + except (CypherParseError, CypherExecutionError) as e: + return { + "status": "error", + "error": str(e), + "rows": [], + "row_count": 0, + } + + start = time.time() + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + rows = conn.execute(sql, params).fetchall() + conn.close() + except sqlite3.Error as e: + return { + "status": "error", + "error": f"SQL execution failed: {e}", + "sql": sql, + "params": params, + "rows": [], + "row_count": 0, + "elapsed_ms": int((time.time() - start) * 1000), + } + + elapsed_ms = int((time.time() - start) * 1000) + result_rows = [_row_to_result(r, return_items) for r in rows] + limit = query.limit if query.limit is not None else DEFAULT_LIMIT + + return { + "status": "ok", + "rows": result_rows, + "row_count": len(result_rows), + "truncated": len(result_rows) == limit, + "sql": sql, + "elapsed_ms": elapsed_ms, + } + + +def query_graph( + cypher: str, + workspace: str, + db_path: Optional[str] = None, +) -> Dict[str, Any]: + """High-level entry point: parse + execute a Cypher query. + + Args: + cypher: Cypher query string. + workspace: Workspace root path (for default db_path resolution). + db_path: Optional explicit db path. Defaults to + ``/.codelens/codelens.db``. + + Returns: + Result dict from :func:`execute`, plus the original query string + and (on parse error) a ``parse_error`` field. + """ + workspace = os.path.abspath(workspace) + db_path = db_path or default_db_path(workspace) + + try: + parsed = parse(cypher) + except CypherParseError as e: + return { + "status": "error", + "error": f"Parse error: {e}", + "parse_error": str(e), + "query": cypher, + "rows": [], + "row_count": 0, + } + + result = execute(parsed, db_path) + result["query"] = cypher + return result diff --git a/scripts/graph_model.py b/scripts/graph_model.py index 1790fb7a..a3cc6a08 100644 --- a/scripts/graph_model.py +++ b/scripts/graph_model.py @@ -13,7 +13,7 @@ - New tables `graph_nodes` and `graph_edges` are additive (prefixed `graph_` to avoid colliding with any existing table name). - The flat registry tables and JSON files are untouched. -- All 68 existing CLI commands continue to work unchanged. +- All 70 existing CLI commands continue to work unchanged. Schema: graph_nodes( diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 323739a9..67af3269 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -1099,6 +1099,49 @@ "required": ["workspace"] } }, + # ── Issue #9: Cypher-subset graph query engine ── + "query-graph": { + "description": ( + "Cypher-subset structural query over the code graph (graph_nodes + graph_edges). " + "Replaces 3-5 trace/impact/context tool calls for typical structural questions. " + "Supported clauses: MATCH, WHERE, RETURN, LIMIT. " + "Predicates: =, !=, <, >, <=, >=, CONTAINS, IS NULL, IS NOT NULL, " + "EXISTS { pattern }, NOT EXISTS { pattern }, AND, OR, NOT. " + "Node labels: Function, Class, File, Module, Route, Type, Interface. " + "Edge types: CALLS, IMPORTS, DEFINES, INHERITS, IMPLEMENTS, USES_TYPE. " + "Read-only — no write clauses. Default LIMIT 100, hard cap 1000. " + "Examples: " + "\"MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name, g.file\" | " + "\"MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name\" (dead code) | " + "\"MATCH (c:Class)-[:INHERITS]->(p) WHERE p.name = 'BaseModel' RETURN c.name\"" + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": ( + "Cypher-subset query string. Must contain MATCH ... RETURN. " + "Example: \"MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name, g.file\"" + ) + }, + "workspace": { + "type": "string", + "description": "Path to workspace root directory" + }, + "db_path": { + "type": "string", + "description": "Custom SQLite db path (default: /.codelens/codelens.db)" + }, + "explain": { + "type": "boolean", + "description": "Show the translated SQL and parameters without executing the query", + "default": False + } + }, + "required": ["query", "workspace"] + } + }, } diff --git a/skill.json b/skill.json index 20d1a64d..76595063 100755 --- a/skill.json +++ b/skill.json @@ -1,7 +1,7 @@ { "name": "codelens", "version": "8.2.0", - "description": "Live Codebase Reference Intelligence. 68 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", + "description": "Live Codebase Reference Intelligence. 70 commands for AI-powered code analysis, security auditing, quality scoring, and pre-write safety checks. Supports 28+ languages with regex+AST hybrid parsing. Must activate before writing/editing/deleting any class, id, or function.", "author": "codelens", "command_categories": { "setup": [ diff --git a/tests/test_cypher_query.py b/tests/test_cypher_query.py new file mode 100644 index 00000000..bb1518f7 --- /dev/null +++ b/tests/test_cypher_query.py @@ -0,0 +1,620 @@ +""" +Tests for the Cypher-subset graph query engine (issue #9). + +Covers three layers: + 1. Parser — tokenization + AST construction + validation + 2. Translator — AST → parameterized SQL + 3. Executor — end-to-end query against a test SQLite database + +The executor tests build a small in-memory graph (6 nodes, 3 edges) +and verify that the three example queries from issue #9 return the +expected results. +""" + +import os +import sys +import json +import tempfile +import sqlite3 +from pathlib import Path + +import pytest + +# Make scripts/ importable +SCRIPT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +sys.path.insert(0, SCRIPT_DIR) + +from cypher_query_engine import ( + parse, translate, execute, query_graph, + CypherParseError, CypherExecutionError, + CypherQuery, NodePattern, EdgePattern, PathPattern, + Comparison, IsNull, ExistsSubquery, BoolOp, NotOp, ReturnItem, + DEFAULT_LIMIT, MAX_LIMIT, +) +from graph_model import ( + init_graph_schema, GRAPH_NODES_TABLE, GRAPH_EDGES_TABLE, +) + + +# ─── Test fixtures ──────────────────────────────────────────────────────── + + +@pytest.fixture +def test_db(): + """Create a temporary workspace with a small test graph. + + Graph layout: + handleRequest (function, api.py:1) + └─CALLS→ processData (function, api.py:10) + └─CALLS→ helper (function, utils.py:20) + + User (class, models.py:40) + └─INHERITS→ BaseModel (class, models.py:30) + + orphan (function, dead.py:50) — no callers, no callees + """ + ws = tempfile.mkdtemp(prefix="cypher-test-") + db_path = os.path.join(ws, ".codelens", "codelens.db") + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + conn = sqlite3.connect(db_path) + init_graph_schema(conn) + + nodes = [ + ("file:1:handleRequest", "function", "handleRequest", "api.py", 1), + ("file:10:processData", "function", "processData", "api.py", 10), + ("file:20:helper", "function", "helper", "utils.py", 20), + ("file:30:BaseModel", "class", "BaseModel", "models.py", 30), + ("file:40:User", "class", "User", "models.py", 40), + ("file:50:orphan", "function", "orphan", "dead.py", 50), + ] + for n in nodes: + conn.execute( + f"INSERT INTO {GRAPH_NODES_TABLE} (node_id, node_type, name, file, line) VALUES (?,?,?,?,?)", + n, + ) + + edges = [ + ("file:1:handleRequest", "file:10:processData", "CALLS", "api.py", 2), + ("file:10:processData", "file:20:helper", "CALLS", "api.py", 12), + ("file:40:User", "file:30:BaseModel", "INHERITS", "models.py", 41), + ] + for e in edges: + conn.execute( + f"INSERT INTO {GRAPH_EDGES_TABLE} (source_id, target_id, edge_type, file, line) VALUES (?,?,?,?,?)", + e, + ) + conn.commit() + conn.close() + + return ws, db_path + + +# ─── Parser tests ───────────────────────────────────────────────────────── + + +class TestTokenizer: + """Tokenizer edge cases.""" + + def test_simple_query_tokenizes(self): + q = parse("MATCH (f:Function) RETURN f.name") + assert len(q.patterns) == 1 + assert q.patterns[0].start.var == "f" + assert q.patterns[0].start.label == "Function" + + def test_comment_stripped(self): + q = parse("MATCH (f:Function) RETURN f.name -- this is a comment") + assert len(q.return_items) == 1 + assert q.return_items[0].var == "f" + + def test_string_with_escaped_quote(self): + q = parse("MATCH (f:Function) WHERE f.name = 'it''s' RETURN f.name") + comp = q.where + assert isinstance(comp, Comparison) + assert comp.value == "it's" + + def test_multiline_query(self): + q = parse(""" + MATCH (f:Function)-[:CALLS]->(g) + WHERE f.name = 'handleRequest' + RETURN g.name, g.file + """) + assert len(q.patterns[0].edges) == 1 + assert q.patterns[0].edges[0].edge_type == "CALLS" + + +class TestParserBasic: + """Basic parsing — happy path for each clause.""" + + def test_parse_single_node_pattern(self): + q = parse("MATCH (f:Function) RETURN f.name") + assert isinstance(q, CypherQuery) + assert len(q.patterns) == 1 + assert q.patterns[0].start.var == "f" + assert q.patterns[0].start.label == "Function" + assert len(q.patterns[0].edges) == 0 + + def test_parse_edge_pattern_right(self): + q = parse("MATCH (f:Function)-[:CALLS]->(g) RETURN g.name") + pat = q.patterns[0] + assert len(pat.edges) == 1 + edge = pat.edges[0] + assert edge.edge_type == "CALLS" + assert edge.direction == "->" + assert edge.target.var == "g" + + def test_parse_edge_pattern_left(self): + q = parse("MATCH (f:Function)<-[:CALLS]-(g) RETURN f.name") + edge = q.patterns[0].edges[0] + assert edge.edge_type == "CALLS" + assert edge.direction == "<-" + + def test_parse_edge_pattern_undirected(self): + q = parse("MATCH (f:Function)-[:CALLS]-(g) RETURN f.name") + edge = q.patterns[0].edges[0] + assert edge.direction == "-" + + def test_parse_edge_no_type(self): + q = parse("MATCH (f:Function)-[]->(g) RETURN f.name") + edge = q.patterns[0].edges[0] + assert edge.edge_type is None + assert edge.direction == "->" + + def test_parse_anonymous_node(self): + q = parse("MATCH ()-[:CALLS]->(f) RETURN f.name") + assert q.patterns[0].start.var is None + assert q.patterns[0].edges[0].target.var == "f" + + def test_parse_where_string_equality(self): + q = parse("MATCH (f:Function) WHERE f.name = 'handleRequest' RETURN f.name") + assert isinstance(q.where, Comparison) + assert q.where.var == "f" + assert q.where.prop == "name" + assert q.where.op == "=" + assert q.where.value == "handleRequest" + + def test_parse_where_integer_equality(self): + q = parse("MATCH (f:Function) WHERE f.line = 42 RETURN f.name") + assert q.where.value == 42 + assert isinstance(q.where.value, int) + + def test_parse_where_contains(self): + q = parse("MATCH (f:Function) WHERE f.name CONTAINS 'handler' RETURN f.name") + assert q.where.op == "CONTAINS" + + def test_parse_where_is_null(self): + q = parse("MATCH (f:Function) WHERE f.file IS NULL RETURN f.name") + assert isinstance(q.where, IsNull) + assert q.where.negate is False + + def test_parse_where_is_not_null(self): + q = parse("MATCH (f:Function) WHERE f.file IS NOT NULL RETURN f.name") + assert isinstance(q.where, IsNull) + assert q.where.negate is True + + def test_parse_where_and(self): + q = parse("MATCH (f:Function) WHERE f.name = 'a' AND f.line = 1 RETURN f.name") + assert isinstance(q.where, BoolOp) + assert q.where.op == "AND" + + def test_parse_where_or(self): + q = parse("MATCH (f:Function) WHERE f.name = 'a' OR f.name = 'b' RETURN f.name") + assert isinstance(q.where, BoolOp) + assert q.where.op == "OR" + + def test_parse_where_not(self): + q = parse("MATCH (f:Function) WHERE NOT f.name = 'a' RETURN f.name") + assert isinstance(q.where, NotOp) + + def test_parse_where_exists(self): + q = parse("MATCH (f:Function) WHERE EXISTS { (g)-[:CALLS]->(f) } RETURN f.name") + assert isinstance(q.where, ExistsSubquery) + assert q.where.negate is False + + def test_parse_where_not_exists(self): + q = parse("MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name") + assert isinstance(q.where, ExistsSubquery) + assert q.where.negate is True + + def test_parse_return_whole_node(self): + q = parse("MATCH (f:Function) RETURN f") + assert len(q.return_items) == 1 + assert q.return_items[0].var == "f" + assert q.return_items[0].prop is None + + def test_parse_return_multiple(self): + q = parse("MATCH (f:Function) RETURN f.name, f.file, f.line") + assert len(q.return_items) == 3 + + def test_parse_limit(self): + q = parse("MATCH (f:Function) RETURN f.name LIMIT 50") + assert q.limit == 50 + + def test_parse_no_limit_defaults_to_none(self): + q = parse("MATCH (f:Function) RETURN f.name") + assert q.limit is None # executor applies DEFAULT_LIMIT + + +class TestParserValidation: + """Parser should reject invalid queries with clear errors.""" + + def test_reject_write_clause_create(self): + with pytest.raises(CypherParseError, match="read-only"): + parse("CREATE (n:Function {name: 'test'})") + + def test_reject_write_clause_delete(self): + with pytest.raises(CypherParseError, match="read-only"): + parse("MATCH (n) DELETE n") + + def test_reject_unknown_label(self): + with pytest.raises(CypherParseError, match="Unknown node label"): + parse("MATCH (f:NotARealLabel) RETURN f.name") + + def test_reject_unknown_edge_type(self): + with pytest.raises(CypherParseError, match="Unknown edge type"): + parse("MATCH (f)-[:NOT_A_REAL_EDGE]->(g) RETURN f.name") + + def test_reject_unknown_property(self): + with pytest.raises(CypherParseError, match="Unknown property"): + parse("MATCH (f:Function) WHERE f.not_a_property = 'x' RETURN f.name") + + def test_reject_unbound_return_var(self): + with pytest.raises(CypherParseError, match="not bound"): + parse("MATCH (f:Function) RETURN g.name") + + def test_reject_unbound_where_var(self): + with pytest.raises(CypherParseError, match="not bound"): + parse("MATCH (f:Function) WHERE g.name = 'x' RETURN f.name") + + def test_reject_limit_zero(self): + with pytest.raises(CypherParseError, match="positive integer"): + parse("MATCH (f:Function) RETURN f.name LIMIT 0") + + def test_reject_limit_exceeds_cap(self): + with pytest.raises(CypherParseError, match="exceeds hard cap"): + parse(f"MATCH (f:Function) RETURN f.name LIMIT {MAX_LIMIT + 1}") + + def test_reject_missing_match(self): + with pytest.raises(CypherParseError): + parse("RETURN f.name") + + def test_reject_missing_return(self): + with pytest.raises(CypherParseError): + parse("MATCH (f:Function)") + + def test_reject_unterminated_string(self): + with pytest.raises(CypherParseError, match="Unterminated"): + parse("MATCH (f:Function) WHERE f.name = 'unterminated RETURN f.name") + + +# ─── Translator tests ───────────────────────────────────────────────────── + + +class TestTranslator: + """SQL translation — placeholder/param count must match.""" + + def _check(self, query_str): + ast = parse(query_str) + sql, params, ret = translate(ast) + ph_count = sql.count("?") + assert ph_count == len(params), ( + f"Placeholder count ({ph_count}) != param count ({len(params)}) " + f"for query: {query_str}\nSQL: {sql}\nParams: {params}" + ) + return sql, params, ret + + def test_single_node_query(self): + sql, params, _ = self._check("MATCH (f:Function) RETURN f.name") + assert "graph_nodes AS n_f" in sql + assert "n_f.node_type = ?" in sql + assert "function" in params + + def test_edge_query_params(self): + sql, params, _ = self._check( + "MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name" + ) + assert "function" in params + assert "CALLS" in params + assert "handleRequest" in params + + def test_contains_translates_to_like(self): + sql, params, _ = self._check( + "MATCH (f:Function) WHERE f.name CONTAINS 'handler' RETURN f.name" + ) + assert "LIKE ?" in sql + assert "%handler%" in params + + def test_not_exists_translates_to_correlated_subquery(self): + sql, params, _ = self._check( + "MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name" + ) + assert "NOT EXISTS" in sql + # The subquery should correlate to the outer n_f alias. + assert "n_f.node_id" in sql + assert "CALLS" in params + + def test_limit_added_as_param(self): + sql, params, _ = self._check( + "MATCH (f:Function) RETURN f.name LIMIT 50" + ) + assert "LIMIT ?" in sql + assert 50 in params + + def test_default_limit_applied(self): + ast = parse("MATCH (f:Function) RETURN f.name") + sql, params, _ = translate(ast) + assert "LIMIT ?" in sql + assert DEFAULT_LIMIT in params + + def test_all_ops_translate(self): + for op in ["=", "!=", "<", ">", "<=", ">="]: + q = f"MATCH (f:Function) WHERE f.line {op} 10 RETURN f.name" + self._check(q) + + def test_and_or_translate(self): + self._check("MATCH (f:Function) WHERE f.name = 'a' AND f.line = 1 RETURN f.name") + self._check("MATCH (f:Function) WHERE f.name = 'a' OR f.name = 'b' RETURN f.name") + + def test_return_whole_node_selects_5_columns(self): + sql, _, _ = self._check("MATCH (f:Function) RETURN f") + # node_id, node_type, name, file, line + assert "n_f.node_id" in sql + assert "n_f.node_type" in sql + assert "n_f.name" in sql + assert "n_f.file" in sql + assert "n_f.line" in sql + + +# ─── Executor tests ─────────────────────────────────────────────────────── + + +class TestExecutor: + """End-to-end query execution against the test database.""" + + def test_issue9_example1_callees_of_handleRequest(self, test_db): + """MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' RETURN g.name, g.file""" + ws, _ = test_db + result = query_graph( + "MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'handleRequest' " + "RETURN g.name, g.file", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + row = result["rows"][0] + assert row["g.name"] == "processData" + assert row["g.file"] == "api.py" + + def test_issue9_example2_classes_inheriting_baseModel(self, test_db): + """MATCH (c:Class)-[:INHERITS]->(p) WHERE p.name = 'BaseModel' RETURN c.name""" + ws, _ = test_db + result = query_graph( + "MATCH (c:Class)-[:INHERITS]->(p) WHERE p.name = 'BaseModel' RETURN c.name", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + assert result["rows"][0]["c.name"] == "User" + + def test_issue9_example3_dead_code_no_callers(self, test_db): + """MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name""" + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE NOT EXISTS { ()-[:CALLS]->(f) } RETURN f.name", + ws, + ) + assert result["status"] == "ok" + names = {r["f.name"] for r in result["rows"]} + # handleRequest has no callers (entry point), orphan has no callers (dead code). + # processData and helper ARE called, so they should NOT appear. + assert "handleRequest" in names + assert "orphan" in names + assert "processData" not in names + assert "helper" not in names + + def test_contains_predicate(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name CONTAINS 'handler' RETURN f.name, f.file", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + assert result["rows"][0]["f.name"] == "handleRequest" + + def test_exists_predicate(self, test_db): + """Functions that ARE called by some other function.""" + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE EXISTS { (g:Function)-[:CALLS]->(f) } " + "RETURN f.name", + ws, + ) + assert result["status"] == "ok" + names = {r["f.name"] for r in result["rows"]} + # processData is called by handleRequest, helper is called by processData. + assert "processData" in names + assert "helper" in names + # handleRequest and orphan are NOT called by anyone. + assert "handleRequest" not in names + assert "orphan" not in names + + def test_return_whole_node(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name = 'handleRequest' RETURN f", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + node = result["rows"][0]["f"] + assert node["name"] == "handleRequest" + assert node["node_type"] == "function" + assert node["file"] == "api.py" + assert node["line"] == 1 + + def test_limit_truncates(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) RETURN f.name LIMIT 2", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 2 + assert result["truncated"] is True + + def test_and_predicate(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name = 'handleRequest' AND f.file = 'api.py' " + "RETURN f.name", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + + def test_or_predicate(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name = 'handleRequest' OR f.name = 'orphan' " + "RETURN f.name", + ws, + ) + assert result["status"] == "ok" + names = {r["f.name"] for r in result["rows"]} + assert names == {"handleRequest", "orphan"} + + def test_is_not_null_predicate(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.line IS NOT NULL RETURN f.name LIMIT 10", + ws, + ) + assert result["status"] == "ok" + # All test nodes have line set. + assert result["row_count"] == 4 # 4 functions + + def test_left_directed_edge(self, test_db): + """<-[:CALLS]- finds callers.""" + ws, _ = test_db + result = query_graph( + "MATCH (f:Function)<-[:CALLS]-(g) WHERE f.name = 'helper' RETURN g.name", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 1 + assert result["rows"][0]["g.name"] == "processData" + + def test_no_results_returns_empty(self, test_db): + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name = 'doesNotExist' RETURN f.name", + ws, + ) + assert result["status"] == "ok" + assert result["row_count"] == 0 + assert result["rows"] == [] + assert result["truncated"] is False + + +class TestExecutorErrors: + """Error handling — missing db, missing tables, parse errors.""" + + def test_missing_database(self, tmp_path): + ws = str(tmp_path / "noexist") + result = query_graph("MATCH (f:Function) RETURN f.name", ws) + assert result["status"] == "error" + assert "Database not found" in result["error"] + + def test_parse_error_returns_status(self, test_db): + ws, _ = test_db + result = query_graph("MATCH (f:NotARealLabel) RETURN f.name", ws) + assert result["status"] == "error" + assert "Parse error" in result["error"] + + def test_empty_query_raises(self): + with pytest.raises(CypherParseError): + parse("") + + def test_sql_injection_safe(self, test_db): + """String literals are parameterized — no SQL injection.""" + ws, _ = test_db + result = query_graph( + "MATCH (f:Function) WHERE f.name = 'x'; DROP TABLE graph_nodes; --' " + "RETURN f.name", + ws, + ) + # The query should either parse-fail (due to semicolon) or execute safely. + # Either way, the graph_nodes table must still exist. + if result["status"] == "error": + assert "Parse error" in result["error"] + else: + # Verify the table wasn't dropped. + db_path = os.path.join(ws, ".codelens", "codelens.db") + conn = sqlite3.connect(db_path) + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + conn.close() + assert ("graph_nodes",) in tables + + +# ─── CLI command tests ──────────────────────────────────────────────────── + + +class TestQueryGraphCommand: + """Test the CLI command wrapper.""" + + def test_explain_mode(self, test_db): + """--explain should return SQL without executing.""" + from commands.query_graph import execute as cmd_execute + + ws, _ = test_db + + class Args: + query = "MATCH (f:Function) RETURN f.name" + workspace = ws + db_path = None + explain = True + + result = cmd_execute(Args(), ws) + assert result["status"] == "ok" + assert "sql" in result + assert "params" in result + assert "return_items" in result + + def test_command_registered(self): + """query-graph should be in the command registry.""" + from commands import COMMAND_REGISTRY + assert "query-graph" in COMMAND_REGISTRY + + +# ─── MCP tool registration test ────────────────────────────────────────── + + +class TestMCPToolDefinition: + """Verify the static MCP tool definition for query-graph.""" + + def test_tool_in_static_definitions(self): + import mcp_server + assert "query-graph" in mcp_server._TOOL_DEFINITIONS + + def test_tool_has_required_fields(self): + import mcp_server + tool = mcp_server._TOOL_DEFINITIONS["query-graph"] + assert "description" in tool + assert "parameters" in tool + props = tool["parameters"]["properties"] + assert "query" in props + assert "workspace" in props + assert "query-graph" in tool["parameters"].get("required", []) or \ + "query" in tool["parameters"].get("required", []) + + def test_tool_name_uses_underscore(self): + """MCP tool name should be codelens_query_graph (hyphens → underscores).""" + import mcp_server + # The _handle_tools_list method converts hyphens to underscores. + tool_name = "codelens_query-graph".replace("-", "_") + assert tool_name == "codelens_query_graph"