diff --git a/agents/explorer-rag-cli.md b/agents/explorer-rag-cli.md index cb52905..98fc86a 100644 --- a/agents/explorer-rag-cli.md +++ b/agents/explorer-rag-cli.md @@ -97,7 +97,7 @@ The Decision Framework above tells you *which* command; reach for `--help` only **Resolve-first contract.** Every `` command resolves the identifier first, then maps `one` / `many` / `none` onto one envelope: `one` → run; `many` → return candidates and stop, **no silent guess across distinct types** (a class sharing its simple name with its own constructor still resolves to the type — narrow with `--kind` / `--role` / `--fqn-contains` / `--service`); `none` → `status: not_found` (exit 0), fall back to `search` or `Grep`. Pass names (FQN / simple name / route path / topic) or prior `sym:`/`route:`/`client:`/`producer:` ids — never raw node ids. `--kind` is a true resolve input; `--role` / `--java-kind` / `--fqn-contains` post-filter client-side. -**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). +**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). Output-shaping flags (every query / listing / traversal command — not `status` / `microservices` / `vocab-index`, which reject them): `--count` prints just the result count (bare int in text; `{"status","count"}` in json), `--exists` prints `true`/`false` (`{"status","exists"}` in json) and exits 0 on a hit / 2 on a miss (scriptable existence gate — `find X --exists`, `inspect X --exists`), `--fields fqn,role,…` projects each node to a comma-separated field allowlist (overrides `--detail`; ignored with `--count`/`--exists`; primarily a `--format json` lever). **Edge semantics `--help` doesn't spell out.** `callers` / `callees` = `CALLS` in/out (on a controller/entry-point type, `callers` also lists the routes its methods `EXPOSE`). `impact` = bounded fan-in over `INJECTS` / `IMPLEMENTS` / `EXTENDS` (default depth 2; raise with `--depth`). `flow ` follows `EXPOSES` → `HTTP_CALLS` / `ASYNC_CALLS` → `CALLS`. `connection ` = inbound/outbound cross-service seams (its positional is a literal service name, not a query). Per-command edge mappings and the rest of the flag surface live in each command's `--help`. diff --git a/skills/explore-codebase-cli/SKILL.md b/skills/explore-codebase-cli/SKILL.md index 4493706..f1eb1a1 100644 --- a/skills/explore-codebase-cli/SKILL.md +++ b/skills/explore-codebase-cli/SKILL.md @@ -96,7 +96,7 @@ The Decision Framework above tells you *which* command; reach for `--help` only **Resolve-first contract.** Every `` command resolves the identifier first, then maps `one` / `many` / `none` onto one envelope: `one` → run; `many` → return candidates and stop, **no silent guess across distinct types** (a class sharing its simple name with its own constructor still resolves to the type — narrow with `--kind` / `--role` / `--fqn-contains` / `--service`); `none` → `status: not_found` (exit 0), fall back to `search` or `Grep`. Pass names (FQN / simple name / route path / topic) or prior `sym:`/`route:`/`client:`/`producer:` ids — never raw node ids. `--kind` is a true resolve input; `--role` / `--java-kind` / `--fqn-contains` post-filter client-side. -**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). +**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). Output-shaping flags (every query / listing / traversal command — not `status` / `microservices` / `vocab-index`, which reject them): `--count` prints just the result count (bare int in text; `{"status","count"}` in json), `--exists` prints `true`/`false` (`{"status","exists"}` in json) and exits 0 on a hit / 2 on a miss (scriptable existence gate — `find X --exists`, `inspect X --exists`), `--fields fqn,role,…` projects each node to a comma-separated field allowlist (overrides `--detail`; ignored with `--count`/`--exists`; primarily a `--format json` lever). **Edge semantics `--help` doesn't spell out.** `callers` / `callees` = `CALLS` in/out (on a controller/entry-point type, `callers` also lists the routes its methods `EXPOSE`). `impact` = bounded fan-in over `INJECTS` / `IMPLEMENTS` / `EXTENDS` (default depth 2; raise with `--depth`). `flow ` follows `EXPOSES` → `HTTP_CALLS` / `ASYNC_CALLS` → `CALLS`. `connection ` = inbound/outbound cross-service seams (its positional is a literal service name, not a query). Per-command edge mappings and the rest of the flag surface live in each command's `--help`. diff --git a/src/java_codebase_rag/install_data/agents/explorer-rag-cli.md b/src/java_codebase_rag/install_data/agents/explorer-rag-cli.md index cb52905..98fc86a 100644 --- a/src/java_codebase_rag/install_data/agents/explorer-rag-cli.md +++ b/src/java_codebase_rag/install_data/agents/explorer-rag-cli.md @@ -97,7 +97,7 @@ The Decision Framework above tells you *which* command; reach for `--help` only **Resolve-first contract.** Every `` command resolves the identifier first, then maps `one` / `many` / `none` onto one envelope: `one` → run; `many` → return candidates and stop, **no silent guess across distinct types** (a class sharing its simple name with its own constructor still resolves to the type — narrow with `--kind` / `--role` / `--fqn-contains` / `--service`); `none` → `status: not_found` (exit 0), fall back to `search` or `Grep`. Pass names (FQN / simple name / route path / topic) or prior `sym:`/`route:`/`client:`/`producer:` ids — never raw node ids. `--kind` is a true resolve input; `--role` / `--java-kind` / `--fqn-contains` post-filter client-side. -**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). +**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). Output-shaping flags (every query / listing / traversal command — not `status` / `microservices` / `vocab-index`, which reject them): `--count` prints just the result count (bare int in text; `{"status","count"}` in json), `--exists` prints `true`/`false` (`{"status","exists"}` in json) and exits 0 on a hit / 2 on a miss (scriptable existence gate — `find X --exists`, `inspect X --exists`), `--fields fqn,role,…` projects each node to a comma-separated field allowlist (overrides `--detail`; ignored with `--count`/`--exists`; primarily a `--format json` lever). **Edge semantics `--help` doesn't spell out.** `callers` / `callees` = `CALLS` in/out (on a controller/entry-point type, `callers` also lists the routes its methods `EXPOSE`). `impact` = bounded fan-in over `INJECTS` / `IMPLEMENTS` / `EXTENDS` (default depth 2; raise with `--depth`). `flow ` follows `EXPOSES` → `HTTP_CALLS` / `ASYNC_CALLS` → `CALLS`. `connection ` = inbound/outbound cross-service seams (its positional is a literal service name, not a query). Per-command edge mappings and the rest of the flag surface live in each command's `--help`. diff --git a/src/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md b/src/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md index 4493706..f1eb1a1 100644 --- a/src/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +++ b/src/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md @@ -96,7 +96,7 @@ The Decision Framework above tells you *which* command; reach for `--help` only **Resolve-first contract.** Every `` command resolves the identifier first, then maps `one` / `many` / `none` onto one envelope: `one` → run; `many` → return candidates and stop, **no silent guess across distinct types** (a class sharing its simple name with its own constructor still resolves to the type — narrow with `--kind` / `--role` / `--fqn-contains` / `--service`); `none` → `status: not_found` (exit 0), fall back to `search` or `Grep`. Pass names (FQN / simple name / route path / topic) or prior `sym:`/`route:`/`client:`/`producer:` ids — never raw node ids. `--kind` is a true resolve input; `--role` / `--java-kind` / `--fqn-contains` post-filter client-side. -**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). +**Output.** Default is compact text; `--format json` emits `{status, nodes, edges, candidates, truncated, agent_next_actions, file_location}` (empty fields dropped; `file_location` is a `filename:line` string; `agent_next_actions` suggests ≤5 next commands). `truncated` pages via `--limit` / `--offset` (`find` / `search` only). Output-shaping flags (every query / listing / traversal command — not `status` / `microservices` / `vocab-index`, which reject them): `--count` prints just the result count (bare int in text; `{"status","count"}` in json), `--exists` prints `true`/`false` (`{"status","exists"}` in json) and exits 0 on a hit / 2 on a miss (scriptable existence gate — `find X --exists`, `inspect X --exists`), `--fields fqn,role,…` projects each node to a comma-separated field allowlist (overrides `--detail`; ignored with `--count`/`--exists`; primarily a `--format json` lever). **Edge semantics `--help` doesn't spell out.** `callers` / `callees` = `CALLS` in/out (on a controller/entry-point type, `callers` also lists the routes its methods `EXPOSE`). `impact` = bounded fan-in over `INJECTS` / `IMPLEMENTS` / `EXTENDS` (default depth 2; raise with `--depth`). `flow ` follows `EXPOSES` → `HTTP_CALLS` / `ASYNC_CALLS` → `CALLS`. `connection ` = inbound/outbound cross-service seams (its positional is a literal service name, not a query). Per-command edge mappings and the rest of the flag surface live in each command's `--help`. diff --git a/src/java_codebase_rag/jrag.py b/src/java_codebase_rag/jrag.py index b63e6fc..4cb7db9 100644 --- a/src/java_codebase_rag/jrag.py +++ b/src/java_codebase_rag/jrag.py @@ -195,6 +195,46 @@ def _clamped_limit(args: argparse.Namespace) -> int: return min(raw_limit, 499) +def _emit(env, args: argparse.Namespace, *, noun: str = "", + shape: str | None = None, next_offset: int | None = None) -> int: + """Final render+print funnel honoring ``--count`` / ``--exists`` / ``--fields``; + returns the exit code. + + The single output seam every ok / not_found / ambiguous result routes + through. The shared helpers (:func:`_render_listing`, :func:`_emit_traversal`) + delegate their tail here; inline success render sites call it directly. True + usage / setup errors (missing index, kind guard, ``neighbors_v2`` failure, + argparse errors) bypass it — those render normally via :func:`render`, since a + count/exists shape would hide the actionable error message. + + Exit code: ``--exists`` forces 0 when results are present and 2 otherwise + (resolve miss AND empty ok both count as absent), computed via + :func:`jrag_render.has_results` so output and exit code agree. Without + ``--exists``, rc follows the envelope (error -> 2, else 0); ``--count`` does + not gate (a zero count on an ok envelope stays exit 0). + + ``getattr`` defaults keep this safe for parsers that lack the flags + (``_core_parser`` commands), though only ``_common_parser`` commands are + routed here today. + """ + from java_codebase_rag.jrag_render import has_results, render + + print(render( + env, + fmt=getattr(args, "format", "text"), + detail=getattr(args, "detail", "normal"), + noun=noun, + next_offset=next_offset, + shape=shape, + count=getattr(args, "count", False), + exists=getattr(args, "exists", False), + fields=getattr(args, "fields", None), + )) + if getattr(args, "exists", False): + return 0 if has_results(env, shape) else 2 + return 2 if env.status == "error" else 0 + + def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str, extra_hints: list[str] | None = None) -> int: """Apply +1-fetch truncation, build the envelope, render as a listing. @@ -209,7 +249,6 @@ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str, (jobs / listeners / entities). """ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, to_envelope_rows - from java_codebase_rag.jrag_render import render node_list = to_envelope_rows(rows) if rows and not isinstance(rows[0], dict) else list(rows) display_nodes_list, truncated = mark_truncated(node_list, limit) @@ -227,8 +266,7 @@ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str, seen.add(h) env.agent_next_actions.append(h) env.agent_next_actions = env.agent_next_actions[:5] - print(render(env, fmt=args.format, detail=args.detail, noun=noun)) - return 0 + return _emit(env, args, noun=noun) def _symbol_hit_to_dict(hit) -> dict: @@ -429,6 +467,43 @@ def _common_parser() -> argparse.ArgumentParser: "normal = +module/role/file/score; full = +signature/annotations/snippet." ), ) + # Output-shaping flags (issue #376). NOT on _core_parser: status / + # microservices are aggregate rollups (a count there is meaningless) and + # vocab-index prints plain text outside the render path, so adding them + # there would create silently-ignored flags (violates the + # "inapplicable flags never silently ignored" principle). + common.add_argument( + "--count", + action="store_true", + default=False, + help=( + "Print only the result count (no rows) — bare int in text, " + "{\"status\",\"count\"} in json. Counts nodes (listing), edges " + "(traversal), or 1 (inspect)." + ), + ) + common.add_argument( + "--exists", + action="store_true", + default=False, + help=( + "Print only an exists boolean (true/false, or " + "{\"status\",\"exists\"} in json). Exit 0 when results exist, " + "2 otherwise (incl. resolve miss / empty result)." + ), + ) + common.add_argument( + "--fields", + type=str, + default=None, + metavar="LIST", + help=( + "Comma-separated node-field allowlist that overrides --detail " + "(e.g. fqn,role,signature). Ignored with --count/--exists; " + "primarily a --format json lever; text still labels rows from " + "whatever identity fields survive." + ), + ) return common # Core-only parser for AGGREGATE commands (status / microservices) that have @@ -1444,7 +1519,6 @@ def _cmd_find_query_mode( ``--java-kind``. """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum - from java_codebase_rag.jrag_render import render query = args.query @@ -1588,8 +1662,7 @@ def _cmd_find_query_mode( env.message = hint # 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")) - return 0 + return _emit(env, args, noun="symbol") def _build_node_filter_or_error(filter_dict: dict): @@ -1705,8 +1778,7 @@ def _cmd_find_filter_mode( # Render with offset hint if truncated next_offset = args.offset + limit if truncated else None - print(render(env, fmt=args.format, detail=args.detail, noun=kind, next_offset=next_offset)) - return 0 + return _emit(env, args, noun=kind, next_offset=next_offset) def _cmd_inspect(args: argparse.Namespace) -> int: @@ -1740,8 +1812,10 @@ def _cmd_inspect(args: argparse.Namespace) -> int: ) if env.status != "ok": - print(render(env, fmt=args.format, detail=args.detail)) - return 2 if env.status == "error" else 0 + # _emit so --exists/--count shape a resolve miss (inspect Missing + # --exists -> false, rc 2). rc matches the prior 2-if-error-else-0 + # when no flag is set. + return _emit(env, args) # Node resolved successfully - call describe_v2 desc_out = mcp_v2.describe_v2(id=node.id, graph=graph) @@ -1813,8 +1887,7 @@ def _cmd_inspect(args: argparse.Namespace) -> int: next_actions_hook(env, root=node_id, edge_summary=record_dict.get("edge_summary")) # Render with inspect shape - print(render(env, fmt=args.format, detail=args.detail, shape="inspect")) - return 0 + return _emit(env, args, shape="inspect") def _backfill_service_from_filename(row: dict) -> None: @@ -1912,7 +1985,6 @@ def _cmd_producers(args: argparse.Namespace) -> int: def _cmd_topics(args: argparse.Namespace) -> int: from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook - from java_codebase_rag.jrag_render import render _, graph, rc = _load_graph_or_error(args) if rc: @@ -1992,8 +2064,7 @@ def _cmd_topics(args: argparse.Namespace) -> int: warnings=warnings + _auto_scope_notice(args), ) next_actions_hook(env, command=getattr(args, "command", None)) - print(render(env, fmt=args.format, detail=args.detail, noun="topic")) - return 0 + return _emit(env, args, noun="topic") def _inspect_hints_for_rows(rows: list[dict], *, limit: int = 2) -> list[str]: @@ -2196,7 +2267,6 @@ def _resolve_traversal_node( resolves for the cross-service route-caller flow. """ from java_codebase_rag.jrag_envelope import resolve_query - from java_codebase_rag.jrag_render import render node, env = resolve_query( args.query, @@ -2210,8 +2280,10 @@ def _resolve_traversal_node( module=(getattr(args, "module", None) or "") if apply_scope else "", ) if env.status != "ok": - print(render(env, fmt=args.format, detail=args.detail)) - return None, env, 2 if env.status == "error" else 0 + # Route through _emit so --exists/--count shape a resolve miss (e.g. + # `callers Missing --exists` -> false, rc 2) instead of rendering the + # not_found body. rc matches the prior 2-if-error-else-0 when no flag. + return None, env, _emit(env, args) return node, env, 0 @@ -2271,7 +2343,6 @@ def _emit_traversal( ``jrag implementations ``). """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render env = Envelope( status="ok", @@ -2290,8 +2361,7 @@ def _emit_traversal( seen.add(h) env.agent_next_actions.append(h) env.agent_next_actions = env.agent_next_actions[:5] - print(render(env, fmt=args.format, detail=args.detail, noun=noun)) - return 0 + return _emit(env, args, noun=noun) def _require_kind( @@ -3368,7 +3438,6 @@ def _cmd_connection(args: argparse.Namespace) -> int: limit = _clamped_limit(args) from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render # Validate the microservice against the known set so a bogus name surfaces a # clear error instead of an empty inbound:/outbound: view (silent wrong @@ -3568,8 +3637,7 @@ def _calls_service_match_in(caller_microservice: str) -> bool: truncated=truncated, ) next_actions_hook(env, root=root_id, result_edges=display_edges) - print(render(env, fmt=args.format, detail=args.detail, noun="connection")) - return 0 + return _emit(env, args, noun="connection") def _resolve_source_path(cfg, file_arg: str) -> Path | None: @@ -3652,8 +3720,7 @@ def _cmd_outline(args: argparse.Namespace) -> int: # thing to inspect from an outline. Per-row inspect hints for the leading # entries give the agent a concrete next step. env.agent_next_actions = _inspect_hints_for_rows(display, limit=2) - print(render(env, fmt=args.format, detail=args.detail, noun="symbol")) - return 0 + return _emit(env, args, noun="symbol") def _cmd_imports(args: argparse.Namespace) -> int: @@ -3766,8 +3833,7 @@ def _cmd_imports(args: argparse.Namespace) -> int: env = Envelope(status="ok", nodes=nodes, edges=edges, warnings=warnings) next_actions_hook(env, result_edges=edges) - print(render(env, fmt=args.format, detail=args.detail, noun="import")) - return 0 + return _emit(env, args, noun="import") # ============================================================================ @@ -3817,7 +3883,6 @@ def _cmd_map(args: argparse.Namespace) -> int: axis, which made "group by ALL modules" unreachable. """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render _, graph, rc = _load_graph_or_error(args) if rc: @@ -3871,14 +3936,12 @@ def _cmd_map(args: argparse.Namespace) -> int: hints.append(f"jrag overview {drill_scope}") hints.append("jrag conventions") env.agent_next_actions = hints[:5] - print(render(env, fmt=args.format, detail=args.detail, noun="map", shape="inspect")) - return 0 + return _emit(env, args, noun="map", shape="inspect") def _cmd_conventions(args: argparse.Namespace) -> int: """conventions [--service] — dominant roles + framework tallies.""" from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render _, graph, rc = _load_graph_or_error(args) if rc: @@ -3940,8 +4003,7 @@ def _cmd_conventions(args: argparse.Namespace) -> int: hints.append(f"jrag find --role {top_role}{scope_suffix}") hints.append("jrag map") env.agent_next_actions = hints[:5] - print(render(env, fmt=args.format, detail=args.detail, noun="conventions", shape="inspect")) - return 0 + return _emit(env, args, noun="conventions", shape="inspect") def _overview_detect_type(subject: str, graph) -> str: @@ -3976,7 +4038,6 @@ def _overview_microservice(args: argparse.Namespace, graph, microservice: str) - bundle to empty (subject node) or keep all samples equally (rollup node). """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render limit = _clamped_limit(args) routes = graph.list_routes(microservice=microservice, limit=limit + 1) @@ -4030,8 +4091,7 @@ def _overview_microservice(args: argparse.Namespace, graph, microservice: str) - nodes={f"microservice:{microservice}": node}, ) next_actions_hook(env) - print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect")) - return 0 + return _emit(env, args, noun="overview", shape="inspect") def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> int: @@ -4045,8 +4105,7 @@ def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> in cfg=cfg, graph=graph, ) if renv.status != "ok" or node is None: - print(render(renv, fmt=args.format, detail=args.detail)) - return 2 if renv.status == "error" else 0 + return _emit(renv, args) if node.kind != "route": env = Envelope( @@ -4091,8 +4150,7 @@ def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> in edges = edges[:limit] env = Envelope(status="ok", nodes=nodes_dict, edges=edges, root=root_id, truncated=truncated) next_actions_hook(env, root=root_id, result_edges=edges) - print(render(env, fmt=args.format, detail=args.detail, noun="overview")) - return 0 + return _emit(env, args, noun="overview") def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int: @@ -4105,7 +4163,6 @@ def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int: samples, full = +limit samples. """ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook - from java_codebase_rag.jrag_render import render limit = _clamped_limit(args) # Producers: exact topic match first, then substring match as fallback. @@ -4159,8 +4216,7 @@ def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int: nodes={f"topic:{topic}": topic_node}, ) next_actions_hook(env) - print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect")) - return 0 + return _emit(env, args, noun="overview", shape="inspect") def _cmd_overview(args: argparse.Namespace) -> int: @@ -4318,8 +4374,7 @@ def _cmd_search(args: argparse.Namespace) -> int: warnings=_auto_scope_notice(args), ) next_actions_hook(env) - print(render(env, fmt=args.format, detail=args.detail, noun="search")) - return 0 + return _emit(env, args, noun="search") # Build NodeFilter from flags (same set as `find` filter mode). filter_dict: dict = {} @@ -4466,8 +4521,7 @@ def _cmd_search(args: argparse.Namespace) -> int: if display: env.agent_next_actions = _inspect_hints_for_rows(display, limit=2) next_offset = args.offset + limit if truncated else None - print(render(env, fmt=args.format, detail=args.detail, noun="search", next_offset=next_offset)) - return 0 + return _emit(env, args, noun="search", next_offset=next_offset) def _suppress_runtime_stderr_noise() -> None: diff --git a/src/java_codebase_rag/jrag_render.py b/src/java_codebase_rag/jrag_render.py index 5c4ab0a..2b8c259 100644 --- a/src/java_codebase_rag/jrag_render.py +++ b/src/java_codebase_rag/jrag_render.py @@ -10,12 +10,13 @@ """ from __future__ import annotations +import json from typing import Any from java_codebase_rag.absence.absence_types import AbsenceDiagnosis from java_codebase_rag.jrag_envelope import Envelope, project_envelope, simple_name -__all__ = ["render", "tiered_name", "display_name"] +__all__ = ["render", "tiered_name", "display_name", "count_results", "has_results"] # Edge labels that carry a ``confidence`` column (CALLS-family). ``conf:`` is @@ -717,6 +718,92 @@ def _render_text_shape(envelope: Envelope, *, noun: str, shape: str | None, deta return _render_scalar(envelope) +def count_results(envelope: Envelope, shape: str | None) -> int: + """How many result items ``envelope`` represents, per its render shape. + + The single source of truth shared by ``--count`` (output) and ``--exists`` + (output + exit code). Counts the collection the agent thinks of as the + result, not the raw node dict (a traversal envelope includes the root + subject in ``nodes`` — counting nodes would report N+1, not N): + + * ``shape="inspect"`` -> 1 when a subject resolved (else 0). ``inspect`` + /``status``/``map``/``conventions``/``overview`` declare this shape. + * ``root is not None`` (traversal) -> ``len(edges)``: the callers/callees/ + hierarchy members are the result; the root is the resolved subject. + * otherwise (listing) -> ``len(nodes)``. + + Callers that only care about emptiness should use :func:`has_results`, which + also gates on ``status == "ok"`` (a ``not_found`` / ``error`` envelope has no + result regardless of any carried nodes/edges). + """ + if shape == "inspect": + return 1 if envelope.nodes else 0 + if envelope.root is not None: + return len(envelope.edges) + return len(envelope.nodes) + + +def has_results(envelope: Envelope, shape: str | None) -> bool: + """True only when the envelope is a non-empty ``ok`` result. + + ``not_found`` / ``ambiguous`` / ``error`` carry no result even when they + carry nodes/candidates (e.g. ambiguous candidates are narrowing hints, not + hits). Used by ``--exists`` for both its output and its exit code. + """ + return envelope.status == "ok" and count_results(envelope, shape) > 0 + + +def _field_set(fields: str) -> set[str]: + """Parse a comma-separated ``--fields`` allowlist into a set of trimmed names.""" + return {name.strip() for name in fields.split(",") if name.strip()} + + +def _project_to_fields(envelope: Envelope, fields: str) -> Envelope: + """Return a copy of ``envelope`` (at FULL detail) whose nodes keep only the + requested field names. + + ``--fields`` is an explicit allowlist that OVERRIDES ``--detail``: project to + full (so a ``full``-tier field like ``signature`` is available even at the + default detail), then keep only the requested keys per node. Names absent on + a node are simply not present. Graph-id fields stay stripped + (``project_envelope(full)`` already strips them). Edges/candidates are left + at full; ``--fields`` is documented as a node projection. + + Delegates the envelope copy to :func:`project_envelope` (the single + projection seam) and only rewrites ``nodes`` on the already-copied result, + so the per-field Envelope construction can't drift out of sync as fields are + added. ``project_envelope`` returns an independent copy and the node dicts + are rebuilt by the comprehension, so the caller's ``envelope`` is untouched. + """ + wanted = _field_set(fields) + projected = project_envelope(envelope, "full") + projected.nodes = { + nid: {k: v for k, v in node.items() if k in wanted} + for nid, node in projected.nodes.items() + } + return projected + + +def _render_count(envelope: Envelope, *, fmt: str, shape: str | None) -> str: + """``--count`` output: bare integer (text) or ``{"status","count"}`` (json). + + Non-``ok`` envelopes count as 0 (a miss / error has no result items). Text is + the bare count — "only the result count", script-friendly for ``$(...)``. + """ + n = count_results(envelope, shape) if envelope.status == "ok" else 0 + if fmt == "json": + return json.dumps({"status": envelope.status, "count": n}) + return str(n) + + +def _render_exists(envelope: Envelope, *, fmt: str, shape: str | None) -> str: + """``--exists`` output: ``true``/``false`` (text) or ``{"status","exists"}`` (json).""" + exists = has_results(envelope, shape) + if fmt == "json": + return json.dumps({"status": envelope.status, "exists": exists}) + return "true" if exists else "false" + + def render( envelope: Envelope, *, @@ -725,6 +812,9 @@ def render( noun: str = "", next_offset: int | None = None, shape: str | None = None, + count: bool = False, + exists: bool = False, + fields: str | None = None, ) -> str: """Dispatch on ``fmt`` (text default; json emits the projected envelope). @@ -750,8 +840,33 @@ def render( ``nodes``/``noun`` -> listing, else scalar. Listing nodes frequently carry dict-valued fields after ``.model_dump()``, so inspect is NEVER inferred from node contents - only an explicit ``shape="inspect"`` routes there. + + Output-shaping flags (issue #376), orthogonal to the command that produced + the envelope and honored on both text and json: + + * ``exists=True`` -> ``true``/``false`` (or ``{"status","exists"}``). Takes + precedence over ``count`` (a gate is more specific than a tally). + * ``count=True`` -> the bare result count (or ``{"status","count"}``); see + :func:`count_results` for what is counted per shape. + * ``fields`` -> comma-separated node-field allowlist that OVERRIDES + ``--detail`` (project to full, then keep only the named fields). Applies + only to ``status="ok"`` output; composes with the normal render, not with + ``count``/``exists``. A whitespace/comma-only allowlist is treated as + not given (falls back to the normal projection). Primarily a JSON lever + (text rendering still labels rows from whatever identity fields survive + the allowlist). + + The exit-code side of ``--exists`` is decided by the caller (``jrag._emit``) + via :func:`has_results` — render() only shapes output. """ - projected = project_envelope(envelope, detail) + if exists: + return _render_exists(envelope, fmt=fmt, shape=shape) + if count: + return _render_count(envelope, fmt=fmt, shape=shape) + if fields and envelope.status == "ok" and _field_set(fields): + projected = _project_to_fields(envelope, fields) + else: + projected = project_envelope(envelope, detail) if fmt == "json": return projected.to_json() body = _render_text_shape(projected, noun=noun, shape=shape, detail=detail) diff --git a/tests/package/test_jrag_locate.py b/tests/package/test_jrag_locate.py index bf4678e..ba9073d 100644 --- a/tests/package/test_jrag_locate.py +++ b/tests/package/test_jrag_locate.py @@ -668,3 +668,77 @@ def test_find_fuzzy_forwards_scope_filters( 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 + + +# ----- issue #376: --count / --exists / --fields end-to-end (exit-code contract) ----- + +_KNOWN_FQN = "com.bank.chat.assign.ChatAssignApplication" +_MISS_FQN = "com.bank.chat.assign.DoesNotExistGibberishXYZ123" + + +def _index_env(corpus_root: Path, ladybug_db_path: Path) -> dict[str, str]: + """Env pointing jrag at the shared fixture index (DRY for the flag tests).""" + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(corpus_root) + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(ladybug_db_path.parent) + return env + + +def test_find_exists_true_on_hit(corpus_root: Path, ladybug_db_path: Path) -> None: + """find --exists -> 'true', exit 0.""" + proc = _run_jrag(["find", _KNOWN_FQN, "--exists"], env=_index_env(corpus_root, ladybug_db_path)) + assert proc.returncode == 0, f"rc={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}" + assert proc.stdout.strip() == "true" + + +def test_find_exists_false_on_miss_exits_2(corpus_root: Path, ladybug_db_path: Path) -> None: + """find --exists -> 'false', exit 2 (the gating contract).""" + proc = _run_jrag(["find", _MISS_FQN, "--exists"], env=_index_env(corpus_root, ladybug_db_path)) + assert proc.returncode == 2, f"rc={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}" + assert proc.stdout.strip() == "false" + + +def test_find_count_reports_bare_int_and_json(corpus_root: Path, ladybug_db_path: Path) -> None: + """--count text is a bare int (rc 0 even when >0); --format json shapes it.""" + env = _index_env(corpus_root, ladybug_db_path) + proc_c = _run_jrag(["find", "--role", "controller", "--count"], env=env) + assert proc_c.returncode == 0, f"rc={proc_c.returncode}\nstderr={proc_c.stderr}" + n = int(proc_c.stdout.strip()) + assert n > 0, f"expected controllers in fixture, got count={n}" + + proc_j = _run_jrag(["find", "--role", "controller", "--count", "--format", "json"], env=env) + payload = json.loads(proc_j.stdout) + assert payload == {"status": "ok", "count": n} + + +def test_find_count_matches_row_count(corpus_root: Path, ladybug_db_path: Path) -> None: + """--count equals the number of node rows the same query returns without it.""" + env = _index_env(corpus_root, ladybug_db_path) + proc_rows = _run_jrag(["find", "--role", "controller", "--format", "json"], env=env) + assert proc_rows.returncode == 0, proc_rows.stderr + row_count = len(json.loads(proc_rows.stdout).get("nodes", {})) + + proc_count = _run_jrag(["find", "--role", "controller", "--count"], env=env) + assert int(proc_count.stdout.strip()) == row_count + + +def test_inspect_exists_false_on_miss_exits_2(corpus_root: Path, ladybug_db_path: Path) -> None: + """inspect --exists routes the resolve-miss through the flag -> + 'false', exit 2 (exercises the _resolve/inspect not_found -> _emit path).""" + proc = _run_jrag(["inspect", _MISS_FQN, "--exists"], env=_index_env(corpus_root, ladybug_db_path)) + assert proc.returncode == 2, f"rc={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}" + assert proc.stdout.strip() == "false" + + +def test_find_fields_projects_json_to_allowlist(corpus_root: Path, ladybug_db_path: Path) -> None: + """--fields fqn,role keeps only those keys on each node (overrides --detail).""" + proc = _run_jrag( + ["find", _KNOWN_FQN, "--format", "json", "--fields", "fqn,role"], + env=_index_env(corpus_root, ladybug_db_path), + ) + assert proc.returncode == 0, f"rc={proc.returncode}\nstderr={proc.stderr}" + payload = json.loads(proc.stdout) + assert payload["status"] == "ok" and payload["nodes"] + for node in payload["nodes"].values(): + assert set(node.keys()) <= {"fqn", "role"}, f"unexpected keys: {node.keys()}" + assert "fqn" in node diff --git a/tests/package/test_jrag_render.py b/tests/package/test_jrag_render.py index 67454ce..a0cea3c 100644 --- a/tests/package/test_jrag_render.py +++ b/tests/package/test_jrag_render.py @@ -885,3 +885,175 @@ def test_render_decompose_multistage_waterfall_lists_all_roles() -> None: assert "OrderController" in out assert "OrderRepository" in out assert "InventoryClient" in out + + +# ----- issue #376: --count / --exists / --fields output shaping ----- +# +# Pure render-level coverage (no graph/index). The end-to-end exit-code contract +# for --exists is pinned in test_jrag_locate.py; these pin the output shaping +# and the shape-aware counting shared by --count and --exists. + + +def test_count_text_is_bare_integer() -> None: + """--count text is just the number — "only the result count", script-friendly.""" + env = Envelope(status="ok", nodes={ + "a": {"fqn": "x.A"}, "b": {"fqn": "x.B"}, "c": {"fqn": "x.C"}, + }) + assert render(env, count=True) == "3" + + +def test_count_json_shape() -> None: + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A"}, "b": {"fqn": "x.B"}}) + assert json.loads(render(env, fmt="json", count=True)) == {"status": "ok", "count": 2} + + +def test_count_traversal_counts_edges_not_nodes() -> None: + """A traversal envelope carries the root subject in ``nodes`` too; --count + must report the edge count (1), not the node count (2).""" + env = Envelope( + status="ok", root="r", + nodes={"r": {"fqn": "x.R"}, "t": {"fqn": "x.T"}}, + edges=[{"other_id": "t", "edge_type": "CALLS"}], + ) + assert render(env, count=True) == "1" + assert json.loads(render(env, fmt="json", count=True))["count"] == 1 + + +def test_count_inspect_shape_counts_subject() -> None: + """inspect (shape=inspect) counts the resolved subject (1), not its keys.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A", "role": "SERVICE"}}) + assert render(env, count=True, shape="inspect") == "1" + assert render(Envelope(status="ok", nodes={}), count=True, shape="inspect") == "0" + + +def test_count_zero_on_empty_ok_and_on_non_ok() -> None: + assert render(Envelope(status="ok", nodes={}), count=True) == "0" + assert render(Envelope(status="not_found", message="m"), count=True) == "0" + assert json.loads(render(Envelope(status="not_found", message="m"), fmt="json", count=True)) == { + "status": "not_found", "count": 0, + } + + +def test_exists_text_true_false() -> None: + assert render(Envelope(status="ok", nodes={"a": {"fqn": "x.A"}}), exists=True) == "true" + assert render(Envelope(status="ok", nodes={}), exists=True) == "false" + + +def test_exists_json_shape() -> None: + yes = Envelope(status="ok", nodes={"a": {"fqn": "x.A"}}) + no = Envelope(status="ok", nodes={}) + assert json.loads(render(yes, fmt="json", exists=True)) == {"status": "ok", "exists": True} + assert json.loads(render(no, fmt="json", exists=True)) == {"status": "ok", "exists": False} + + +def test_exists_false_on_not_found_and_ambiguous() -> None: + """A resolve miss (not_found) and ambiguous candidates are NOT results.""" + assert render(Envelope(status="not_found", message="m"), exists=True) == "false" + amb = Envelope(status="ambiguous", candidates=[{"fqn": "x.A", "reason": "r"}]) + assert render(amb, exists=True) == "false" + + +def test_exists_takes_precedence_over_count() -> None: + """Both flags set -> the gate (--exists) wins; the count is not emitted.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A"}}) + assert render(env, exists=True, count=True) == "true" + + +def test_fields_overrides_detail_surfaces_full_tier_field() -> None: + """--fields is an allowlist that overrides --detail: ``signature`` (normally + full-tier only) appears even at brief when explicitly requested.""" + env = Envelope(status="ok", nodes={ + "a": {"id": "a", "fqn": "x.A", "role": "SERVICE", "signature": "A()"}, + }) + out = json.loads(render(env, fmt="json", detail="brief", fields="fqn,signature")) + # graph-id ``id`` stripped; only requested fields survive. + assert out["nodes"]["x.A"] == {"fqn": "x.A", "signature": "A()"} + + +def test_fields_drops_unrequested_and_absent_silently() -> None: + env = Envelope(status="ok", nodes={ + "a": {"fqn": "x.A", "role": "SERVICE", "module": "core"}, + }) + out = json.loads(render(env, fmt="json", fields="fqn")) + assert out["nodes"]["x.A"] == {"fqn": "x.A"} + # A requested field absent on the node is simply not present (no null). + out2 = json.loads(render(env, fmt="json", fields="fqn,signature")) + assert out2["nodes"]["x.A"] == {"fqn": "x.A"} + + +def test_fields_ignored_on_non_ok_renders_normally() -> None: + """--fields only reshapes status=ok; a not_found renders its body.""" + out = render(Envelope(status="not_found", message="nope"), fields="fqn", noun="x") + assert out.startswith("not found:") + + +def test_count_results_and_has_results_exported() -> None: + """The shape-aware counting helpers are the shared source of truth for + --count output and the --exists exit code (jrag._emit imports has_results).""" + from java_codebase_rag.jrag_render import count_results, has_results + + listing = Envelope(status="ok", nodes={"a": {}, "b": {}}) + assert count_results(listing, None) == 2 + assert has_results(listing, None) is True + trav = Envelope(status="ok", root="r", nodes={"r": {}}, edges=[{"other_id": "t"}]) + assert count_results(trav, None) == 1 + # non-ok never has results even when it carries candidates/nodes + assert has_results(Envelope(status="ambiguous", candidates=[{}]), None) is False + assert has_results(Envelope(status="not_found", message="m"), None) is False + + +# ----- precedence of the exists/count/fields/detail if-chain (load-bearing) ----- + + +def test_count_takes_precedence_over_fields() -> None: + """--count wins over --fields: the bare int is emitted, not projected nodes.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A", "role": "SERVICE"}}) + assert render(env, count=True, fields="fqn") == "1" + + +def test_exists_takes_precedence_over_fields() -> None: + """--exists wins over --fields: the boolean is emitted, not projected nodes.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A"}}) + assert render(env, exists=True, fields="role") == "true" + + +# ----- --fields CSV parsing + empty-allowlist fallback ----- + + +def test_field_set_parses_whitespace_duplicates_and_empties() -> None: + """_field_set trims whitespace, drops empty entries, and dedups.""" + from java_codebase_rag.jrag_render import _field_set + + assert _field_set(" fqn , role ,, fqn ,") == {"fqn", "role"} + assert _field_set(",,,") == set() + assert _field_set("fqn") == {"fqn"} + + +def test_fields_csv_whitespace_and_duplicates_resolve() -> None: + """An allowlist with spaces/dups/padding still projects the right fields.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A", "role": "SERVICE"}}) + out = json.loads(render(env, fmt="json", fields=" fqn , role ,, fqn ")) + assert out["nodes"]["x.A"] == {"fqn": "x.A", "role": "SERVICE"} + + +def test_fields_empty_allowlist_falls_back_to_normal() -> None: + """A whitespace/comma-only --fields is treated as not given (normal render), + not an empty projection that strips every node to ``{}``.""" + env = Envelope(status="ok", nodes={"a": {"fqn": "x.A", "role": "SERVICE"}}) + for raw in (" ", ",", " , "): + node = json.loads(render(env, fmt="json", fields=raw))["nodes"]["x.A"] + assert node, raw # empty-allowlist bug would yield {} + assert "fqn" in node, raw + + +# ----- --exists JSON shape on non-ok status ----- + + +def test_exists_json_shape_on_non_ok() -> None: + """--exists --format json reports the real status alongside exists:false.""" + nf = render(Envelope(status="not_found", message="m"), fmt="json", exists=True) + assert json.loads(nf) == {"status": "not_found", "exists": False} + amb = Envelope(status="ambiguous", candidates=[{"fqn": "x.A"}]) + assert json.loads(render(amb, fmt="json", exists=True)) == { + "status": "ambiguous", "exists": False, + }