diff --git a/src/java_codebase_rag/graph/ladybug_queries.py b/src/java_codebase_rag/graph/ladybug_queries.py index d820f2d..2c78f64 100644 --- a/src/java_codebase_rag/graph/ladybug_queries.py +++ b/src/java_codebase_rag/graph/ladybug_queries.py @@ -997,8 +997,35 @@ def microservice_counts(self) -> dict[str, int]: def find_by_name_or_fqn(self, name_or_fqn: str, *, kinds: list[str] | None = None, module: str | None = None, microservice: str | None = None, - limit: int = 50) -> list[SymbolHit]: - filters = ["(s.name = $needle OR s.fqn = $needle)"] + limit: int = 50, + mode: str = "exact") -> list[SymbolHit]: + # ``mode`` selects the name/FQN predicate. ``exact`` (default) preserves the + # original ``s.name = $needle OR s.fqn = $needle``. ``prefix`` / ``contains`` + # use STARTS WITH / CONTAINS (Ladybug Cypher supports both — see + # resolve_service.py); they back the ``find --fuzzy`` fallback (issue #375). + # Fuzzy modes additionally exclude file/package Symbol nodes: their fqn is a + # filesystem path, so a substring/prefix would leak filename rows (mirrors + # the find_v2 fix, #411). Exact mode is unchanged for back-compat. + # Empty needle: STARTS WITH '' / CONTAINS '' match every string, so a + # fuzzy mode would silently return up to `limit` arbitrary Symbols. The + # CLI guards this (query mode requires a positional), but keep the + # backend safe-by-construction for any future caller. + if mode != "exact" and not name_or_fqn: + return [] + if mode == "exact": + filters = ["(s.name = $needle OR s.fqn = $needle)"] + elif mode == "prefix": + filters = [ + "(s.name STARTS WITH $needle OR s.fqn STARTS WITH $needle)", + "(s.kind <> 'file' AND s.kind <> 'package')", + ] + elif mode == "contains": + filters = [ + "(s.name CONTAINS $needle OR s.fqn CONTAINS $needle)", + "(s.kind <> 'file' AND s.kind <> 'package')", + ] + else: + raise ValueError(f"unknown find_by_name_or_fqn mode: {mode!r}") params: dict[str, Any] = {"needle": name_or_fqn} if kinds: params["kinds"] = kinds diff --git a/src/java_codebase_rag/jrag.py b/src/java_codebase_rag/jrag.py index bbb43ff..47cb350 100644 --- a/src/java_codebase_rag/jrag.py +++ b/src/java_codebase_rag/jrag.py @@ -484,7 +484,8 @@ def _core_parser() -> argparse.ArgumentParser: parents=[_common_parser()], description=( "Find nodes by query or filter. Two modes:\n" - " Query mode (positional ): search by exact name/FQN (symbols only).\n" + " Query mode (positional ): search by name/FQN (symbols only); --fuzzy\n" + " falls back exact -> prefix -> substring when the exact match is empty.\n" " Filter mode (no positional): apply structured filters (NodeFilter flags).\n" "Kind inference: domain flags (--http-method, --client-kind, --producer-kind) imply\n" "route/client/producer when --kind is omitted. Contradiction emits an error envelope.\n" @@ -507,6 +508,12 @@ def _core_parser() -> argparse.ArgumentParser: find.add_argument("--framework", type=_lower_snake, choices=_FRAMEWORK_CHOICES, default=None, help="Filter by framework.") find.add_argument("--source-layer", type=str, default=None, help="Filter by source layer.") find.add_argument("--fqn-contains", type=str, default=None, help="Filter by FQN substring.") + find.add_argument( + "--fuzzy", + action="store_true", + help="Query mode: fall back from exact name/FQN to prefix then substring " + "(case-sensitive) when the exact match is empty.", + ) find.add_argument("--http-method", type=str, default=None, help="Filter by HTTP method (route).") find.add_argument("--path-contains", type=str, default=None, help="Filter by path substring (route).") find.add_argument("--client-kind", type=str, default=None, help="Filter by client kind (client).") @@ -1427,14 +1434,14 @@ def _cmd_find_query_mode( graph, limit: int, ) -> int: - """Find query mode: g.find_by_name_or_fqn (Symbol-only, exact name/FQN match). - - ``find_by_name_or_fqn`` runs ``MATCH (s:Symbol) WHERE s.name=$needle OR - s.fqn=$needle`` — Symbol-only, exact-only. There is no fuzzy/prefix/contains - path; ``--fuzzy`` was deferred (see plans/active/PLAN-JRAG-CLI.md Out of - scope). Query mode is gated to ``effective_kind == "symbol"`` upstream in - ``_cmd_find``, so the only ``kinds`` filter we may pass is symbol sub-kinds - derived from ``--java-kind``. + """Find query mode: g.find_by_name_or_fqn (Symbol-only name/FQN match). + + Default is exact (``s.name``/``s.fqn`` = needle). With ``--fuzzy``, an empty + exact result widens to prefix (``STARTS WITH``) then substring (``CONTAINS``) + on name/FQN — fuzzy modes exclude file/package Symbol nodes (#411). Query + mode is gated to ``effective_kind == "symbol"`` upstream in ``_cmd_find``, + so the only ``kinds`` filter we may pass is symbol sub-kinds derived from + ``--java-kind``. """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum from java_codebase_rag.jrag_render import render @@ -1450,7 +1457,8 @@ def _cmd_find_query_mode( else: kinds = None - # Call find_by_name_or_fqn (exact name OR fqn match). + # Call find_by_name_or_fqn. Default is exact name/FQN match; with --fuzzy, + # widen to prefix then substring when the exact match is empty (issue #375). rows = graph.find_by_name_or_fqn( query, kinds=kinds, @@ -1458,6 +1466,20 @@ def _cmd_find_query_mode( microservice=args.service, limit=limit + 1, # +1 for truncated detection ) + matched_mode = "exact" + if not rows and getattr(args, "fuzzy", False) and query: + for fb_mode in ("prefix", "contains"): + rows = graph.find_by_name_or_fqn( + query, kinds=kinds, module=args.module, microservice=args.service, + limit=limit + 1, mode=fb_mode, + ) + if rows: + matched_mode = fb_mode + break + # Did any tier (exact or fallback) return rows BEFORE post-filters? Used to + # distinguish "identifier genuinely matched nothing" from "matched but + # --role/--annotation/--capability removed all hits" in the empty-result hint. + identifier_matched = bool(rows) # Truncation is decided by the RAW name/FQN fetch (limit+1), BEFORE # post-filters reduce the set — otherwise a post-filter that drops rows # would silently clear `truncated` even though more name matches may exist @@ -1504,6 +1526,12 @@ def _cmd_find_query_mode( # Display at most `limit` of the (post-filtered) rows. display_rows = rows[:limit] + # Map internal mode -> user-facing term (help/empty-hint say "substring"). + mode_label = "substring" if matched_mode == "contains" else matched_mode + if matched_mode != "exact" and display_rows: + warnings.append( + f"no exact name/FQN match; --fuzzy matched via {mode_label}" + ) nodes = {} for row in display_rows: node_id = row.id @@ -1539,17 +1567,25 @@ def _cmd_find_query_mode( ) next_actions_hook(env) - # Empty-result discoverability: query mode is exact-match only (name OR fqn), - # so a partial like `find ChatManagement` legitimately returns 0. Surface a - # cross-ref so the agent knows the substring fallback exists instead of - # seeing a bare `0 symbol`. Carried as both a `message` (renders inline) and - # an `agent_next_action` (renders as `next:` / JSON). A literal FQN-shaped - # query (contains '.') almost certainly won't substring-match either, so the - # hint applies regardless of shape. + # Empty-result discoverability: a partial like `find ChatManag` returns 0 + # under exact match. Three cases: (1) some tier matched the identifier but + # --role/--exclude-role/--annotation/--capability removed every hit — blame + # the filter, not the query; (2) --fuzzy widened to prefix/substring and + # still found nothing; (3) no --fuzzy, so suggest it. Carried as `message` + # (inline) + `agent_next_actions` (`next:`/JSON). if not nodes and query: - hint = f"no exact match for {query!r} — try `jrag find --fqn-contains {query}` for substring" + if identifier_matched and post_filter_active: + hint = ( + f"matched {query!r} (via {mode_label}), but " + "--role/--exclude-role/--annotation/--capability removed all hits" + ) + elif getattr(args, "fuzzy", False): + hint = f"no match for {query!r} (tried exact, prefix, substring)" + env.agent_next_actions = [f"jrag find --fqn-contains {query}"] + else: + hint = f"no exact match for {query!r} — try `jrag find {query} --fuzzy`" + env.agent_next_actions = [f"jrag find {query} --fuzzy"] env.message = hint - env.agent_next_actions = [f"jrag find --fqn-contains {query}"] # Offset is not supported in query mode (find_by_name_or_fqn has no offset). print(render(env, fmt=args.format, detail=args.detail, noun="symbol")) diff --git a/tests/graph/test_ladybug_queries.py b/tests/graph/test_ladybug_queries.py index 8bb2476..a221b2f 100644 --- a/tests/graph/test_ladybug_queries.py +++ b/tests/graph/test_ladybug_queries.py @@ -91,6 +91,63 @@ def test_find_by_name_or_fqn_fqn(ladybug_graph) -> None: assert rows[0].role == "SERVICE" +# ---------------- find_by_name_or_fqn (mode: prefix/contains) ---------------- + + +def test_find_by_name_or_fqn_prefix_matches_partial_name(ladybug_graph) -> None: + """mode='prefix' matches names/FQNs that START WITH the needle.""" + rows = ladybug_graph.find_by_name_or_fqn("ChatManag", mode="prefix") + # The exact simple name is 'ChatManagementService'; the prefix must reach it. + assert any(r.fqn.endswith(".ChatManagementService") for r in rows), rows + # Every hit must genuinely start with the needle (on name OR fqn). + for r in rows: + assert r.name.startswith("ChatManag") or r.fqn.startswith("ChatManag"), r + + +def test_find_by_name_or_fqn_default_mode_is_exact(ladybug_graph) -> None: + """A partial needle returns nothing under the default (exact) mode, + proving mode='prefix'/'contains' is what widens the match.""" + rows = ladybug_graph.find_by_name_or_fqn("ChatManag") + assert rows == [] + assert ladybug_graph.find_by_name_or_fqn("Management", mode="contains"), ( + "contains mode should match where exact does not" + ) + + +def test_find_by_name_or_fqn_contains_matches_substring(ladybug_graph) -> None: + """mode='contains' matches names/FQNs that CONTAIN the needle.""" + rows = ladybug_graph.find_by_name_or_fqn("Management", mode="contains") + assert len(rows) >= 1, rows + for r in rows: + assert "Management" in (r.name or "") or "Management" in (r.fqn or ""), r + + +def test_find_by_name_or_fqn_fuzzy_excludes_file_package(ladybug_graph) -> None: + """Fuzzy modes must not surface structural file/package Symbol nodes (#411): + 'Controller' appears in class names AND in filenames (...Controller.java).""" + rows = ladybug_graph.find_by_name_or_fqn("Controller", mode="contains") + assert rows, "expected at least one Controller symbol" + for r in rows: + assert r.kind not in ("file", "package"), r + prefix_rows = ladybug_graph.find_by_name_or_fqn("com", mode="prefix") + assert prefix_rows, "expected prefix 'com' to match class FQNs" + for r in prefix_rows: + assert r.kind not in ("file", "package"), r + + +def test_find_by_name_or_fqn_bad_mode_raises(ladybug_graph) -> None: + with pytest.raises(ValueError): + ladybug_graph.find_by_name_or_fqn("X", mode="regex") + + +def test_find_by_name_or_fqn_empty_needle_fuzzy_returns_empty(ladybug_graph) -> None: + """Empty needle in a fuzzy mode must NOT widen to 'all symbols' (STARTS WITH + '' / CONTAINS '' match every string). Defensive guard for future callers; + the CLI never sends an empty positional.""" + assert ladybug_graph.find_by_name_or_fqn("", mode="prefix") == [] + assert ladybug_graph.find_by_name_or_fqn("", mode="contains") == [] + + # ---------------- find_implementors / find_subclasses ---------------- diff --git a/tests/package/test_jrag_locate.py b/tests/package/test_jrag_locate.py index 94d60ae..bf4678e 100644 --- a/tests/package/test_jrag_locate.py +++ b/tests/package/test_jrag_locate.py @@ -15,9 +15,15 @@ 12. test_inspect_returns_edge_summary_with_composed_keys - OVERRIDDEN_BY virtual key 13. test_inspect_ambiguous_returns_candidates - resolve returns many 14. test_inspect_populates_file_location - file_location set by resolve - -Note: --fuzzy was deferred (backend find_by_name_or_fqn is exact-only; see -plans/active/PLAN-JRAG-CLI.md Out of scope). +15. test_find_fuzzy_returns_results_when_exact_misses - --fuzzy prefix/substring fallback +16. test_find_fuzzy_exact_match_skips_fallback - exact hit does not trigger fallback +17. test_find_fuzzy_no_match_reports_tried_modes - gibberish notes all three modes tried +18. test_find_empty_without_fuzzy_suggests_flag - empty exact result suggests --fuzzy +19. test_find_fuzzy_match_removed_by_postfilter_blames_filter - fuzzy hit emptied by --role blames filter +20. test_find_fuzzy_forwards_scope_filters - --service flows into the fuzzy fallback tiers + +Note: --fuzzy is implemented as an exact -> prefix -> substring fallback on +name/FQN (issue #375); fuzzy modes exclude file/package Symbol nodes (#411). """ from __future__ import annotations @@ -513,3 +519,152 @@ def test_inspect_populates_file_location(corpus_root: Path, ladybug_db_path: Pat assert file_location is not None, "expected file_location to be populated for a real symbol" # Should be in format "filename:start_line" (start_line present for symbols). assert "ChatAssignApplication.java" in file_location, f"unexpected file_location: {file_location}" + + +# ----- Test 15-18: find --fuzzy (exact -> prefix -> substring fallback, #375) ----- + + +def test_find_fuzzy_returns_results_when_exact_misses( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """--fuzzy widens an empty exact match to prefix/substring on name/FQN.""" + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + # Exact miss: no symbol is named exactly 'ChatManag'. + proc_exact = _run_jrag(["find", "ChatManag", "--format", "json"], env=env) + assert proc_exact.returncode == 0, proc_exact.stderr + assert len(json.loads(proc_exact.stdout).get("nodes", {})) == 0 + + # Fuzzy hit: prefix matches ChatManagementService. + proc = _run_jrag(["find", "ChatManag", "--fuzzy", "--format", "json"], env=env) + assert proc.returncode == 0, f"{proc.stderr}\n{proc.stdout}" + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", payload + nodes = payload.get("nodes", {}) + assert len(nodes) >= 1, payload + assert any( + n.get("name") == "ChatManagementService" + or n.get("fqn", "").endswith(".ChatManagementService") + for n in nodes.values() + ), nodes + # Fuzzy fallback is surfaced as a warning naming the matched mode. + assert any("fuzzy" in w.lower() for w in payload.get("warnings", [])), payload + + +def test_find_fuzzy_exact_match_skips_fallback( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """When the exact name matches, --fuzzy is a no-op: no fallback warning.""" + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + proc = _run_jrag( + ["find", "ChatManagementService", "--fuzzy", "--format", "json"], env=env + ) + assert proc.returncode == 0, f"{proc.stderr}\n{proc.stdout}" + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", payload + assert len(payload.get("nodes", {})) >= 1, payload + # Exact hit means no fuzzy fallback fired -> no fuzzy warning. + assert not any("fuzzy" in w.lower() for w in payload.get("warnings", [])), payload + + +def test_find_fuzzy_no_match_reports_tried_modes( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """Gibberish + --fuzzy: 0 nodes and a message noting all three modes were tried.""" + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + proc = _run_jrag( + ["find", "ZZZNoSuchSymbolXYZ", "--fuzzy", "--format", "json"], env=env + ) + assert proc.returncode == 0, f"{proc.stderr}\n{proc.stdout}" + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", payload + assert len(payload.get("nodes", {})) == 0, payload + msg = (payload.get("message") or "").lower() + assert "exact" in msg and "prefix" in msg and "substring" in msg, payload + assert payload.get("agent_next_actions"), payload + + +def test_find_empty_without_fuzzy_suggests_flag( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """An empty exact result without --fuzzy points the user at --fuzzy.""" + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + proc = _run_jrag(["find", "ChatManag", "--format", "json"], env=env) + assert proc.returncode == 0, proc.stderr + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", payload + assert len(payload.get("nodes", {})) == 0, payload + assert "--fuzzy" in (payload.get("message") or ""), payload + + +# ----- Test 19-20: find --fuzzy edge cases (review feedback) ----- + + +def test_find_fuzzy_match_removed_by_postfilter_blames_filter( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """Fuzzy matched the identifier, but --role removed every hit: the message + must blame the filter, not claim 'tried exact, prefix, substring'. + + `ChatManag` prefix-matches ChatManagementController (CONTROLLER) and + ChatManagementService (SERVICE), both in chat-assign. Neither is a REPOSITORY, + so --role repository empties the set after the fuzzy tier already matched. + """ + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + proc = _run_jrag( + ["find", "ChatManag", "--fuzzy", "--role", "repository", "--format", "json"], + env=env, + ) + assert proc.returncode == 0, f"{proc.stderr}\n{proc.stdout}" + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", payload + assert len(payload.get("nodes", {})) == 0, payload + msg = (payload.get("message") or "").lower() + assert "removed all hits" in msg, payload + assert "tried exact, prefix, substring" not in msg, payload + + +def test_find_fuzzy_forwards_scope_filters( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """--service must flow into the fuzzy fallback tiers, not just the exact fetch. + + `ChatManag` lives only in chat-assign; scoping the fuzzy fallback to chat-core + must yield 0 (proving the scope filter reached the prefix/contains tiers), + while scoping to chat-assign returns them, all within chat-assign. + """ + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + + proc_core = _run_jrag( + ["find", "ChatManag", "--fuzzy", "--service", "chat-core", "--format", "json"], + env=env, + ) + payload_core = json.loads(proc_core.stdout) + assert payload_core["status"] == "ok", payload_core + assert len(payload_core.get("nodes", {})) == 0, payload_core + + proc_assign = _run_jrag( + ["find", "ChatManag", "--fuzzy", "--service", "chat-assign", "--format", "json"], + env=env, + ) + payload_assign = json.loads(proc_assign.stdout) + assert payload_assign["status"] == "ok", payload_assign + nodes = payload_assign.get("nodes", {}) + assert len(nodes) >= 1, payload_assign + assert all("chat.assign" in n.get("fqn", "") for n in nodes.values()), payload_assign