Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/java_codebase_rag/graph/ladybug_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 55 additions & 19 deletions src/java_codebase_rag/jrag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>): search by exact name/FQN (symbols only).\n"
" Query mode (positional <query>): 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"
Expand All @@ -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).")
Expand Down Expand Up @@ -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
Expand All @@ -1450,14 +1457,29 @@ 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,
module=args.module,
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
57 changes: 57 additions & 0 deletions tests/graph/test_ladybug_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------


Expand Down
161 changes: 158 additions & 3 deletions tests/package/test_jrag_locate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading