java-codebase-rag 0.6.7__py3-none-any.whl → 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ast_java.py +8 -3
- build_ast_graph.py +72 -16
- graph_enrich.py +2 -1
- graph_types.py +133 -0
- java_codebase_rag/_fdlimit.py +10 -2
- java_codebase_rag/_stdio.py +32 -0
- java_codebase_rag/cli.py +135 -24
- java_codebase_rag/config.py +128 -9
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
- java_codebase_rag/installer.py +438 -103
- java_codebase_rag/jrag.py +4300 -0
- java_codebase_rag/jrag_envelope.py +1085 -0
- java_codebase_rag/jrag_hints.py +204 -0
- java_codebase_rag/jrag_render.py +688 -0
- java_codebase_rag/pipeline.py +20 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
- java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
- java_index_flow_lancedb.py +34 -19
- java_ontology.py +12 -0
- ladybug_queries.py +233 -52
- mcp_hints.py +6 -6
- mcp_v2.py +205 -617
- resolve_service.py +649 -0
- search_lancedb.py +10 -1
- server.py +20 -12
- java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
- {java_codebase_rag-0.6.7.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
|
+
)
|