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.
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.6.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.6.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
  22. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
  23. {java_codebase_rag-0.6.6.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.6.dist-info/RECORD +0 -34
  33. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1085 @@
1
+ """JRAG envelope dataclass + resolve-first mapper + enum normalization (PR-JRAG-1a).
2
+
3
+ This is the frozen contract every later JRAG-CLI PR builds on. The envelope is a
4
+ lean ``@dataclass`` (not pydantic): backend pydantic outputs cross the boundary
5
+ via ``.model_dump()`` exactly once in :func:`to_envelope_rows`. Renderers and
6
+ ``to_json()`` operate on plain dicts only.
7
+
8
+ Lazy imports: :mod:`resolve_service` and :mod:`ladybug_queries` are imported
9
+ inside :func:`resolve_query` so this module's import stays light (no torch, no
10
+ sentence_transformers, no mcp_v2). The dataclass and pure helpers
11
+ (``normalize_enum``/``mark_truncated``/``simple_name``/``to_envelope_rows``) do
12
+ not need any backend module.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Literal
20
+
21
+ from graph_types import NodeRef
22
+
23
+ __all__ = [
24
+ "Envelope",
25
+ "EnvelopeStatus",
26
+ "resolve_query",
27
+ "normalize_enum",
28
+ "mark_truncated",
29
+ "simple_name",
30
+ "to_envelope_rows",
31
+ "next_actions_hook",
32
+ "project_node",
33
+ "project_edge",
34
+ "project_envelope",
35
+ "node_key",
36
+ ]
37
+
38
+
39
+ EnvelopeStatus = Literal["ok", "ambiguous", "not_found", "error"]
40
+
41
+ # Explicit lookup tables for kinds whose stored literal is not a plain
42
+ # UPPER_SNAKE form of the user's input. Confirmed against java_ontology.py and
43
+ # graph_enrich.py source:
44
+ # - client_kind literals: feign_method / rest_template / web_client
45
+ # (java_ontology.VALID_CLIENT_KINDS)
46
+ # - producer_kind literals: kafka_send / stream_bridge_send
47
+ # (java_ontology.VALID_PRODUCER_KINDS)
48
+ # - source_layer literals: builtin / layer_a_meta / layer_b_ann /
49
+ # layer_b_fqn / layer_c_source (graph_enrich.route_source_layer assignments)
50
+ #
51
+ # Keys are the *normalized* form (lowercase + kebab/space -> underscore).
52
+ _CLIENT_KIND_TABLE: dict[str, str] = {
53
+ "feign": "feign_method",
54
+ "feign_method": "feign_method",
55
+ "rest_template": "rest_template",
56
+ "resttemplate": "rest_template",
57
+ "web_client": "web_client",
58
+ "webclient": "web_client",
59
+ }
60
+
61
+ _PRODUCER_KIND_TABLE: dict[str, str] = {
62
+ "kafka": "kafka_send",
63
+ "kafka_send": "kafka_send",
64
+ "stream_bridge": "stream_bridge_send",
65
+ "stream_bridge_send": "stream_bridge_send",
66
+ "streambridge": "stream_bridge_send",
67
+ }
68
+
69
+ _SOURCE_LAYER_TABLE: dict[str, str] = {
70
+ "builtin": "builtin",
71
+ "layer_a": "layer_a_meta",
72
+ "layer_a_meta": "layer_a_meta",
73
+ "layer_b_ann": "layer_b_ann",
74
+ "layer_b_fqn": "layer_b_fqn",
75
+ "layer_c": "layer_c_source",
76
+ "layer_c_source": "layer_c_source",
77
+ }
78
+
79
+ _ENUM_LOOKUP_TABLES: dict[str, dict[str, str]] = {
80
+ "client_kind": _CLIENT_KIND_TABLE,
81
+ "producer_kind": _PRODUCER_KIND_TABLE,
82
+ "source_layer": _SOURCE_LAYER_TABLE,
83
+ }
84
+
85
+
86
+ @dataclass
87
+ class Envelope:
88
+ """The single output shape every jrag command emits.
89
+
90
+ Backend pydantic outputs are converted to plain dicts at the boundary
91
+ (``to_envelope_rows``); the renderer and ``to_json()`` operate on dicts
92
+ only. ``to_dict()`` omits empty optionals so a clean status=ok envelope
93
+ stays small.
94
+
95
+ Internal vs agent-facing: ``nodes`` is keyed by the graph node id internally
96
+ (handlers build ``nodes[h.id] = ...``), and ``to_dict()`` preserves that
97
+ id-keyed shape for debugging / internal use. ``to_json()`` — the CLI output
98
+ boundary — is id-free: it re-keys ``nodes`` to each node's natural key
99
+ (FQN / path / topic / literal), strips graph-id fields (``id`` / ``*_id``),
100
+ and collapses edge id-refs into ``target``. The CLI is resolve-first, so no
101
+ raw graph id ever reaches an agent on either the text or the JSON surface.
102
+ """
103
+
104
+ status: EnvelopeStatus
105
+ nodes: dict[str, dict] = field(default_factory=dict)
106
+ edges: list[dict] = field(default_factory=list)
107
+ root: str | None = None
108
+ candidates: list[dict] = field(default_factory=list)
109
+ agent_next_actions: list[str] = field(default_factory=list)
110
+ warnings: list[str] = field(default_factory=list)
111
+ truncated: bool = False
112
+ file_location: str | None = None
113
+ # Used to carry the resolve ``message`` for not_found / error envelopes
114
+ # (the renderer surfaces it as ``not found: <message>``). None on ok/ambiguous.
115
+ message: str | None = None
116
+ # Set when a traversal root is a server-exposed entrypoint (an HTTP route
117
+ # with an inbound EXPOSES edge from a controller Symbol) that genuinely has
118
+ # zero in-repo callers. Distinguishes the *correct* empty result ("external
119
+ # entrypoint — no in-repo callers") from a bug-looking bare "0 callers".
120
+ is_external_entrypoint: bool = False
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Serialize to a JSON-ready dict, omitting empty optionals.
124
+
125
+ Top-level collection fields are shallow-copied (``list(...)`` /
126
+ ``dict(...)``); their VALUES are shared references - mutating a node
127
+ dict in place will propagate to a prior snapshot. Callers that need
128
+ true snapshot isolation across subsequent mutation should
129
+ ``copy.deepcopy`` the result. (In practice the envelope is short-lived:
130
+ built, rendered via ``to_json()`` in the same call site, then discarded
131
+ - so shared references are not a hazard.)
132
+ """
133
+ out: dict[str, Any] = {"status": self.status}
134
+ if self.nodes:
135
+ out["nodes"] = dict(self.nodes)
136
+ if self.edges:
137
+ out["edges"] = list(self.edges)
138
+ if self.root is not None:
139
+ out["root"] = self.root
140
+ if self.candidates:
141
+ out["candidates"] = list(self.candidates)
142
+ if self.agent_next_actions:
143
+ out["agent_next_actions"] = list(self.agent_next_actions)
144
+ if self.warnings:
145
+ out["warnings"] = list(self.warnings)
146
+ if self.truncated:
147
+ out["truncated"] = True
148
+ if self.file_location is not None:
149
+ out["file_location"] = self.file_location
150
+ if self.message is not None:
151
+ out["message"] = self.message
152
+ if self.is_external_entrypoint:
153
+ out["is_external_entrypoint"] = True
154
+ return out
155
+
156
+ def to_json(self) -> str:
157
+ """Serialize to the AGENT-FACING id-free JSON string.
158
+
159
+ This is the CLI output boundary: it does NOT delegate to
160
+ :meth:`to_dict` (which stays id-keyed for internal/debug use and is
161
+ unit-tested as such). Instead it builds a fresh, id-free dict:
162
+
163
+ * ``nodes`` is re-keyed from raw graph ids to each node's natural key
164
+ via :func:`node_key`; when ``node_key`` returns ``None`` (no
165
+ identity field — e.g. status/orientation rollup nodes) the existing
166
+ dict key is kept unchanged. Each node value is stripped of graph-id
167
+ fields (``id`` / ``*_id``).
168
+ * each edge's ``other_id`` / ``dst_id`` / ``target_id`` / ``term_id``
169
+ (only ``other_id`` is ever emitted by handlers) collapses to a
170
+ single ``"target"`` holding the referenced node's natural key. A
171
+ dangling ref (no matching node) keeps its literal value — never
172
+ null, never silently dropped.
173
+ * ``root`` becomes the root node's natural key (omitted when absent).
174
+ * ``candidates`` are stripped of graph-id fields.
175
+ * Envelope-level scalars (``status`` / ``warnings`` / ``truncated`` /
176
+ ``file_location`` / ``message`` / ``agent_next_actions``) pass
177
+ through unchanged.
178
+
179
+ Builds NEW dicts throughout (no in-place mutation of ``self``).
180
+ """
181
+ return json.dumps(self._to_idfree_dict())
182
+
183
+ def _to_idfree_dict(self) -> dict[str, Any]:
184
+ """Build the id-free agent-facing dict (see :meth:`to_json`)."""
185
+ # 1. id -> natural key map (falls back to the existing key when node_key
186
+ # returns None, preserving literal keys like "index"/"microservices").
187
+ id_to_key: dict[str, str] = {}
188
+ used: set[str] = set()
189
+ for id_key, node in self.nodes.items():
190
+ natural = node_key(node)
191
+ if natural is None:
192
+ # No semantic identity. Keep the existing dict key ONLY when it
193
+ # is not a raw graph id (e.g. the literal "index" / "microservices"
194
+ # rollup keys). If it IS a raw id (40-hex SHA or a prefixed hash
195
+ # form like r:phantom:<hex> / ucs:<hex>), synthesize an opaque
196
+ # positional key so no graph id ever leaks as a JSON key.
197
+ if _looks_like_raw_graph_id(id_key):
198
+ key = f"node-{len(used)}"
199
+ else:
200
+ key = id_key
201
+ else:
202
+ key = natural
203
+ # Collision suffix: first occurrence unsuffixed, then #2, #3, ...
204
+ if key in used:
205
+ base = key
206
+ n = 2
207
+ while key in used:
208
+ key = f"{base}#{n}"
209
+ n += 1
210
+ used.add(key)
211
+ id_to_key[id_key] = key
212
+
213
+ out: dict[str, Any] = {"status": self.status}
214
+
215
+ if self.nodes:
216
+ out["nodes"] = {
217
+ id_to_key[nid]: _strip_graph_id_fields(dict(node))
218
+ for nid, node in self.nodes.items()
219
+ }
220
+ if self.edges:
221
+ out["edges"] = [self._edge_to_idfree(e, id_to_key) for e in self.edges]
222
+ if self.root is not None:
223
+ # The root's natural key (falls back to the raw root id only if the
224
+ # root node isn't in self.nodes — a defensive no-op in practice).
225
+ out["root"] = id_to_key.get(self.root, self.root)
226
+ if self.candidates:
227
+ out["candidates"] = [_strip_graph_id_fields(dict(c)) for c in self.candidates]
228
+ if self.agent_next_actions:
229
+ out["agent_next_actions"] = list(self.agent_next_actions)
230
+ if self.warnings:
231
+ out["warnings"] = list(self.warnings)
232
+ if self.truncated:
233
+ out["truncated"] = True
234
+ if self.file_location is not None:
235
+ out["file_location"] = self.file_location
236
+ if self.message is not None:
237
+ out["message"] = self.message
238
+ if self.is_external_entrypoint:
239
+ out["is_external_entrypoint"] = True
240
+ return out
241
+
242
+ @staticmethod
243
+ def _edge_to_idfree(edge: dict[str, Any], id_to_key: dict[str, str]) -> dict[str, Any]:
244
+ """Copy an edge, collapsing id-ref variants into one ``target`` key.
245
+
246
+ Mirrors :func:`jrag_render._node_id`'s variant list. Only ``other_id``
247
+ is emitted by handlers in practice; the others are defensive. The
248
+ remaining raw id-ref keys are dropped; all other edge attrs pass through.
249
+ """
250
+ out: dict[str, Any] = {}
251
+ ref_value: str | None = None
252
+ for k, v in edge.items():
253
+ if k in ("other_id", "dst_id", "target_id", "term_id"):
254
+ if ref_value is None and isinstance(v, str) and v:
255
+ ref_value = v
256
+ # skip (don't copy the raw id-ref key)
257
+ elif not _is_graph_id_field(k):
258
+ out[k] = v
259
+ if ref_value is not None:
260
+ out["target"] = id_to_key.get(ref_value, ref_value)
261
+ return out
262
+
263
+
264
+ def simple_name(node_dict: dict[str, Any]) -> str:
265
+ """Simple name = ``fqn.rsplit('.', 1)[-1]``.
266
+
267
+ ``NodeRef`` carries no ``name`` field; the rendering layer derives a short
268
+ label from the FQN on demand. Empty/missing FQN returns "".
269
+ """
270
+ fqn = str(node_dict.get("fqn") or "")
271
+ if not fqn:
272
+ return ""
273
+ return fqn.rsplit(".", 1)[-1]
274
+
275
+
276
+ def node_key(node: dict[str, Any]) -> str | None:
277
+ """Derive a stable, agent-meaningful, NON-graph-id key for a node.
278
+
279
+ Used by :meth:`Envelope.to_json` to re-key ``nodes`` (away from raw graph
280
+ ids) and to translate edge ``other_id`` refs. Returns ``None`` when no
281
+ identity field is derivable, in which case :meth:`Envelope.to_json` keeps
282
+ the existing dict key unchanged (this preserves already-id-free literal
283
+ keys such as ``"index"`` / ``"microservices"`` / ``"map"`` / ``"conventions"``
284
+ that status / orientation commands build).
285
+
286
+ Precedence (first non-empty wins):
287
+ * ``fqn`` -> symbols AND route roots. Route roots come from
288
+ ``NodeRef.model_dump()`` whose ``fqn`` already
289
+ carries ``"METHOD path"`` (no separate path/method
290
+ fields), so this single branch covers them.
291
+ * ``member_fqn`` -> clients: ``member_fqn->target_service`` (disambiguates
292
+ a client member from a symbol of the same name).
293
+ * ``topic`` -> producers/topics: ``topic:<name>`` (the ``topic:``
294
+ prefix matches the existing _cmd_topics key shape).
295
+ * ``name`` -> fallback for any other named node.
296
+ * ``file`` -> unresolved/phantom routes carry no fqn/path/topic/name
297
+ but DO carry a composed ``file`` location; keying by
298
+ it avoids leaking the raw graph id (e.g.
299
+ ``r:phantom:<hash>``) when no semantic id exists.
300
+ * else -> ``None`` (caller keeps the existing dict key — safe
301
+ only when that key is already a non-id literal, e.g.
302
+ the ``"index"`` / ``"microservices"`` rollup keys).
303
+ """
304
+ fqn = str(node.get("fqn") or "").strip()
305
+ if fqn:
306
+ return fqn
307
+ member_fqn = str(node.get("member_fqn") or "").strip()
308
+ if member_fqn:
309
+ target = str(node.get("target_service") or "").strip()
310
+ return f"{member_fqn}->{target}" if target else member_fqn
311
+ topic = str(node.get("topic") or "").strip()
312
+ if topic:
313
+ return f"topic:{topic}"
314
+ name = str(node.get("name") or "").strip()
315
+ if name:
316
+ return name
317
+ file_loc = str(node.get("file") or "").strip()
318
+ if file_loc:
319
+ return file_loc
320
+ return None
321
+
322
+
323
+ def to_envelope_rows(pydantic_results: list[Any]) -> list[dict[str, Any]]:
324
+ """Pydantic -> dict boundary: ``.model_dump()`` each item exactly once.
325
+
326
+ Accepts pydantic models (``.model_dump()``) or plain dicts (passthrough).
327
+ Any other type raises ``TypeError`` rather than silently coercing - the
328
+ boundary is a single-shape conversion, not a best-effort adapter, and a
329
+ non-dict/non-pydantic item signals a backend-contract bug we want to
330
+ surface immediately.
331
+ """
332
+ out: list[dict[str, Any]] = []
333
+ for item in pydantic_results:
334
+ if hasattr(item, "model_dump"):
335
+ out.append(item.model_dump())
336
+ elif isinstance(item, dict):
337
+ out.append(item)
338
+ else:
339
+ raise TypeError(
340
+ f"to_envelope_rows: expected pydantic model or dict, got {type(item).__name__}"
341
+ )
342
+ return out
343
+
344
+
345
+ def mark_truncated(rows: list[Any], limit: int) -> tuple[list[Any], bool]:
346
+ """+1-fetch trick.
347
+
348
+ Pass ``limit+1`` to the backend; this helper drops the overflow row and
349
+ reports whether truncation occurred. ``limit`` must be ``>= 0``.
350
+ """
351
+ if limit < 0:
352
+ raise ValueError(f"mark_truncated: limit must be >= 0, got {limit}")
353
+ truncated = len(rows) > limit
354
+ if not truncated:
355
+ return list(rows), False
356
+ return list(rows[:limit]), True
357
+
358
+
359
+ def normalize_enum(value: str, *, kind: str) -> str:
360
+ """Normalize a user-supplied enum to the graph's stored literal form.
361
+
362
+ * role / capability / framework / java_kind: case + kebab -> UPPER_SNAKE
363
+ (the stored literals are uppercase; e.g. ``Controller``/``controller``
364
+ -> ``CONTROLLER``, ``web-flux`` -> ``WEB_FLUX``).
365
+ * client_kind / producer_kind / source_layer: routed through the explicit
366
+ lookup tables above (the stored literals are lowercase_snake with
367
+ non-obvious suffixes: ``feign`` -> ``feign_method``, ``kafka`` ->
368
+ ``kafka_send``, ``layer-a`` -> ``layer_a_meta``).
369
+
370
+ Empty input returns empty. Unknown lookup values fall through to the
371
+ UPPER_SNAKE path so callers see *something* (validation against the
372
+ graph's ``VALID_*`` set happens at the command layer).
373
+ """
374
+ raw = (value or "").strip()
375
+ if not raw:
376
+ return raw
377
+ table = _ENUM_LOOKUP_TABLES.get(kind)
378
+ if table is not None:
379
+ if raw in table:
380
+ return table[raw]
381
+ norm = raw.lower().replace("-", "_").replace(" ", "_")
382
+ if norm in table:
383
+ return table[norm]
384
+ # Fall through to UPPER_SNAKE for unknown values; the command layer
385
+ # validates against VALID_CLIENT_KINDS / VALID_PRODUCER_KINDS / the
386
+ # source_layer set and emits an actionable error envelope.
387
+ # framework / java_kind (symbol_kind) literals are stored LOWERCASE — both
388
+ # in the graph (Route.framework, Symbol.kind) and in the NodeFilter Literal
389
+ # types (mcp_v2.Framework / DeclarationSymbolKind). Uppercasing them broke
390
+ # `routes --framework`, `find --java-kind` filter mode, and crashed
391
+ # `search --framework` with a pydantic ValidationError. role / capability
392
+ # stay UPPER_SNAKE (those ARE stored uppercase).
393
+ if kind in ("framework", "java_kind"):
394
+ return raw.lower().replace("-", "_").replace(" ", "_")
395
+ return raw.upper().replace("-", "_").replace(" ", "_")
396
+
397
+
398
+ def _matches_post_filters(
399
+ node: NodeRef,
400
+ *,
401
+ java_kind: str | None,
402
+ role: str | None,
403
+ fqn_contains: str | None,
404
+ ) -> bool:
405
+ """Client-side post-filter on a resolved node (PR-JRAG-1a resolve-first)."""
406
+ if java_kind is not None:
407
+ want = normalize_enum(java_kind, kind="java_kind")
408
+ # symbol_kind is stored LOWERCASE (DeclarationSymbolKind: class/method/...);
409
+ # normalize_enum now returns lowercase for java_kind, so compare on the
410
+ # lowercased actual (was upper-vs-upper, which only worked by accident).
411
+ actual = (node.symbol_kind or "").lower().replace("-", "_")
412
+ if actual != want:
413
+ return False
414
+ if role is not None:
415
+ want = normalize_enum(role, kind="role")
416
+ actual = (node.role or "").upper().replace("-", "_")
417
+ if actual != want:
418
+ return False
419
+ if fqn_contains is not None:
420
+ if fqn_contains not in (node.fqn or ""):
421
+ return False
422
+ return True
423
+
424
+
425
+ def _candidate_to_dict(node: NodeRef, reason: str) -> dict[str, Any]:
426
+ """Build a candidate dict for the ambiguous envelope, carrying ``reason``.
427
+
428
+ No ``file`` / ``score`` fields — ambiguous candidates are not file pointers
429
+ or ranked matches, they are *narrowing* hints (PR-JRAG-1a renderer spec).
430
+ """
431
+ return {
432
+ "id": node.id,
433
+ "fqn": node.fqn,
434
+ "kind": node.kind,
435
+ "name": simple_name({"fqn": node.fqn}),
436
+ "microservice": node.microservice,
437
+ "module": node.module,
438
+ "role": node.role,
439
+ "symbol_kind": node.symbol_kind,
440
+ "reason": reason,
441
+ }
442
+
443
+
444
+ def _constructor_owner_fqn(node: NodeRef) -> str | None:
445
+ """If ``node`` is a constructor, return its owning class FQN; else None.
446
+
447
+ A constructor's FQN is ``<classFqn>#<simpleName>(args)`` where the member
448
+ name equals the class's simple name (``com.x.Foo#Foo(...)``). ``symbol_kind``
449
+ may be ``"constructor"`` or, on older nodes, ``"method"`` — the FQN shape is
450
+ authoritative. Used by the class-vs-constructor auto-pick in
451
+ :func:`resolve_query` so ``inspect/callers/callees <ClassName>`` does not
452
+ bounce to "ambiguous" just because the class shares its name with its ctor.
453
+ """
454
+ fqn = (node.fqn or "").strip()
455
+ if "#" not in fqn:
456
+ return None
457
+ head, rest = fqn.split("#", 1)
458
+ member = rest.split("(", 1)[0].strip()
459
+ class_simple = head.rsplit(".", 1)[-1]
460
+ if member and member == class_simple:
461
+ return head
462
+ return None
463
+
464
+
465
+ def _node_file_location(graph: Any, node_id: str) -> str | None:
466
+ """Fetch ``filename:start_line`` for a resolved node from the graph.
467
+
468
+ ``NodeRef`` does not carry ``filename`` / ``start_line`` (graph_types.NodeRef
469
+ only has id/kind/fqn/symbol_kind/microservice/module/role); the resolved
470
+ node's location is fetched separately via a single-column Cypher lookup.
471
+ """
472
+ rows = graph._rows( # noqa: SLF001 - same pattern as mcp_v2._load_node_record
473
+ "MATCH (n) WHERE n.id = $id "
474
+ "RETURN n.filename AS filename, n.start_line AS start_line LIMIT 1",
475
+ {"id": node_id},
476
+ )
477
+ if not rows:
478
+ return None
479
+ row = rows[0]
480
+ filename = str(row.get("filename") or "").strip()
481
+ if not filename:
482
+ return None
483
+ start_line = row.get("start_line")
484
+ if start_line:
485
+ try:
486
+ return f"{filename}:{int(start_line)}"
487
+ except (TypeError, ValueError):
488
+ return filename
489
+ return filename
490
+
491
+
492
+ def resolve_query(
493
+ identifier: str,
494
+ *,
495
+ hint_kind: Literal["symbol", "route", "client", "producer"] | None,
496
+ java_kind: str | None,
497
+ role: str | None,
498
+ fqn_contains: str | None,
499
+ cfg: Any,
500
+ graph: Any | None = None,
501
+ microservice: str = "",
502
+ module: str = "",
503
+ ) -> tuple[NodeRef | None, Envelope]:
504
+ """Resolve-first mapper: runs ``resolve_v2`` and maps its contract to the envelope.
505
+
506
+ * ``one`` -> apply post-filters (``java_kind`` / ``role`` / ``fqn_contains``)
507
+ to the resolved node. If pass: ``(node, env ok)`` with
508
+ ``env.file_location`` set from the node's ``filename`` + ``start_line``
509
+ and ``env.root = node.id``. If fail: ``(None, env not_found)``.
510
+ * ``many`` -> apply post-filters to candidates. If exactly one survives,
511
+ treat as ``one`` (proceed). Else ``(None, env ambiguous)`` with candidates
512
+ capped at 10, each carrying ``reason``. Auto-pick is forbidden.
513
+ * ``none`` -> ``(None, env not_found)`` with a message mentioning
514
+ ``jrag search``.
515
+
516
+ ``cfg`` is a ``ResolvedOperatorConfig`` (typed loosely to keep this module
517
+ cocoindex-free and to avoid importing the operator config layer here).
518
+ ``graph`` is optional for testability; in production the caller passes the
519
+ graph it loaded via :func:`jrag._load_graph`.
520
+
521
+ ``microservice`` / ``module`` are optional resolve-time filters (pushed
522
+ down from ``--service`` / ``--module``) that narrow candidate resolution
523
+ rather than acting only as traversal post-filters. For a Route root this
524
+ means ``--service`` disambiguates which microservice's route is selected.
525
+ """
526
+ # Lazy imports — keeps build_parser() / `jrag --help` free of resolve/ladybug.
527
+ from resolve_service import resolve_v2
528
+
529
+ if graph is None:
530
+ from ladybug_queries import LadybugGraph
531
+
532
+ graph = LadybugGraph.get(str(cfg.ladybug_path))
533
+
534
+ out = resolve_v2(
535
+ identifier, hint_kind=hint_kind, graph=graph,
536
+ microservice=microservice, module=module,
537
+ )
538
+
539
+ if out.status == "one" and out.node is not None:
540
+ node = out.node
541
+ if _matches_post_filters(node, java_kind=java_kind, role=role, fqn_contains=fqn_contains):
542
+ env = Envelope(status="ok", root=node.id)
543
+ loc = _node_file_location(graph, node.id)
544
+ if loc is not None:
545
+ env.file_location = loc
546
+ return node, env
547
+ return None, Envelope(
548
+ status="not_found",
549
+ message=(
550
+ f"No matches for {identifier!r} after applying --java-kind/--role/--fqn-contains "
551
+ "filters; use `jrag search <query>` for ranked fuzzy lookup."
552
+ ),
553
+ )
554
+
555
+ if out.status == "many" and out.candidates:
556
+ survivors = [
557
+ c for c in out.candidates
558
+ if _matches_post_filters(c.node, java_kind=java_kind, role=role, fqn_contains=fqn_contains)
559
+ ]
560
+ if len(survivors) == 1:
561
+ node = survivors[0].node
562
+ env = Envelope(status="ok", root=node.id)
563
+ loc = _node_file_location(graph, node.id)
564
+ if loc is not None:
565
+ env.file_location = loc
566
+ return node, env
567
+ if not survivors:
568
+ # Every `many` candidate was rejected by the post-filters — there is
569
+ # nothing left to disambiguate, so this is not_found, NOT an empty
570
+ # ambiguous list (which would render as "0 ambiguous matches" with no
571
+ # narrowing value). Same message as the `one` post-filter-fail branch.
572
+ return None, Envelope(
573
+ status="not_found",
574
+ message=(
575
+ f"No matches for {identifier!r} after applying --java-kind/--role/--fqn-contains "
576
+ "filters; use `jrag search <query>` for ranked fuzzy lookup."
577
+ ),
578
+ )
579
+ # Class-vs-constructor auto-pick: a class and its constructor share a
580
+ # simple name, so resolve_v2 returns "many" for ANY
581
+ # `inspect/callers/callees/decompose/dependencies <ClassName>`. When the
582
+ # survivors are exactly ONE type (FQN with no '#') plus one-or-more
583
+ # constructors OF THAT SAME TYPE, auto-pick the type. The constructor
584
+ # stays reachable via its explicit FQN or `--java-kind constructor`.
585
+ # Two genuinely-different types (same simple name across services) still
586
+ # surface as ambiguous — we never silently guess across distinct classes.
587
+ if len(survivors) >= 2:
588
+ type_survivors = [c for c in survivors if "#" not in (c.node.fqn or "")]
589
+ member_survivors = [c for c in survivors if "#" in (c.node.fqn or "")]
590
+ if len(type_survivors) == 1 and member_survivors:
591
+ type_fqn = (type_survivors[0].node.fqn or "").strip()
592
+ if all(_constructor_owner_fqn(c.node) == type_fqn for c in member_survivors):
593
+ node = type_survivors[0].node
594
+ env = Envelope(status="ok", root=node.id)
595
+ loc = _node_file_location(graph, node.id)
596
+ if loc is not None:
597
+ env.file_location = loc
598
+ return node, env
599
+ capped = survivors[:10]
600
+ env = Envelope(
601
+ status="ambiguous",
602
+ candidates=[_candidate_to_dict(c.node, c.reason) for c in capped],
603
+ )
604
+ return None, env
605
+
606
+ # status == "none" (or "one"/"many" with missing data — treat as not_found).
607
+ raw_msg = out.message or f"No matches for {identifier!r}."
608
+ # Always surface the CLI-specific `jrag search` hint (resolve_v2's built-in
609
+ # message references the MCP `search(query=...)` form, which is wrong for
610
+ # the agent-facing CLI).
611
+ if "jrag search" not in raw_msg:
612
+ raw_msg = f"{raw_msg} Use `jrag search <query>` for ranked fuzzy lookup."
613
+ return None, Envelope(status="not_found", message=raw_msg)
614
+
615
+
616
+ # Listing breadcrumbs (root is None): 1–2 template hints pointing at the natural
617
+ # follow-up form for a listing command. These are guidance with a placeholder,
618
+ # NOT runnable verbatim — they do NOT go through jrag_hints.next_actions (which
619
+ # requires a resolved root fqn). Other listings (microservices, map, ...) get no
620
+ # breadcrumb: don't over-build.
621
+ _LISTING_BREADCRUMBS: dict[str, list[str]] = {
622
+ "http-routes": ["jrag callers '<path>'", "jrag flow '<path>'"],
623
+ "http-clients": ["jrag callees '<client>'"],
624
+ "producers": ["jrag callees '<producer>'"],
625
+ "topics": ["jrag listeners '<topic>'"],
626
+ }
627
+
628
+
629
+ def next_actions_hook(
630
+ envelope: Envelope,
631
+ root: str | None = None,
632
+ edge_summary: dict[str, Any] | None = None,
633
+ result_edges: list[dict[str, Any]] | None = None,
634
+ command: str | None = None,
635
+ ) -> list[str]:
636
+ """Populate ``envelope.agent_next_actions`` via :mod:`jrag_hints` (PR-JRAG-4).
637
+
638
+ Every command that produces edges or an edge_summary calls this hook. The
639
+ hook extracts the root node's FQN from ``envelope.nodes[root]`` and delegates
640
+ to :func:`jrag_hints.next_actions`, which maps edge labels → ``jrag <cmd>
641
+ <fqn>`` hints (≤5, zero-direction suppressed, dot-keys covered,
642
+ root-kind-aware). The result is assigned to ``envelope.agent_next_actions``
643
+ (auto-omitted from ``to_dict()`` when empty — see :meth:`Envelope.to_dict`).
644
+
645
+ Listing mode (``root is None``): emits 1–2 template breadcrumbs from
646
+ :data:`_LISTING_BREADCRUMBS` when ``command`` is a known listing
647
+ (``http-routes`` / ``http-clients`` / ``producers`` / ``topics``), so the
648
+ agent sees the natural follow-up form. Other listings get nothing.
649
+
650
+ Skipped (returns ``[]``) when:
651
+ * ``root`` is ``None`` and ``command`` is not a breadcrumb listing.
652
+ * The root node is absent from ``envelope.nodes`` (defensive).
653
+ * The root node's ``fqn`` is empty/missing.
654
+ * The root node is a synthetic kind (``microservice`` / ``topic`` /
655
+ ``unresolved_import``) — hints targeting a synthetic id would never
656
+ resolve and would mislead the agent.
657
+
658
+ Args:
659
+ envelope: The output envelope (mutated in place: ``agent_next_actions``
660
+ is set on success).
661
+ root: The root node id (for commands that resolve a single node).
662
+ edge_summary: The edge_summary from describe_v2 (inspect command only).
663
+ result_edges: Raw edge rows from traversal commands (used when
664
+ ``edge_summary`` is ``None``).
665
+ command: The current jrag subcommand name (``args.command``), used to
666
+ drop self-hints and to key listing breadcrumbs.
667
+
668
+ Returns:
669
+ The list of hint strings assigned to ``envelope.agent_next_actions``
670
+ (empty when the hook was a no-op for this call).
671
+ """
672
+ if root is None:
673
+ # Listing breadcrumb: template hints for the natural follow-up form.
674
+ crumbs = _LISTING_BREADCRUMBS.get(command or "")
675
+ if not crumbs:
676
+ return []
677
+ # Filter out the current command if it appears in any breadcrumb;
678
+ # de-dup and cap at _MAX_HINTS (5).
679
+ filtered: list[str] = []
680
+ for c in crumbs:
681
+ if c in filtered:
682
+ continue
683
+ parts = c.split()
684
+ if len(parts) >= 2 and parts[1] == command:
685
+ continue
686
+ filtered.append(c)
687
+ envelope.agent_next_actions = filtered[:5]
688
+ return envelope.agent_next_actions
689
+ root_node = envelope.nodes.get(root)
690
+ if root_node is None:
691
+ return []
692
+ root_fqn = str(root_node.get("fqn") or "").strip()
693
+ if not root_fqn:
694
+ return []
695
+ # Suppress hints for synthetic roots (microservice connection view, topic
696
+ # grouping, unresolved imports) — these would produce ``jrag callees <name>``
697
+ # style hints that would never resolve.
698
+ kind = str(root_node.get("kind") or "")
699
+ if kind in ("microservice", "topic", "unresolved_import"):
700
+ return []
701
+ from java_codebase_rag.jrag_hints import next_actions
702
+
703
+ envelope.agent_next_actions = next_actions(
704
+ root_fqn=root_fqn,
705
+ edge_summary=edge_summary,
706
+ result_edges=result_edges if result_edges is not None else list(envelope.edges),
707
+ current_command=command,
708
+ root_kind=kind,
709
+ )
710
+ return envelope.agent_next_actions
711
+
712
+
713
+ # ---------------------------------------------------------------------------
714
+ # Output detail projection (PR-JRAG-6).
715
+ #
716
+ # ``--detail brief|normal|full`` is ORTHOGONAL to ``--format text|json``. The
717
+ # renderer calls :func:`project_envelope` once, then BOTH the JSON path and the
718
+ # text renderers consume the trimmed dict — so ``--format json --detail brief``
719
+ # and ``--format text --detail brief`` go through the SAME field set.
720
+ #
721
+ # Detail was previously decided per-handler at node-dict construction
722
+ # (``_symbol_hit_to_dict`` trimmed; ``SearchHit.model_dump()`` carried the full
723
+ # snippet), which coupled "how much" to "which format" and made JSON dump 50-
724
+ # line snippets + 10 empty fields while text showed only ``Name @service``.
725
+ # Inverting to "carry full, trim at one seam" makes the two axes independent.
726
+ #
727
+ # Key-sets are CATEGORY-based (intersected with each node's present keys), so
728
+ # they are kind-agnostic and auto-handle new node kinds: a route at ``normal``
729
+ # shows the same categories of fields as a symbol at ``normal``.
730
+ # ---------------------------------------------------------------------------
731
+
732
+ # Raw location columns carried by SymbolHit; folded into the display field
733
+ # ``file`` by :func:`_compose_file`. They are NOT display fields themselves.
734
+ _RAW_LOCATION_KEYS = frozenset(
735
+ {"filename", "start_line", "end_line", "start_byte", "end_byte"}
736
+ )
737
+
738
+ # Identity only == the keys the text renderers' display_name / tiered_name read.
739
+ # Reproduces today's terse text output exactly at ``brief``. ``reason`` is
740
+ # candidate-structural (the ambiguous narrowing hint), so it survives at every
741
+ # level — a candidate without its reason is useless.
742
+ #
743
+ # ``score`` is included at brief because for ranked result sets (``search``)
744
+ # the score IS the point — dropping it at brief made ``jrag search --detail
745
+ # brief`` indistinguishable from unranked listings, hiding the relevance signal
746
+ # an agent needs to pick a hit. ``score`` is identity-adjacent (a per-node
747
+ # ranking), and listing/traversal rows built from NodeRef carry no ``score``
748
+ # field at all (only SearchHit does), so including it here only affects search.
749
+ #
750
+ # NOTE: ``id`` is intentionally ABSENT. Graph node ids (40-hex SHAs) are an
751
+ # internal join key, never an agent-facing identifier — the CLI is resolve-first
752
+ # (agents pass FQN / simple name / route / topic). :func:`project_node` drops
753
+ # ``id`` and every ``*_id`` graph foreign key at every detail level via
754
+ # :func:`_is_graph_id_field`; see the boundary-strip rule there.
755
+ _BRIEF_NODE_KEYS: frozenset[str] = frozenset(
756
+ {
757
+ "kind",
758
+ "fqn",
759
+ "name",
760
+ "microservice",
761
+ "path",
762
+ "method",
763
+ "topic",
764
+ "member_fqn",
765
+ "target_service",
766
+ "broker",
767
+ "client_kind",
768
+ "producer_kind",
769
+ "import_simple",
770
+ "import_fqn",
771
+ "import_kind",
772
+ "resolved",
773
+ "reason",
774
+ "score",
775
+ }
776
+ )
777
+
778
+ # brief + location / classification / ranking. ``file`` is the composed
779
+ # ``filename:start_line`` display field (see :func:`_compose_file`).
780
+ _NORMAL_NODE_KEYS: frozenset[str] = _BRIEF_NODE_KEYS | frozenset(
781
+ {"module", "role", "symbol_kind", "framework", "file", "score"}
782
+ )
783
+
784
+ # Edge attrs the text renderers read at the default level (target id variants
785
+ # across backends + the grouping/confidence keys).
786
+ _BRIEF_EDGE_KEYS: frozenset[str] = frozenset(
787
+ {
788
+ "other_id",
789
+ "dst_id",
790
+ "target_id",
791
+ "term_id",
792
+ "edge_type",
793
+ "stored_edge_type",
794
+ "label",
795
+ "type",
796
+ "confidence",
797
+ "direction",
798
+ "section",
799
+ "stage",
800
+ "resolved",
801
+ }
802
+ )
803
+
804
+ # brief + the cheap edge attrs (injection mechanism, role label, origin fqn).
805
+ _NORMAL_EDGE_KEYS: frozenset[str] = _BRIEF_EDGE_KEYS | frozenset(
806
+ {"mechanism", "role", "from_fqn"}
807
+ )
808
+
809
+
810
+ def _is_empty(value: Any) -> bool:
811
+ """True for values that carry no information: ``None`` / ``""`` / ``[]`` / ``{}``.
812
+
813
+ ``False`` and ``0`` / ``0.0`` are NOT empty (they are meaningful: an
814
+ unresolved ``resolved=False`` flag, a ``0.0`` confidence). Only None and
815
+ zero-length containers are dropped.
816
+ """
817
+ if value is None:
818
+ return True
819
+ if isinstance(value, (str, list, dict)) and len(value) == 0:
820
+ return True
821
+ return False
822
+
823
+
824
+ def _is_graph_id_field(key: str) -> bool:
825
+ """True for raw graph node-id fields stripped at the CLI boundary.
826
+
827
+ Boundary-strip rule for the agent-facing surface: the CLI is resolve-first
828
+ (agents pass FQN / simple name / route / topic — never a raw id), so no
829
+ graph-internal id or graph foreign-key column reaches text or JSON. The rule
830
+ is ``key == "id" or key.endswith("_id")``, which catches ``id``,
831
+ ``parent_id`` (SymbolHit), ``chunk_id`` / ``symbol_id`` (SearchHit), and
832
+ ``member_id`` (raw list_clients/list_producers rows), plus any future graph
833
+ FK. No agent-meaningful field in this domain uses the ``_id`` suffix —
834
+ topics are keyed by name, routes by path — so the suffix rule is safe.
835
+
836
+ Applied in :func:`project_node` (fields) and :meth:`Envelope.to_json`
837
+ (boundary reshape); the internal envelope + :meth:`Envelope.to_dict` stay
838
+ id-keyed for join/debug use.
839
+ """
840
+ return key == "id" or key.endswith("_id")
841
+
842
+
843
+ def _strip_graph_id_fields(node: dict[str, Any]) -> dict[str, Any]:
844
+ """Return a copy of ``node`` with graph-id fields removed (see :func:`_is_graph_id_field`).
845
+
846
+ RECURSES into nested dicts and list-of-dicts values so ids embedded in
847
+ sub-records are stripped too — e.g. the ``data`` sub-dict on a
848
+ ``NodeRecord.model_dump()`` (the ``inspect`` envelope node) carries its own
849
+ ``id`` / ``parent_id``; a top-level-only strip would leave them. Scalars and
850
+ non-dict lists pass through unchanged.
851
+ """
852
+ out: dict[str, Any] = {}
853
+ for k, v in node.items():
854
+ if _is_graph_id_field(k):
855
+ continue
856
+ out[k] = _strip_nested_ids(v)
857
+ return out
858
+
859
+
860
+ def _looks_like_raw_graph_id(key: str) -> bool:
861
+ """Heuristic: does this string look like a raw graph node id?
862
+
863
+ Used by :meth:`Envelope._to_idfree_dict` to decide whether to synthesize an
864
+ opaque positional key when a node has no semantic identity (see
865
+ :func:`node_key` returning ``None``). Matches 40-hex SHA-1 ids and the
866
+ prefixed hash forms the graph builder emits (``r:phantom:<hex>``,
867
+ ``ucs:<hex>``, ``sym:<hex>``, ``chunk:<hex>``). Meaningful literal keys
868
+ (``"index"``, ``"microservices"``) and handler-built synthetics
869
+ (``microservice:<name>``, ``import:<fqn>``, ``topic:<name>``) do NOT match.
870
+ """
871
+ if not key:
872
+ return False
873
+ if _SHA1_RE.fullmatch(key):
874
+ return True
875
+ # prefixed hash forms: "<prefix>:<optional sub>:<hex-ish>"
876
+ if ":" in key:
877
+ head = key.split(":", 1)[0]
878
+ tail = key.rsplit(":", 1)[-1]
879
+ if head in _GRAPH_ID_PREFIXES and _HEX_TAIL_RE.search(tail):
880
+ return True
881
+ return False
882
+
883
+
884
+ _GRAPH_ID_PREFIXES = frozenset({"r", "ucs", "sym", "chunk", "route", "member"})
885
+ _SHA1_RE = re.compile(r"[0-9a-f]{40}")
886
+ _HEX_TAIL_RE = re.compile(r"[0-9a-f]{8,}")
887
+
888
+
889
+ def _strip_nested_ids(value: Any) -> Any:
890
+ """Recursively strip graph-id fields from nested dicts / list-of-dicts."""
891
+ if isinstance(value, dict):
892
+ return _strip_graph_id_fields(value)
893
+ if isinstance(value, list):
894
+ return [_strip_nested_ids(item) for item in value]
895
+ return value
896
+
897
+
898
+ def _drop_empty(node: dict[str, Any]) -> dict[str, Any]:
899
+ """Drop keys whose value is ``None`` / ``""`` / ``[]`` / ``{}``.
900
+
901
+ Extends the "omit empty optionals" rule from :meth:`Envelope.to_dict` DOWN
902
+ into each node/edge dict so JSON stops serializing ``"symbol_id": null`` /
903
+ ``"role": null`` (the "10 empty fields" complaint). Applied at every detail
904
+ level — no consumer benefits from empty fields, and the text renderers
905
+ already skip missing keys, so this only changes JSON (for the better).
906
+ """
907
+ return {k: v for k, v in node.items() if not _is_empty(v)}
908
+
909
+
910
+ def _compose_file(node: dict[str, Any]) -> dict[str, Any]:
911
+ """Fold raw ``filename`` + ``start_line`` into a display ``file`` field.
912
+
913
+ SymbolHit-derived nodes carry ``filename`` / ``start_line`` (raw graph
914
+ columns) that are not display fields. Compose them into one
915
+ ``"filename:start_line"`` string (or just ``filename`` when no line) so the
916
+ ``normal`` tier can show location as a single stable field, then drop the
917
+ raw location columns. Returns the node unchanged (minus raw columns) when
918
+ no ``filename`` is present. Returns a new dict; the input is not mutated.
919
+ """
920
+ filename = str(node.get("filename") or "").strip()
921
+ if not filename:
922
+ return {k: v for k, v in node.items() if k not in _RAW_LOCATION_KEYS}
923
+ start_line = node.get("start_line")
924
+ try:
925
+ file_value = f"{filename}:{int(start_line)}" if start_line not in (None, "") else filename
926
+ except (TypeError, ValueError):
927
+ file_value = filename
928
+ out = {k: v for k, v in node.items() if k not in _RAW_LOCATION_KEYS}
929
+ out["file"] = file_value
930
+ return out
931
+
932
+
933
+ def _is_structural_section(value: Any) -> bool:
934
+ """True for a non-empty dict or a non-empty list-of-dicts (structural payload).
935
+
936
+ Inspect-shape aggregate nodes carry their payload as nested sections:
937
+ ``counts``/``edges`` (dict) on status, ``services`` (dict) on microservices,
938
+ ``roles``/``frameworks`` (dict) on conventions, ``bundle`` (dict) +
939
+ ``route_sample``/``client_sample`` (list-of-dicts) on overview microservice,
940
+ ``producers``/``consumers`` (list-of-dicts) on overview topic. Keeping these
941
+ at brief/normal restores the inspect payload that the scalar-only
942
+ allow-list otherwise strips to empty.
943
+
944
+ A scalar list (e.g. ``annotations: list[str]``) is NOT a structural section
945
+ — it stays gated by the scalar allow-list, so an inspect subject's
946
+ ``annotations`` only appears at ``full`` (per the inspect brief/normal/full
947
+ contract).
948
+ """
949
+ if isinstance(value, dict) and value:
950
+ return True
951
+ if isinstance(value, list) and bool(value) and all(isinstance(x, dict) for x in value):
952
+ return True
953
+ return False
954
+
955
+
956
+ # Identity-bearing keys whose presence marks a node as a SUBJECT (symbol/route/
957
+ # client/producer/topic) rather than a pure rollup. ``project_node`` uses this
958
+ # to distinguish inspect-subject nodes (fqn/name carry identity; rich detail
959
+ # fields gated to ``full``) from rollup aggregates (no identity — the nested
960
+ # sections ARE the data, kept at every level).
961
+ #
962
+ # ``kind`` is intentionally ABSENT: it is a type tag, not identity (an
963
+ # overview microservice/topic bundle carries ``kind`` for self-identification
964
+ # but is still a rollup whose payload is the nested ``bundle`` dict + sample
965
+ # lists). ``microservice`` is also absent: classification, not identity.
966
+ _ROLLUP_IDENTITY_KEYS: tuple[str, ...] = (
967
+ "fqn", "name", "path", "topic", "member_fqn",
968
+ )
969
+
970
+
971
+ def _is_rollup_node(node: dict[str, Any]) -> bool:
972
+ """True for an aggregate/rollup node carrying no symbol/route/topic identity.
973
+
974
+ Rollup nodes (status/microservices/map/conventions, and overview bundles
975
+ built without top-level identity) have their entire payload as nested
976
+ dict/list sections and carry no fqn/kind/name/path/topic/member_fqn. For
977
+ such nodes :func:`project_node` keeps nested sections at brief/normal too —
978
+ otherwise the projection strips them to ``{}`` and ``jrag status --detail
979
+ brief`` emits nothing (the sections ARE the data; there is nothing else to
980
+ show). Subject nodes keep the strict scalar allow-list so their rich
981
+ detail fields (signature/annotations/edge_summary/...) stay gated to
982
+ ``full``.
983
+ """
984
+ for k in _ROLLUP_IDENTITY_KEYS:
985
+ v = node.get(k)
986
+ if isinstance(v, str):
987
+ if v.strip():
988
+ return False
989
+ elif v:
990
+ return False
991
+ return True
992
+
993
+
994
+ def project_node(node: dict[str, Any], detail: str) -> dict[str, Any]:
995
+ """Project a node dict to the field set for ``detail``.
996
+
997
+ * ``"full"`` -> keep every present key (still :func:`_compose_file` +
998
+ :func:`_drop_empty`, so raw location columns become ``file`` and empties
999
+ vanish).
1000
+ * ``"normal"`` -> keep ``_NORMAL_NODE_KEYS`` (identity + location +
1001
+ classification + ranking). This is the default and the fix for the
1002
+ "text too terse" complaint: adds ``file`` / ``score`` / ``role`` /
1003
+ ``module``.
1004
+ * ``"brief"`` -> keep ``_BRIEF_NODE_KEYS`` (identity only == today's text).
1005
+
1006
+ For rollup (aggregate) nodes — those with no fqn/kind/name/path/topic/
1007
+ member_fqn — nested dict sections and list-of-dict sections (counts/edges/
1008
+ services/roles/bundle/samples/...) are kept at brief/normal too. Without
1009
+ this, ``jrag status --detail brief`` (and microservices/map/conventions/
1010
+ overview) emit empty nodes because the entire payload is dict/list-valued
1011
+ and none of it is in the scalar allow-list. Subject nodes (inspect/
1012
+ traversal/listing rows) carry identity and go through the strict scalar
1013
+ allow-list, so their rich detail stays gated to ``full``.
1014
+
1015
+ ``file`` is composed before selection so it is available at ``normal`` /
1016
+ ``full``. Empty values are dropped at every level. Graph-id fields (``id``,
1017
+ ``*_id``) are stripped at every level via :func:`_strip_graph_id_fields` —
1018
+ the CLI is resolve-first, so raw graph ids never reach the agent. Returns a
1019
+ new dict.
1020
+ """
1021
+ composed = _compose_file(node)
1022
+ if detail == "full":
1023
+ selected = composed
1024
+ else:
1025
+ allow = _NORMAL_NODE_KEYS if detail == "normal" else _BRIEF_NODE_KEYS
1026
+ if _is_rollup_node(composed):
1027
+ # Keep allowed scalars + structural sections (dict / list-of-dicts).
1028
+ # The sections are the rollup's payload; without them brief/normal
1029
+ # would render an empty node. Listing/traversal/inspect-subject
1030
+ # nodes (with identity) take the strict-scalar branch below.
1031
+ selected = {
1032
+ k: v
1033
+ for k, v in composed.items()
1034
+ if k in allow or _is_structural_section(v)
1035
+ }
1036
+ else:
1037
+ selected = {k: v for k, v in composed.items() if k in allow}
1038
+ return _drop_empty(_strip_graph_id_fields(selected))
1039
+
1040
+
1041
+ def project_edge(edge: dict[str, Any], detail: str) -> dict[str, Any]:
1042
+ """Project an edge row to the attr set for ``detail`` (mirrors :func:`project_node`).
1043
+
1044
+ * ``"full"`` -> all attrs.
1045
+ * ``"normal"`` -> ``_NORMAL_EDGE_KEYS`` (adds ``mechanism`` / ``role`` /
1046
+ ``from_fqn`` over brief).
1047
+ * ``"brief"`` -> ``_BRIEF_EDGE_KEYS`` (target id + label + confidence +
1048
+ grouping keys == what the text renderers read today).
1049
+ """
1050
+ if detail == "full":
1051
+ selected = edge
1052
+ else:
1053
+ allow = _NORMAL_EDGE_KEYS if detail == "normal" else _BRIEF_EDGE_KEYS
1054
+ selected = {k: v for k, v in edge.items() if k in allow}
1055
+ return _drop_empty(selected)
1056
+
1057
+
1058
+ def project_envelope(envelope: Envelope, detail: str) -> Envelope:
1059
+ """Return a new Envelope with nodes/edges/candidates projected to ``detail``.
1060
+
1061
+ The single projection seam: :func:`jrag_render.render` calls this once,
1062
+ then both the JSON path (``to_json``) and the text renderers consume the
1063
+ result. Envelope-level fields (``status`` / ``root`` / ``warnings`` /
1064
+ ``truncated`` / ``file_location`` / ``message`` / ``agent_next_actions``)
1065
+ are passed through unchanged — they are not node-level and have no detail
1066
+ axis. ``detail`` is validated up front so a typo raises instead of
1067
+ silently behaving like ``full``.
1068
+ """
1069
+ if detail not in ("brief", "normal", "full"):
1070
+ raise ValueError(
1071
+ f"project_envelope: detail must be brief|normal|full, got {detail!r}"
1072
+ )
1073
+ return Envelope(
1074
+ status=envelope.status,
1075
+ nodes={nid: project_node(n, detail) for nid, n in envelope.nodes.items()},
1076
+ edges=[project_edge(e, detail) for e in envelope.edges],
1077
+ root=envelope.root,
1078
+ candidates=[project_node(c, detail) for c in envelope.candidates],
1079
+ agent_next_actions=list(envelope.agent_next_actions),
1080
+ warnings=list(envelope.warnings),
1081
+ truncated=envelope.truncated,
1082
+ file_location=envelope.file_location,
1083
+ message=envelope.message,
1084
+ is_external_entrypoint=envelope.is_external_entrypoint,
1085
+ )