Skip to content

Commit 9fe34f4

Browse files
HumanBean17claude
andcommitted
fix(jrag): address fresh-review findings A-G (silent-wrong-output + test debt)
Folded review fan-out (review-fresh-1..4) findings into one commit. Code fixes (silent-wrong-output / spec-compliance): - A: resolve_query returned `ambiguous` with empty candidates when a post-filter rejected every `many` candidate; now `not_found` with the filter-failure message (an empty ambiguous list had no narrowing value). - B: _cmd_connection wrapped list_by_capability(MESSAGE_LISTENER) in a bare `except: listener_hits = []` -> silent wrong-results; now warns-and-continues so an empty async inbound section is distinguishable from "no listeners". - C: find query-mode truncation was decided on the POST-filtered list, so a post-filter that dropped rows silently cleared `truncated`; now decided on the raw name/FQN fetch (limit+1), with a warning when post-filters apply after a capped fetch. find filter-mode didn't slice out.results -> displayed limit+1 rows at the boundary; now slices to limit and sets truncated from has_more_results OR len>limit. - D: overrides/overridden-by set direction="up"/"down" on edge rows, tripping the renderer's has_direction guard -> mislabeled flat lists as `↑ supertypes:` / `↓ subtypes:`. Dropped the direction key (flat is correct). - E: jrag_hints result_edges fallback emitted self-hints (after `callers` it suggested `jrag callers` again). Added current_command param (plumbed via next_actions_hook + _emit_traversal) to skip the self-hint; the inverse direction (`callees` after `callers`) is kept — that's the useful signal. - F: warnings[] were JSON-only (the renderer never emitted them in text), so the "inapplicable flags never silently ignored" spec was unenforced for text consumers. The renderer now appends `warning:` lines; status/microservices/ map/conventions/topics/impact now warn on inherited flags they don't apply. Tests (G + pinning): - Pin A/D/E/F with focused regression tests. - Strengthen vacuous assertions: test_resolve_service (7/10 skipped -> use the ladybug_db_path fixture; tautological `status in (one,many,none)` -> per-branch contracts), test_jrag_listing (truncated now actually verified; client_kind enum must not accept un-normalized "feign"; routes must have `path`), test_jrag_locate (find_by_capability/annotation prove narrowing vs. unfiltered; inspect-ambiguous asserts each branch instead of `elif ok: pass`), test_jrag_orientation test 7 (overview --as vacuous `if status==ok` guard -> unconditional dispatch assertion). Verified: 138 focused jrag/resolve tests green; full suite 1006 passed / 16 failed, all 16 pre-existing or fixture-pollution (4 TestPR4IndexProgress are order-dependent pollution from test_java_codebase_rag_cli init/erase tests running against the real corpus_root — tracked as a separate follow-up, not a regression from this commit). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8f19f88 commit 9fe34f4

10 files changed

Lines changed: 460 additions & 190 deletions

