Skip to content

Commit 9d535be

Browse files
committed
feat(search): auto-detect Cypher queries and warn on pattern/workspace swap (closes #239)
Two related runtime UX gaps, both about the tool correcting toward the right answer instead of silently returning the wrong one: 1. `search` is the only umbrella command with pattern before workspace (opposite of every other command). Getting it backwards doesn't error — the workspace path silently becomes the search pattern and returns an empty "ok" result with zero indication anything went wrong (the docstring/--help example order was already fixed in a prior PR this session; this fixes the runtime behavior itself). Now: if `pattern` is an existing directory, a `_hints` entry flags the likely argument swap. 2. A Cypher-shaped pattern (`MATCH (...`) passed without `--mode graph` previously got run through whatever mode was set (default: semantic), producing a confusing near-empty result instead of the graph query the caller almost certainly meant. Now: high-confidence matches (pattern starts with `MATCH (`) auto-route to `--mode graph` with a `_hints` entry explaining the auto-route (silence it by passing `--mode graph` explicitly). Both heuristics are narrow by design — no false positives on the existing search test suite, and a negative-control test confirms a pattern that merely mentions "MATCH" mid-string is not reinterpreted.
1 parent 0cf6a56 commit 9d535be

2 files changed

Lines changed: 159 additions & 5 deletions

File tree

scripts/commands/search.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import argparse
2727
import os
28+
import re
2829
import sys
2930
from typing import Any, Dict
3031

@@ -171,6 +172,34 @@ def _run_graph(args, workspace) -> Dict[str, Any]:
171172
return _qg_execute(sub_args, workspace)
172173

173174

175+
_CYPHER_START_RE = re.compile(r"^\s*MATCH\s*\(", re.IGNORECASE)
176+
177+
178+
def _detect_pattern_workspace_swap(pattern, workspace):
179+
"""Heuristic for issue #239: `search` is the only umbrella command with
180+
pattern before workspace (opposite of every other command) — a very easy
181+
mistake, and one that doesn't error, it just silently searches for the
182+
real workspace path as the pattern and returns an empty "ok" result.
183+
184+
Returns a hint string if ``pattern`` looks like it's actually a
185+
workspace path (an existing directory) — a strong signal the two
186+
arguments were swapped — else None.
187+
"""
188+
if not pattern or not isinstance(pattern, str):
189+
return None
190+
try:
191+
if os.path.isdir(pattern):
192+
return (
193+
f"pattern {pattern!r} is an existing directory — this looks like "
194+
"the pattern/workspace arguments may be swapped. `search` takes "
195+
"pattern first, workspace second (opposite of every other "
196+
"umbrella command): `search \"query\" <workspace>`."
197+
)
198+
except (OSError, ValueError):
199+
pass
200+
return None
201+
202+
174203
def execute(args, workspace):
175204
"""Dispatch to the selected search mode and normalize output shape.
176205
@@ -179,6 +208,25 @@ def execute(args, workspace):
179208
@MUTATES: nothing (read-only)
180209
"""
181210
mode = getattr(args, "mode", "semantic") or "semantic"
211+
pattern = getattr(args, "pattern", None)
212+
hints = []
213+
214+
swap_hint = _detect_pattern_workspace_swap(pattern, workspace)
215+
if swap_hint:
216+
hints.append(swap_hint)
217+
218+
# Issue #239: auto-route Cypher-shaped patterns to graph mode. Only
219+
# fires on high-confidence matches (starts with "MATCH (") to avoid
220+
# second-guessing genuine regex/symbol/semantic queries.
221+
if mode != "graph" and pattern and _CYPHER_START_RE.match(pattern):
222+
hints.append(
223+
f"pattern looks like a Cypher query but --mode was '{mode}' — "
224+
"auto-routed to --mode graph. Pass --mode graph explicitly to "
225+
"silence this hint."
226+
)
227+
mode = "graph"
228+
args.mode = "graph"
229+
182230
try:
183231
if mode == "semantic":
184232
result = _run_semantic(args, workspace)
@@ -192,25 +240,34 @@ def execute(args, workspace):
192240
return {"s": "error", "st": {"mode": mode}, "r": [],
193241
"error": f"unknown mode '{mode}'"}
194242
except Exception as exc:
195-
return {"s": "error", "st": {"mode": mode},
196-
"r": [], "error": str(exc),
197-
"error_type": type(exc).__name__}
243+
out = {"s": "error", "st": {"mode": mode},
244+
"r": [], "error": str(exc),
245+
"error_type": type(exc).__name__}
246+
if hints:
247+
out["_hints"] = hints
248+
return out
198249

199250
# Normalize to {s, st, r} shape while preserving original payload.
200251
if not isinstance(result, dict):
201-
return {"s": "ok", "st": {"mode": mode}, "r": [{"result": result}]}
252+
out = {"s": "ok", "st": {"mode": mode}, "r": [{"result": result}]}
253+
if hints:
254+
out["_hints"] = hints
255+
return out
202256
status = result.pop("status", "ok")
203257
# Move large payload lists into ``r`` if present, keep stats in ``st``.
204258
rows = None
205259
for key in ("matches", "results", "rows", "findings"):
206260
if key in result and isinstance(result[key], list):
207261
rows = result.pop(key)
208262
break
209-
return {
263+
out = {
210264
"s": status,
211265
"st": {"mode": mode, **result},
212266
"r": rows if rows is not None else [],
213267
}
268+
if hints:
269+
out["_hints"] = hints
270+
return out
214271

215272

216273
register_command(

tests/test_search_command.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Tests for search command self-correction hints (issue #239).
2+
3+
`search` is the only umbrella command with `pattern` before `workspace`
4+
(opposite of every other command) — getting it backwards doesn't error,
5+
it silently searches for the workspace path as the pattern and returns
6+
an empty "ok" result. Separately, a Cypher-shaped pattern passed without
7+
`--mode graph` gets misinterpreted by the default semantic mode instead
8+
of erroring or hinting. Both are runtime self-correction, not just docs.
9+
"""
10+
11+
import argparse
12+
import os
13+
import sys
14+
import tempfile
15+
from unittest import mock
16+
17+
import pytest
18+
19+
SCRIPT_DIR = os.path.join(
20+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"
21+
)
22+
if SCRIPT_DIR not in sys.path:
23+
sys.path.insert(0, SCRIPT_DIR)
24+
25+
from commands.search import ( # noqa: E402
26+
_detect_pattern_workspace_swap,
27+
execute,
28+
)
29+
30+
31+
class TestDetectPatternWorkspaceSwap:
32+
def test_pattern_is_existing_directory_flags_swap(self):
33+
with tempfile.TemporaryDirectory() as tmpdir:
34+
hint = _detect_pattern_workspace_swap(tmpdir, "some pattern")
35+
assert hint is not None
36+
assert "swapped" in hint
37+
38+
def test_pattern_is_normal_string_no_hint(self):
39+
assert _detect_pattern_workspace_swap("getAccessMode", ".") is None
40+
41+
def test_none_pattern_no_crash(self):
42+
assert _detect_pattern_workspace_swap(None, ".") is None
43+
44+
45+
class TestSearchExecuteHints:
46+
def _args(self, pattern, mode="semantic"):
47+
return argparse.Namespace(
48+
pattern=pattern, mode=mode, top=None, db_path=None,
49+
file_type=None, file=None, max_results=200, context=0,
50+
ignore_case=False, whole_word=False, domain=None, fuzzy=False,
51+
validate=False, limit=None, offset=0,
52+
)
53+
54+
def test_argument_swap_produces_hint(self):
55+
with tempfile.TemporaryDirectory() as tmpdir, \
56+
mock.patch("commands.search._run_semantic", return_value={"status": "ok"}):
57+
result = execute(self._args(pattern=tmpdir), tmpdir)
58+
assert result.get("_hints")
59+
assert any("swapped" in h for h in result["_hints"])
60+
61+
def test_cypher_pattern_auto_routes_to_graph_mode(self):
62+
with mock.patch("commands.search._run_graph", return_value={"status": "ok"}) as mock_graph, \
63+
mock.patch("commands.search._run_semantic") as mock_semantic:
64+
args = self._args(pattern="MATCH (n) RETURN n LIMIT 5", mode="semantic")
65+
result = execute(args, ".")
66+
67+
mock_graph.assert_called_once()
68+
mock_semantic.assert_not_called()
69+
assert result["st"]["mode"] == "graph"
70+
assert result.get("_hints")
71+
assert any("auto-routed" in h for h in result["_hints"])
72+
73+
def test_cypher_pattern_with_explicit_graph_mode_no_hint(self):
74+
"""If the caller already passed --mode graph, no hint is needed —
75+
the auto-route heuristic should be a no-op, not noisy."""
76+
with mock.patch("commands.search._run_graph", return_value={"status": "ok"}):
77+
args = self._args(pattern="MATCH (n) RETURN n LIMIT 5", mode="graph")
78+
result = execute(args, ".")
79+
assert not result.get("_hints")
80+
81+
def test_normal_symbol_query_no_hints(self):
82+
with mock.patch("commands.search._run_symbol", return_value={"status": "ok", "results": []}):
83+
args = self._args(pattern="getAccessMode", mode="symbol")
84+
result = execute(args, ".")
85+
assert "_hints" not in result
86+
87+
def test_regex_pattern_resembling_but_not_cypher_not_rerouted(self):
88+
"""A regex pattern that merely contains the word MATCH somewhere
89+
(not at the start followed by a paren) must not be reinterpreted
90+
as Cypher — only high-confidence matches auto-route."""
91+
with mock.patch("commands.search._run_regex", return_value={"status": "ok", "matches": []}) as mock_regex, \
92+
mock.patch("commands.search._run_graph") as mock_graph:
93+
args = self._args(pattern="function MATCHER(x) {", mode="regex")
94+
result = execute(args, ".")
95+
mock_graph.assert_not_called()
96+
mock_regex.assert_called_once()
97+
assert "_hints" not in result

0 commit comments

Comments
 (0)