java-codebase-rag 0.6.7__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +135 -24
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
  13. java_codebase_rag/installer.py +438 -103
  14. java_codebase_rag/jrag.py +4300 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +688 -0
  18. java_codebase_rag/pipeline.py +20 -0
  19. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
  20. java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
  21. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
  22. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
  23. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
  24. java_index_flow_lancedb.py +34 -19
  25. java_ontology.py +12 -0
  26. ladybug_queries.py +233 -52
  27. mcp_hints.py +6 -6
  28. mcp_v2.py +205 -617
  29. resolve_service.py +649 -0
  30. search_lancedb.py +10 -1
  31. server.py +20 -12
  32. java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
  33. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,688 @@
1
+ """JRAG text rendering (PR-JRAG-1a).
2
+
3
+ Fresh-built renderer (``cli_format.py`` is styling-primitives only — glyphs and
4
+ ANSI — it ships no renderers). The default output is compact text; ``--format
5
+ json`` emits the envelope verbatim via :meth:`Envelope.to_json`.
6
+
7
+ This module imports only the envelope module (which itself imports no heavy
8
+ backend modules), so it stays import-safe under the ``build_parser`` lazy
9
+ invariant.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from java_codebase_rag.jrag_envelope import Envelope, project_envelope, simple_name
16
+
17
+ __all__ = ["render", "tiered_name", "display_name"]
18
+
19
+
20
+ # Edge labels that carry a ``confidence`` column (CALLS-family). ``conf:`` is
21
+ # rendered only for these (PR-JRAG-1a renderer spec). Confirmed against
22
+ # java_ontology.EDGE_SCHEMA: CALLS / HTTP_CALLS / ASYNC_CALLS each carry an
23
+ # ``EdgeAttr("confidence", "DOUBLE", ...)``; the structural edges
24
+ # (EXTENDS/IMPLEMENTS/INJECTS/DECLARES/OVERRIDES/EXPOSES/DECLARES_CLIENT/
25
+ # DECLARES_PRODUCER) do not all carry confidence, and even where they do, the
26
+ # CALLS-family is what the agent-facing ``conf:`` road-sign is reserved for.
27
+ _CALLS_FAMILY_EDGES = frozenset({"CALLS", "HTTP_CALLS", "ASYNC_CALLS"})
28
+
29
+ # Route node kinds → short text tag so the routes listing distinguishes HTTP
30
+ # endpoints from Kafka topics (otherwise they mash together with no indicator).
31
+ # Only route kinds are tagged; symbol/client/producer rows carry other kinds (or
32
+ # none) and are left untagged.
33
+ _ROUTE_KIND_TAGS: dict[str, str] = {"kafka_topic": "kafka", "http_endpoint": "http"}
34
+
35
+ # Identity keys already represented in a listing line (display_name + @service +
36
+ # kind tag). At ``--detail full`` the per-row kv-block skips these (they are in
37
+ # the header line) and renders every OTHER key, so full listing == per-row
38
+ # inspect block. ``id`` is absent here AND stripped by the envelope projector's
39
+ # graph-id-field rule (see jrag_envelope._strip_graph_id_fields) — listed for
40
+ # documentation of the identity set, but the projector is the authoritative
41
+ # strip seam. Must agree with the identity half of the envelope projector's
42
+ # ``_BRIEF_NODE_KEYS`` (see jrag_envelope.py).
43
+ _LISTING_LINE_KEYS: frozenset[str] = frozenset(
44
+ {
45
+ "kind",
46
+ "fqn",
47
+ "name",
48
+ "microservice",
49
+ "path",
50
+ "method",
51
+ "topic",
52
+ "member_fqn",
53
+ "target_service",
54
+ "broker",
55
+ "client_kind",
56
+ "producer_kind",
57
+ "import_simple",
58
+ "import_fqn",
59
+ "import_kind",
60
+ }
61
+ )
62
+
63
+ # Fixed left-to-right order for the inline extras appended at ``--detail normal``
64
+ # (only the non-empty ones are rendered). Equals the envelope projector's
65
+ # ``_NORMAL_NODE_KEYS - _BRIEF_NODE_KEYS``.
66
+ _NORMAL_INLINE_EXTRAS: tuple[str, ...] = (
67
+ "module",
68
+ "role",
69
+ "symbol_kind",
70
+ "framework",
71
+ "file",
72
+ "score",
73
+ )
74
+
75
+ # Identity-adjacent extras shown inline at ``--detail brief``. ``score`` is the
76
+ # ONLY brief-tier extra because for ranked result sets (``search``) the score
77
+ # IS the point — hiding it at brief made ``jrag search --detail brief`` show an
78
+ # unranked-looking list. Listing/traversal rows built from NodeRef carry no
79
+ # ``score`` field (only SearchHit does), so this is a no-op for non-search
80
+ # listings (``find``, routes/clients/producers, traversal target rows).
81
+ _BRIEF_INLINE_EXTRAS: tuple[str, ...] = ("score",)
82
+
83
+ # Edge attrs the edge line already renders (label/confidence); at ``--detail
84
+ # full`` these are skipped when appending the remaining attrs inline.
85
+ _EDGE_LINE_KEYS: frozenset[str] = frozenset(
86
+ {"other_id", "dst_id", "target_id", "term_id", "edge_type", "stored_edge_type", "label", "type", "confidence"}
87
+ )
88
+
89
+
90
+ def _next_action_lines(envelope: Envelope) -> list[str]:
91
+ """Build up to 2 ``next: <hint>`` lines from ``agent_next_actions``.
92
+
93
+ Cap at 2 to keep text-mode output token-lean (consistent with the ambiguous
94
+ renderer at :func:`_render_ambiguous`); JSON carries all ≤5. Returns an empty
95
+ list when ``agent_next_actions`` is empty (commands with no root produce no
96
+ hints → nothing appended).
97
+ """
98
+ return [f"next: {hint}" for hint in envelope.agent_next_actions[:2]]
99
+
100
+
101
+ def display_name(node: dict[str, Any]) -> str:
102
+ """Best short label for a node across all kinds (symbol + route/client/producer).
103
+
104
+ Listing rows and traversal targets carry different identifying fields per
105
+ kind; this picks the most informative one rather than assuming every node
106
+ has an FQN (routes have ``path``/``method``; clients/producers have
107
+ ``member_fqn`` + ``topic``/``target_service``). Precedence:
108
+
109
+ * explicit ``name`` -> symbols (SymbolHit carries one)
110
+ * ``member_fqn`` -> the member making the call/emit, with
111
+ ``→ topic`` / ``→ target_service`` when present
112
+ * ``path`` -> ``METHOD path`` (route) or ``path`` (client)
113
+ * ``topic`` -> bare topic (producer without a member)
114
+ * ``fqn`` -> fqn-derived simple name (classes/methods)
115
+
116
+ Returns ``""`` only when nothing identifiable is present.
117
+
118
+ For a method symbol (``pkg.Class#method(args)``) the label is
119
+ ``Class#method``, NOT the bare ``name``: ``getId`` / ``process`` / ``create``
120
+ collide across classes, so a traversal/listing row reduced to the bare
121
+ method name is ambiguous (the SlaService callees example — four ``getId``,
122
+ five ``process``). The declaring class is identity-level disambiguation, so
123
+ it folds into the label at every detail tier (brief included). ``name`` (the
124
+ clean method name, no args) is preferred when present; the FQN-derived method
125
+ name is the fallback when ``name`` is absent.
126
+ """
127
+ fqn = str(node.get("fqn") or "").strip()
128
+ if "#" in fqn:
129
+ head, _, tail = fqn.partition("#")
130
+ cls = head.rsplit(".", 1)[-1]
131
+ raw_name = str(node.get("name") or "").strip()
132
+ # Some backends populate `name` with the full ``Class#method(args)``
133
+ # form already (ambiguous-resolve candidates do this). Prepending
134
+ # ``cls`` would double it (``Class#Class#method``); use it verbatim —
135
+ # it already carries the declaring class plus args, so it stays
136
+ # identity-unique. Traversal method nodes carry the bare clean name
137
+ # (no ``#``), so they still take the ``Class#method`` path below.
138
+ if raw_name and "#" in raw_name:
139
+ return raw_name
140
+ method = raw_name or tail.split("(", 1)[0]
141
+ if cls and method:
142
+ return f"{cls}#{method}"
143
+ name = str(node.get("name") or "").strip()
144
+ if name:
145
+ return name
146
+ member_fqn = str(node.get("member_fqn") or "").strip()
147
+ if member_fqn:
148
+ base = member_fqn.rsplit(".", 1)[-1]
149
+ topic = str(node.get("topic") or "").strip()
150
+ if topic:
151
+ return f"{base} → {topic}"
152
+ target = str(node.get("target_service") or "").strip()
153
+ if target:
154
+ return f"{base} → {target}"
155
+ return base
156
+ path = str(node.get("path") or "").strip()
157
+ if path:
158
+ method = str(node.get("method") or "").strip()
159
+ return f"{method} {path}" if method else path
160
+ topic = str(node.get("topic") or "").strip()
161
+ if topic:
162
+ return topic
163
+ # Symbol / fallback: fqn-derived simple name.
164
+ return simple_name(node)
165
+
166
+
167
+ def tiered_name(node_id: str, nodes: dict[str, dict]) -> str:
168
+ """Tiered label: ``display_name @service`` -> display_name -> FQN -> id.
169
+
170
+ ``display_name`` covers symbols (fqn) AND route/client/producer nodes
171
+ (path/member_fqn/topic). ``@service`` is appended when ``microservice`` is
172
+ present; if the node still yields no label, the raw FQN (then the id) is
173
+ returned so a traversal target is never rendered empty.
174
+ """
175
+ node = nodes.get(node_id) or {}
176
+ name = display_name(node)
177
+ service = str(node.get("microservice") or "").strip()
178
+ if name and service:
179
+ return f"{name} @{service}"
180
+ if name:
181
+ return name
182
+ fqn = str(node.get("fqn") or "").strip()
183
+ return fqn or node_id
184
+
185
+
186
+ def _node_id(edge: dict) -> str:
187
+ """Pull the *other-end* node id out of an edge row across backend variants.
188
+
189
+ ``neighbors_v2`` returns ``other_id``; traversal LadybugGraph methods return
190
+ one of ``dst_id`` / ``target_id`` / ``term_id``. We try them in order.
191
+ """
192
+ for key in ("other_id", "dst_id", "target_id", "term_id"):
193
+ val = edge.get(key)
194
+ if isinstance(val, str) and val:
195
+ return val
196
+ return ""
197
+
198
+
199
+ def _edge_label(edge: dict) -> str:
200
+ for key in ("edge_type", "stored_edge_type", "label", "type"):
201
+ val = edge.get(key)
202
+ if isinstance(val, str) and val:
203
+ return val
204
+ return ""
205
+
206
+
207
+ def _truncated_hint(*, next_offset: int | None) -> str:
208
+ if next_offset is not None:
209
+ return f"truncated: more results — use --offset {next_offset}"
210
+ return "truncated: more results — narrow your query"
211
+
212
+
213
+ def _render_error(envelope: Envelope) -> str:
214
+ msg = envelope.message or (envelope.warnings[0] if envelope.warnings else "error")
215
+ return f"error: {msg}"
216
+
217
+
218
+ def _render_not_found(envelope: Envelope) -> str:
219
+ msg = envelope.message or "not found"
220
+ return f"not found: {msg}"
221
+
222
+
223
+ def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") -> str:
224
+ lines: list[str] = []
225
+ for _node_id, node in envelope.nodes.items():
226
+ # Listing omits FQN (PR-JRAG-1a test 11): display_name + @service only.
227
+ # display_name handles routes (METHOD path) / clients / producers, which
228
+ # carry no FQN — simple_name would render them blank.
229
+ name = display_name(node)
230
+ if not name:
231
+ # Unresolved brownfield routes can carry empty path+topic+member;
232
+ # fall back to the file basename (then a placeholder) so the row
233
+ # never renders as a blank line or a bare ``@service``. The
234
+ # projector composes raw filename+start_line into ``file``, so check
235
+ # both ``file`` and the raw ``filename`` (present pre-projection /
236
+ # when no start_line was carried).
237
+ label = ""
238
+ for key in ("file", "filename"):
239
+ raw = str(node.get(key) or "").strip()
240
+ if raw:
241
+ base = raw.rsplit(":", 1)[0] if raw.rsplit(":", 1)[-1].isdigit() else raw
242
+ label = base.rsplit("/", 1)[-1]
243
+ break
244
+ name = label or "(no identifier)"
245
+ service = str(node.get("microservice") or "").strip()
246
+ tag = _ROUTE_KIND_TAGS.get(str(node.get("kind") or ""))
247
+ parts: list[str] = [f"[{tag}]", name] if tag else [name]
248
+ line = " ".join(parts)
249
+ if service:
250
+ line += f" @{service}"
251
+ # PR-JRAG-3b: distinguish unresolved imports from resolved graph nodes
252
+ # in TEXT mode. Without this marker, `imports <file>` renders resolved
253
+ # Symbols and unresolved placeholders identically (only JSON carries
254
+ # the resolved flag), leaving a text-mode agent unable to tell which
255
+ # imports resolved. The marker is gated on the synthetic
256
+ # `kind="unresolved_import"` set by _cmd_imports.
257
+ if node.get("kind") == "unresolved_import":
258
+ line += " (unresolved)"
259
+ # detail > brief: surface the fields the terse line drops. The projector
260
+ # has already trimmed the node to the requested field set, so we only
261
+ # decide PRESENTATION. brief = append identity-adjacent extras that
262
+ # matter even at the terse tier (score on search hits — without it the
263
+ # ranking is invisible). normal = append inline location/classification/
264
+ # ranking extras to the SAME line (one line per row — the fix for "text
265
+ # too terse": adds module/role/file/score). full = per-row inspect block
266
+ # of every non-identity key (signature/annotations/snippet/...).
267
+ if detail == "brief":
268
+ extras = [
269
+ f"{key}={node[key]}"
270
+ for key in _BRIEF_INLINE_EXTRAS
271
+ if key in node and node[key] not in ("", None)
272
+ ]
273
+ if extras:
274
+ line += " " + " ".join(extras)
275
+ elif detail == "normal":
276
+ extras = [
277
+ f"{key}={node[key]}"
278
+ for key in _NORMAL_INLINE_EXTRAS
279
+ if key in node and node[key] not in ("", None)
280
+ ]
281
+ if extras:
282
+ line += " " + " ".join(extras)
283
+ lines.append(line)
284
+ if detail == "full":
285
+ rest = {k: v for k, v in node.items() if k not in _LISTING_LINE_KEYS}
286
+ if rest:
287
+ lines.extend(_render_inspect_block(rest, 1))
288
+ if not lines:
289
+ lines.append(f"0 {noun}".rstrip())
290
+ # Listing breadcrumbs (Phase 2): <=2 `next:` hint lines when the listing
291
+ # command emitted agent_next_actions (routes/clients/producers/topics).
292
+ lines.extend(_next_action_lines(envelope))
293
+ return "\n".join(lines)
294
+
295
+
296
+ def _node_normal_extras(node: dict[str, Any]) -> str:
297
+ """Inline ``key=value`` extras for a node at ``normal`` detail.
298
+
299
+ Mirrors :func:`_render_listing`'s normal-tier inline append exactly (same
300
+ ``_NORMAL_INLINE_EXTRAS`` key list / format) so a traversal row and a listing
301
+ row show the SAME fields at the same level, and so text matches the field
302
+ set JSON carries at ``normal``. Returns ``""`` when none of the extras are
303
+ present (the line is left unchanged).
304
+ """
305
+ extras = [
306
+ f"{key}={node[key]}"
307
+ for key in _NORMAL_INLINE_EXTRAS
308
+ if key in node and node[key] not in ("", None)
309
+ ]
310
+ return (" " + " ".join(extras)) if extras else ""
311
+
312
+
313
+ def _node_full_rows(node: dict[str, Any], indent: int) -> list[str]:
314
+ """Indented kv-block of a node's non-identity fields at ``full`` detail.
315
+
316
+ Mirrors :func:`_render_listing`'s full-tier block: identity keys
317
+ (``_LISTING_LINE_KEYS`` — already represented in the label/@service line) are
318
+ skipped, the rest recurse via :func:`_render_inspect_block` so
319
+ signature / annotations / modifiers / package render as readable nested kv
320
+ lines. Returns ``[]`` when the node has no content fields.
321
+ """
322
+ rest = {k: v for k, v in node.items() if k not in _LISTING_LINE_KEYS}
323
+ return _render_inspect_block(rest, indent) if rest else []
324
+
325
+
326
+ def _format_edge_rows(edge: dict, nodes: dict[str, dict], *, detail: str = "normal") -> list[str]:
327
+ """Format an edge as one header line plus (at ``full``) a per-edge block.
328
+
329
+ Shared across all render modes (flat + grouped). The header is
330
+ `` <tiered name>`` plus ``conf=N.NN`` for CALLS-family edges. The caller is
331
+ responsible for any grouping header above these rows.
332
+
333
+ NODE-level detail is honored symmetrically with :func:`_render_listing`
334
+ (PR-JRAG-6 fixed listings but not traversals; this closes that gap so
335
+ ``jrag callees`` text carries the same fields as its JSON, and ``--detail
336
+ full`` is no longer a no-op):
337
+
338
+ * ``brief`` -> header only (label + conf). Identity only; the label already
339
+ carries the declaring class for methods via :func:`display_name`.
340
+ * ``normal`` -> header + edge ``mechanism`` + the target node's
341
+ ``_NORMAL_INLINE_EXTRAS`` (module/role/symbol_kind/framework/file/score)
342
+ inline — the same inline append listings use.
343
+ * ``full`` -> header + every remaining EDGE attr inline (annotation /
344
+ field_or_param / from_fqn / …) + a per-edge indented block of the target
345
+ node's content fields (signature/annotations/modifiers/...).
346
+ """
347
+ target_id = _node_id(edge)
348
+ target = nodes.get(target_id) or {}
349
+ label = tiered_name(target_id, nodes) if target_id else "(missing)"
350
+ line = f" {label}"
351
+ edge_type = _edge_label(edge)
352
+ # conf: only on CALLS-family edges (PR-JRAG-1a test 12).
353
+ if edge_type in _CALLS_FAMILY_EDGES:
354
+ conf = edge.get("confidence")
355
+ if conf is not None:
356
+ try:
357
+ line += f" conf={float(conf):.2f}"
358
+ except (TypeError, ValueError):
359
+ pass
360
+ if detail == "normal":
361
+ mech = edge.get("mechanism")
362
+ if mech not in ("", None):
363
+ line += f" mechanism={mech}"
364
+ line += _node_normal_extras(target)
365
+ return [line]
366
+ if detail == "full":
367
+ for key in edge:
368
+ if key in _EDGE_LINE_KEYS:
369
+ continue
370
+ val = edge.get(key)
371
+ if val in ("", None):
372
+ continue
373
+ line += f" {key}={val}"
374
+ rows = [line]
375
+ rows.extend(_node_full_rows(target, 2))
376
+ return rows
377
+ return [line]
378
+
379
+
380
+ def _render_traversal(envelope: Envelope, *, noun: str, detail: str = "normal") -> str:
381
+ lines: list[str] = []
382
+ root_id = envelope.root or ""
383
+ if root_id:
384
+ # root: tiered name (Class / Class#method + @service). At normal the
385
+ # root node's module/role/file/score append inline; at full a kv-block
386
+ # renders under it — the SAME detail contract as an edge-target row, so
387
+ # the resolved-subject line carries the same fields JSON shows (parity
388
+ # with the listing/edge detail work; pre-fix the root was always bare).
389
+ root_node = envelope.nodes.get(root_id, {})
390
+ root_label = tiered_name(root_id, envelope.nodes)
391
+ if detail == "normal":
392
+ lines.append(f"root: {root_label}{_node_normal_extras(root_node)}")
393
+ elif detail == "full":
394
+ lines.append(f"root: {root_label}")
395
+ lines.extend(_node_full_rows(root_node, 1))
396
+ else:
397
+ lines.append(f"root: {root_label}")
398
+ if not envelope.edges:
399
+ # Zero-results line for a traversal: "0 <noun> <fqn> @<service>".
400
+ # The fqn + service come from the root node (the resolved subject). When
401
+ # the producer flagged the root as a server-exposed entrypoint with no
402
+ # in-repo callers, lead with that honest note instead of the bare,
403
+ # bug-looking "0 <noun>" — the empty result is correct here.
404
+ root_node = envelope.nodes.get(root_id, {})
405
+ root_fqn = str(root_node.get("fqn") or "").strip()
406
+ root_svc = str(root_node.get("microservice") or "").strip()
407
+ if envelope.is_external_entrypoint:
408
+ parts = ["external entrypoint — no in-repo callers"]
409
+ else:
410
+ parts = [f"0 {noun}".rstrip()]
411
+ if root_fqn:
412
+ parts.append(root_fqn)
413
+ if root_svc:
414
+ parts.append(f"@{root_svc}")
415
+ lines.append(" ".join(parts))
416
+ lines.extend(_next_action_lines(envelope))
417
+ return "\n".join(lines)
418
+
419
+ # Grouped rendering fires ONLY when the producer attached the grouping
420
+ # key (hierarchy sets `direction`; decompose sets `stage`; connection sets
421
+ # `section`). Other traversals (callers/callees/dependents/...) leave all
422
+ # three unset and fall through to the flat list below — current behavior
423
+ # unchanged (Fix 1).
424
+ has_stages = any(e.get("stage") is not None for e in envelope.edges)
425
+ has_direction = any(e.get("direction") for e in envelope.edges)
426
+ has_section = any(e.get("section") for e in envelope.edges)
427
+
428
+ if has_section:
429
+ # connection: group under inbound:/outbound: headers. Edges carry a
430
+ # `section` key set to "inbound" or "outbound" by _cmd_connection.
431
+ # Unknown section values are rendered under their literal name so the
432
+ # agent sees the data even if a future caller adds a new section.
433
+ in_sec = [e for e in envelope.edges if e.get("section") == "inbound"]
434
+ out_sec = [e for e in envelope.edges if e.get("section") == "outbound"]
435
+ other = [e for e in envelope.edges if e.get("section") not in ("inbound", "outbound")]
436
+ if in_sec:
437
+ lines.append("inbound:")
438
+ for e in in_sec:
439
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
440
+ if out_sec:
441
+ lines.append("outbound:")
442
+ for e in out_sec:
443
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
444
+ for e in other:
445
+ section = str(e.get("section") or "")
446
+ if section:
447
+ lines.append(f"{section}:")
448
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
449
+ lines.extend(_next_action_lines(envelope))
450
+ return "\n".join(lines)
451
+
452
+ if has_stages:
453
+ # decompose role-waterfall: group edges under `stage N` headers.
454
+ # The role on each edge (carried from StageSymbol) labels the stage
455
+ # when homogeneous; otherwise we just number it.
456
+ stage_order: list[int] = []
457
+ by_stage: dict[int, list[dict]] = {}
458
+ for e in envelope.edges:
459
+ s = int(e.get("stage") or 0)
460
+ if s not in by_stage:
461
+ by_stage[s] = []
462
+ stage_order.append(s)
463
+ by_stage[s].append(e)
464
+ for s in stage_order:
465
+ stage_edges = by_stage[s]
466
+ roles = {str(e.get("role") or "").upper() for e in stage_edges if e.get("role")}
467
+ if s == 0:
468
+ header = "stage 0 (seed):"
469
+ elif len(roles) == 1:
470
+ header = f"stage {s} ({next(iter(roles)).lower()}):"
471
+ else:
472
+ header = f"stage {s}:"
473
+ lines.append(header)
474
+ for e in stage_edges:
475
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
476
+ lines.extend(_next_action_lines(envelope))
477
+ return "\n".join(lines)
478
+
479
+ if has_direction:
480
+ # hierarchy tree: group under ↑ supertypes / ↓ subtypes headers.
481
+ up = [e for e in envelope.edges if e.get("direction") == "up"]
482
+ dn = [e for e in envelope.edges if e.get("direction") == "down"]
483
+ if up:
484
+ lines.append("↑ supertypes:")
485
+ for e in up:
486
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
487
+ if dn:
488
+ lines.append("↓ subtypes:")
489
+ for e in dn:
490
+ lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
491
+ lines.extend(_next_action_lines(envelope))
492
+ return "\n".join(lines)
493
+
494
+ # Flat: callers / callees / implementations / subclasses / overrides /
495
+ # overridden-by / dependents / impact / flow (current behavior).
496
+ for edge in envelope.edges:
497
+ lines.extend(_format_edge_rows(edge, envelope.nodes, detail=detail))
498
+ lines.extend(_next_action_lines(envelope))
499
+ return "\n".join(lines)
500
+
501
+
502
+ def _inspect_inline(val: Any) -> str:
503
+ """One-line rendering for a leaf value or a collapsed list/dict item.
504
+
505
+ Scalars render as themselves; a list of scalars joins with ``, ``; a dict
506
+ collapses to ``k: v, k: v`` (used for list-of-dict sample items, which are
507
+ short). Empty list/dict render as ``[]`` / ``{}``.
508
+ """
509
+ if isinstance(val, list):
510
+ return ", ".join(_inspect_inline(x) for x in val) if val else "[]"
511
+ if isinstance(val, dict):
512
+ return ", ".join(f"{k}: {_inspect_inline(v)}" for k, v in val.items()) if val else "{}"
513
+ if isinstance(val, str):
514
+ return val
515
+ return str(val)
516
+
517
+
518
+ def _is_dict_list(v: Any) -> bool:
519
+ """True for a non-empty list whose every item is a dict (rendered as blocks)."""
520
+ return isinstance(v, list) and bool(v) and all(isinstance(x, dict) for x in v)
521
+
522
+
523
+ def _render_inspect_block(node: dict[str, Any], indent: int) -> list[str]:
524
+ """Recursively render a dict's keys as indented kv lines.
525
+
526
+ dict -> header + recurse (so ``counts: {svc: {kind: n}}`` nests fully);
527
+ non-empty list-of-dicts -> header + one ``- <inline item>`` line per entry
528
+ (sample lists like ``client_sample``/``route_sample``); other lists and
529
+ scalars -> inline. Replaces the old single-level renderer that printed
530
+ nested dicts and list-of-dicts as Python ``repr()``.
531
+ """
532
+ pad = " " * indent
533
+ out: list[str] = []
534
+ for key in sorted(node.keys(), key=str):
535
+ val = node[key]
536
+ if isinstance(val, dict) and val:
537
+ out.append(f"{pad}{key}:")
538
+ out.extend(_render_inspect_block(val, indent + 1))
539
+ elif _is_dict_list(val):
540
+ out.append(f"{pad}{key}:")
541
+ for item in val:
542
+ out.append(f"{pad} - {_inspect_inline(item)}")
543
+ else:
544
+ out.append(f"{pad}{key}: {_inspect_inline(val)}")
545
+ return out
546
+
547
+
548
+ def _render_inspect(envelope: Envelope) -> str:
549
+ """kv-block renderer for nodes carrying one or more nested dict sections.
550
+
551
+ Generic: ANY dict-typed value on a node renders as a header line plus
552
+ indented sorted sub-keys, recursing fully. This is the dispatch signal for
553
+ the inspect shape (PR-JRAG-1a status uses it for ``counts`` / ``edges``;
554
+ PR-JRAG-3 ``inspect`` uses it for ``edge_summary`` and other rollups). The
555
+ ``edge_summary`` key is NOT special here - it is reserved for real edge
556
+ data in PR-JRAG-3 and is one of many possible section sources.
557
+ """
558
+ lines: list[str] = []
559
+ for _node_id, node in envelope.nodes.items():
560
+ # ALL dict keys alphabetical (PR-JRAG-1a test 13); nested dicts and
561
+ # list-of-dicts recurse via _render_inspect_block instead of repr().
562
+ lines.extend(_render_inspect_block(node, 0))
563
+ lines.extend(_next_action_lines(envelope))
564
+ return "\n".join(lines)
565
+
566
+
567
+ def _render_ambiguous(envelope: Envelope, *, noun: str) -> str:
568
+ count = len(envelope.candidates)
569
+ header = f"{count} ambiguous matches for {noun!r}" if noun else f"{count} ambiguous matches"
570
+ lines = [header, "Narrow with --kind --java-kind --role --fqn-contains:"]
571
+ for cand in envelope.candidates:
572
+ # Ambiguous candidates carry reason; NO file / score (PR-JRAG-1a test 14).
573
+ # display_name only — graph id is NOT a fallback (the envelope projector
574
+ # strips id/parent_id at every detail level; an unidentified candidate
575
+ # renders with "(no identifier)" rather than leaking a raw SHA).
576
+ name = display_name(cand) or "(no identifier)"
577
+ service = str(cand.get("microservice") or "").strip()
578
+ reason = str(cand.get("reason") or "").strip()
579
+ line = f" {name}"
580
+ if service:
581
+ line += f" @{service}"
582
+ if reason:
583
+ line += f" ({reason})"
584
+ lines.append(line)
585
+ # <=2 next: hints; no auto-pick (PR-JRAG-1a renderer spec).
586
+ for hint in envelope.agent_next_actions[:2]:
587
+ lines.append(f"next: {hint}")
588
+ return "\n".join(lines)
589
+
590
+
591
+ def _render_scalar(envelope: Envelope) -> str:
592
+ if envelope.message is not None:
593
+ return envelope.message
594
+ if envelope.warnings:
595
+ return "\n".join(envelope.warnings)
596
+ return envelope.status
597
+
598
+
599
+ def _render_text_shape(envelope: Envelope, *, noun: str, shape: str | None, detail: str = "normal") -> str:
600
+ if envelope.status == "error":
601
+ return _render_error(envelope)
602
+ if envelope.status == "not_found":
603
+ return _render_not_found(envelope)
604
+ if envelope.status == "ambiguous":
605
+ return _render_ambiguous(envelope, noun=noun)
606
+ # status == "ok": dispatch on EXPLICIT shape hint first, then envelope
607
+ # structure. The shape hint is the only path to ``_render_inspect`` -
608
+ # listing nodes typically carry dict-valued fields after ``.model_dump()``
609
+ # (Symbol nodes have ``source_range`` / ``annotations`` / ``capabilities``
610
+ # / ``metadata`` etc.), so inferring inspect from "any node has a dict
611
+ # value" would silently mis-render listings as inspect (FQN alphabetical).
612
+ # Inspect is declared by the caller, never guessed from node contents.
613
+ #
614
+ # Traversal shape: a root subject is set (the resolved node the edges are
615
+ # relative to). This is true even when the traversal produced zero edges
616
+ # — the zero-edges traversal line is "0 <noun> <fqn> @<service>", NOT
617
+ # the scalar fallback.
618
+ #
619
+ # Precedence: explicit ``shape="inspect"`` wins over ``root``/listing
620
+ # by intent (callers declare what they want); then ``root`` wins over
621
+ # listing (a root signals "edges are the story").
622
+ #
623
+ # detail: the envelope passed in is ALREADY projected (see :func:`render`),
624
+ # so each renderer sees only the keys for its detail level. ``detail`` is
625
+ # threaded in only to choose PRESENTATION (inline vs block / which edge
626
+ # attrs to print) — the field-set decision was made once, up front, by
627
+ # :func:`project_envelope`. ``_render_inspect`` needs no ``detail`` kwarg:
628
+ # it renders whatever keys survived projection (few at brief, all at full).
629
+ if shape == "inspect":
630
+ return _render_inspect(envelope)
631
+ if envelope.root is not None:
632
+ return _render_traversal(envelope, noun=noun, detail=detail)
633
+ # Listing shape: zero or more node rows. Empty listing renders "0 <noun>".
634
+ if envelope.nodes or noun:
635
+ return _render_listing(envelope, noun=noun, detail=detail)
636
+ return _render_scalar(envelope)
637
+
638
+
639
+ def render(
640
+ envelope: Envelope,
641
+ *,
642
+ fmt: str = "text",
643
+ detail: str = "normal",
644
+ noun: str = "",
645
+ next_offset: int | None = None,
646
+ shape: str | None = None,
647
+ ) -> str:
648
+ """Dispatch on ``fmt`` (text default; json emits the projected envelope).
649
+
650
+ ``detail`` (``brief`` / ``normal`` / ``full``, default ``normal``) is
651
+ ORTHOGONAL to ``fmt``: the envelope is projected to the requested field set
652
+ ONCE via :func:`project_envelope`, then BOTH the JSON path (``to_json``)
653
+ and the text renderers consume the projected result. So ``--format json
654
+ --detail brief`` and ``--format text --detail brief`` go through the same
655
+ field set. ``brief`` reproduces today's terse text; ``normal`` adds
656
+ ``module``/``role``/``symbol_kind``/``framework``/``file``/``score`` (the
657
+ fix for "text too terse"); ``full`` keeps everything (incl. ``snippet`` /
658
+ ``signature`` / ``annotations``) and drops empty fields at all levels.
659
+
660
+ ``noun`` is the human-readable noun for the result kind (e.g. ``"callers"``,
661
+ ``"matches"``); used in zero-results and ambiguous headers. ``next_offset``
662
+ selects the truncated hint: ``None`` -> ``narrow your query`` (no offset
663
+ support on this command); a number -> ``use --offset <N>`` (find/search).
664
+
665
+ ``shape`` is the EXPLICIT render-shape hint. The only accepted value today
666
+ is ``"inspect"`` (kv-block + indented alphabetical sections); callers that
667
+ need it declare it (PR-JRAG-1a ``status``, future PR-JRAG-1b/3 ``inspect``).
668
+ ``None`` falls back to structural inference: ``root`` -> traversal,
669
+ ``nodes``/``noun`` -> listing, else scalar. Listing nodes frequently carry
670
+ dict-valued fields after ``.model_dump()``, so inspect is NEVER inferred
671
+ from node contents - only an explicit ``shape="inspect"`` routes there.
672
+ """
673
+ projected = project_envelope(envelope, detail)
674
+ if fmt == "json":
675
+ return projected.to_json()
676
+ body = _render_text_shape(projected, noun=noun, shape=shape, detail=detail)
677
+ if projected.truncated:
678
+ hint = _truncated_hint(next_offset=next_offset)
679
+ body = f"{body}\n{hint}" if body else hint
680
+ # Warnings are rendered in text mode (one ``warning:`` line each) so an
681
+ # agent running without ``--format json`` still sees inapplicable-flag /
682
+ # post-filter notices. Without this the warnings[] field was JSON-only and
683
+ # the "inapplicable flags never silently ignored" spec was effectively
684
+ # unenforced for text consumers.
685
+ if projected.warnings:
686
+ warning_lines = "\n".join(f"warning: {w}" for w in projected.warnings)
687
+ body = f"{body}\n{warning_lines}" if body else warning_lines
688
+ return body