java-codebase-rag 0.9.6__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. java_codebase_rag/absence/absence_vocab.py +7 -2
  2. java_codebase_rag/analysis/pr_analysis.py +33 -3
  3. java_codebase_rag/ast/ast_java.py +2 -1
  4. java_codebase_rag/cli.py +23 -8
  5. java_codebase_rag/config.py +68 -1
  6. java_codebase_rag/graph/build_ast_graph.py +123 -4
  7. java_codebase_rag/graph/graph_types.py +109 -22
  8. java_codebase_rag/graph/ladybug_queries.py +45 -2
  9. java_codebase_rag/index/java_index_flow_lancedb.py +10 -16
  10. java_codebase_rag/install_data/agents/explorer-rag-cli.md +3 -1
  11. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +3 -1
  12. java_codebase_rag/jrag.py +627 -661
  13. java_codebase_rag/jrag_envelope.py +13 -0
  14. java_codebase_rag/jrag_render.py +160 -3
  15. java_codebase_rag/lance_optimize.py +11 -12
  16. java_codebase_rag/mcp/mcp_v2.py +16 -1
  17. java_codebase_rag/pipeline.py +47 -1
  18. java_codebase_rag/read_payloads.py +781 -0
  19. java_codebase_rag/search/search_lancedb.py +138 -6
  20. java_codebase_rag/search/search_lexical.py +128 -30
  21. java_codebase_rag/search/search_scoring.py +82 -0
  22. java_codebase_rag/watch/__init__.py +0 -0
  23. java_codebase_rag/watch/client.py +230 -0
  24. java_codebase_rag/watch/daemon.py +368 -0
  25. java_codebase_rag/watch/lock.py +201 -0
  26. java_codebase_rag/watch/paths.py +76 -0
  27. java_codebase_rag/watch/protocol.py +122 -0
  28. java_codebase_rag/watch/server.py +273 -0
  29. java_codebase_rag/watch/warm.py +105 -0
  30. java_codebase_rag/watch/watcher.py +352 -0
  31. {java_codebase_rag-0.9.6.dist-info → java_codebase_rag-0.10.0.dist-info}/METADATA +30 -31
  32. java_codebase_rag-0.10.0.dist-info/RECORD +67 -0
  33. java_codebase_rag-0.9.6.dist-info/RECORD +0 -57
  34. {java_codebase_rag-0.9.6.dist-info → java_codebase_rag-0.10.0.dist-info}/WHEEL +0 -0
  35. {java_codebase_rag-0.9.6.dist-info → java_codebase_rag-0.10.0.dist-info}/entry_points.txt +0 -0
  36. {java_codebase_rag-0.9.6.dist-info → java_codebase_rag-0.10.0.dist-info}/licenses/LICENSE +0 -0
  37. {java_codebase_rag-0.9.6.dist-info → java_codebase_rag-0.10.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,781 @@
1
+ """Payload-returning cores for the ``jrag`` read commands.
2
+
3
+ Each ``<cmd>_payload(args, cfg, graph)`` function assembles the
4
+ JSON-serializable payload the corresponding ``jrag`` handler renders, WITHOUT
5
+ rendering. This lets the ``jrag watch`` daemon (later tasks) serve these
6
+ commands over a socket by calling the payload function and serializing,
7
+ reusing the exact cold read path.
8
+
9
+ Behavior-preserving extraction (Task 5 of the jrag-watch plan): the compute
10
+ (graph calls + folds) is lifted verbatim from the handlers in ``jrag.py``;
11
+ rendering stays in the handlers. Each handler is restructured to::
12
+
13
+ payload = <cmd>_payload(args, cfg, graph) # may raise PayloadError
14
+ render(payload, args) # unchanged render call
15
+
16
+ On resolve / kind-guard / validation failure the payload raises
17
+ :class:`PayloadError` carrying the :class:`Envelope` and ``rc`` the handler
18
+ would have rendered, so the handler can render the error byte-identically.
19
+
20
+ Payload shapes:
21
+ * ``search_payload`` -> ``SearchOutput`` (pydantic; the raw ``search_v2`` result)
22
+ * ``find_payload`` -> ``{"mode": "query"|"filter", ...}`` carrying either the
23
+ ``find_by_name_or_fqn`` post-filtered rows or a ``FindOutput``
24
+ * ``inspect_payload`` -> ``{"describe": DescribeOutput, "node_id", "node_fqn",
25
+ "file_location"}`` (inspect's renderer needs resolve-derived
26
+ ``file_location`` which is not on ``DescribeOutput``)
27
+ * ``callers_payload`` / ``callees_payload`` / ``flow_payload`` -> traversal dict
28
+ ``{"root_id", "nodes", "edges", "noun", "warnings", "truncated",
29
+ "is_external_entrypoint"}`` shaped exactly as ``_emit_traversal`` consumes.
30
+
31
+ All payloads round-trip through ``json.dumps``/``loads`` (pydantic via
32
+ ``model_dump``; the rest are plain dict/list/scalar).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ from typing import Any
38
+
39
+ # Top-level imports are safe here: ``jrag`` imports this module lazily (inside
40
+ # its handlers), so there is no import cycle -- by the time this module loads,
41
+ # ``jrag`` is already fully initialized, and these are stable module-level helpers.
42
+ from java_codebase_rag.jrag import (
43
+ _build_node_filter_or_error,
44
+ _check_kind_contradiction,
45
+ _clamped_limit,
46
+ _dedupe_traversal_edges,
47
+ _infer_kind,
48
+ _noderef_to_node_dict,
49
+ _symbol_hit_to_dict,
50
+ _warn_unapplied_scope,
51
+ )
52
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, normalize_enum, resolve_query
53
+
54
+
55
+ class PayloadError(Exception):
56
+ """Raised by a ``*_payload`` function on resolve / kind-guard / validation
57
+ failure.
58
+
59
+ Carries the :class:`Envelope` the handler renders and the ``rc`` it returns,
60
+ so the handler renders the error byte-identically to before the extraction.
61
+ """
62
+
63
+ def __init__(self, env: Envelope, rc: int) -> None:
64
+ self.env = env
65
+ self.rc = rc
66
+ super().__init__(env.message or env.status)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Shared resolve frame (verbatim logic of jrag._resolve_traversal_node, minus
71
+ # the print — the handler renders the Envelope carried by PayloadError).
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _resolve_traversal(args: argparse.Namespace, *, cfg, graph, hint_kind, apply_scope: bool):
76
+ """Resolve the traversal root non-printingly.
77
+
78
+ Returns the resolved ``node`` (NodeRef). Raises :class:`PayloadError`
79
+ carrying the resolve ``Envelope`` + ``rc`` on failure (rc=2 on error,
80
+ 0 on ambiguous/not_found — matches ``_resolve_traversal_node``).
81
+ """
82
+ node, env = resolve_query(
83
+ args.query,
84
+ hint_kind=hint_kind,
85
+ java_kind=getattr(args, "java_kind", None),
86
+ role=getattr(args, "role", None),
87
+ fqn_contains=getattr(args, "fqn_contains", None),
88
+ cfg=cfg,
89
+ graph=graph,
90
+ microservice=(getattr(args, "service", None) or "") if apply_scope else "",
91
+ module=(getattr(args, "module", None) or "") if apply_scope else "",
92
+ )
93
+ if env.status != "ok":
94
+ raise PayloadError(env, 2 if env.status == "error" else 0)
95
+ return node
96
+
97
+
98
+ def _kind_guard(node, *, args: argparse.Namespace, expected: str, kinds: tuple[str, ...], hint: str = "") -> None:
99
+ """Non-printing kind guard (logic of jrag._require_kind, minus the print).
100
+
101
+ Raises :class:`PayloadError` with the same error message ``_require_kind``
102
+ builds when ``node.kind`` is not in ``kinds``.
103
+ """
104
+ if node.kind not in kinds:
105
+ msg = f"{expected}; resolved kind is {node.kind!r}."
106
+ if hint:
107
+ msg = f"{msg} {hint}"
108
+ raise PayloadError(Envelope(status="error", message=msg), 2)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # callers_payload
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def callers_payload(args: argparse.Namespace, cfg, graph) -> dict[str, Any]:
117
+ """Assemble the traversal payload for ``jrag callers``.
118
+
119
+ Route root -> ``find_route_callers`` (plus the external-entrypoint fold for
120
+ a zero-caller http_endpoint). Symbol root -> ``find_callers`` plus the
121
+ EXPOSES-inbound fold (DECLARES.EXPOSES routes surfaced as additional rows).
122
+ Both folds are transcribed verbatim from ``_cmd_callers``.
123
+ """
124
+ limit = _clamped_limit(args)
125
+ # callers opts into resolve-time --service/--module narrowing (apply_scope).
126
+ node = _resolve_traversal(
127
+ args, cfg=cfg, graph=graph, hint_kind=args.kind, apply_scope=True
128
+ )
129
+
130
+ root_dict = _noderef_to_node_dict(node)
131
+ root_id = node.id
132
+
133
+ # ---- Route root -> find_route_callers (+ external-entrypoint fold) ----
134
+ if node.kind == "route":
135
+ route_callers = graph.find_route_callers(route_id=root_id)
136
+ warnings: list[str] = []
137
+ # No backend limit on find_route_callers; client-side slice for truncation.
138
+ truncated = len(route_callers) > limit
139
+ display = route_callers[:limit]
140
+ nodes: dict[str, dict] = {}
141
+ edges: list[dict] = []
142
+ for rc in display:
143
+ caller_id = rc.caller_node_id
144
+ if rc.caller_node_kind == "client":
145
+ edge_type = "HTTP_CALLS"
146
+ else:
147
+ edge_type = "ASYNC_CALLS"
148
+ node_row = {
149
+ "id": caller_id,
150
+ "kind": rc.caller_node_kind,
151
+ "fqn": rc.declaring_symbol_fqn or caller_id,
152
+ "microservice": rc.caller_microservice,
153
+ }
154
+ if rc.target_service:
155
+ node_row["target_service"] = rc.target_service
156
+ if rc.caller_node_kind == "client" and rc.raw_uri:
157
+ node_row["raw_uri"] = rc.raw_uri
158
+ elif rc.caller_node_kind != "client" and rc.topic:
159
+ node_row["topic"] = rc.topic
160
+ nodes[caller_id] = node_row
161
+ edges.append(
162
+ {"other_id": caller_id, "edge_type": edge_type, "confidence": rc.confidence}
163
+ )
164
+ # Include the root (Route) node so the zero-callers rendering surfaces
165
+ # the route path rather than a bare "0 callers" line.
166
+ nodes[root_id] = root_dict
167
+ # External-entrypoint detection (http_endpoint with an inbound EXPOSES
168
+ # edge from a controller Symbol genuinely has zero in-repo callers).
169
+ is_external_entrypoint = False
170
+ if not display:
171
+ kind_row = graph._rows( # noqa: SLF001 - same pattern as jrag_envelope._node_file_location
172
+ "MATCH (r:Route) WHERE r.id = $rid RETURN r.kind AS kind LIMIT 1",
173
+ {"rid": root_id},
174
+ )
175
+ route_kind = str(kind_row[0].get("kind") or "") if kind_row else ""
176
+ if route_kind == "http_endpoint" and graph.find_route_handlers(route_id=root_id):
177
+ is_external_entrypoint = True
178
+ return {
179
+ "root_id": root_id,
180
+ "nodes": nodes,
181
+ "edges": edges,
182
+ "noun": "callers",
183
+ "warnings": warnings,
184
+ "truncated": truncated,
185
+ "is_external_entrypoint": is_external_entrypoint,
186
+ }
187
+
188
+ # callers accepts Symbol OR Route; anything else is a usage error.
189
+ if node.kind != "symbol":
190
+ raise PayloadError(
191
+ Envelope(
192
+ status="error",
193
+ message=(
194
+ f"callers expects a Symbol or Route root; resolved node kind is "
195
+ f"{node.kind!r}. Use --kind to narrow resolve."
196
+ ),
197
+ ),
198
+ 2,
199
+ )
200
+
201
+ # ---- Symbol root -> find_callers (+ EXPOSES-inbound fold) ----
202
+ depth = getattr(args, "depth", 1)
203
+ min_conf = getattr(args, "min_confidence", 0.0)
204
+ exclude_external = not getattr(args, "include_external", False)
205
+ call_edges = graph.find_callers(
206
+ node.fqn,
207
+ depth=depth,
208
+ limit=limit + 1,
209
+ min_confidence=min_conf,
210
+ exclude_external=exclude_external,
211
+ module=args.module,
212
+ microservice=args.service,
213
+ )
214
+ display, truncated = mark_truncated(call_edges, limit)
215
+ nodes = {}
216
+ edges = []
217
+ for ce in display:
218
+ nodes[ce.src.id] = _symbol_hit_to_dict(ce.src)
219
+ edges.append(
220
+ {"other_id": ce.src.id, "edge_type": "CALLS", "confidence": ce.confidence}
221
+ )
222
+ # EXPOSES-inbound fold: surface routes this type's methods EXPOSE as
223
+ # additional rows (controllers/listeners are entry points invoked via the
224
+ # framework dispatch, not via in-repo CALLS edges).
225
+ expose_rows = graph._rows( # noqa: SLF001 - one-shot aggregation, cf. _cmd_callees client path
226
+ "MATCH (t:Symbol {id: $tid})-[:DECLARES]->(m:Symbol)-[e:EXPOSES]->(r:Route) "
227
+ "RETURN r.id AS rid, r.method AS rmethod, r.path AS rpath, "
228
+ "r.path_template AS rpt, r.microservice AS rms, "
229
+ "m.fqn AS via_fqn, e.confidence AS conf",
230
+ {"tid": root_id},
231
+ )
232
+ for row in expose_rows:
233
+ rid = str(row.get("rid") or "")
234
+ if not rid or rid in nodes:
235
+ continue
236
+ rmethod = str(row.get("rmethod") or "")
237
+ rpath = str(row.get("rpt") or row.get("rpath") or "")
238
+ nodes[rid] = {
239
+ "id": rid,
240
+ "kind": "route",
241
+ "fqn": f"{rmethod} {rpath}".strip(),
242
+ "method": rmethod,
243
+ "path": rpath,
244
+ "microservice": str(row.get("rms") or ""),
245
+ }
246
+ edge_row: dict = {"other_id": rid, "edge_type": "EXPOSES"}
247
+ via_fqn = str(row.get("via_fqn") or "")
248
+ if via_fqn:
249
+ edge_row["from_fqn"] = via_fqn
250
+ edges.append(edge_row)
251
+ nodes[root_id] = root_dict
252
+ return {
253
+ "root_id": root_id,
254
+ "nodes": nodes,
255
+ "edges": edges,
256
+ "noun": "callers",
257
+ "warnings": [],
258
+ "truncated": truncated,
259
+ "is_external_entrypoint": False,
260
+ }
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # callees_payload
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def callees_payload(args: argparse.Namespace, cfg, graph) -> dict[str, Any]:
269
+ """Assemble the traversal payload for ``jrag callees``.
270
+
271
+ Client/Producer root -> ``neighbors_v2`` (HTTP_CALLS / ASYNC_CALLS out).
272
+ CLIENT-role type Symbol -> the CLIENT-role HTTP_CALLS fold (declared Client
273
+ nodes -> HTTP_CALLS -> Route). Symbol root -> ``find_callees``. The
274
+ CLIENT-role fold is transcribed verbatim from ``_cmd_callees``.
275
+ """
276
+ from java_codebase_rag.mcp import mcp_v2
277
+ from java_codebase_rag.jrag_envelope import Envelope as _Envelope # noqa: F811 (local alias for clarity)
278
+
279
+ limit = _clamped_limit(args)
280
+ node = _resolve_traversal(
281
+ args, cfg=cfg, graph=graph, hint_kind=args.kind, apply_scope=False
282
+ )
283
+
284
+ _kind_guard(
285
+ node,
286
+ args=args,
287
+ expected="callees expects a Symbol, Client, or Producer root",
288
+ kinds=("symbol", "client", "producer"),
289
+ hint="Use --kind to narrow resolve.",
290
+ )
291
+
292
+ # ---- Client/Producer root -> neighbors_v2 (HTTP_CALLS / ASYNC_CALLS out) ----
293
+ if node.kind in ("client", "producer"):
294
+ edge_types = ["HTTP_CALLS"] if node.kind == "client" else ["ASYNC_CALLS"]
295
+ out = mcp_v2.neighbors_v2(
296
+ [node.id], direction="out", edge_types=edge_types,
297
+ limit=limit + 1, graph=graph,
298
+ )
299
+ if not out.success:
300
+ raise PayloadError(
301
+ _Envelope(status="error", message=out.message or "neighbors_v2 failed"), 2
302
+ )
303
+ root_id = node.id
304
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
305
+ edges: list[dict] = []
306
+ for e in out.results:
307
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
308
+ edges.append(
309
+ {
310
+ "other_id": e.other.id,
311
+ "edge_type": e.edge_type,
312
+ "confidence": e.attrs.get("confidence"),
313
+ }
314
+ )
315
+ truncated = bool(out.has_more_results) or len(edges) > limit
316
+ if len(edges) > limit:
317
+ edges = edges[:limit]
318
+ # --include-external is accepted but does not apply on Client/Producer roots.
319
+ warnings: list[str] = []
320
+ if getattr(args, "include_external", False):
321
+ warnings.append(
322
+ "--include-external does not apply to Client/Producer roots "
323
+ "(HTTP_CALLS/ASYNC_CALLS reach :Route, which is always in-graph)"
324
+ )
325
+ edges = _dedupe_traversal_edges(edges)
326
+ truncated = truncated or len(edges) > limit
327
+ edges = edges[:limit]
328
+ return {
329
+ "root_id": root_id,
330
+ "nodes": nodes,
331
+ "edges": edges,
332
+ "noun": "callees",
333
+ "warnings": warnings,
334
+ "truncated": truncated,
335
+ "is_external_entrypoint": False,
336
+ }
337
+
338
+ # ---- CLIENT-role type Symbol -> HTTP_CALLS fold ----
339
+ if (node.role or "") == "CLIENT":
340
+ root_id = node.id
341
+ client_rows = graph._rows( # noqa: SLF001 - one-shot aggregation query
342
+ "MATCH (iface:Symbol {id: $sid})-[:DECLARES]->(m:Symbol)"
343
+ "-[:DECLARES_CLIENT]->(c:Client) "
344
+ "OPTIONAL MATCH (c)-[e:HTTP_CALLS]->(r:Route) "
345
+ "RETURN c.id AS cid, c.member_fqn AS cfqn, c.path AS cpath, "
346
+ "c.method AS cmethod, c.microservice AS cms, "
347
+ "r.id AS rid, r.method AS rmethod, r.path AS rpath, "
348
+ "r.path_template AS rpt, r.microservice AS rms, "
349
+ "e.confidence AS conf",
350
+ {"sid": root_id},
351
+ )
352
+ nodes = {root_id: _noderef_to_node_dict(node)}
353
+ edges = []
354
+ for row in client_rows:
355
+ rid = str(row.get("rid") or "")
356
+ if rid:
357
+ target_id = rid
358
+ rmethod = str(row.get("rmethod") or "")
359
+ rpath = str(row.get("rpt") or row.get("rpath") or "")
360
+ nodes[target_id] = {
361
+ "id": target_id,
362
+ "kind": "route",
363
+ "fqn": f"{rmethod} {rpath}".strip(),
364
+ "microservice": str(row.get("rms") or ""),
365
+ }
366
+ edge_type = "HTTP_CALLS"
367
+ else:
368
+ # Client with no resolved HTTP_CALLS edge: surface the client
369
+ # node + its declared path so the outbound intent is visible.
370
+ target_id = str(row.get("cid") or "")
371
+ if not target_id:
372
+ continue
373
+ cmethod = str(row.get("cmethod") or "")
374
+ cpath = str(row.get("cpath") or "")
375
+ nodes[target_id] = {
376
+ "id": target_id,
377
+ "kind": "client",
378
+ "fqn": f"{cmethod} {cpath}".strip() or str(row.get("cfqn") or ""),
379
+ "microservice": str(row.get("cms") or ""),
380
+ }
381
+ edge_type = "HTTP_CALLS"
382
+ edges.append({
383
+ "other_id": target_id,
384
+ "edge_type": edge_type,
385
+ "confidence": float(row.get("conf") or 0.0) or None,
386
+ })
387
+ edges = _dedupe_traversal_edges(edges)
388
+ truncated = len(edges) > limit
389
+ edges = edges[:limit]
390
+ return {
391
+ "root_id": root_id,
392
+ "nodes": nodes,
393
+ "edges": edges,
394
+ "noun": "callees",
395
+ "warnings": [],
396
+ "truncated": truncated,
397
+ "is_external_entrypoint": False,
398
+ }
399
+
400
+ # ---- Symbol root -> find_callees ----
401
+ depth = getattr(args, "depth", 1)
402
+ min_conf = getattr(args, "min_confidence", 0.0)
403
+ exclude_external = not getattr(args, "include_external", False)
404
+ call_edges = graph.find_callees(
405
+ node.fqn,
406
+ depth=depth,
407
+ limit=limit + 1,
408
+ min_confidence=min_conf,
409
+ exclude_external=exclude_external,
410
+ module=args.module,
411
+ microservice=args.service,
412
+ )
413
+ display, truncated = mark_truncated(call_edges, limit)
414
+ root_id = node.id
415
+ nodes = {root_id: _noderef_to_node_dict(node)}
416
+ edges = []
417
+ for ce in display:
418
+ nodes[ce.dst.id] = _symbol_hit_to_dict(ce.dst)
419
+ edges.append(
420
+ {"other_id": ce.dst.id, "edge_type": "CALLS", "confidence": ce.confidence}
421
+ )
422
+ edges = _dedupe_traversal_edges(edges)
423
+ truncated = truncated or len(edges) > limit
424
+ edges = edges[:limit]
425
+ return {
426
+ "root_id": root_id,
427
+ "nodes": nodes,
428
+ "edges": edges,
429
+ "noun": "callees",
430
+ "warnings": [],
431
+ "truncated": truncated,
432
+ "is_external_entrypoint": False,
433
+ }
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # flow_payload
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def flow_payload(args: argparse.Namespace, cfg, graph) -> dict[str, Any]:
442
+ """Assemble the traversal payload for ``jrag flow``.
443
+
444
+ Route root -> ``trace_request_flow`` plus the inbound/outbound merge +
445
+ client-side truncation, transcribed verbatim from ``_cmd_flow``.
446
+ """
447
+ node = _resolve_traversal(
448
+ args, cfg=cfg, graph=graph, hint_kind="route", apply_scope=False
449
+ )
450
+
451
+ _kind_guard(
452
+ node,
453
+ args=args,
454
+ expected="flow requires a Route root",
455
+ kinds=("route",),
456
+ hint="Pass a route path (e.g. /chat/assign).",
457
+ )
458
+
459
+ warnings = _warn_unapplied_scope(
460
+ args,
461
+ reason="trace_request_flow carries no microservice predicate; intra-codebase is an index-time data property",
462
+ )
463
+
464
+ limit = _clamped_limit(args)
465
+ max_hops = max(1, min(8, getattr(args, "depth", 5)))
466
+ flow_data = graph.trace_request_flow(entry_route_id=node.id, max_hops=max_hops)
467
+
468
+ root_id = node.id
469
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
470
+ edges: list[dict] = []
471
+ # Inbound: cross-service HTTP/async callers (Client/Producer two-hop).
472
+ for row in flow_data.get("inbound", []):
473
+ caller_id = str(row.get("caller_node_id") or "")
474
+ if not caller_id:
475
+ continue
476
+ kind = str(row.get("caller_node_kind") or "")
477
+ nodes[caller_id] = {
478
+ "id": caller_id,
479
+ "kind": kind,
480
+ "fqn": str(row.get("declaring_symbol_fqn") or ""),
481
+ "microservice": str(row.get("microservice") or ""),
482
+ }
483
+ edges.append(
484
+ {
485
+ "other_id": caller_id,
486
+ "edge_type": "HTTP_CALLS" if kind == "client" else "ASYNC_CALLS",
487
+ "confidence": float(row.get("confidence") or 0.0),
488
+ }
489
+ )
490
+ # Outbound: CALLS hops from the route handler (intra-service by construction).
491
+ for row in flow_data.get("outbound", []):
492
+ next_id = str(row.get("next_symbol_id") or "")
493
+ if not next_id:
494
+ continue
495
+ nodes[next_id] = {
496
+ "id": next_id,
497
+ "kind": "symbol",
498
+ "fqn": str(row.get("next_fqn") or ""),
499
+ "microservice": str(row.get("next_microservice") or ""),
500
+ }
501
+ edges.append({"other_id": next_id, "edge_type": "CALLS"})
502
+
503
+ # Client-side slice for truncation (trace_request_flow has no limit param).
504
+ truncated = len(edges) > limit
505
+ if truncated:
506
+ edges = edges[:limit]
507
+ return {
508
+ "root_id": root_id,
509
+ "nodes": nodes,
510
+ "edges": edges,
511
+ "noun": "flow",
512
+ "warnings": warnings,
513
+ "truncated": truncated,
514
+ "is_external_entrypoint": False,
515
+ }
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # search_payload
520
+ # ---------------------------------------------------------------------------
521
+
522
+
523
+ def search_payload(args: argparse.Namespace, cfg, graph):
524
+ """Return the ``SearchOutput`` for ``jrag search``.
525
+
526
+ Builds the ``NodeFilter`` from args (the same filter set the handler builds)
527
+ and calls ``mcp_v2.search_v2`` with ``limit+1`` for +1-fetch truncation.
528
+ Pre-validation (``--fuzzy``, ``limit==0`` short-circuit, ``--framework``
529
+ enum check) and post-processing (framework post-filter, ``--explain``,
530
+ ``--min-score``, truncation, zero-result guidance) stay in the handler —
531
+ this is the clean ``search_v2`` core.
532
+ """
533
+ from java_codebase_rag.mcp import mcp_v2
534
+
535
+ # Build NodeFilter from flags (same set as the handler / `find` filter mode).
536
+ # NOTE: --framework is intentionally NOT placed in the NodeFilter (the graph
537
+ # stores framework only on Route nodes; the handler applies it as a
538
+ # client-side POST-filter after the hits come back).
539
+ filter_dict: dict = {}
540
+ if args.service:
541
+ filter_dict["microservice"] = args.service
542
+ if args.module:
543
+ filter_dict["module"] = args.module
544
+ if args.role:
545
+ filter_dict["role"] = normalize_enum(args.role, kind="role")
546
+ if args.exclude_role:
547
+ filter_dict["exclude_roles"] = [normalize_enum(args.exclude_role, kind="role")]
548
+ if args.annotation:
549
+ filter_dict["annotation"] = args.annotation
550
+ if args.capability:
551
+ filter_dict["capability"] = args.capability
552
+ if args.fqn_contains:
553
+ filter_dict["fqn_contains"] = args.fqn_contains
554
+ if args.java_kind:
555
+ filter_dict["symbol_kind"] = normalize_enum(args.java_kind, kind="java_kind")
556
+
557
+ node_filter, err_env = _build_node_filter_or_error(filter_dict)
558
+ if err_env is not None:
559
+ raise PayloadError(err_env, 2)
560
+
561
+ limit = min(args.limit if args.limit is not None else 20, 499)
562
+ return mcp_v2.search_v2(
563
+ args.query,
564
+ table=args.table,
565
+ hybrid=args.hybrid,
566
+ limit=limit + 1, # +1 for truncated detection
567
+ offset=args.offset,
568
+ path_contains=args.path_contains,
569
+ filter=node_filter,
570
+ explain=args.explain,
571
+ graph=graph,
572
+ dedup=not getattr(args, "chunks", False),
573
+ )
574
+
575
+
576
+ # ---------------------------------------------------------------------------
577
+ # find_payload
578
+ # ---------------------------------------------------------------------------
579
+
580
+
581
+ def find_payload(args: argparse.Namespace, cfg, graph) -> dict[str, Any]:
582
+ """Return the find payload, selecting query vs filter mode exactly as
583
+ ``_cmd_find`` / ``_cmd_find_*`` do today.
584
+
585
+ * Query mode (positional ``<query>``): ``graph.find_by_name_or_fqn`` plus
586
+ the existing client-side post-filters (role/annotation/capability). Returns
587
+ ``{"mode": "query", "rows", "raw_truncated", "post_filter_active", "limit",
588
+ "query", "kinds", "matched_mode", "identifier_matched"}``. With ``--fuzzy``,
589
+ an empty exact result widens to prefix then substring (issue #375);
590
+ ``matched_mode`` is the tier that hit (``exact``/``prefix``/``contains``).
591
+ * Filter mode: ``mcp_v2.find_v2``. Returns
592
+ ``{"mode": "filter", "kind", "out" (FindOutput), "limit"}``.
593
+
594
+ Kind-contradiction / query-mode-kind errors raise :class:`PayloadError`.
595
+ Rendering (nodes/warnings/empty-result hint/offset) stays in the handler.
596
+ """
597
+ from java_codebase_rag.mcp import mcp_v2
598
+
599
+ inferred = _infer_kind(args)
600
+ is_contradiction, error_msg = _check_kind_contradiction(args, inferred)
601
+ if is_contradiction:
602
+ raise PayloadError(
603
+ Envelope(status="error", message=error_msg or "kind contradiction"), 2
604
+ )
605
+
606
+ # Cap at 499 so limit+1 <= 500 (backend clamp); default 20.
607
+ raw_limit = args.limit if args.limit is not None else 20
608
+ limit = min(raw_limit, 499)
609
+
610
+ # ---- Query mode: positional <query> present ----
611
+ if args.query:
612
+ effective_kind = inferred or "symbol"
613
+ if effective_kind != "symbol":
614
+ raise PayloadError(
615
+ Envelope(
616
+ status="error",
617
+ message=(
618
+ f"query mode (positional <query>) only searches Symbols, but kind "
619
+ f"'{effective_kind}' was {'inferred from domain flags' if args.kind is None else 'set via --kind'}. "
620
+ "Drop the positional <query> and use filter mode (the domain flags) "
621
+ "for route/client/producer searches."
622
+ ),
623
+ ),
624
+ 2,
625
+ )
626
+ query = args.query
627
+ # find_by_name_or_fqn is always Symbol; the only valid kinds filter is
628
+ # the symbol sub-kind derived from --java-kind.
629
+ if args.java_kind:
630
+ java_kind_norm = normalize_enum(args.java_kind, kind="java_kind")
631
+ kinds = [java_kind_norm.lower()]
632
+ else:
633
+ kinds = None
634
+
635
+ rows = graph.find_by_name_or_fqn(
636
+ query,
637
+ kinds=kinds,
638
+ module=args.module,
639
+ microservice=args.service,
640
+ limit=limit + 1, # +1 for truncated detection
641
+ )
642
+ # --fuzzy: widen an empty exact result to prefix (STARTS WITH) then
643
+ # substring (CONTAINS) on name/FQN (issue #375). Fuzzy modes exclude
644
+ # file/package Symbol nodes (their fqn is a filesystem path) — enforced
645
+ # in find_by_name_or_fqn's ``mode`` handling.
646
+ matched_mode = "exact"
647
+ if not rows and getattr(args, "fuzzy", False) and query:
648
+ for fb_mode in ("prefix", "contains"):
649
+ rows = graph.find_by_name_or_fqn(
650
+ query, kinds=kinds, module=args.module, microservice=args.service,
651
+ limit=limit + 1, mode=fb_mode,
652
+ )
653
+ if rows:
654
+ matched_mode = fb_mode
655
+ break
656
+ # Did any tier (exact or fallback) return rows BEFORE post-filters? Used
657
+ # to distinguish "identifier genuinely matched nothing" from "matched but
658
+ # --role/--annotation/--capability removed all hits" in the empty-result hint.
659
+ identifier_matched = bool(rows)
660
+ # Truncation is decided by the RAW name/FQN fetch (limit+1), BEFORE
661
+ # post-filters reduce the set.
662
+ raw_truncated = len(rows) > limit
663
+
664
+ # Post-filter by role/annotation/capability (SymbolHit carries these).
665
+ post_filter_active = False
666
+ if args.role:
667
+ post_filter_active = True
668
+ role_norm = normalize_enum(args.role, kind="role")
669
+ rows = [r for r in rows if (r.role or "").upper().replace("-", "_") == role_norm.upper()]
670
+ if args.exclude_role:
671
+ post_filter_active = True
672
+ exclude_role_norm = normalize_enum(args.exclude_role, kind="role")
673
+ rows = [r for r in rows if (r.role or "").upper().replace("-", "_") != exclude_role_norm.upper()]
674
+ if args.annotation:
675
+ post_filter_active = True
676
+ rows = [r for r in rows if args.annotation in (r.annotations or [])]
677
+ if args.capability:
678
+ post_filter_active = True
679
+ rows = [r for r in rows if args.capability in (r.capabilities or [])]
680
+
681
+ return {
682
+ "mode": "query",
683
+ "rows": rows,
684
+ "raw_truncated": raw_truncated,
685
+ "post_filter_active": post_filter_active,
686
+ "limit": limit,
687
+ "query": query,
688
+ "kinds": kinds,
689
+ "matched_mode": matched_mode,
690
+ "identifier_matched": identifier_matched,
691
+ }
692
+
693
+ # ---- Filter mode: build NodeFilter and call find_v2 ----
694
+ kind = inferred or "symbol"
695
+ filter_dict: dict = {}
696
+ if args.service:
697
+ filter_dict["microservice"] = args.service
698
+ if args.module:
699
+ filter_dict["module"] = args.module
700
+ if args.role:
701
+ filter_dict["role"] = normalize_enum(args.role, kind="role")
702
+ if args.exclude_role:
703
+ filter_dict["exclude_roles"] = [normalize_enum(args.exclude_role, kind="role")]
704
+ if args.annotation:
705
+ filter_dict["annotation"] = args.annotation
706
+ if args.capability:
707
+ filter_dict["capability"] = args.capability
708
+ if args.fqn_contains:
709
+ filter_dict["fqn_contains"] = args.fqn_contains
710
+ if args.java_kind:
711
+ filter_dict["symbol_kind"] = normalize_enum(args.java_kind, kind="java_kind")
712
+ if args.framework:
713
+ filter_dict["framework"] = normalize_enum(args.framework, kind="framework")
714
+ if args.source_layer:
715
+ filter_dict["source_layer"] = normalize_enum(args.source_layer, kind="source_layer")
716
+ if args.http_method:
717
+ filter_dict["http_method"] = args.http_method.upper()
718
+ if args.path_contains:
719
+ filter_dict["path_contains"] = args.path_contains
720
+ if args.client_kind:
721
+ filter_dict["client_kind"] = normalize_enum(args.client_kind, kind="client_kind")
722
+ if args.calls_service:
723
+ filter_dict["target_service"] = args.calls_service
724
+ if args.calls_path_contains:
725
+ filter_dict["target_path_contains"] = args.calls_path_contains
726
+ if args.producer_kind:
727
+ filter_dict["producer_kind"] = normalize_enum(args.producer_kind, kind="producer_kind")
728
+ if args.topic_contains:
729
+ filter_dict["topic_contains"] = args.topic_contains
730
+
731
+ node_filter, err_env = _build_node_filter_or_error(filter_dict)
732
+ if err_env is not None:
733
+ raise PayloadError(err_env, 2)
734
+
735
+ out = mcp_v2.find_v2(
736
+ kind=kind,
737
+ filter=node_filter,
738
+ limit=limit + 1, # +1 for has_more_results detection
739
+ offset=args.offset,
740
+ graph=graph,
741
+ )
742
+ return {"mode": "filter", "kind": kind, "out": out, "limit": limit}
743
+
744
+
745
+ # ---------------------------------------------------------------------------
746
+ # inspect_payload
747
+ # ---------------------------------------------------------------------------
748
+
749
+
750
+ def inspect_payload(args: argparse.Namespace, cfg, graph) -> dict[str, Any]:
751
+ """Return the inspect payload: resolve + ``describe_v2``.
752
+
753
+ inspect's renderer needs ``file_location`` from the resolve ``Envelope``
754
+ (not present on ``DescribeOutput``), so the payload carries the resolved
755
+ node id/fqn and file_location alongside the ``DescribeOutput``. The
756
+ NodeRecord -> envelope-node flatten + render stays in the handler.
757
+ """
758
+ from java_codebase_rag.mcp import mcp_v2
759
+
760
+ # inspect forwards --service/--module into resolve (disambiguates by scope).
761
+ node, env = resolve_query(
762
+ args.query,
763
+ hint_kind=args.kind,
764
+ java_kind=args.java_kind,
765
+ role=args.role,
766
+ fqn_contains=args.fqn_contains,
767
+ cfg=cfg,
768
+ graph=graph,
769
+ microservice=getattr(args, "service", None) or "",
770
+ module=getattr(args, "module", None) or "",
771
+ )
772
+ if env.status != "ok":
773
+ raise PayloadError(env, 2 if env.status == "error" else 0)
774
+
775
+ desc_out = mcp_v2.describe_v2(id=node.id, graph=graph)
776
+ return {
777
+ "describe": desc_out,
778
+ "node_id": node.id,
779
+ "node_fqn": node.fqn,
780
+ "file_location": env.file_location,
781
+ }