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