diff --git a/README.md b/README.md index 55759b4..03c00e1 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ jrag entities # JPA entities # Traversals (all resolve-first) jrag callers ChatService#assign(Request) # who calls me? +jrag callers ChatIngressController # controller: also lists its EXPOSES routes jrag callees ChatService#assign(Request) # what do I call? jrag hierarchy AbstractBase # type tree (parents + children) jrag implementations PaymentProcessor # classes implementing an interface diff --git a/agents/explorer-rag-cli.md b/agents/explorer-rag-cli.md index 8970d92..735f599 100644 --- a/agents/explorer-rag-cli.md +++ b/agents/explorer-rag-cli.md @@ -61,6 +61,7 @@ Run `jrag --help` for the canonical list. | Question type | Primary approach | | --- | --- | | "Who calls method M?" / "What does M call?" | `jrag callers ` / `jrag callees ` | +| "What routes does a controller expose?" | `jrag callers ` (folds in its `EXPOSES` routes) | | "Where is class X?" | `jrag inspect `; fallback `Grep`/`Glob` | | "All controllers in service S" | `jrag find --role CONTROLLER --service S` | | "Routes/endpoints in service S" | `jrag http-routes --service S` | @@ -101,7 +102,7 @@ Never look up a raw node ID — pass an FQN, simple name, prior `sym:`/`route:`/ | Intent (command) | Underlying edges | | --- | --- | -| `callers` / `callees` | `CALLS` in / out | +| `callers` / `callees` | `CALLS` in / out. On a controller/entry-point type, `callers` also folds in the routes its methods `EXPOSE` (the inbound callers of an entry point) | | `hierarchy` | `EXTENDS` + `IMPLEMENTS`, both directions (parents + children) | | `implementations` / `subclasses` | `IMPLEMENTS` / `EXTENDS` in | | `overrides` / `overridden-by` | `OVERRIDES` out (subtype→supertype) / in | diff --git a/java_codebase_rag/install_data/agents/explorer-rag-cli.md b/java_codebase_rag/install_data/agents/explorer-rag-cli.md index 8970d92..735f599 100644 --- a/java_codebase_rag/install_data/agents/explorer-rag-cli.md +++ b/java_codebase_rag/install_data/agents/explorer-rag-cli.md @@ -61,6 +61,7 @@ Run `jrag --help` for the canonical list. | Question type | Primary approach | | --- | --- | | "Who calls method M?" / "What does M call?" | `jrag callers ` / `jrag callees ` | +| "What routes does a controller expose?" | `jrag callers ` (folds in its `EXPOSES` routes) | | "Where is class X?" | `jrag inspect `; fallback `Grep`/`Glob` | | "All controllers in service S" | `jrag find --role CONTROLLER --service S` | | "Routes/endpoints in service S" | `jrag http-routes --service S` | @@ -101,7 +102,7 @@ Never look up a raw node ID — pass an FQN, simple name, prior `sym:`/`route:`/ | Intent (command) | Underlying edges | | --- | --- | -| `callers` / `callees` | `CALLS` in / out | +| `callers` / `callees` | `CALLS` in / out. On a controller/entry-point type, `callers` also folds in the routes its methods `EXPOSE` (the inbound callers of an entry point) | | `hierarchy` | `EXTENDS` + `IMPLEMENTS`, both directions (parents + children) | | `implementations` / `subclasses` | `IMPLEMENTS` / `EXTENDS` in | | `overrides` / `overridden-by` | `OVERRIDES` out (subtype→supertype) / in | diff --git a/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md b/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md index 1de2424..c5b4415 100644 --- a/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +++ b/java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md @@ -79,6 +79,7 @@ Run `jrag --help` for the canonical list. | Outbound async producers | `jrag producers [--topic-contains …]` | `callees ` | | Topics + consumers/producers | `jrag topics [--topic-contains …]` | — | | Who calls / what does M call? | `jrag callers ` / `jrag callees ` | `inspect` | +| What routes does a controller expose? | `jrag callers ` | `inspect` (`DECLARES.EXPOSES`) | | Who hits this route? | `jrag callers ` | — | | Implementations / subtypes of T? | `jrag implementations ` / `jrag subclasses ` | — | | Overriding / overridden methods? | `jrag overrides ` (UP) / `jrag overridden-by ` | — | @@ -134,7 +135,7 @@ Never look up a raw node ID — pass an FQN, simple name, prior `sym:`/`route:`/ | Intent (command) | Underlying edges | | --- | --- | -| `callers` / `callees` | `CALLS` in / out | +| `callers` / `callees` | `CALLS` in / out. On a controller/entry-point type, `callers` also folds in the routes its methods `EXPOSE` (they are the inbound callers) — use it to list a controller's endpoints. `decompose` defaults to `--follow-calls`; `--per-stage-limit` caps symbols per stage (not stage count) | | `hierarchy` | `EXTENDS` + `IMPLEMENTS`, both directions (parents + children) | | `implementations` / `subclasses` | `IMPLEMENTS` / `EXTENDS` in | | `overrides` / `overridden-by` | `OVERRIDES` out (subtype→supertype) / in | diff --git a/java_codebase_rag/jrag.py b/java_codebase_rag/jrag.py index ed95426..d6bd0cc 100644 --- a/java_codebase_rag/jrag.py +++ b/java_codebase_rag/jrag.py @@ -777,16 +777,21 @@ def _core_parser() -> argparse.ArgumentParser: decompose.add_argument("--depth", type=int, default=2, help="Neighbour hop count per stage (clamped 1..3, default 2).") decompose.add_argument( "--follow-calls", - action="store_true", + action=argparse.BooleanOptionalAction, + default=True, dest="follow_calls", - help="Follow DECLARES+CALLS type-to-type hops to top up each stage.", + help=( + "Top up each stage with DECLARES+CALLS type-to-type hops when the " + "structural INJECTS/EXTENDS/IMPLEMENTS pass under-fills it (default: " + "on). --no-follow-calls restricts the waterfall to structural edges." + ), ) decompose.add_argument( - "--max-stage", + "--per-stage-limit", type=int, default=20, - dest="max_stage", - help="Cap on symbols per stage (stage_limit, default 20).", + dest="per_stage_limit", + help="Cap on symbols per stage (stage_limit, default 20). Not a stage-count knob.", ) decompose.add_argument( "--min-confidence", @@ -2404,6 +2409,44 @@ def _cmd_callers(args: argparse.Namespace) -> int: edges.append( {"other_id": ce.src.id, "edge_type": "CALLS", "confidence": ce.confidence} ) + # Entry-point awareness. A controller / messaging-listener type is invoked + # via the routes its methods EXPOSE (Controller -[:DECLARES]-> method + # -[:EXPOSES]-> Route), NOT via in-repo CALLS edges — so find_callers is + # typically empty for HTTP handlers. Without this fold, `callers + # ` returns a bug-looking empty list when the controller is the + # very thing the agent is investigating. The routes ARE its inbound callers, + # so surface them as additional EXPOSES rows alongside any CALLS-in edges. + # Gated on having DECLARES.EXPOSES out-edges (covers any entry-point holder, + # not just role=CONTROLLER). Routes are additive and usually few, so they do + # not count against the CALLS --limit (cf. the callees client/producer path, + # which likewise emits its own targets without sharing the CALLS budget). + expose_rows = graph._rows( # noqa: SLF001 - one-shot aggregation, cf. _cmd_callees client path + "MATCH (t:Symbol {id: $tid})-[:DECLARES]->(m:Symbol)-[e:EXPOSES]->(r:Route) " + "RETURN r.id AS rid, r.method AS rmethod, r.path AS rpath, " + "r.path_template AS rpt, r.microservice AS rms, " + "m.fqn AS via_fqn, e.confidence AS conf", + {"tid": root_id}, + ) + for row in expose_rows: + rid = str(row.get("rid") or "") + if not rid or rid in nodes: + continue + rmethod = str(row.get("rmethod") or "") + rpath = str(row.get("rpt") or row.get("rpath") or "") + nodes[rid] = { + "id": rid, + "kind": "route", + "fqn": f"{rmethod} {rpath}".strip(), + "method": rmethod, + "path": rpath, + "microservice": str(row.get("rms") or ""), + } + edge_row: dict = {"other_id": rid, "edge_type": "EXPOSES"} + via_fqn = str(row.get("via_fqn") or "") + if via_fqn: + # Declaring method that exposes the route; rendered at --detail full. + edge_row["from_fqn"] = via_fqn + edges.append(edge_row) nodes[root_id] = root_dict return _emit_traversal( args, root_id=root_id, nodes=nodes, edges=edges, @@ -2956,8 +2999,8 @@ def _cmd_decompose(args: argparse.Namespace) -> int: stages = graph.trace_flow( seed_fqns=[seed_fqn], depth=depth, - follow_calls=getattr(args, "follow_calls", False), - stage_limit=getattr(args, "max_stage", 20), + follow_calls=getattr(args, "follow_calls", True), + stage_limit=getattr(args, "per_stage_limit", 20), min_call_confidence=getattr(args, "min_confidence", 0.0), exclude_external=not getattr(args, "include_external", False), microservice=args.service, @@ -2983,12 +3026,12 @@ def _cmd_decompose(args: argparse.Namespace) -> int: edge_row["from_fqn"] = via.from_fqn edges.append(edge_row) # --limit is inherited from common but does not cap decompose (trace_flow - # is stage-limited via --max-stage, not a total edge count). Warn when the + # is stage-limited via --per-stage-limit, not a total edge count). Warn when the # user explicitly set --limit away from the default so they get a signal # rather than a silent multi-stage dump (Fix 4). if args.limit is not None and args.limit != 20: warnings.append( - "--limit does not apply to decompose; use --max-stage to cap per-stage breadth" + "--limit does not apply to decompose; use --per-stage-limit to cap per-stage breadth" ) return _emit_traversal( args, root_id=root_id, nodes=nodes, edges=edges, diff --git a/java_codebase_rag/jrag_render.py b/java_codebase_rag/jrag_render.py index 2c98eb2..979cbd1 100644 --- a/java_codebase_rag/jrag_render.py +++ b/java_codebase_rag/jrag_render.py @@ -472,11 +472,19 @@ def _render_traversal(envelope: Envelope, *, noun: str, detail: str = "normal") by_stage[s].append(e) for s in stage_order: stage_edges = by_stage[s] - roles = {str(e.get("role") or "").upper() for e in stage_edges if e.get("role")} + # Preserve first-seen order so a mixed stage reads naturally + # (e.g. `stage 1 (service, component):`) instead of dropping the + # role label entirely — the role allow-list is the whole point of a + # role-waterfall, so hiding it on the busiest stages is a loss. + seen: list[str] = [] + for e in stage_edges: + r = str(e.get("role") or "").strip().lower() + if r and r not in seen: + seen.append(r) if s == 0: header = "stage 0 (seed):" - elif len(roles) == 1: - header = f"stage {s} ({next(iter(roles)).lower()}):" + elif seen: + header = f"stage {s} ({', '.join(seen)}):" else: header = f"stage {s}:" lines.append(header) diff --git a/skills/explore-codebase-cli/SKILL.md b/skills/explore-codebase-cli/SKILL.md index 1de2424..c5b4415 100644 --- a/skills/explore-codebase-cli/SKILL.md +++ b/skills/explore-codebase-cli/SKILL.md @@ -79,6 +79,7 @@ Run `jrag --help` for the canonical list. | Outbound async producers | `jrag producers [--topic-contains …]` | `callees ` | | Topics + consumers/producers | `jrag topics [--topic-contains …]` | — | | Who calls / what does M call? | `jrag callers ` / `jrag callees ` | `inspect` | +| What routes does a controller expose? | `jrag callers ` | `inspect` (`DECLARES.EXPOSES`) | | Who hits this route? | `jrag callers ` | — | | Implementations / subtypes of T? | `jrag implementations ` / `jrag subclasses ` | — | | Overriding / overridden methods? | `jrag overrides ` (UP) / `jrag overridden-by ` | — | @@ -134,7 +135,7 @@ Never look up a raw node ID — pass an FQN, simple name, prior `sym:`/`route:`/ | Intent (command) | Underlying edges | | --- | --- | -| `callers` / `callees` | `CALLS` in / out | +| `callers` / `callees` | `CALLS` in / out. On a controller/entry-point type, `callers` also folds in the routes its methods `EXPOSE` (they are the inbound callers) — use it to list a controller's endpoints. `decompose` defaults to `--follow-calls`; `--per-stage-limit` caps symbols per stage (not stage count) | | `hierarchy` | `EXTENDS` + `IMPLEMENTS`, both directions (parents + children) | | `implementations` / `subclasses` | `IMPLEMENTS` / `EXTENDS` in | | `overrides` / `overridden-by` | `OVERRIDES` out (subtype→supertype) / in | diff --git a/tests/test_jrag_render.py b/tests/test_jrag_render.py index e88d640..ea6aec9 100644 --- a/tests/test_jrag_render.py +++ b/tests/test_jrag_render.py @@ -806,3 +806,49 @@ def test_listing_normal_without_explain_omits_token() -> None: ) line = render(env, fmt="text", noun="symbol", detail="normal").splitlines()[0] assert "explain=" not in line, f"explain token should not render when absent: {line}" + + +# ----- decompose role-waterfall: multi-stage rendering ----- + + +def test_render_decompose_multistage_waterfall_lists_all_roles() -> None: + """A 3-stage role-waterfall renders every stage with its role allow-list. + + The bank-chat fixture tops out at 2 stages (no REPOSITORY/MAPPER symbols), + so this synthetic envelope is what actually pins the renderer's handling of + the 3rd role tier (`stage 2 (client, repository):`) that ``trace_flow`` + produces on a deeper codebase. It also covers the mixed-role case + (`stage 1 (...)` with two roles) — the gap where the renderer used to drop + the role label on its busiest stage. + """ + env = Envelope( + status="ok", + root="sym:0", + nodes={ + "sym:0": {"fqn": "com.foo.OrderController", "microservice": "orders"}, + "sym:1": {"fqn": "com.foo.OrderService", "microservice": "orders"}, + "sym:2": {"fqn": "com.foo.PriceComponent", "microservice": "orders"}, + "sym:3": {"fqn": "com.foo.OrderRepository", "microservice": "orders"}, + "sym:4": {"fqn": "com.foo.InventoryClient", "microservice": "orders"}, + }, + edges=[ + {"edge_type": "SEED", "other_id": "sym:0", "stage": 0, "role": "CONTROLLER"}, + {"edge_type": "INJECTS", "other_id": "sym:1", "stage": 1, "role": "SERVICE"}, + {"edge_type": "INJECTS", "other_id": "sym:2", "stage": 1, "role": "COMPONENT"}, + {"edge_type": "INJECTS", "other_id": "sym:3", "stage": 2, "role": "REPOSITORY"}, + {"edge_type": "INJECTS", "other_id": "sym:4", "stage": 2, "role": "CLIENT"}, + ], + ) + out = render(env, fmt="text", noun="decompose") + # All three stages render, in order. + lines = out.splitlines() + headers = [ln for ln in lines if ln.startswith("stage ")] + assert [ln.rstrip(":") for ln in headers] == [ + "stage 0 (seed)", + "stage 1 (service, component)", + "stage 2 (repository, client)", + ], f"stage headers wrong:\n{out}" + # Seed + every tier target appears under its own header. + assert "OrderController" in out + assert "OrderRepository" in out + assert "InventoryClient" in out diff --git a/tests/test_jrag_traversal_direct.py b/tests/test_jrag_traversal_direct.py index 9e1104d..45a37c3 100644 --- a/tests/test_jrag_traversal_direct.py +++ b/tests/test_jrag_traversal_direct.py @@ -107,6 +107,48 @@ def test_callers_symbol_uses_find_callers(corpus_root: Path, ladybug_db_path: Pa ) +# ----- Test 1b: callers on a controller CLASS surfaces its EXPOSES routes ----- + + +def test_callers_on_controller_class_surfaces_exposes_routes( + corpus_root: Path, ladybug_db_path: Path +) -> None: + """callers on a controller type folds in the routes its methods EXPOSE. + + A controller is an HTTP entry point: its handler methods are invoked via + EXPOSES (the framework dispatches the route), not via in-repo CALLS edges. + So `callers ` must surface those routes as EXPOSES rows rather + than returning a bug-looking empty CALLS-in list. The routes are additive + to any CALLS-in edges (the consumer contract: "not only CALLS"). + """ + env = _env_for(corpus_root, ladybug_db_path) + proc = _run_jrag(["callers", _INGRESS_CTRL, "--format", "json"], env=env) + assert proc.returncode == 0, ( + f"callers failed: rc={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}" + ) + payload = json.loads(proc.stdout) + assert payload["status"] == "ok", f"expected ok, got {payload}" + edges = payload.get("edges", []) + nodes = payload.get("nodes", {}) + expose_edges = [e for e in edges if e.get("edge_type") == "EXPOSES"] + assert expose_edges, ( + f"expected at least one EXPOSES edge for a controller root, got edge types " + f"{[e.get('edge_type') for e in edges]}" + ) + # Every EXPOSES target must be a route node carrying an HTTP method+path. + for e in expose_edges: + tgt = nodes.get(e.get("target"), {}) + assert tgt.get("kind") == "route", f"EXPOSES target is not a route: {tgt}" + assert tgt.get("method"), f"EXPOSES route missing method: {tgt}" + assert tgt.get("path"), f"EXPOSES route missing path: {tgt}" + # Text rendering: the route must appear under the root (not a bare "0 callers"). + proc_text = _run_jrag(["callers", _INGRESS_CTRL], env=env) + assert proc_text.returncode == 0, f"text callers failed: {proc_text.stderr}" + assert "/api/v1/chat/events" in proc_text.stdout, ( + f"expected the exposed route path in text output:\n{proc_text.stdout}" + ) + + # ----- Test 2: callers (Route) --service narrows resolve (no post-filter) ----- @@ -562,6 +604,17 @@ def test_decompose_renders_role_waterfall( assert "stage 1" in text, ( f"expected 'stage 1' header in text output, got:\n{text}" ) + # Stage 1 on this fixture mixes SERVICE + COMPONENT, so the renderer must + # list BOTH roles in the header (`stage 1 (component, service):`) rather + # than dropping the role label on its busiest stage. The role allow-list is + # the whole point of a role-waterfall — hiding it where it matters most is + # the gap this closes. + assert "stage 1 (" in text and "stage 1" in text, ( + f"expected a role-labelled 'stage 1 (...):' header, got:\n{text}" + ) + assert "component" in text and "service" in text, ( + f"expected both 'component' and 'service' roles in the stage-1 header:\n{text}" + ) # The seed stage must list the controller; a later stage lists engine components. seed_section = text.split("stage 1", 1)[0] assert "ChatIngressController" in seed_section, (