java-codebase-rag 0.12.0__py3-none-any.whl → 0.12.2__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 (77) hide show
  1. java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
  2. java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
  3. {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
  4. java_codebase_rag/_deprecation.py +0 -103
  5. java_codebase_rag/_fdlimit.py +0 -56
  6. java_codebase_rag/_stdio.py +0 -32
  7. java_codebase_rag/_version.py +0 -35
  8. java_codebase_rag/absence/__init__.py +0 -0
  9. java_codebase_rag/absence/absence_diagnosis.py +0 -700
  10. java_codebase_rag/absence/absence_types.py +0 -124
  11. java_codebase_rag/absence/absence_vocab.py +0 -460
  12. java_codebase_rag/analysis/__init__.py +0 -0
  13. java_codebase_rag/analysis/pr_analysis.py +0 -563
  14. java_codebase_rag/analysis/resolve_service.py +0 -740
  15. java_codebase_rag/ast/__init__.py +0 -0
  16. java_codebase_rag/ast/ast_java.py +0 -2847
  17. java_codebase_rag/ast/ast_kotlin.py +0 -1794
  18. java_codebase_rag/ast/brownfield_events.py +0 -58
  19. java_codebase_rag/ast/chunk_heuristics.py +0 -83
  20. java_codebase_rag/ast/language.py +0 -117
  21. java_codebase_rag/cli.py +0 -1215
  22. java_codebase_rag/cli_dispatch.py +0 -251
  23. java_codebase_rag/cli_format.py +0 -85
  24. java_codebase_rag/cli_progress.py +0 -94
  25. java_codebase_rag/config.py +0 -833
  26. java_codebase_rag/eval/__init__.py +0 -1
  27. java_codebase_rag/eval/ground_truth.py +0 -100
  28. java_codebase_rag/eval/metrics.py +0 -107
  29. java_codebase_rag/eval/runner.py +0 -556
  30. java_codebase_rag/graph/__init__.py +0 -0
  31. java_codebase_rag/graph/build_ast_graph.py +0 -4593
  32. java_codebase_rag/graph/graph_enrich.py +0 -1940
  33. java_codebase_rag/graph/graph_types.py +0 -224
  34. java_codebase_rag/graph/java_ontology.py +0 -465
  35. java_codebase_rag/graph/ladybug_queries.py +0 -2213
  36. java_codebase_rag/graph/path_filtering.py +0 -509
  37. java_codebase_rag/index/__init__.py +0 -0
  38. java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
  39. java_codebase_rag/index/java_index_v1_common.py +0 -33
  40. java_codebase_rag/install_data/__init__.py +0 -0
  41. java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
  42. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
  43. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
  44. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
  45. java_codebase_rag/installer.py +0 -2188
  46. java_codebase_rag/jrag.py +0 -4545
  47. java_codebase_rag/jrag_envelope.py +0 -1107
  48. java_codebase_rag/jrag_hints.py +0 -204
  49. java_codebase_rag/jrag_render.py +0 -926
  50. java_codebase_rag/lance_optimize.py +0 -264
  51. java_codebase_rag/mcp/__init__.py +0 -0
  52. java_codebase_rag/mcp/mcp_hints.py +0 -932
  53. java_codebase_rag/mcp/mcp_v2.py +0 -1916
  54. java_codebase_rag/mcp/server.py +0 -886
  55. java_codebase_rag/pipeline.py +0 -531
  56. java_codebase_rag/progress.py +0 -570
  57. java_codebase_rag/read_payloads.py +0 -781
  58. java_codebase_rag/search/__init__.py +0 -0
  59. java_codebase_rag/search/index_common.py +0 -10
  60. java_codebase_rag/search/search_lancedb.py +0 -1296
  61. java_codebase_rag/search/search_lexical.py +0 -449
  62. java_codebase_rag/search/search_scoring.py +0 -537
  63. java_codebase_rag/watch/__init__.py +0 -0
  64. java_codebase_rag/watch/client.py +0 -230
  65. java_codebase_rag/watch/daemon.py +0 -396
  66. java_codebase_rag/watch/lock.py +0 -201
  67. java_codebase_rag/watch/paths.py +0 -76
  68. java_codebase_rag/watch/protocol.py +0 -122
  69. java_codebase_rag/watch/server.py +0 -273
  70. java_codebase_rag/watch/warm.py +0 -105
  71. java_codebase_rag/watch/watcher.py +0 -394
  72. java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
  73. java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
  74. java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
  75. java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
  76. java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
  77. /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.2.dist-info/top_level.txt +0 -0
@@ -1,926 +0,0 @@
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
- import json
14
- from typing import Any
15
-
16
- from java_codebase_rag.absence.absence_types import AbsenceDiagnosis
17
- from java_codebase_rag.jrag_envelope import Envelope, project_envelope, simple_name
18
-
19
- __all__ = ["render", "tiered_name", "display_name", "count_results", "has_results"]
20
-
21
-
22
- # Edge labels that carry a ``confidence`` column (CALLS-family). ``conf:`` is
23
- # rendered only for these (PR-JRAG-1a renderer spec). Confirmed against
24
- # java_ontology.EDGE_SCHEMA: CALLS / HTTP_CALLS / ASYNC_CALLS each carry an
25
- # ``EdgeAttr("confidence", "DOUBLE", ...)``; the structural edges
26
- # (EXTENDS/IMPLEMENTS/INJECTS/DECLARES/OVERRIDES/EXPOSES/DECLARES_CLIENT/
27
- # DECLARES_PRODUCER) do not all carry confidence, and even where they do, the
28
- # CALLS-family is what the agent-facing ``conf:`` road-sign is reserved for.
29
- _CALLS_FAMILY_EDGES = frozenset({"CALLS", "HTTP_CALLS", "ASYNC_CALLS"})
30
-
31
- # Route node kinds → short text tag so the routes listing distinguishes HTTP
32
- # endpoints from Kafka topics (otherwise they mash together with no indicator).
33
- # Only route kinds are tagged; symbol/client/producer rows carry other kinds (or
34
- # none) and are left untagged.
35
- _ROUTE_KIND_TAGS: dict[str, str] = {"kafka_topic": "kafka", "http_endpoint": "http"}
36
-
37
- # Absence verdict → human-readable label, shared by the not-found / listing /
38
- # traversal empty-result renderers. ``AbsenceVerdict`` is a closed Literal of
39
- # these four values.
40
- _ABSENCE_VERDICT_TEXT: dict[str, str] = {
41
- "not_in_project": "not in project",
42
- "external_dependency": "external dependency",
43
- "refine_query": "refine your query",
44
- "correct_empty": "correct empty",
45
- }
46
-
47
-
48
- def _verdict_line(absence: AbsenceDiagnosis) -> str | None:
49
- """A ``Verdict: <label>`` line for an absence diagnosis, or ``None`` if the
50
- verdict is not one of the known values."""
51
- text = _ABSENCE_VERDICT_TEXT.get(absence.verdict)
52
- return f"Verdict: {text}" if text else None
53
-
54
- # Identity keys already represented in a listing line (display_name + @service +
55
- # kind tag). At ``--detail full`` the per-row kv-block skips these (they are in
56
- # the header line) and renders every OTHER key, so full listing == per-row
57
- # inspect block. ``id`` is absent here AND stripped by the envelope projector's
58
- # graph-id-field rule (see jrag_envelope._strip_graph_id_fields) — listed for
59
- # documentation of the identity set, but the projector is the authoritative
60
- # strip seam. Must agree with the identity half of the envelope projector's
61
- # ``_BRIEF_NODE_KEYS`` (see jrag_envelope.py).
62
- _LISTING_LINE_KEYS: frozenset[str] = frozenset(
63
- {
64
- "kind",
65
- "fqn",
66
- "name",
67
- "microservice",
68
- "path",
69
- "method",
70
- "topic",
71
- "member_fqn",
72
- "target_service",
73
- "broker",
74
- "client_kind",
75
- "producer_kind",
76
- "import_simple",
77
- "import_fqn",
78
- "import_kind",
79
- }
80
- )
81
-
82
- # Fixed left-to-right order for the inline extras appended at ``--detail normal``
83
- # (only the non-empty ones are rendered). Equals the envelope projector's
84
- # ``_NORMAL_NODE_KEYS - _BRIEF_NODE_KEYS``.
85
- _NORMAL_INLINE_EXTRAS: tuple[str, ...] = (
86
- "module",
87
- "role",
88
- "symbol_kind",
89
- "framework",
90
- "file",
91
- "score",
92
- "explain",
93
- "chunks",
94
- )
95
-
96
- # Identity-adjacent extras shown inline at ``--detail brief``. ``score`` is the
97
- # ONLY brief-tier extra because for ranked result sets (``search``) the score
98
- # IS the point — hiding it at brief made ``jrag search --detail brief`` show an
99
- # unranked-looking list. Listing/traversal rows built from NodeRef carry no
100
- # ``score`` field (only SearchHit does), so this is a no-op for non-search
101
- # listings (``find``, routes/clients/producers, traversal target rows).
102
- _BRIEF_INLINE_EXTRAS: tuple[str, ...] = ("score",)
103
-
104
- # Edge attrs the edge line already renders (label/confidence); at ``--detail
105
- # full`` these are skipped when appending the remaining attrs inline.
106
- _EDGE_LINE_KEYS: frozenset[str] = frozenset(
107
- {"other_id", "dst_id", "target_id", "term_id", "edge_type", "stored_edge_type", "label", "type", "confidence"}
108
- )
109
-
110
-
111
- def _format_inline_value(value: Any) -> str:
112
- """Format a value for inline rendering: round floats to 3 decimals, others verbatim."""
113
- if isinstance(value, float):
114
- return f"{value:.3f}"
115
- return str(value)
116
-
117
-
118
- def _next_action_lines(envelope: Envelope) -> list[str]:
119
- """Build up to 2 ``next: <hint>`` lines from ``agent_next_actions``.
120
-
121
- Cap at 2 to keep text-mode output token-lean (consistent with the ambiguous
122
- renderer at :func:`_render_ambiguous`); JSON carries all ≤5. Returns an empty
123
- list when ``agent_next_actions`` is empty (commands with no root produce no
124
- hints → nothing appended).
125
- """
126
- return [f"next: {hint}" for hint in envelope.agent_next_actions[:2]]
127
-
128
-
129
- def _is_dynamic_topic_ref(topic: str) -> bool:
130
- """True when a producer ``topic`` string is a bare Java identifier — a
131
- variable or method-call name the indexer captured because it could not
132
- resolve the destination at index time — rather than a real topic name.
133
-
134
- Real topics are dotted (``banking.chat.audit``) or CONSTANT references
135
- (``ChatTopics.ESCALATION``, ``OPERATOR_NOTIFICATIONS``); both contain a
136
- ``.`` or ``_``. A single lowercase/identifier token (``topic``,
137
- ``distributionTopic``) is a runtime reference. Used by :func:`display_name`
138
- only when the producer is also ``resolved=False`` — a resolved bare token is
139
- treated as a (rare) real single-word topic.
140
- """
141
- if "." in topic or "_" in topic:
142
- return False
143
- return bool(topic) and topic[0].islower() and topic.isalnum()
144
-
145
-
146
- def display_name(node: dict[str, Any]) -> str:
147
- """Best short label for a node across all kinds (symbol + route/client/producer).
148
-
149
- Listing rows and traversal targets carry different identifying fields per
150
- kind; this picks the most informative one rather than assuming every node
151
- has an FQN (routes have ``path``/``method``; clients/producers have
152
- ``member_fqn`` + ``topic``/``target_service``). Precedence:
153
-
154
- * explicit ``name`` -> symbols (SymbolHit carries one)
155
- * ``member_fqn`` -> the member making the call/emit, with
156
- ``→ topic`` / ``→ target_service`` when present
157
- * ``path`` -> ``METHOD path`` (route) or ``path`` (client)
158
- * ``topic`` -> bare topic (producer without a member)
159
- * ``fqn`` -> fqn-derived simple name (classes/methods)
160
-
161
- Returns ``""`` only when nothing identifiable is present.
162
-
163
- For a method symbol (``pkg.Class#method(args)``) the label is
164
- ``Class#method``, NOT the bare ``name``: ``getId`` / ``process`` / ``create``
165
- collide across classes, so a traversal/listing row reduced to the bare
166
- method name is ambiguous (the SlaService callees example — four ``getId``,
167
- five ``process``). The declaring class is identity-level disambiguation, so
168
- it folds into the label at every detail tier (brief included). ``name`` (the
169
- clean method name, no args) is preferred when present; the FQN-derived method
170
- name is the fallback when ``name`` is absent.
171
- """
172
- fqn = str(node.get("fqn") or "").strip()
173
- if "#" in fqn:
174
- head, _, tail = fqn.partition("#")
175
- cls = head.rsplit(".", 1)[-1]
176
- raw_name = str(node.get("name") or "").strip()
177
- # Some backends populate `name` with the full ``Class#method(args)``
178
- # form already (ambiguous-resolve candidates do this). Prepending
179
- # ``cls`` would double it (``Class#Class#method``); use it verbatim —
180
- # it already carries the declaring class plus args, so it stays
181
- # identity-unique. Traversal method nodes carry the bare clean name
182
- # (no ``#``), so they still take the ``Class#method`` path below.
183
- if raw_name and "#" in raw_name:
184
- return raw_name
185
- method = raw_name or tail.split("(", 1)[0]
186
- if cls and method:
187
- return f"{cls}#{method}"
188
- name = str(node.get("name") or "").strip()
189
- if name:
190
- return name
191
- member_fqn = str(node.get("member_fqn") or "").strip()
192
- if member_fqn:
193
- base = member_fqn.rsplit(".", 1)[-1]
194
- topic = str(node.get("topic") or "").strip()
195
- if topic:
196
- # An unresolved topic that is a bare Java identifier is a runtime
197
- # reference the indexer could not resolve (e.g. the variable
198
- # ``topic``, or ``getKafka().getDistributionTopic()`` reduced to
199
- # ``distributionTopic``). Printing it verbatim would show
200
- # ``→ topic`` and mislead the agent into treating the variable name
201
- # as the Kafka destination; surface it as dynamic instead. Real
202
- # topic names (dotted / CONSTANT) stay verbatim.
203
- if node.get("resolved") is False and _is_dynamic_topic_ref(topic):
204
- return f"{base} → (dynamic topic)"
205
- return f"{base} → {topic}"
206
- target = str(node.get("target_service") or "").strip()
207
- if target:
208
- return f"{base} → {target}"
209
- return base
210
- path = str(node.get("path") or "").strip()
211
- if path:
212
- method = str(node.get("method") or "").strip()
213
- return f"{method} {path}" if method else path
214
- topic = str(node.get("topic") or "").strip()
215
- if topic:
216
- return topic
217
- # Symbol / fallback: fqn-derived simple name.
218
- return simple_name(node)
219
-
220
-
221
- def tiered_name(node_id: str, nodes: dict[str, dict]) -> str:
222
- """Tiered label: ``display_name @service`` -> display_name -> FQN -> id.
223
-
224
- ``display_name`` covers symbols (fqn) AND route/client/producer nodes
225
- (path/member_fqn/topic). ``@service`` is appended when ``microservice`` is
226
- present; if the node still yields no label, the raw FQN (then the id) is
227
- returned so a traversal target is never rendered empty.
228
- """
229
- node = nodes.get(node_id) or {}
230
- name = display_name(node)
231
- service = str(node.get("microservice") or "").strip()
232
- if name and service:
233
- return f"{name} @{service}"
234
- if name:
235
- return name
236
- fqn = str(node.get("fqn") or "").strip()
237
- return fqn or node_id
238
-
239
-
240
- def _node_id(edge: dict) -> str:
241
- """Pull the *other-end* node id out of an edge row across backend variants.
242
-
243
- ``neighbors_v2`` returns ``other_id``; traversal LadybugGraph methods return
244
- one of ``dst_id`` / ``target_id`` / ``term_id``. We try them in order.
245
- """
246
- for key in ("other_id", "dst_id", "target_id", "term_id"):
247
- val = edge.get(key)
248
- if isinstance(val, str) and val:
249
- return val
250
- return ""
251
-
252
-
253
- def _edge_label(edge: dict) -> str:
254
- for key in ("edge_type", "stored_edge_type", "label", "type"):
255
- val = edge.get(key)
256
- if isinstance(val, str) and val:
257
- return val
258
- return ""
259
-
260
-
261
- def _truncated_hint(*, next_offset: int | None) -> str:
262
- if next_offset is not None:
263
- return f"truncated: more results — use --offset {next_offset}"
264
- return "truncated: more results — narrow your query"
265
-
266
-
267
- def _render_error(envelope: Envelope) -> str:
268
- msg = envelope.message or (envelope.warnings[0] if envelope.warnings else "error")
269
- return f"error: {msg}"
270
-
271
-
272
- def _render_not_found(envelope: Envelope) -> str:
273
- msg = envelope.message or "not found"
274
- base = f"not found: {msg}"
275
-
276
- # If absence diagnosis is present, append verdict + message (+ did-you-mean)
277
- if envelope.absence is not None:
278
- lines = [base]
279
- # Verdict line (human-readable label)
280
- vline = _verdict_line(envelope.absence)
281
- if vline:
282
- lines.append(vline)
283
-
284
- # Per-cause explanation message — surfaces the diagnosis's authored help
285
- # (external-identity context, filter-relaxation suggestions, etc.).
286
- if envelope.absence.message:
287
- lines.append(envelope.absence.message)
288
-
289
- # Add did-you-mean line if closest_symbols is non-empty
290
- if envelope.absence.closest_symbols:
291
- symbols = [s.fqn for s in envelope.absence.closest_symbols]
292
- if len(symbols) == 1:
293
- lines.append(f"Did you mean: {symbols[0]}?")
294
- elif len(symbols) == 2:
295
- lines.append(f"Did you mean: {symbols[0]} or {symbols[1]}?")
296
- else:
297
- joined = ", ".join(symbols[:-1]) + f", or {symbols[-1]}"
298
- lines.append(f"Did you mean: {joined}?")
299
-
300
- return "\n".join(lines)
301
-
302
- return base
303
-
304
-
305
- def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") -> str:
306
- lines: list[str] = []
307
- for _node_id, node in envelope.nodes.items():
308
- # Listing omits FQN (PR-JRAG-1a test 11): display_name + @service only.
309
- # display_name handles routes (METHOD path) / clients / producers, which
310
- # carry no FQN — simple_name would render them blank.
311
- name = display_name(node)
312
- if not name:
313
- # Unresolved brownfield routes can carry empty path+topic+member;
314
- # fall back to the file basename (then a placeholder) so the row
315
- # never renders as a blank line or a bare ``@service``. The
316
- # projector composes raw filename+start_line into ``file``, so check
317
- # both ``file`` and the raw ``filename`` (present pre-projection /
318
- # when no start_line was carried).
319
- label = ""
320
- for key in ("file", "filename"):
321
- raw = str(node.get(key) or "").strip()
322
- if raw:
323
- base = raw.rsplit(":", 1)[0] if raw.rsplit(":", 1)[-1].isdigit() else raw
324
- label = base.rsplit("/", 1)[-1]
325
- break
326
- name = label or "(no identifier)"
327
- service = str(node.get("microservice") or "").strip()
328
- tag = _ROUTE_KIND_TAGS.get(str(node.get("kind") or ""))
329
- parts: list[str] = [f"[{tag}]", name] if tag else [name]
330
- line = " ".join(parts)
331
- if service:
332
- line += f" @{service}"
333
- # PR-JRAG-3b: distinguish unresolved imports from resolved graph nodes
334
- # in TEXT mode. Without this marker, `imports <file>` renders resolved
335
- # Symbols and unresolved placeholders identically (only JSON carries
336
- # the resolved flag), leaving a text-mode agent unable to tell which
337
- # imports resolved. The marker is gated on the synthetic
338
- # `kind="unresolved_import"` set by _cmd_imports.
339
- if node.get("kind") == "unresolved_import":
340
- line += " (unresolved)"
341
- # detail > brief: surface the fields the terse line drops. The projector
342
- # has already trimmed the node to the requested field set, so we only
343
- # decide PRESENTATION. brief = append identity-adjacent extras that
344
- # matter even at the terse tier (score on search hits — without it the
345
- # ranking is invisible). normal = append inline location/classification/
346
- # ranking extras to the SAME line (one line per row — the fix for "text
347
- # too terse": adds module/role/file/score). full = per-row inspect block
348
- # of every non-identity key (signature/annotations/snippet/...).
349
- if detail == "brief":
350
- extras = [
351
- f"{key}={_format_inline_value(node[key])}"
352
- for key in _BRIEF_INLINE_EXTRAS
353
- if key in node and node[key] not in ("", None)
354
- ]
355
- if extras:
356
- line += " " + " ".join(extras)
357
- elif detail == "normal":
358
- extras = [
359
- f"{key}={_format_inline_value(node[key])}"
360
- for key in _NORMAL_INLINE_EXTRAS
361
- if key in node and node[key] not in ("", None) and not (key == "chunks" and node[key] == 1)
362
- ]
363
- if extras:
364
- line += " " + " ".join(extras)
365
- lines.append(line)
366
- if detail == "full":
367
- rest = {k: v for k, v in node.items() if k not in _LISTING_LINE_KEYS}
368
- if rest:
369
- lines.extend(_render_inspect_block(rest, 1))
370
- if not lines:
371
- # Handle absence diagnosis (PR-ABS-4)
372
- if envelope.absence is not None:
373
- vline = _verdict_line(envelope.absence)
374
- lines.append(vline if vline else f"0 {noun}".rstrip())
375
- else:
376
- lines.append(f"0 {noun}".rstrip())
377
- # Listing breadcrumbs (Phase 2): <=2 `next:` hint lines when the listing
378
- # command emitted agent_next_actions (routes/clients/producers/topics).
379
- lines.extend(_next_action_lines(envelope))
380
- return "\n".join(lines)
381
-
382
-
383
- def _node_normal_extras(node: dict[str, Any]) -> str:
384
- """Inline ``key=value`` extras for a node at ``normal`` detail.
385
-
386
- Mirrors :func:`_render_listing`'s normal-tier inline append exactly (same
387
- ``_NORMAL_INLINE_EXTRAS`` key list / format) so a traversal row and a listing
388
- row show the SAME fields at the same level, and so text matches the field
389
- set JSON carries at ``normal``. Returns ``""`` when none of the extras are
390
- present (the line is left unchanged).
391
- """
392
- extras = [
393
- f"{key}={node[key]}"
394
- for key in _NORMAL_INLINE_EXTRAS
395
- if key in node and node[key] not in ("", None)
396
- ]
397
- return (" " + " ".join(extras)) if extras else ""
398
-
399
-
400
- def _node_full_rows(node: dict[str, Any], indent: int) -> list[str]:
401
- """Indented kv-block of a node's non-identity fields at ``full`` detail.
402
-
403
- Mirrors :func:`_render_listing`'s full-tier block: identity keys
404
- (``_LISTING_LINE_KEYS`` — already represented in the label/@service line) are
405
- skipped, the rest recurse via :func:`_render_inspect_block` so
406
- signature / annotations / modifiers / package render as readable nested kv
407
- lines. Returns ``[]`` when the node has no content fields.
408
- """
409
- rest = {k: v for k, v in node.items() if k not in _LISTING_LINE_KEYS}
410
- return _render_inspect_block(rest, indent) if rest else []
411
-
412
-
413
- def _format_edge_rows(edge: dict, nodes: dict[str, dict], *, detail: str = "normal") -> list[str]:
414
- """Format an edge as one header line plus (at ``full``) a per-edge block.
415
-
416
- Shared across all render modes (flat + grouped). The header is
417
- `` <tiered name>`` plus ``conf=N.NN`` for CALLS-family edges. The caller is
418
- responsible for any grouping header above these rows.
419
-
420
- NODE-level detail is honored symmetrically with :func:`_render_listing`
421
- (PR-JRAG-6 fixed listings but not traversals; this closes that gap so
422
- ``jrag callees`` text carries the same fields as its JSON, and ``--detail
423
- full`` is no longer a no-op):
424
-
425
- * ``brief`` -> header only (label + conf). Identity only; the label already
426
- carries the declaring class for methods via :func:`display_name`.
427
- * ``normal`` -> header + edge ``mechanism`` + the target node's
428
- ``_NORMAL_INLINE_EXTRAS`` (module/role/symbol_kind/framework/file/score)
429
- inline — the same inline append listings use.
430
- * ``full`` -> header + every remaining EDGE attr inline (annotation /
431
- field_or_param / from_fqn / …) + a per-edge indented block of the target
432
- node's content fields (signature/annotations/modifiers/...).
433
- """
434
- target_id = _node_id(edge)
435
- target = nodes.get(target_id) or {}
436
- label = tiered_name(target_id, nodes) if target_id else "(missing)"
437
- line = f" {label}"
438
- edge_type = _edge_label(edge)
439
- # conf: only on CALLS-family edges (PR-JRAG-1a test 12).
440
- if edge_type in _CALLS_FAMILY_EDGES:
441
- conf = edge.get("confidence")
442
- if conf is not None:
443
- try:
444
- line += f" conf={float(conf):.2f}"
445
- except (TypeError, ValueError):
446
- pass
447
- if detail == "normal":
448
- mech = edge.get("mechanism")
449
- if mech not in ("", None):
450
- line += f" mechanism={mech}"
451
- line += _node_normal_extras(target)
452
- return [line]
453
- if detail == "full":
454
- for key in edge:
455
- if key in _EDGE_LINE_KEYS:
456
- continue
457
- val = edge.get(key)
458
- if val in ("", None):
459
- continue
460
- line += f" {key}={val}"
461
- rows = [line]
462
- rows.extend(_node_full_rows(target, 2))
463
- return rows
464
- return [line]
465
-
466
-
467
- def _render_traversal(envelope: Envelope, *, noun: str, detail: str = "normal") -> str:
468
- lines: list[str] = []
469
- root_id = envelope.root or ""
470
- if root_id:
471
- # root: tiered name (Class / Class#method + @service). At normal the
472
- # root node's module/role/file/score append inline; at full a kv-block
473
- # renders under it — the SAME detail contract as an edge-target row, so
474
- # the resolved-subject line carries the same fields JSON shows (parity
475
- # with the listing/edge detail work; pre-fix the root was always bare).
476
- root_node = envelope.nodes.get(root_id, {})
477
- root_label = tiered_name(root_id, envelope.nodes)
478
- if detail == "normal":
479
- lines.append(f"root: {root_label}{_node_normal_extras(root_node)}")
480
- elif detail == "full":
481
- lines.append(f"root: {root_label}")
482
- lines.extend(_node_full_rows(root_node, 1))
483
- else:
484
- lines.append(f"root: {root_label}")
485
- if not envelope.edges:
486
- # Zero-results line for a traversal: "0 <noun> <fqn> @<service>".
487
- # The fqn + service come from the root node (the resolved subject). When
488
- # the producer flagged the root as a server-exposed entrypoint with no
489
- # in-repo callers, lead with that honest note instead of the bare,
490
- # bug-looking "0 <noun>" — the empty result is correct here.
491
- root_node = envelope.nodes.get(root_id, {})
492
- root_fqn = str(root_node.get("fqn") or "").strip()
493
- root_svc = str(root_node.get("microservice") or "").strip()
494
-
495
- # Handle absence diagnosis (PR-ABS-4)
496
- if envelope.absence is not None:
497
- absence = envelope.absence
498
- if absence.verdict == "correct_empty":
499
- # Same text as the is_external_entrypoint case.
500
- parts = ["external entrypoint — no in-repo callers"]
501
- else:
502
- vline = _verdict_line(absence)
503
- if vline:
504
- lines.append(vline)
505
- parts = [f"0 {noun}".rstrip()]
506
- elif envelope.is_external_entrypoint:
507
- parts = ["external entrypoint — no in-repo callers"]
508
- else:
509
- parts = [f"0 {noun}".rstrip()]
510
-
511
- if root_fqn:
512
- parts.append(root_fqn)
513
- if root_svc:
514
- parts.append(f"@{root_svc}")
515
- lines.append(" ".join(parts))
516
- lines.extend(_next_action_lines(envelope))
517
- return "\n".join(lines)
518
-
519
- # Grouped rendering fires ONLY when the producer attached the grouping
520
- # key (hierarchy sets `direction`; decompose sets `stage`; connection sets
521
- # `section`). Other traversals (callers/callees/dependents/...) leave all
522
- # three unset and fall through to the flat list below — current behavior
523
- # unchanged (Fix 1).
524
- has_stages = any(e.get("stage") is not None for e in envelope.edges)
525
- has_direction = any(e.get("direction") for e in envelope.edges)
526
- has_section = any(e.get("section") for e in envelope.edges)
527
-
528
- if has_section:
529
- # connection: group under inbound:/outbound: headers. Edges carry a
530
- # `section` key set to "inbound" or "outbound" by _cmd_connection.
531
- # Unknown section values are rendered under their literal name so the
532
- # agent sees the data even if a future caller adds a new section.
533
- in_sec = [e for e in envelope.edges if e.get("section") == "inbound"]
534
- out_sec = [e for e in envelope.edges if e.get("section") == "outbound"]
535
- other = [e for e in envelope.edges if e.get("section") not in ("inbound", "outbound")]
536
- if in_sec:
537
- lines.append("inbound:")
538
- for e in in_sec:
539
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
540
- if out_sec:
541
- lines.append("outbound:")
542
- for e in out_sec:
543
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
544
- for e in other:
545
- section = str(e.get("section") or "")
546
- if section:
547
- lines.append(f"{section}:")
548
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
549
- lines.extend(_next_action_lines(envelope))
550
- return "\n".join(lines)
551
-
552
- if has_stages:
553
- # decompose role-waterfall: group edges under `stage N` headers.
554
- # The role on each edge (carried from StageSymbol) labels the stage
555
- # when homogeneous; otherwise we just number it.
556
- stage_order: list[int] = []
557
- by_stage: dict[int, list[dict]] = {}
558
- for e in envelope.edges:
559
- s = int(e.get("stage") or 0)
560
- if s not in by_stage:
561
- by_stage[s] = []
562
- stage_order.append(s)
563
- by_stage[s].append(e)
564
- for s in stage_order:
565
- stage_edges = by_stage[s]
566
- # Preserve first-seen order so a mixed stage reads naturally
567
- # (e.g. `stage 1 (service, component):`) instead of dropping the
568
- # role label entirely — the role allow-list is the whole point of a
569
- # role-waterfall, so hiding it on the busiest stages is a loss.
570
- seen: list[str] = []
571
- for e in stage_edges:
572
- r = str(e.get("role") or "").strip().lower()
573
- if r and r not in seen:
574
- seen.append(r)
575
- if s == 0:
576
- header = "stage 0 (seed):"
577
- elif seen:
578
- header = f"stage {s} ({', '.join(seen)}):"
579
- else:
580
- header = f"stage {s}:"
581
- lines.append(header)
582
- for e in stage_edges:
583
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
584
- lines.extend(_next_action_lines(envelope))
585
- return "\n".join(lines)
586
-
587
- if has_direction:
588
- # hierarchy tree: group under ↑ supertypes / ↓ subtypes headers.
589
- up = [e for e in envelope.edges if e.get("direction") == "up"]
590
- dn = [e for e in envelope.edges if e.get("direction") == "down"]
591
- if up:
592
- lines.append("↑ supertypes:")
593
- for e in up:
594
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
595
- if dn:
596
- lines.append("↓ subtypes:")
597
- for e in dn:
598
- lines.extend(_format_edge_rows(e, envelope.nodes, detail=detail))
599
- lines.extend(_next_action_lines(envelope))
600
- return "\n".join(lines)
601
-
602
- # Flat: callers / callees / implementations / subclasses / overrides /
603
- # overridden-by / dependents / impact / flow (current behavior).
604
- for edge in envelope.edges:
605
- lines.extend(_format_edge_rows(edge, envelope.nodes, detail=detail))
606
- lines.extend(_next_action_lines(envelope))
607
- return "\n".join(lines)
608
-
609
-
610
- def _inspect_inline(val: Any) -> str:
611
- """One-line rendering for a leaf value or a collapsed list/dict item.
612
-
613
- Scalars render as themselves; a list of scalars joins with ``, ``; a dict
614
- collapses to ``k: v, k: v`` (used for list-of-dict sample items, which are
615
- short). Empty list/dict render as ``[]`` / ``{}``.
616
- """
617
- if isinstance(val, list):
618
- return ", ".join(_inspect_inline(x) for x in val) if val else "[]"
619
- if isinstance(val, dict):
620
- return ", ".join(f"{k}: {_inspect_inline(v)}" for k, v in val.items()) if val else "{}"
621
- if isinstance(val, str):
622
- return val
623
- return str(val)
624
-
625
-
626
- def _is_dict_list(v: Any) -> bool:
627
- """True for a non-empty list whose every item is a dict (rendered as blocks)."""
628
- return isinstance(v, list) and bool(v) and all(isinstance(x, dict) for x in v)
629
-
630
-
631
- def _render_inspect_block(node: dict[str, Any], indent: int) -> list[str]:
632
- """Recursively render a dict's keys as indented kv lines.
633
-
634
- dict -> header + recurse (so ``counts: {svc: {kind: n}}`` nests fully);
635
- non-empty list-of-dicts -> header + one ``- <inline item>`` line per entry
636
- (sample lists like ``client_sample``/``route_sample``); other lists and
637
- scalars -> inline. Replaces the old single-level renderer that printed
638
- nested dicts and list-of-dicts as Python ``repr()``.
639
- """
640
- pad = " " * indent
641
- out: list[str] = []
642
- for key in sorted(node.keys(), key=str):
643
- val = node[key]
644
- if isinstance(val, dict) and val:
645
- out.append(f"{pad}{key}:")
646
- out.extend(_render_inspect_block(val, indent + 1))
647
- elif _is_dict_list(val):
648
- out.append(f"{pad}{key}:")
649
- item_pad = " " * (indent + 1)
650
- # Rich items (>2 keys: e.g. ``topics`` producers) render one kv per
651
- # line for readability — a wall of comma-joined keys is unreadable
652
- # and hides the composed ``file`` location. Short sample items
653
- # (``route_sample`` / ``client_sample``) stay on a single line.
654
- rich = any(len(it) > 2 for it in val)
655
- for item in val:
656
- if rich:
657
- # Render at indent+2 so continuation kvs align under the
658
- # first kv (which sits after the `- ` marker at indent+1).
659
- item_lines = _render_inspect_block(item, indent + 2)
660
- if not item_lines:
661
- out.append(f"{item_pad}- {{}}")
662
- else:
663
- out.append(f"{item_pad}- {item_lines[0].lstrip()}")
664
- out.extend(item_lines[1:])
665
- else:
666
- out.append(f"{item_pad}- {_inspect_inline(item)}")
667
- else:
668
- out.append(f"{pad}{key}: {_inspect_inline(val)}")
669
- return out
670
-
671
-
672
- def _render_inspect(envelope: Envelope) -> str:
673
- """kv-block renderer for nodes carrying one or more nested dict sections.
674
-
675
- Generic: ANY dict-typed value on a node renders as a header line plus
676
- indented sorted sub-keys, recursing fully. This is the dispatch signal for
677
- the inspect shape (PR-JRAG-1a status uses it for ``counts`` / ``edges``;
678
- PR-JRAG-3 ``inspect`` uses it for ``edge_summary`` and other rollups). The
679
- ``edge_summary`` key is NOT special here - it is reserved for real edge
680
- data in PR-JRAG-3 and is one of many possible section sources.
681
- """
682
- lines: list[str] = []
683
- for _node_id, node in envelope.nodes.items():
684
- # ALL dict keys alphabetical (PR-JRAG-1a test 13); nested dicts and
685
- # list-of-dicts recurse via _render_inspect_block instead of repr().
686
- lines.extend(_render_inspect_block(node, 0))
687
- lines.extend(_next_action_lines(envelope))
688
- return "\n".join(lines)
689
-
690
-
691
- def _render_ambiguous(envelope: Envelope, *, noun: str) -> str:
692
- count = len(envelope.candidates)
693
- header = f"{count} ambiguous matches for {noun!r}" if noun else f"{count} ambiguous matches"
694
- lines = [header, "Narrow with --kind --java-kind --role --fqn-contains:"]
695
- for cand in envelope.candidates:
696
- # Ambiguous candidates carry reason; NO file / score (PR-JRAG-1a test 14).
697
- # display_name only — graph id is NOT a fallback (the envelope projector
698
- # strips id/parent_id at every detail level; an unidentified candidate
699
- # renders with "(no identifier)" rather than leaking a raw SHA).
700
- name = display_name(cand) or "(no identifier)"
701
- service = str(cand.get("microservice") or "").strip()
702
- reason = str(cand.get("reason") or "").strip()
703
- line = f" {name}"
704
- if service:
705
- line += f" @{service}"
706
- if reason:
707
- line += f" ({reason})"
708
- lines.append(line)
709
- # <=2 next: hints; no auto-pick (PR-JRAG-1a renderer spec).
710
- for hint in envelope.agent_next_actions[:2]:
711
- lines.append(f"next: {hint}")
712
- return "\n".join(lines)
713
-
714
-
715
- def _render_scalar(envelope: Envelope) -> str:
716
- if envelope.message is not None:
717
- return envelope.message
718
- if envelope.warnings:
719
- return "\n".join(envelope.warnings)
720
- return envelope.status
721
-
722
-
723
- def _render_text_shape(envelope: Envelope, *, noun: str, shape: str | None, detail: str = "normal") -> str:
724
- if envelope.status == "error":
725
- return _render_error(envelope)
726
- if envelope.status == "not_found":
727
- return _render_not_found(envelope)
728
- if envelope.status == "ambiguous":
729
- return _render_ambiguous(envelope, noun=noun)
730
- # status == "ok": dispatch on EXPLICIT shape hint first, then envelope
731
- # structure. The shape hint is the only path to ``_render_inspect`` -
732
- # listing nodes typically carry dict-valued fields after ``.model_dump()``
733
- # (Symbol nodes have ``source_range`` / ``annotations`` / ``capabilities``
734
- # / ``metadata`` etc.), so inferring inspect from "any node has a dict
735
- # value" would silently mis-render listings as inspect (FQN alphabetical).
736
- # Inspect is declared by the caller, never guessed from node contents.
737
- #
738
- # Traversal shape: a root subject is set (the resolved node the edges are
739
- # relative to). This is true even when the traversal produced zero edges
740
- # — the zero-edges traversal line is "0 <noun> <fqn> @<service>", NOT
741
- # the scalar fallback.
742
- #
743
- # Precedence: explicit ``shape="inspect"`` wins over ``root``/listing
744
- # by intent (callers declare what they want); then ``root`` wins over
745
- # listing (a root signals "edges are the story").
746
- #
747
- # detail: the envelope passed in is ALREADY projected (see :func:`render`),
748
- # so each renderer sees only the keys for its detail level. ``detail`` is
749
- # threaded in only to choose PRESENTATION (inline vs block / which edge
750
- # attrs to print) — the field-set decision was made once, up front, by
751
- # :func:`project_envelope`. ``_render_inspect`` needs no ``detail`` kwarg:
752
- # it renders whatever keys survived projection (few at brief, all at full).
753
- if shape == "inspect":
754
- return _render_inspect(envelope)
755
- if envelope.root is not None:
756
- return _render_traversal(envelope, noun=noun, detail=detail)
757
- # Listing shape: zero or more node rows. Empty listing renders "0 <noun>".
758
- if envelope.nodes or noun:
759
- return _render_listing(envelope, noun=noun, detail=detail)
760
- return _render_scalar(envelope)
761
-
762
-
763
- def count_results(envelope: Envelope, shape: str | None) -> int:
764
- """How many result items ``envelope`` represents, per its render shape.
765
-
766
- The single source of truth shared by ``--count`` (output) and ``--exists``
767
- (output + exit code). Counts the collection the agent thinks of as the
768
- result, not the raw node dict (a traversal envelope includes the root
769
- subject in ``nodes`` — counting nodes would report N+1, not N):
770
-
771
- * ``shape="inspect"`` -> 1 when a subject resolved (else 0). ``inspect``
772
- /``status``/``map``/``conventions``/``overview`` declare this shape.
773
- * ``root is not None`` (traversal) -> ``len(edges)``: the callers/callees/
774
- hierarchy members are the result; the root is the resolved subject.
775
- * otherwise (listing) -> ``len(nodes)``.
776
-
777
- Callers that only care about emptiness should use :func:`has_results`, which
778
- also gates on ``status == "ok"`` (a ``not_found`` / ``error`` envelope has no
779
- result regardless of any carried nodes/edges).
780
- """
781
- if shape == "inspect":
782
- return 1 if envelope.nodes else 0
783
- if envelope.root is not None:
784
- return len(envelope.edges)
785
- return len(envelope.nodes)
786
-
787
-
788
- def has_results(envelope: Envelope, shape: str | None) -> bool:
789
- """True only when the envelope is a non-empty ``ok`` result.
790
-
791
- ``not_found`` / ``ambiguous`` / ``error`` carry no result even when they
792
- carry nodes/candidates (e.g. ambiguous candidates are narrowing hints, not
793
- hits). Used by ``--exists`` for both its output and its exit code.
794
- """
795
- return envelope.status == "ok" and count_results(envelope, shape) > 0
796
-
797
-
798
- def _field_set(fields: str) -> set[str]:
799
- """Parse a comma-separated ``--fields`` allowlist into a set of trimmed names."""
800
- return {name.strip() for name in fields.split(",") if name.strip()}
801
-
802
-
803
- def _project_to_fields(envelope: Envelope, fields: str) -> Envelope:
804
- """Return a copy of ``envelope`` (at FULL detail) whose nodes keep only the
805
- requested field names.
806
-
807
- ``--fields`` is an explicit allowlist that OVERRIDES ``--detail``: project to
808
- full (so a ``full``-tier field like ``signature`` is available even at the
809
- default detail), then keep only the requested keys per node. Names absent on
810
- a node are simply not present. Graph-id fields stay stripped
811
- (``project_envelope(full)`` already strips them). Edges/candidates are left
812
- at full; ``--fields`` is documented as a node projection.
813
-
814
- Delegates the envelope copy to :func:`project_envelope` (the single
815
- projection seam) and only rewrites ``nodes`` on the already-copied result,
816
- so the per-field Envelope construction can't drift out of sync as fields are
817
- added. ``project_envelope`` returns an independent copy and the node dicts
818
- are rebuilt by the comprehension, so the caller's ``envelope`` is untouched.
819
- """
820
- wanted = _field_set(fields)
821
- projected = project_envelope(envelope, "full")
822
- projected.nodes = {
823
- nid: {k: v for k, v in node.items() if k in wanted}
824
- for nid, node in projected.nodes.items()
825
- }
826
- return projected
827
-
828
-
829
- def _render_count(envelope: Envelope, *, fmt: str, shape: str | None) -> str:
830
- """``--count`` output: bare integer (text) or ``{"status","count"}`` (json).
831
-
832
- Non-``ok`` envelopes count as 0 (a miss / error has no result items). Text is
833
- the bare count — "only the result count", script-friendly for ``$(...)``.
834
- """
835
- n = count_results(envelope, shape) if envelope.status == "ok" else 0
836
- if fmt == "json":
837
- return json.dumps({"status": envelope.status, "count": n})
838
- return str(n)
839
-
840
-
841
- def _render_exists(envelope: Envelope, *, fmt: str, shape: str | None) -> str:
842
- """``--exists`` output: ``true``/``false`` (text) or ``{"status","exists"}`` (json)."""
843
- exists = has_results(envelope, shape)
844
- if fmt == "json":
845
- return json.dumps({"status": envelope.status, "exists": exists})
846
- return "true" if exists else "false"
847
-
848
-
849
- def render(
850
- envelope: Envelope,
851
- *,
852
- fmt: str = "text",
853
- detail: str = "normal",
854
- noun: str = "",
855
- next_offset: int | None = None,
856
- shape: str | None = None,
857
- count: bool = False,
858
- exists: bool = False,
859
- fields: str | None = None,
860
- ) -> str:
861
- """Dispatch on ``fmt`` (text default; json emits the projected envelope).
862
-
863
- ``detail`` (``brief`` / ``normal`` / ``full``, default ``normal``) is
864
- ORTHOGONAL to ``fmt``: the envelope is projected to the requested field set
865
- ONCE via :func:`project_envelope`, then BOTH the JSON path (``to_json``)
866
- and the text renderers consume the projected result. So ``--format json
867
- --detail brief`` and ``--format text --detail brief`` go through the same
868
- field set. ``brief`` reproduces today's terse text; ``normal`` adds
869
- ``module``/``role``/``symbol_kind``/``framework``/``file``/``score`` (the
870
- fix for "text too terse"); ``full`` keeps everything (incl. ``snippet`` /
871
- ``signature`` / ``annotations``) and drops empty fields at all levels.
872
-
873
- ``noun`` is the human-readable noun for the result kind (e.g. ``"callers"``,
874
- ``"matches"``); used in zero-results and ambiguous headers. ``next_offset``
875
- selects the truncated hint: ``None`` -> ``narrow your query`` (no offset
876
- support on this command); a number -> ``use --offset <N>`` (find/search).
877
-
878
- ``shape`` is the EXPLICIT render-shape hint. The only accepted value today
879
- is ``"inspect"`` (kv-block + indented alphabetical sections); callers that
880
- need it declare it (PR-JRAG-1a ``status``, future PR-JRAG-1b/3 ``inspect``).
881
- ``None`` falls back to structural inference: ``root`` -> traversal,
882
- ``nodes``/``noun`` -> listing, else scalar. Listing nodes frequently carry
883
- dict-valued fields after ``.model_dump()``, so inspect is NEVER inferred
884
- from node contents - only an explicit ``shape="inspect"`` routes there.
885
-
886
- Output-shaping flags (issue #376), orthogonal to the command that produced
887
- the envelope and honored on both text and json:
888
-
889
- * ``exists=True`` -> ``true``/``false`` (or ``{"status","exists"}``). Takes
890
- precedence over ``count`` (a gate is more specific than a tally).
891
- * ``count=True`` -> the bare result count (or ``{"status","count"}``); see
892
- :func:`count_results` for what is counted per shape.
893
- * ``fields`` -> comma-separated node-field allowlist that OVERRIDES
894
- ``--detail`` (project to full, then keep only the named fields). Applies
895
- only to ``status="ok"`` output; composes with the normal render, not with
896
- ``count``/``exists``. A whitespace/comma-only allowlist is treated as
897
- not given (falls back to the normal projection). Primarily a JSON lever
898
- (text rendering still labels rows from whatever identity fields survive
899
- the allowlist).
900
-
901
- The exit-code side of ``--exists`` is decided by the caller (``jrag._emit``)
902
- via :func:`has_results` — render() only shapes output.
903
- """
904
- if exists:
905
- return _render_exists(envelope, fmt=fmt, shape=shape)
906
- if count:
907
- return _render_count(envelope, fmt=fmt, shape=shape)
908
- if fields and envelope.status == "ok" and _field_set(fields):
909
- projected = _project_to_fields(envelope, fields)
910
- else:
911
- projected = project_envelope(envelope, detail)
912
- if fmt == "json":
913
- return projected.to_json()
914
- body = _render_text_shape(projected, noun=noun, shape=shape, detail=detail)
915
- if projected.truncated:
916
- hint = _truncated_hint(next_offset=next_offset)
917
- body = f"{body}\n{hint}" if body else hint
918
- # Warnings are rendered in text mode (one ``warning:`` line each) so an
919
- # agent running without ``--format json`` still sees inapplicable-flag /
920
- # post-filter notices. Without this the warnings[] field was JSON-only and
921
- # the "inapplicable flags never silently ignored" spec was effectively
922
- # unenforced for text consumers.
923
- if projected.warnings:
924
- warning_lines = "\n".join(f"warning: {w}" for w in projected.warnings)
925
- body = f"{body}\n{warning_lines}" if body else warning_lines
926
- return body