java-codebase-rag 0.6.6__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.
- ast_java.py +8 -3
- build_ast_graph.py +72 -16
- graph_enrich.py +2 -1
- graph_types.py +133 -0
- java_codebase_rag/_fdlimit.py +10 -2
- java_codebase_rag/_stdio.py +32 -0
- java_codebase_rag/cli.py +135 -24
- java_codebase_rag/config.py +128 -9
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
- java_codebase_rag/installer.py +438 -103
- java_codebase_rag/jrag.py +4300 -0
- java_codebase_rag/jrag_envelope.py +1085 -0
- java_codebase_rag/jrag_hints.py +204 -0
- java_codebase_rag/jrag_render.py +688 -0
- java_codebase_rag/pipeline.py +20 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
- java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
- java_index_flow_lancedb.py +34 -19
- java_ontology.py +12 -0
- ladybug_queries.py +233 -52
- mcp_hints.py +6 -6
- mcp_v2.py +205 -617
- resolve_service.py +649 -0
- search_lancedb.py +10 -1
- server.py +20 -12
- java_codebase_rag-0.6.6.dist-info/RECORD +0 -34
- {java_codebase_rag-0.6.6.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
|