java_codebase_rag/jrag.py

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
882882
# ``counts`` / ``edges`` render as indented alphabetical sections without
883883
# abusing ``edge_summary`` (which is reserved for PR-JRAG-3 real edge
884884
# data). See jrag_render._render_inspect / _render_text_shape.
885+
warnings = _warn_inapplicable_common(args, service=True, module=True, limit=True)
885886
env = Envelope(
886887
status="ok",
887888
nodes={
@@ -897,6 +898,7 @@ def _cmd_status(args: argparse.Namespace) -> int:
897898
"edges": dict(edge_counts),
898899
},
899900
},
901+
warnings=warnings,
900902
)
901903
print(render(env, fmt=args.format, noun="status", shape="inspect"))
902904
return 0
@@ -1015,7 +1017,7 @@ def _cmd_find_query_mode(
10151017
``_cmd_find``, so the only ``kinds`` filter we may pass is symbol sub-kinds
10161018
derived from ``--java-kind``.
10171019
"""
1018-
from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, normalize_enum
1020+
from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum
10191021
from java_codebase_rag.jrag_render import render
10201022

10211023
query = args.query
@@ -1037,17 +1039,27 @@ def _cmd_find_query_mode(
10371039
microservice=args.service,
10381040
limit=limit + 1, # +1 for truncated detection
10391041
)
1042+
# Truncation is decided by the RAW name/FQN fetch (limit+1), BEFORE
1043+
# post-filters reduce the set — otherwise a post-filter that drops rows
1044+
# would silently clear `truncated` even though more name matches may exist
1045+
# beyond the fetch (silent wrong-results).
1046+
raw_truncated = len(rows) > limit
10401047

10411048
# Post-filter by role/annotation/capability (SymbolHit carries these).
1049+
post_filter_active = False
10421050
if args.role:
1051+
post_filter_active = True
10431052
role_norm = normalize_enum(args.role, kind="role")
10441053
rows = [r for r in rows if (r.role or "").upper().replace("-", "_") == role_norm.upper()]
10451054
if args.exclude_role:
1055+
post_filter_active = True
10461056
exclude_role_norm = normalize_enum(args.exclude_role, kind="role")
10471057
rows = [r for r in rows if (r.role or "").upper().replace("-", "_") != exclude_role_norm.upper()]
10481058
if args.annotation:
1059+
post_filter_active = True
10491060
rows = [r for r in rows if args.annotation in (r.annotations or [])]
10501061
if args.capability:
1062+
post_filter_active = True
10511063
rows = [r for r in rows if args.capability in (r.capabilities or [])]
10521064

10531065
# Build warnings for filters that cannot apply in query mode. SymbolHit
@@ -1062,10 +1074,19 @@ def _cmd_find_query_mode(
10621074
warnings.append(
10631075
"--source-layer ignored in query mode (applies to routes; use filter mode)"
10641076
)
1077+
# When post-filters apply after a capped fetch, `truncated` reflects the
1078+
# pre-filter name-match count and cannot know whether MORE filtered matches
1079+
# exist beyond the fetch — surface that honestly.
1080+
if raw_truncated and post_filter_active:
1081+
warnings.append(
1082+
"results truncated before --role/--annotation/--capability filters; "
1083+
"additional filtered matches may exist beyond the fetch"
1084+
)
10651085

1066-
# Convert SymbolHit rows to NodeRef-like dicts for the envelope.
1086+
# Display at most `limit` of the (post-filtered) rows.
1087+
display_rows = rows[:limit]
10671088
nodes = {}
1068-
for row in rows:
1089+
for row in display_rows:
10691090
node_id = row.id
10701091
nodes[node_id] = {
10711092
"id": node_id,
@@ -1078,15 +1099,7 @@ def _cmd_find_query_mode(
10781099
"role": row.role,
10791100
}
10801101

1081-
# mark_truncated operates on a list; envelope.nodes is a dict keyed by id.
1082-
# Round-trip dict -> list -> truncate -> dict to apply the +1-fetch drop
1083-
# (the truncated flag is computed off the list length, which equals the
1084-
# dict size, so this is sound).
1085-
node_list = list(nodes.values())
1086-
display_nodes_list, truncated = mark_truncated(node_list, limit)
1087-
display_nodes = {node["id"]: node for node in display_nodes_list}
1088-
1089-
env = Envelope(status="ok", nodes=display_nodes, truncated=truncated, warnings=warnings)
1102+
env = Envelope(status="ok", nodes=nodes, truncated=raw_truncated, warnings=warnings)
10901103
next_actions_hook(env)
10911104

10921105
# Offset is not supported in query mode (find_by_name_or_fqn has no offset).
@@ -1162,9 +1175,14 @@ def _cmd_find_filter_mode(
11621175
print(render(env, fmt=args.format))
11631176
return 2
11641177

1165-
# Convert results to envelope rows
1166-
nodes_dict = {ref.id: to_envelope_rows([ref])[0] for ref in out.results}
1167-
truncated = out.has_more_results or False
1178+
# Convert results to envelope rows. Slice to `limit`: find_v2 was called with
1179+
# limit+1, so when exactly user_limit+1 matches exist `out.results` carries
1180+
# one extra row that must be dropped (off-by-one guard). `truncated` is True
1181+
# when the backend reports more OR the +1 row is present.
1182+
results = list(out.results)
1183+
truncated = bool(out.has_more_results) or len(results) > limit
1184+
display_refs = results[:limit]
1185+
nodes_dict = {ref.id: to_envelope_rows([ref])[0] for ref in display_refs}
11681186

11691187
env = Envelope(status="ok", nodes=nodes_dict, truncated=truncated)
11701188
next_actions_hook(env)
@@ -1331,6 +1349,13 @@ def _cmd_topics(args: argparse.Namespace) -> int:
13311349
warnings.append(
13321350
f"{no_topic_count} producer(s) had no topic and were excluded"
13331351
)
1352+
# list_producers has no module kwarg (only microservice/topic_prefix); --module
1353+
# would be silently dropped — surface it (use --producer-in to scope by svc).
1354+
if getattr(args, "module", None):
1355+
warnings.append(
1356+
"--module is not applied on topics (list_producers has no module param; "
1357+
"use --producer-in to scope producers by microservice)"
1358+
)
13341359

13351360
# If --consumer-in is provided, resolve consumers for each topic group.
13361361
# A consumer of a topic IS a listener: the edge path is
@@ -1584,7 +1609,7 @@ def _emit_traversal(
15841609
warnings=warnings or [],
15851610
truncated=truncated,
15861611
)
1587-
next_actions_hook(env, root=root_id, result_edges=edges)
1612+
next_actions_hook(env, root=root_id, result_edges=edges, command=getattr(args, "command", None))
15881613
print(render(env, fmt=args.format, noun=noun))
15891614
return 0
15901615

@@ -1635,6 +1660,30 @@ def _warn_unapplied_scope(args: argparse.Namespace, *, reason: str) -> list[str]
16351660
return warnings
16361661

16371662

1663+
def _warn_inapplicable_common(
1664+
args: argparse.Namespace, *, service: bool, module: bool, limit: bool
1665+
) -> list[str]:
1666+
"""Warn when common flags that don't apply to a command are set.
1667+
1668+
Companion to :func:`_warn_unapplied_scope` for the aggregate / orientation
1669+
commands (status / microservices / map / conventions) which inherit the
1670+
``common`` parent parser (``--service`` / ``--module`` / ``--limit``) but
1671+
don't apply all of them. Each kwarg names whether THAT flag is inapplicable
1672+
for this command (``True`` -> warn if the user set it). The plan principle
1673+
"inapplicable flags never silently ignored" requires the warning; with the
1674+
renderer now printing ``warning:`` lines, this is visible to text consumers
1675+
too (not just ``--format json``).
1676+
"""
1677+
warnings: list[str] = []
1678+
if service and args.service:
1679+
warnings.append("--service is not applied on this command")
1680+
if module and getattr(args, "module", None):
1681+
warnings.append("--module is not applied on this command")
1682+
if limit and getattr(args, "limit", None) is not None and args.limit != 20:
1683+
warnings.append("--limit is not applied on this command")
1684+
return warnings
1685+
1686+
16381687
def _cmd_callers(args: argparse.Namespace) -> int:
16391688
cfg, graph, rc = _load_graph_or_error(args)
16401689
if rc:
@@ -2013,7 +2062,10 @@ def _cmd_overrides(args: argparse.Namespace) -> int:
20132062
edges: list[dict] = []
20142063
for e in out.results:
20152064
nodes[e.other.id] = _noderef_to_node_dict(e.other)
2016-
edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES", "direction": "up"})
2065+
# No `direction` key: overrides is a flat list, not a tree. Setting
2066+
# direction="up" would trip the renderer's has_direction guard and
2067+
# mis-label these rows as `↑ supertypes:` (hierarchy). Flat is correct.
2068+
edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES"})
20172069
truncated = bool(out.has_more_results) or len(edges) > limit
20182070
if len(edges) > limit:
20192071
edges = edges[:limit]
@@ -2062,7 +2114,10 @@ def _cmd_overridden_by(args: argparse.Namespace) -> int:
20622114
edges: list[dict] = []
20632115
for e in out.results:
20642116
nodes[e.other.id] = _noderef_to_node_dict(e.other)
2065-
edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES", "direction": "down"})
2117+
# No `direction` key — see _cmd_overrides: a `direction` value would
2118+
# route these into the hierarchy renderer (`↓ subtypes:`), mis-labeling
2119+
# a flat overridden-by list.
2120+
edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES"})
20662121
truncated = bool(out.has_more_results) or len(edges) > limit
20672122
if len(edges) > limit:
20682123
edges = edges[:limit]
@@ -2137,6 +2192,11 @@ def _cmd_impact(args: argparse.Namespace) -> int:
21372192
"--service is a post-filter on impact (impact_analysis has no microservice param)"
21382193
)
21392194
impacts = [h for h in impacts if (h.microservice or "") == args.service]
2195+
if getattr(args, "module", None):
2196+
# impact_analysis has no module param either; warn rather than drop silently.
2197+
warnings.append(
2198+
"--module is not applied on impact (impact_analysis has no module param)"
2199+
)
21402200
display, truncated = mark_truncated(impacts, limit)
21412201
root_id = node.id
21422202
nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
@@ -2487,7 +2547,13 @@ def _calls_service_match_in(caller_microservice: str) -> bool:
24872547
microservice=microservice,
24882548
limit=_CONSUMER_FETCH_LIMIT,
24892549
)
2490-
except Exception:
2550+
except Exception as e: # noqa: BLE001 - best-effort multi-section view
2551+
# Don't swallow silently: surface the failure so an empty async
2552+
# inbound section is distinguishable from "no listeners". HTTP
2553+
# inbound above is unaffected; the command still returns its other
2554+
# sections. (The bare `except: listener_hits = []` this replaces
2555+
# produced silent wrong-results — status:ok with no async + no clue.)
2556+
warnings.append(f"listener lookup failed; async inbound section skipped: {e}")
24912557
listener_hits = []
24922558
topic_route_ids: set[str] = set()
24932559
for h in listener_hits:
@@ -2808,9 +2874,11 @@ def _cmd_microservices(args: argparse.Namespace) -> int:
28082874
return rc
28092875

28102876
counts = graph.microservice_counts()
2877+
warnings = _warn_inapplicable_common(args, service=True, module=True, limit=True)
28112878
env = Envelope(
28122879
status="ok",
28132880
nodes={"microservices": {"counts": dict(counts)}},
2881+
warnings=warnings,
28142882
)
28152883
next_actions_hook(env)
28162884
print(render(env, fmt=args.format, noun="microservices", shape="inspect"))
@@ -2857,9 +2925,13 @@ def _cmd_map(args: argparse.Namespace) -> int:
28572925
kind = str(r.get("kind") or "(unknown)")
28582926
grouped.setdefault(scope, {})[kind] = int(r.get("n") or 0)
28592927

2928+
# --service/--module are applied above (scope_clauses); --limit is not (this
2929+
# is an aggregate count, not a row fetch).
2930+
warnings = _warn_inapplicable_common(args, service=False, module=False, limit=True)
28602931
env = Envelope(
28612932
status="ok",
28622933
nodes={"map": {"group_by": group_col, "counts": grouped}},
2934+
warnings=warnings,
28632935
)
28642936
next_actions_hook(env)
28652937
print(render(env, fmt=args.format, noun="map", shape="inspect"))
@@ -2905,9 +2977,13 @@ def _cmd_conventions(args: argparse.Namespace) -> int:
29052977
if fw:
29062978
framework_counts[fw] = int(r.get("n") or 0)
29072979

2980+
# --service is applied above; --module/--limit are not (no module clause;
2981+
# aggregate count).
2982+
warnings = _warn_inapplicable_common(args, service=False, module=True, limit=True)
29082983
env = Envelope(
29092984
status="ok",
29102985
nodes={"conventions": {"roles": role_counts, "frameworks": framework_counts}},
2986+
warnings=warnings,
29112987
)
29122988
next_actions_hook(env)
29132989
print(render(env, fmt=args.format, noun="conventions", shape="inspect"))

java_codebase_rag/jrag_envelope.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,18 @@ def resolve_query(
351351
if loc is not None:
352352
env.file_location = loc
353353
return node, env
354+
if not survivors:
355+
# Every `many` candidate was rejected by the post-filters — there is
356+
# nothing left to disambiguate, so this is not_found, NOT an empty
357+
# ambiguous list (which would render as "0 ambiguous matches" with no
358+
# narrowing value). Same message as the `one` post-filter-fail branch.
359+
return None, Envelope(
360+
status="not_found",
361+
message=(
362+
f"No matches for {identifier!r} after applying --java-kind/--role/--fqn-prefix "
363+
"filters; use `jrag search <query>` for ranked fuzzy lookup."
364+
),
365+
)
354366
capped = survivors[:10]
355367
env = Envelope(
356368
status="ambiguous",
@@ -373,6 +385,7 @@ def next_actions_hook(
373385
root: str | None = None,
374386
edge_summary: dict[str, Any] | None = None,
375387
result_edges: list[dict[str, Any]] | None = None,
388+
command: str | None = None,
376389
) -> list[str]:
377390
"""Populate ``envelope.agent_next_actions`` via :mod:`jrag_hints` (PR-JRAG-4).
378391
@@ -423,5 +436,6 @@ def next_actions_hook(
423436
root_fqn=root_fqn,
424437
edge_summary=edge_summary,
425438
result_edges=result_edges if result_edges is not None else list(envelope.edges),
439+
current_command=command,
426440
)
427441
return envelope.agent_next_actions

java_codebase_rag/jrag_hints.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def next_actions(
8282
edge_summary: dict[str, Any] | None = None,
8383
result_edges: list[dict[str, Any]],
8484
graph: Any = None, # noqa: ARG001 — reserved for future use (brief contract)
85+
current_command: str | None = None,
8586
) -> list[str]:
8687
"""Build ``agent_next_actions`` hints for a resolved root.
8788
@@ -142,7 +143,11 @@ def _add(cmd: str) -> None:
142143
_add(cmds["out"])
143144
else:
144145
# traversal path: infer from result_edges labels.
145-
# No per-direction counts → emit both directions for recognized labels.
146+
# No per-direction counts → emit both directions for recognized labels,
147+
# then drop the self-hint (the command an agent just ran). The inverse
148+
# direction (e.g. `callees` after `callers`) is the useful exploration
149+
# signal and is kept; only the exact command just run is redundant.
150+
# ``current_command`` is the jrag subcommand name (``args.command``).
146151
labels_seen: set[str] = set()
147152
for edge in result_edges or []:
148153
et = str(edge.get("edge_type") or "").strip()
@@ -152,9 +157,8 @@ def _add(cmd: str) -> None:
152157
cmds = _LABEL_COMMANDS.get(label)
153158
if cmds is None:
154159
continue
155-
if "in" in cmds:
156-
_add(cmds["in"])
157-
if "out" in cmds:
158-
_add(cmds["out"])
160+
for d in ("in", "out"):
161+
if d in cmds and cmds[d] != current_command:
162+
_add(cmds[d])
159163

160164
return hints[:_MAX_HINTS]

java_codebase_rag/jrag_render.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,4 +404,12 @@ def render(
404404
if envelope.truncated:
405405
hint = _truncated_hint(next_offset=next_offset)
406406
body = f"{body}\n{hint}" if body else hint
407+
# Warnings are rendered in text mode (one ``warning:`` line each) so an
408+
# agent running without ``--format json`` still sees inapplicable-flag /
409+
# post-filter notices. Without this the warnings[] field was JSON-only and
410+
# the "inapplicable flags never silently ignored" spec was effectively
411+
# unenforced for text consumers.
412+
if envelope.warnings:
413+
warning_lines = "\n".join(f"warning: {w}" for w in envelope.warnings)
414+
body = f"{body}\n{warning_lines}" if body else warning_lines
407415
return body

tests/test_jrag_envelope.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,48 @@ def test_resolve_query_many_caps_candidates_at_ten(monkeypatch: pytest.MonkeyPat
269269
assert len(env.candidates) == 10 # capped
270270

271271

272+
def test_resolve_query_many_post_filter_rejects_all_is_not_found(
273+
monkeypatch: pytest.MonkeyPatch,
274+
) -> None:
275+
"""Regression (review finding A): when a post-filter rejects EVERY `many`
276+
candidate, the result is not_found — NOT an empty ambiguous list.
277+
278+
An empty ambiguous list would render as '0 ambiguous matches' with no
279+
narrowing value; not_found with the filter-failure message is the honest,
280+
actionable result (same message as the `one` post-filter-fail branch).
281+
"""
282+
n1 = _make_node(id="sym:1", fqn="com.foo.Bar.doStuff", microservice="foo", role="SERVICE")
283+
n2 = _make_node(id="sym:2", fqn="com.foo.Baz.doStuff", microservice="bar", role="SERVICE")
284+
fake_output = ResolveOutput(
285+
success=True,
286+
status="many",
287+
candidates=[
288+
ResolveCandidate(node=n1, score=0.9, reason="fqn_suffix"),
289+
ResolveCandidate(node=n2, score=0.5, reason="short_name"),
290+
],
291+
)
292+
monkeypatch.setattr("resolve_service.resolve_v2", lambda *a, **kw: fake_output)
293+
graph = MagicMock()
294+
cfg = MagicMock()
295+
296+
result_node, env = resolve_query(
297+
"doStuff",
298+
hint_kind="symbol",
299+
java_kind=None,
300+
role="CONTROLLER", # neither candidate is CONTROLLER -> all rejected
301+
fqn_prefix=None,
302+
cfg=cfg,
303+
graph=graph,
304+
)
305+
assert result_node is None
306+
assert env.status == "not_found", (
307+
f"empty-many must be not_found (not {env.status!r}); candidates={env.candidates}"
308+
)
309+
assert env.candidates == []
310+
assert env.message is not None
311+
assert "filters" in env.message.lower() or "post-filter" in env.message.lower()
312+
313+
272314
def test_resolve_query_none_is_not_found_with_search_hint(monkeypatch: pytest.MonkeyPatch) -> None:
273315
fake_output = ResolveOutput(
274316
success=True,

0 commit comments

Comments
 (0)