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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion agents/explorer-rag-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <M>` / `jrag callees <M>` |
| "What routes does a controller expose?" | `jrag callers <controller>` (folds in its `EXPOSES` routes) |
| "Where is class X?" | `jrag inspect <X>`; fallback `Grep`/`Glob` |
| "All controllers in service S" | `jrag find --role CONTROLLER --service S` |
| "Routes/endpoints in service S" | `jrag http-routes --service S` |
Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion java_codebase_rag/install_data/agents/explorer-rag-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <M>` / `jrag callees <M>` |
| "What routes does a controller expose?" | `jrag callers <controller>` (folds in its `EXPOSES` routes) |
| "Where is class X?" | `jrag inspect <X>`; fallback `Grep`/`Glob` |
| "All controllers in service S" | `jrag find --role CONTROLLER --service S` |
| "Routes/endpoints in service S" | `jrag http-routes --service S` |
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Run `jrag --help` for the canonical list.
| Outbound async producers | `jrag producers [--topic-contains …]` | `callees <producer>` |
| Topics + consumers/producers | `jrag topics [--topic-contains …]` | — |
| Who calls / what does M call? | `jrag callers <M>` / `jrag callees <M>` | `inspect` |
| What routes does a controller expose? | `jrag callers <controller>` | `inspect` (`DECLARES.EXPOSES`) |
| Who hits this route? | `jrag callers <route>` | — |
| Implementations / subtypes of T? | `jrag implementations <T>` / `jrag subclasses <T>` | — |
| Overriding / overridden methods? | `jrag overrides <method>` (UP) / `jrag overridden-by <method>` | — |
Expand Down Expand Up @@ -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 |
Expand Down
61 changes: 52 additions & 9 deletions java_codebase_rag/jrag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
# <Controller>` 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions java_codebase_rag/jrag_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion skills/explore-codebase-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Run `jrag --help` for the canonical list.
| Outbound async producers | `jrag producers [--topic-contains …]` | `callees <producer>` |
| Topics + consumers/producers | `jrag topics [--topic-contains …]` | — |
| Who calls / what does M call? | `jrag callers <M>` / `jrag callees <M>` | `inspect` |
| What routes does a controller expose? | `jrag callers <controller>` | `inspect` (`DECLARES.EXPOSES`) |
| Who hits this route? | `jrag callers <route>` | — |
| Implementations / subtypes of T? | `jrag implementations <T>` / `jrag subclasses <T>` | — |
| Overriding / overridden methods? | `jrag overrides <method>` (UP) / `jrag overridden-by <method>` | — |
Expand Down Expand Up @@ -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 |
Expand Down
46 changes: 46 additions & 0 deletions tests/test_jrag_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions tests/test_jrag_traversal_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Controller>` 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) -----


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