java-codebase-rag 0.6.7__py3-none-any.whl → 0.9.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 (34) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +149 -25
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +148 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +49 -88
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +183 -0
  13. java_codebase_rag/installer.py +720 -107
  14. java_codebase_rag/jrag.py +4405 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +697 -0
  18. java_codebase_rag/lance_optimize.py +18 -0
  19. java_codebase_rag/pipeline.py +34 -0
  20. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +137 -94
  21. java_codebase_rag-0.9.0.dist-info/RECORD +43 -0
  22. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +1 -1
  23. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +1 -0
  24. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/top_level.txt +2 -0
  25. java_index_flow_lancedb.py +34 -19
  26. java_ontology.py +12 -0
  27. ladybug_queries.py +233 -52
  28. mcp_hints.py +6 -6
  29. mcp_v2.py +276 -632
  30. resolve_service.py +649 -0
  31. search_lancedb.py +159 -4
  32. server.py +31 -12
  33. java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
  34. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,4405 @@
1
+ """jrag - agent-facing CLI (PR-JRAG-1a foundation).
2
+
3
+ Compose-and-render layer over the existing backend (``resolve_v2``,
4
+ ``LadybugGraph``, ``mcp_v2`` handlers, ``run_search``). v1 loads the index
5
+ in-process per call (no daemon); reuses the operator's index directory and
6
+ config resolver (``resolve_operator_config`` + ``apply_to_os_environ``).
7
+
8
+ PR-JRAG-1a ships only the foundation: ``build_parser`` (with ``--offset``
9
+ intentionally NOT global - registered only on find/search in PR-1b/PR-4),
10
+ ``_resolve_cfg`` (operator config reuse), ``_load_graph`` (actionable error
11
+ envelopes), ``main`` (``raise_fd_limit`` first; stdout envelope + stderr
12
+ traceback on error), and the ``status`` command. Later PRs add subcommands and
13
+ fill the ``agent_next_actions`` hook.
14
+
15
+ Lazy-import invariant: ``build_parser()`` imports NO backend modules - so
16
+ ``jrag --help`` stays fast and free of torch/sentence_transformers/mcp_v2.
17
+ Backend imports (``resolve_service``, ``ladybug_queries``,
18
+ ``resolve_operator_config``, ``jrag_envelope`` helpers) live inside command
19
+ handlers. Sentinel:
20
+ python -c "import java_codebase_rag.jrag as j; j.build_parser()"
21
+ loads no torch / sentence_transformers / mcp_v2.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import os
27
+ import sys
28
+ import traceback
29
+ from pathlib import Path
30
+
31
+ from java_codebase_rag._fdlimit import raise_fd_limit
32
+ from java_codebase_rag._stdio import force_utf8_stdio
33
+
34
+ __all__ = ["build_parser", "main", "_console_script_main"]
35
+
36
+
37
+ class _IndexNotFound(RuntimeError):
38
+ """Raised when no LadybugDB graph exists at the resolved path."""
39
+
40
+
41
+ class _IndexStale(RuntimeError):
42
+ """Raised when the on-disk graph's ontology is older than required."""
43
+
44
+
45
+ # Generous limit for the topics --consumer-in / listeners --topic-contains
46
+ # compose fetches (these resolve cross-topic edges and should not silently
47
+ # truncate the listener/consumer set under typical fixture sizes).
48
+ _CONSUMER_FETCH_LIMIT = 200
49
+
50
+
51
+ # Framework tag -> the type-level annotations a Symbol's declaring type carries
52
+ # when it participates in that framework. The graph stores `framework` only on
53
+ # Route nodes (Route.framework = spring_mvc | webflux | kafka | ...); Symbol
54
+ # nodes have no framework field, so `search --framework <name>` (a symbol result
55
+ # set) maps the framework back onto the declaring type via these annotations and
56
+ # post-filters. Mirrors the indexer's own classification heuristic.
57
+ _FRAMEWORK_ANNOTATIONS: dict[str, frozenset[str]] = {
58
+ "spring_mvc": frozenset({
59
+ "RestController", "Controller", "RestControllerAdvice", "RequestMapping",
60
+ "GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping",
61
+ }),
62
+ "webflux": frozenset({
63
+ "RestController", "Controller", "RequestMapping",
64
+ }),
65
+ "kafka": frozenset({"EnableKafka", "KafkaStreams", "KafkaStream"}),
66
+ "rabbitmq": frozenset({"EnableRabbit", "RabbitListener"}),
67
+ "jms": frozenset({"EnableJms", "JmsListener"}),
68
+ "stream": frozenset({"EnableBinding", "StreamBridge", "EnableStream"}),
69
+ "feign": frozenset({"FeignClient", "EnableFeignClients"}),
70
+ }
71
+
72
+
73
+ def _framework_type_fqns(graph, framework: str) -> set[str]:
74
+ """Return the set of type-level Symbol FQNs whose annotations match the
75
+ given framework tag (per :data:`_FRAMEWORK_ANNOTATIONS`).
76
+
77
+ One focused Cypher lookup, cached implicitly per-process (the CLI is
78
+ short-lived). Used by ``search --framework`` as a post-filter on the
79
+ primary-type FQN each SearchHit carries.
80
+ """
81
+ anns = _FRAMEWORK_ANNOTATIONS.get(framework)
82
+ if not anns:
83
+ return set()
84
+ # Ladybug has no parameterized list membership; expand the fixed annotation
85
+ # set as ORed ``list_contains`` predicates (same pattern as trace_flow's
86
+ # capability expansion).
87
+ predicates = " OR ".join(f"list_contains(s.annotations, '{a}')" for a in anns)
88
+ rows = graph._rows( # noqa: SLF001 - focused lookup (same pattern as _resolve_topic_consumers)
89
+ f"MATCH (s:Symbol) WHERE s.kind IN ['class','interface','annotation'] "
90
+ f"AND ({predicates}) RETURN DISTINCT s.fqn AS fqn",
91
+ {},
92
+ )
93
+ return {str(r.get("fqn") or "") for r in rows if r.get("fqn")}
94
+
95
+
96
+ def _apply_auto_scope(args: argparse.Namespace, cfg, graph) -> None:
97
+ """Default ``args.service`` to the microservice implied by cwd (MCP parity).
98
+
99
+ Mirrors ``server.py`` ``ScopeManager``: when cwd sits inside one
100
+ microservice of a system-level index, behave as if the agent had typed
101
+ ``--service <that microservice>`` so the other services' results do not
102
+ leak in. No-op unless the command opted in via
103
+ ``set_defaults(auto_scope=True)`` and the caller did not pass ``--service``.
104
+
105
+ Detection reuses ``graph_enrich.detect_microservice_from_path``; the
106
+ candidate is validated against ``graph.microservice_counts()`` and dropped
107
+ if absent (a mislabeled non-microservice dir would otherwise yield zero
108
+ matches). When the known set is empty/unreadable we KEEP the candidate
109
+ (transient graph error) — same as ``server.py:130-132``. Detection returns
110
+ ``None`` at the system root or outside it, so auto-scope never fires for
111
+ estate-wide work.
112
+
113
+ Records ``args._service_user`` (caller passed ``--service``) for warning
114
+ distinction and ``args._service_auto`` (detected name) for the
115
+ transparency notice.
116
+
117
+ NOTE: three commands inline their graph load and bypass
118
+ ``_load_graph_or_error`` — ``find``, ``inspect``, ``status``. ``find`` is
119
+ opted in and calls this helper itself; ``inspect``/``status`` are NOT
120
+ opted in (they don't use ``--service`` as a result filter today). If
121
+ either is ever opted in, it must call this helper in its own load path.
122
+ """
123
+ # ``--service`` lives on the ``_common_parser``; a few commands (status,
124
+ # microservices) use a bare ``_core_parser`` without it. Gate on opt-in
125
+ # FIRST so those never reach the ``args.service`` read below.
126
+ if not getattr(args, "auto_scope", False):
127
+ return
128
+ args._service_user = getattr(args, "service", None) is not None
129
+ if getattr(args, "service", None) is not None: # explicit --service wins
130
+ return
131
+ if getattr(args, "no_auto_scope", False) or os.environ.get("JRAG_NO_AUTO_SCOPE"):
132
+ return
133
+ source_root = cfg.source_root if cfg.source_root else None
134
+ if not source_root:
135
+ return
136
+ from graph_enrich import detect_microservice_from_path
137
+
138
+ candidate = detect_microservice_from_path(Path.cwd(), Path(source_root))
139
+ if not candidate:
140
+ return
141
+ try:
142
+ known = {name for name in (graph.microservice_counts() or {}) if name}
143
+ except Exception:
144
+ known = set()
145
+ if known and candidate not in known:
146
+ return
147
+ args.service = candidate
148
+ args._service_auto = candidate
149
+ print(f"[jrag] auto-scope: --service {candidate} (cwd)", file=sys.stderr)
150
+
151
+
152
+ def _auto_scope_notice(args: argparse.Namespace) -> list[str]:
153
+ """Envelope ``warnings[]`` line telling the agent results are auto-scoped.
154
+
155
+ Models often do not see stderr (where the ``[jrag] auto-scope`` line goes),
156
+ so this also surfaces the scope in the rendered output. Returns ``[]`` when
157
+ auto-scope did not fire (no detected service) or the command opted out.
158
+ """
159
+ svc = getattr(args, "_service_auto", None)
160
+ if not svc:
161
+ return []
162
+ return [
163
+ f"auto-scope: --service {svc} (inferred from cwd; "
164
+ f"pass --no-auto-scope to disable)"
165
+ ]
166
+
167
+
168
+ def _load_graph_or_error(args: argparse.Namespace):
169
+ """Resolve config + load graph; on missing/stale index, print an error
170
+ envelope and return ``(cfg, graph_or_None, rc)``.
171
+
172
+ Shared by every listing command so the cfg/load/error frame is not
173
+ hand-copied. ``rc`` is 2 on error (envelope already printed), 0 on success.
174
+ """
175
+ from java_codebase_rag.jrag_envelope import Envelope
176
+ from java_codebase_rag.jrag_render import render
177
+
178
+ cfg = _resolve_cfg(args)
179
+ try:
180
+ graph = _load_graph(cfg)
181
+ except (_IndexNotFound, _IndexStale) as exc:
182
+ env = Envelope(status="error", message=str(exc))
183
+ print(render(env, fmt=args.format, detail=args.detail))
184
+ return cfg, None, 2
185
+ # Default --service from cwd before the handler reads it (MCP parity).
186
+ # No-op unless the command opted in via set_defaults(auto_scope=True).
187
+ _apply_auto_scope(args, cfg, graph)
188
+ return cfg, graph, 0
189
+
190
+
191
+ def _clamped_limit(args: argparse.Namespace) -> int:
192
+ """Return the limit clamped so ``limit+1 <= 500`` (backend clamp)."""
193
+ raw_limit = args.limit if args.limit is not None else 20
194
+ return min(raw_limit, 499)
195
+
196
+
197
+ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str,
198
+ extra_hints: list[str] | None = None) -> int:
199
+ """Apply +1-fetch truncation, build the envelope, render as a listing.
200
+
201
+ Shared by the listing commands whose backend returns a flat row list
202
+ (routes / clients / producers). ``rows`` must already be the limit+1
203
+ fetch. Renders as the default shape (no ``shape=``).
204
+
205
+ ``extra_hints`` are merged into ``agent_next_actions`` AFTER the
206
+ edge/breadcrumb-derived hints (deduped, capped at 5). Used by listings
207
+ whose rows map to a natural ``jrag inspect <fqn>`` drill-down
208
+ (jobs / listeners / entities).
209
+ """
210
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, to_envelope_rows
211
+ from java_codebase_rag.jrag_render import render
212
+
213
+ node_list = to_envelope_rows(rows) if rows and not isinstance(rows[0], dict) else list(rows)
214
+ display_nodes_list, truncated = mark_truncated(node_list, limit)
215
+ display_nodes = {node["id"]: node for node in display_nodes_list}
216
+
217
+ env = Envelope(
218
+ status="ok", nodes=display_nodes, truncated=truncated,
219
+ warnings=_auto_scope_notice(args),
220
+ )
221
+ next_actions_hook(env, command=getattr(args, "command", None))
222
+ if extra_hints:
223
+ seen = set(env.agent_next_actions)
224
+ for h in extra_hints:
225
+ if h and h not in seen:
226
+ seen.add(h)
227
+ env.agent_next_actions.append(h)
228
+ env.agent_next_actions = env.agent_next_actions[:5]
229
+ print(render(env, fmt=args.format, detail=args.detail, noun=noun))
230
+ return 0
231
+
232
+
233
+ def _symbol_hit_to_dict(hit) -> dict:
234
+ """Convert a ``SymbolHit`` (dataclass) to the envelope node dict shape.
235
+
236
+ Carries the FULL ``SymbolHit``: ``filename`` / ``start_line`` so the
237
+ projector can compose the ``file`` field at ``--detail normal``, and
238
+ ``signature`` / ``annotations`` / ``capabilities`` / ``modifiers`` /
239
+ ``package`` / ``parent_id`` / ``resolved`` so ``--detail full`` is genuinely
240
+ rich. The projector (:func:`jrag_envelope.project_node`) trims per detail
241
+ level at render time — callers build rich and let the seam trim, inverting
242
+ the old "trim at construction" that coupled detail to format. Empty values
243
+ are dropped by the projector, so carrying them here is harmless. Byte
244
+ offsets (``start_byte`` / ``end_byte``) are intentionally dropped — pure
245
+ noise, never a display field.
246
+ """
247
+ return {
248
+ "id": hit.id,
249
+ "kind": "symbol",
250
+ "fqn": hit.fqn,
251
+ "name": hit.name,
252
+ "symbol_kind": hit.kind,
253
+ "microservice": hit.microservice,
254
+ "module": hit.module,
255
+ "role": hit.role,
256
+ "filename": hit.filename,
257
+ "start_line": hit.start_line,
258
+ "end_line": hit.end_line,
259
+ "signature": hit.signature,
260
+ "annotations": list(hit.annotations or []),
261
+ "capabilities": list(hit.capabilities or []),
262
+ "modifiers": list(hit.modifiers or []),
263
+ "package": hit.package,
264
+ "parent_id": hit.parent_id,
265
+ "resolved": hit.resolved,
266
+ }
267
+
268
+
269
+ class _EnvelopeArgumentParser(argparse.ArgumentParser):
270
+ """ArgumentParser subclass that routes ``error()`` to a raised exception.
271
+
272
+ Stock argparse ``error()`` prints ``usage:`` to stderr and calls SystemExit
273
+ — a raw, non-envelope shape that ignores ``--format json``. With
274
+ ``exit_on_error=False`` the base class raises :class:`argparse.ArgumentError`
275
+ instead, but STILL prints the usage text first. This override suppresses the
276
+ usage dump so :func:`main` can emit a clean ``status: error`` envelope
277
+ honoring ``--format`` (consistent with not_found / missing-index errors).
278
+ """
279
+
280
+ def error(self, message: str) -> None: # type: ignore[override]
281
+ raise argparse.ArgumentError(None, message)
282
+
283
+
284
+ _PREPARSE_PARSER = argparse.ArgumentParser(add_help=False)
285
+ _PREPARSE_PARSER.add_argument("--format", default=None)
286
+ _PREPARSE_PARSER.add_argument("--detail", default=None)
287
+
288
+
289
+ def _preparse_render_flags(raw: list[str]) -> tuple[str | None, str | None, list[str]]:
290
+ """Extract ``--format`` / ``--detail`` from raw argv via a minimal parser.
291
+
292
+ Used by :func:`main` to honor render flags when argparse bailed before
293
+ populating ``args`` (missing required positional, unknown subcommand).
294
+ Returns ``(format, detail, leftover_argv)`` where ``leftover_argv`` has the
295
+ consumed flag tokens stripped so the first remaining non-dash token is the
296
+ subcommand name (not a flag value like the ``json`` in ``--format json``).
297
+ """
298
+ try:
299
+ ns, leftover = _PREPARSE_PARSER.parse_known_args(raw)
300
+ return ns.format, ns.detail, list(leftover)
301
+ except Exception:
302
+ return None, None, list(raw)
303
+
304
+
305
+ def build_parser() -> argparse.ArgumentParser:
306
+ """Argparse builder. Imports no backend modules.
307
+
308
+ ``--offset`` is intentionally NOT a global flag (PR-JRAG-1a contract): it
309
+ is added only to ``find`` / ``search`` subparsers in PR-JRAG-1b / PR-JRAG-4
310
+ (those commands route through ``find_v2`` / ``search_v2`` which take an
311
+ ``offset``). In 1a, no subparser has ``--offset``.
312
+ """
313
+ description = (
314
+ "jrag - agent-facing CLI for graph-native code intelligence.\n\n"
315
+ "Every <query> command resolves the identifier (FQN / simple name /\n"
316
+ "route path / topic) as the first step and maps one/many/none onto a\n"
317
+ "single envelope. Default output is compact text; `--format json` emits\n"
318
+ "the envelope verbatim.\n\n"
319
+ "Commands by group:\n"
320
+ " health: status\n"
321
+ " locate: find, inspect\n"
322
+ " listings: routes, clients, producers, topics, jobs, listeners,\n"
323
+ " entities\n"
324
+ " traversal: callers, callees, hierarchy, implementations, subclasses,\n"
325
+ " overrides, overridden-by, dependents, impact, decompose,\n"
326
+ " flow, dependencies, connection, outline, imports\n"
327
+ " orientation: microservices, map, conventions, overview\n"
328
+ " search: search\n\n"
329
+ "Run `jrag <command> --help` for command-specific options."
330
+ )
331
+ parser = _EnvelopeArgumentParser(
332
+ prog="jrag",
333
+ description=description,
334
+ formatter_class=argparse.RawDescriptionHelpFormatter,
335
+ exit_on_error=False,
336
+ )
337
+ subparsers = parser.add_subparsers(dest="command", parser_class=_EnvelopeArgumentParser)
338
+
339
+ # Common flags applied per command via parents=[_common_parser()]. NOT
340
+ # global so commands can override defaults (e.g. fan-out commands use
341
+ # limit=10). The helper builds a FRESH parser each call so every subparser
342
+ # owns its own --detail Action object — argparse `parents` shares Action
343
+ # objects by reference, and `set_defaults(detail=...)` mutates the shared
344
+ # action's default (CPython walks `self._actions`), so a single shared
345
+ # `common` made `status.set_defaults(detail="full")` poison every other
346
+ # subparser into defaulting to "full". A fresh parser per subparser isolates
347
+ # the override to the command that asked for it.
348
+ def _common_parser() -> argparse.ArgumentParser:
349
+ common = argparse.ArgumentParser(add_help=False)
350
+ common.add_argument("--service", type=str, default=None, help="Filter by microservice.")
351
+ common.add_argument("--module", type=str, default=None, help="Filter by module.")
352
+ common.add_argument(
353
+ "--no-auto-scope",
354
+ dest="no_auto_scope",
355
+ action="store_true",
356
+ default=False,
357
+ help=(
358
+ "Disable cwd-derived auto --service scoping so cross-service "
359
+ "results are visible (also disabled via JRAG_NO_AUTO_SCOPE=1)."
360
+ ),
361
+ )
362
+ common.add_argument(
363
+ "--limit", type=int, default=20, help="Cap on results (default 20; 10 for fan-out)."
364
+ )
365
+ common.add_argument(
366
+ "--index-dir",
367
+ type=str,
368
+ default=None,
369
+ dest="index_dir",
370
+ help="Index directory override (default: discovered from cwd).",
371
+ )
372
+ common.add_argument(
373
+ "--format",
374
+ choices=("text", "json"),
375
+ default="text",
376
+ help="Output format (default: text).",
377
+ )
378
+ common.add_argument(
379
+ "--detail",
380
+ choices=("brief", "normal", "full"),
381
+ default="normal",
382
+ help=(
383
+ "Output detail level (default normal) — ORTHOGONAL to --format: both "
384
+ "text and json honor it. brief = identity only (name @service); "
385
+ "normal = +module/role/file/score; full = +signature/annotations/snippet."
386
+ ),
387
+ )
388
+ return common
389
+
390
+ # Core-only parser for AGGREGATE commands (status / microservices) that have
391
+ # no per-row filtering surface. Excludes --service / --module / --limit so
392
+ # the surface is honest: those flags are REJECTED at parse time (clean
393
+ # error envelope) rather than accepted-then-warned-as-no-op. Keeps
394
+ # --index-dir / --format / --detail.
395
+ def _core_parser() -> argparse.ArgumentParser:
396
+ core = argparse.ArgumentParser(add_help=False)
397
+ core.add_argument(
398
+ "--index-dir",
399
+ type=str,
400
+ default=None,
401
+ dest="index_dir",
402
+ help="Index directory override (default: discovered from cwd).",
403
+ )
404
+ core.add_argument(
405
+ "--format",
406
+ choices=("text", "json"),
407
+ default="text",
408
+ help="Output format (default: text).",
409
+ )
410
+ core.add_argument(
411
+ "--detail",
412
+ choices=("brief", "normal", "full"),
413
+ default="normal",
414
+ help=(
415
+ "Output detail level (default normal) — ORTHOGONAL to --format: both "
416
+ "text and json honor it. brief = identity only (name @service); "
417
+ "normal = +module/role/file/score; full = +signature/annotations/snippet."
418
+ ),
419
+ )
420
+ return core
421
+
422
+ status = subparsers.add_parser(
423
+ "status",
424
+ help="Print index freshness, ontology version, and counts.",
425
+ parents=[_core_parser()],
426
+ description=(
427
+ "Index health and freshness. Reports ontology version, source root, "
428
+ "built_at, parse_errors, edge counts, and the counts dictionary from "
429
+ "GraphMeta. Exits 2 with an actionable envelope if the index is "
430
+ "missing or stale. An aggregate view: --service / --module / --limit "
431
+ "are NOT accepted (rejected at parse time)."
432
+ ),
433
+ )
434
+ status.set_defaults(handler=_cmd_status, detail="full")
435
+
436
+ # find subparser (PR-JRAG-1b)
437
+ find = subparsers.add_parser(
438
+ "find",
439
+ help="Find nodes by query or filter.",
440
+ parents=[_common_parser()],
441
+ description=(
442
+ "Find nodes by query or filter. Two modes:\n"
443
+ " Query mode (positional <query>): search by exact name/FQN (symbols only).\n"
444
+ " Filter mode (no positional): apply structured filters (NodeFilter flags).\n"
445
+ "Kind inference: domain flags (--http-method, --client-kind, --producer-kind) imply\n"
446
+ "route/client/producer when --kind is omitted. Contradiction emits an error envelope.\n"
447
+ "Query mode + non-symbol kind (explicit or inferred) errors: name/FQN lookup only\n"
448
+ "searches symbols; drop the positional <query> and use filter mode for routes/clients/producers."
449
+ ),
450
+ )
451
+ find.add_argument("query", nargs="?", default=None, help="Search query (name/FQN). Omit for filter mode.")
452
+ find.add_argument(
453
+ "--kind",
454
+ choices=("symbol", "route", "client", "producer"),
455
+ default=None,
456
+ help="Node kind (omit for auto-inference from domain flags).",
457
+ )
458
+ find.add_argument("--role", type=str, default=None, help="Filter by role.")
459
+ find.add_argument("--exclude-role", type=str, default=None, help="Exclude by role.")
460
+ find.add_argument("--java-kind", type=str, default=None, help="Filter by Java symbol kind.")
461
+ find.add_argument("--annotation", type=str, default=None, help="Filter by annotation.")
462
+ find.add_argument("--capability", type=str, default=None, help="Filter by capability.")
463
+ find.add_argument("--framework", type=str, default=None, help="Filter by framework.")
464
+ find.add_argument("--source-layer", type=str, default=None, help="Filter by source layer.")
465
+ find.add_argument("--fqn-contains", type=str, default=None, help="Filter by FQN substring.")
466
+ find.add_argument("--http-method", type=str, default=None, help="Filter by HTTP method (route).")
467
+ find.add_argument("--path-contains", type=str, default=None, help="Filter by path substring (route).")
468
+ find.add_argument("--client-kind", type=str, default=None, help="Filter by client kind (client).")
469
+ find.add_argument("--calls-service", type=str, default=None, help="Filter by target service (client).")
470
+ find.add_argument("--calls-path-contains", type=str, default=None, help="Filter by target path substring (client).")
471
+ find.add_argument("--producer-kind", type=str, default=None, help="Filter by producer kind (producer).")
472
+ find.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring (producer).")
473
+ find.add_argument(
474
+ "--offset",
475
+ type=int,
476
+ default=0,
477
+ help="Page offset (filter mode only; ignored in query mode).",
478
+ )
479
+ find.set_defaults(handler=_cmd_find, auto_scope=True)
480
+
481
+ # inspect subparser (PR-JRAG-1b)
482
+ inspect = subparsers.add_parser(
483
+ "inspect",
484
+ help="Inspect a node by query.",
485
+ parents=[_common_parser()],
486
+ description=(
487
+ "Inspect a node by resolving a query (name/FQN) and returning its full details\n"
488
+ "including edge_summary. Uses resolve_v2 internally; on ambiguous candidates,\n"
489
+ "returns them (no auto-pick). On not_found, returns an error envelope."
490
+ ),
491
+ )
492
+ inspect.add_argument("query", help="Search query (name/FQN).")
493
+ inspect.add_argument(
494
+ "--kind",
495
+ choices=("symbol", "route", "client", "producer"),
496
+ default=None,
497
+ help="Hint for resolve (omitted for broad search).",
498
+ )
499
+ inspect.add_argument("--java-kind", type=str, default=None, help="Post-filter by Java symbol kind.")
500
+ inspect.add_argument("--role", type=str, default=None, help="Post-filter by role.")
501
+ inspect.add_argument("--fqn-contains", type=str, default=None, help="Post-filter by FQN substring.")
502
+ inspect.set_defaults(handler=_cmd_inspect, detail="full")
503
+
504
+ # http-routes subparser (PR-JRAG-2)
505
+ http_routes = subparsers.add_parser(
506
+ "http-routes",
507
+ help="List HTTP routes.",
508
+ parents=[_common_parser()],
509
+ description=(
510
+ "List HTTP routes by microservice, framework, path substring, or method. "
511
+ "Returns route nodes (no resolve step). HTTP-server-route surface only — "
512
+ "kafka topics live under `topics`."
513
+ ),
514
+ )
515
+ http_routes.add_argument("--framework", type=str, default=None, help="Filter by framework.")
516
+ http_routes.add_argument("--path-contains", type=str, default=None, help="Filter by path substring.")
517
+ http_routes.add_argument("--method", type=str, default=None, help="Filter by HTTP method.")
518
+ http_routes.set_defaults(handler=_cmd_routes, detail="full", auto_scope=True)
519
+
520
+ # http-clients subparser (PR-JRAG-2)
521
+ http_clients = subparsers.add_parser(
522
+ "http-clients",
523
+ help="List HTTP clients.",
524
+ parents=[_common_parser()],
525
+ description=(
526
+ "List HTTP clients by microservice, client kind, target service, or path substring. "
527
+ "Returns client nodes (no resolve step)."
528
+ ),
529
+ )
530
+ http_clients.add_argument("--client-kind", type=str, default=None, help="Filter by client kind.")
531
+ http_clients.add_argument("--calls-service", type=str, default=None, help="Filter by target service.")
532
+ http_clients.add_argument("--path-contains", type=str, default=None, help="Filter by path substring.")
533
+ http_clients.set_defaults(handler=_cmd_clients, detail="full", auto_scope=True)
534
+
535
+ # producers subparser (PR-JRAG-2)
536
+ producers = subparsers.add_parser(
537
+ "producers",
538
+ help="List async message producers.",
539
+ parents=[_common_parser()],
540
+ description=(
541
+ "List async message producers by microservice, producer kind, or topic substring. "
542
+ "Returns producer nodes (no resolve step)."
543
+ ),
544
+ )
545
+ producers.add_argument("--producer-kind", type=str, default=None, help="Filter by producer kind.")
546
+ producers.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring.")
547
+ producers.set_defaults(handler=_cmd_producers, detail="full", auto_scope=True)
548
+
549
+ # topics subparser (PR-JRAG-2)
550
+ topics = subparsers.add_parser(
551
+ "topics",
552
+ help="List message topics (producer-grouped).",
553
+ parents=[_common_parser()],
554
+ description=(
555
+ "List message topics grouped by producer. "
556
+ "No :Topic node exists; this command groups producers by topic name. "
557
+ "--consumer-in resolves consumers (listener methods) via EXPOSES edges to Route(topic)."
558
+ ),
559
+ )
560
+ topics.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring.")
561
+ topics.add_argument("--producer-in", type=str, default=None, help="Scope producers to this microservice.")
562
+ topics.add_argument("--consumer-in", type=str, default=None, help="Show consumers from this microservice.")
563
+ topics.set_defaults(handler=_cmd_topics, detail="full", auto_scope=True)
564
+
565
+ # jobs subparser (PR-JRAG-2)
566
+ jobs = subparsers.add_parser(
567
+ "jobs",
568
+ help="List scheduled tasks.",
569
+ parents=[_common_parser()],
570
+ description=(
571
+ "List scheduled task symbols (capability=SCHEDULED_TASK). "
572
+ "Returns Symbol nodes with the SCHEDULED_TASK capability."
573
+ ),
574
+ )
575
+ jobs.set_defaults(handler=_cmd_jobs, detail="full", auto_scope=True)
576
+
577
+ # listeners subparser (PR-JRAG-2)
578
+ listeners = subparsers.add_parser(
579
+ "listeners",
580
+ help="List message listeners.",
581
+ parents=[_common_parser()],
582
+ description=(
583
+ "List message listener symbols (capability=MESSAGE_LISTENER). "
584
+ "Returns Symbol nodes with the MESSAGE_LISTENER capability."
585
+ ),
586
+ )
587
+ listeners.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring (on producer member).")
588
+ listeners.set_defaults(handler=_cmd_listeners, detail="full", auto_scope=True)
589
+
590
+ # entities subparser (PR-JRAG-2)
591
+ entities = subparsers.add_parser(
592
+ "entities",
593
+ help="List JPA entities.",
594
+ parents=[_common_parser()],
595
+ description=(
596
+ "List JPA entity symbols (role=ENTITY). "
597
+ "Returns Symbol nodes with the ENTITY role."
598
+ ),
599
+ )
600
+ entities.set_defaults(handler=_cmd_entities, detail="full", auto_scope=True)
601
+
602
+ # ---- Traversal commands (PR-JRAG-3a) ----
603
+ # Shared resolve-disambiguation flags (PR-JRAG-1a contract: only --kind is a
604
+ # true resolve input; the rest are client-side post-filters on resolve's
605
+ # candidate set). Traversals are resolve-first; --offset is NOT registered
606
+ # on any traversal subparser (none of the backends take offset).
607
+ resolve_parent = argparse.ArgumentParser(add_help=False)
608
+ resolve_parent.add_argument(
609
+ "--kind",
610
+ choices=("symbol", "route", "client", "producer"),
611
+ default=None,
612
+ help="Hint for resolve (omit for broad search).",
613
+ )
614
+ resolve_parent.add_argument("--java-kind", type=str, default=None, help="Post-filter by Java symbol kind.")
615
+ resolve_parent.add_argument("--role", type=str, default=None, help="Post-filter by role.")
616
+ resolve_parent.add_argument("--fqn-contains", type=str, default=None, help="Post-filter by FQN substring.")
617
+
618
+ callers = subparsers.add_parser(
619
+ "callers",
620
+ help="Who calls this symbol or route?",
621
+ parents=[_common_parser(), resolve_parent],
622
+ description=(
623
+ "Resolve <query> then traverse the call graph inbound (who calls me?). "
624
+ "Symbol -> g.find_callers (CALLS edges, --service/--module pushed down). "
625
+ "Route -> g.find_route_callers; route callers are cross-service by "
626
+ "construction, so --service narrows WHICH route resolves (a resolve-time "
627
+ "filter) rather than filtering the resulting callers. "
628
+ "--include-external controls whether external (JDK/Spring/Lombok) callers "
629
+ "are excluded (default: excluded)."
630
+ ),
631
+ )
632
+ callers.add_argument("query", help="Symbol FQN/name (e.g. 'pkg.Svc#method(Arg)') or route path.")
633
+ callers.add_argument("--depth", type=int, default=1, help="Call-graph depth (default 1).")
634
+ callers.add_argument(
635
+ "--min-confidence",
636
+ type=float,
637
+ default=0.0,
638
+ dest="min_confidence",
639
+ help="Minimum CALLS edge confidence in [0.0, 1.0].",
640
+ )
641
+ callers.add_argument(
642
+ "--include-external",
643
+ action="store_true",
644
+ help="Include external (JDK/Spring/Lombok) callers/callees (default excluded).",
645
+ )
646
+ callers.set_defaults(handler=_cmd_callers, auto_scope=True)
647
+
648
+ callees = subparsers.add_parser(
649
+ "callees",
650
+ help="What does this symbol call?",
651
+ parents=[_common_parser(), resolve_parent],
652
+ description=(
653
+ "Resolve <query> (Symbol) then traverse the call graph outbound (what do I "
654
+ "call?). Calls g.find_callees; --include-external is symmetric with callers."
655
+ ),
656
+ )
657
+ callees.add_argument("query", help="Symbol FQN/name (e.g. 'pkg.Svc#method(Arg)').")
658
+ callees.add_argument("--depth", type=int, default=1, help="Call-graph depth (default 1).")
659
+ callees.add_argument(
660
+ "--min-confidence",
661
+ type=float,
662
+ default=0.0,
663
+ dest="min_confidence",
664
+ help="Minimum CALLS edge confidence in [0.0, 1.0].",
665
+ )
666
+ callees.add_argument(
667
+ "--include-external",
668
+ action="store_true",
669
+ help="Include external (JDK/Spring/Lombok) callees (default excluded).",
670
+ )
671
+ callees.set_defaults(handler=_cmd_callees, auto_scope=True)
672
+
673
+ hierarchy = subparsers.add_parser(
674
+ "hierarchy",
675
+ help="Type hierarchy (parents and children).",
676
+ parents=[_common_parser(), resolve_parent],
677
+ description=(
678
+ "Resolve <query> (type Symbol) then walk EXTENDS/IMPLEMENTS both directions: "
679
+ "out = supertypes (parents), in = subtypes (children). No --service/--module "
680
+ "push-down (structural edges)."
681
+ ),
682
+ )
683
+ hierarchy.add_argument("query", help="Class/interface FQN or name.")
684
+ hierarchy.set_defaults(handler=_cmd_hierarchy)
685
+
686
+ implementations = subparsers.add_parser(
687
+ "implementations",
688
+ help="Classes implementing an interface.",
689
+ parents=[_common_parser(), resolve_parent],
690
+ description=(
691
+ "Resolve <query> (interface Symbol) then call g.find_implementors. "
692
+ "--service/--module pushed down; --capability pushed down to the backend "
693
+ "(find_implementors accepts a capability filter)."
694
+ ),
695
+ )
696
+ implementations.add_argument("query", help="Interface FQN or name.")
697
+ implementations.add_argument("--capability", type=str, default=None, help="Filter implementors by capability.")
698
+ implementations.set_defaults(handler=_cmd_implementations, auto_scope=True)
699
+
700
+ subclasses = subparsers.add_parser(
701
+ "subclasses",
702
+ help="Classes extending a type.",
703
+ parents=[_common_parser(), resolve_parent],
704
+ description=(
705
+ "Resolve <query> (class Symbol) then call g.find_subclasses (EXTENDS inbound). "
706
+ "--service/--module pushed down."
707
+ ),
708
+ )
709
+ subclasses.add_argument("query", help="Class FQN or name.")
710
+ subclasses.set_defaults(handler=_cmd_subclasses, auto_scope=True)
711
+
712
+ overrides = subparsers.add_parser(
713
+ "overrides",
714
+ help="Methods this method overrides (dispatch UP to declaration).",
715
+ parents=[_common_parser(), resolve_parent],
716
+ description=(
717
+ "Resolve <query> (method Symbol) then neighbors_v2([id], 'out', ['OVERRIDES']). "
718
+ "The stored OVERRIDES edge runs overrider -> declaration (subtype method -> "
719
+ "supertype declared method), so 'out' dispatches UP the hierarchy."
720
+ ),
721
+ )
722
+ overrides.add_argument("query", help="Method FQN or name (e.g. 'pkg.Impl#method(Arg)').")
723
+ overrides.set_defaults(handler=_cmd_overrides)
724
+
725
+ overridden_by = subparsers.add_parser(
726
+ "overridden-by",
727
+ help="Methods overriding this one (dispatch DOWN to overriders).",
728
+ parents=[_common_parser(), resolve_parent],
729
+ description=(
730
+ "Resolve <query> (method Symbol) then neighbors_v2([id], 'in', ['OVERRIDES']) "
731
+ "(= virtual OVERRIDDEN_BY out). 'in' traverses the stored OVERRIDES edge "
732
+ "backward, dispatching DOWN from declaration to overriders."
733
+ ),
734
+ )
735
+ overridden_by.add_argument("query", help="Method FQN or name (e.g. 'pkg.Iface#method(Arg)').")
736
+ overridden_by.set_defaults(handler=_cmd_overridden_by)
737
+
738
+ dependents = subparsers.add_parser(
739
+ "dependents",
740
+ help="Who injects this type?",
741
+ parents=[_common_parser(), resolve_parent],
742
+ description=(
743
+ "Resolve <query> (type Symbol) then call g.find_injectors (INJECTS inbound: "
744
+ "classes that inject this type). --service/--module pushed down."
745
+ ),
746
+ )
747
+ dependents.add_argument("query", help="Type FQN or name.")
748
+ dependents.set_defaults(handler=_cmd_dependents, auto_scope=True)
749
+
750
+ impact = subparsers.add_parser(
751
+ "impact",
752
+ help="Fleet-wide blast radius (INJECTS/IMPLEMENTS/EXTENDS reverse closure).",
753
+ parents=[_common_parser(), resolve_parent],
754
+ description=(
755
+ "Resolve <query> then call g.impact_analysis (reverse closure over "
756
+ "INJECTS+IMPLEMENTS+EXTENDS: who breaks if this changes). --service is a "
757
+ "CLIENT-SIDE post-filter (impact_analysis has no microservice param); "
758
+ "surfaced as a warnings[] entry."
759
+ ),
760
+ )
761
+ impact.add_argument("query", help="Symbol FQN or name.")
762
+ impact.add_argument("--depth", type=int, default=2, help="Closure depth (default 2).")
763
+ impact.set_defaults(handler=_cmd_impact, auto_scope=True)
764
+
765
+ decompose = subparsers.add_parser(
766
+ "decompose",
767
+ help="Role-waterfall flow from an entrypoint.",
768
+ parents=[_common_parser(), resolve_parent],
769
+ description=(
770
+ "Resolve <query> (entrypoint Symbol) then call g.trace_flow. Walks "
771
+ "CONTROLLER -> SERVICE/COMPONENT -> CLIENT/REPOSITORY/MAPPER stages via "
772
+ "INJECTS+EXTENDS+IMPLEMENTS (optionally + CALLS hops). --service/--module "
773
+ "pushed down; --depth clamped to 1..3."
774
+ ),
775
+ )
776
+ decompose.add_argument("query", help="Entrypoint symbol FQN or name.")
777
+ decompose.add_argument("--depth", type=int, default=2, help="Neighbour hop count per stage (clamped 1..3, default 2).")
778
+ decompose.add_argument(
779
+ "--follow-calls",
780
+ action="store_true",
781
+ dest="follow_calls",
782
+ help="Follow DECLARES+CALLS type-to-type hops to top up each stage.",
783
+ )
784
+ decompose.add_argument(
785
+ "--max-stage",
786
+ type=int,
787
+ default=20,
788
+ dest="max_stage",
789
+ help="Cap on symbols per stage (stage_limit, default 20).",
790
+ )
791
+ decompose.add_argument(
792
+ "--min-confidence",
793
+ type=float,
794
+ default=0.0,
795
+ dest="min_confidence",
796
+ help="Min CALLS confidence when --follow-calls is on.",
797
+ )
798
+ decompose.add_argument(
799
+ "--include-external",
800
+ action="store_true",
801
+ help="Include external types reached via the CALLS hop (default excluded).",
802
+ )
803
+ decompose.set_defaults(handler=_cmd_decompose, auto_scope=True)
804
+
805
+ flow = subparsers.add_parser(
806
+ "flow",
807
+ help="Request flow through a route (inbound callers + outbound CALLS hops).",
808
+ parents=[_common_parser()],
809
+ description=(
810
+ "Resolve <query> to a Route then call g.trace_request_flow. Inbound = "
811
+ "cross-service HTTP/async callers (Client/Producer two-hop); outbound = "
812
+ "CALLS hops from the route handler. Intra-service is an INDEX-TIME data "
813
+ "property: CALLS edges are intra-codebase by construction, and the query "
814
+ "carries no microservice predicate, so the result reflects whatever the "
815
+ "fixture indexed (no query-time constraint). --max-hops clamped to 1..8."
816
+ ),
817
+ )
818
+ flow.add_argument("query", help="Route path (e.g. '/chat/assign'). Resolved with hint_kind=route.")
819
+ flow.add_argument("--max-hops", type=int, default=5, dest="max_hops", help="Max CALLS hops (clamped 1..8, default 5).")
820
+ flow.set_defaults(handler=_cmd_flow)
821
+
822
+ # ---- Compose traversals + file inspection (PR-JRAG-3b) ----
823
+ # callees (Client/Producer variant) re-uses the existing _cmd_callees
824
+ # handler from PR-JRAG-3a; the help text below updates to advertise the
825
+ # Client/Producer dispatch (Symbol path is unchanged). --kind picks the
826
+ # resolve hint; the handler dispatches on the resolved node's kind.
827
+ #
828
+ # (The callees subparser was registered above with the Symbol-only help
829
+ # text; we patch its description here to advertise the new variant without
830
+ # duplicating the parser construction.)
831
+ callees.epilog = (
832
+ "Symbol root lists the methods this code calls (CALLS out). Client and\n"
833
+ "Producer roots follow their call edge to the Route they target:\n"
834
+ " Client root -> the :Route it requests (HTTP_CALLS out)\n"
835
+ " Producer root -> the :Route (kafka_topic) it publishes to (ASYNC_CALLS out)\n"
836
+ "--include-external applies to the Symbol path; Client/Producer edges are\n"
837
+ "structural (Client/Producer -> :Route) and have no external-exclusion analog."
838
+ )
839
+
840
+ dependencies = subparsers.add_parser(
841
+ "dependencies",
842
+ help="Types this Symbol injects (INJECTS out).",
843
+ parents=[_common_parser(), resolve_parent],
844
+ description=(
845
+ "Resolve <query> (type Symbol) then neighbors_v2([id], 'out', ['INJECTS']) "
846
+ "= the types this class injects (its direct dependencies). INJECTS is "
847
+ "Symbol -> Symbol (declaring type -> injected type), so 'out' traverses "
848
+ "from the injector to its dependencies. --service/--module are NOT "
849
+ "applied (INJECTS is a structural edge with no microservice predicate); "
850
+ "they surface as warnings[]. --include-external is accepted for surface "
851
+ "symmetry with callers/callees but is a warned no-op here (INJECTS has "
852
+ "no external-exclusion analog at the neighbors_v2 layer)."
853
+ ),
854
+ )
855
+ dependencies.add_argument("query", help="Symbol FQN or name (e.g. 'pkg.Svc').")
856
+ dependencies.add_argument(
857
+ "--include-external",
858
+ action="store_true",
859
+ help="Accepted for symmetry; warned no-op on dependencies (INJECTS is structural).",
860
+ )
861
+ dependencies.set_defaults(handler=_cmd_dependencies)
862
+
863
+ connection = subparsers.add_parser(
864
+ "connection",
865
+ help="Cross-service connections for a microservice (inbound/outbound).",
866
+ parents=[_common_parser()],
867
+ description=(
868
+ "RESOLVE-FIRST EXCEPTION: the first positional is a microservice NAME "
869
+ "(e.g. 'chat-core'), NOT a query — it is passed literally to list_clients/"
870
+ "list_producers/find_route_callers; resolve_v2 is NEVER run on it.\n\n"
871
+ "Direction (default --both): clients/producers in OTHER services "
872
+ "targeting this service. HTTP via list_clients(target_service=<svc>) + "
873
+ "async via find_route_callers on this service's topic Routes.\n"
874
+ "--outbound: clients/producers IN this service. HTTP via "
875
+ "list_clients(microservice=<svc>) + producers via "
876
+ "list_producers(microservice=<svc>).\n"
877
+ "--both: render both inbound and outbound sections.\n\n"
878
+ "--http-method and --calls-service filter HTTP callers only (clients "
879
+ "have a target_service; producers do not). Producers are KEPT under "
880
+ "--calls-service so the async channel stays visible; a warnings[] entry "
881
+ "is emitted when --calls-service bypasses producers."
882
+ ),
883
+ )
884
+ connection.add_argument(
885
+ "microservice",
886
+ help="Microservice NAME (literal — NOT resolved as a query).",
887
+ )
888
+ connection.add_argument(
889
+ "--inbound",
890
+ dest="direction",
891
+ action="store_const",
892
+ const="inbound",
893
+ default=None,
894
+ help="Show only inbound connections (default is --both).",
895
+ )
896
+ connection.add_argument(
897
+ "--outbound",
898
+ dest="direction",
899
+ action="store_const",
900
+ const="outbound",
901
+ help="Show only outbound connections (default is --both).",
902
+ )
903
+ connection.add_argument(
904
+ "--both",
905
+ dest="direction",
906
+ action="store_const",
907
+ const="both",
908
+ help="Show both inbound and outbound sections (this is the default).",
909
+ )
910
+ connection.add_argument(
911
+ "--http-method",
912
+ type=str,
913
+ default=None,
914
+ help="Filter HTTP callers by method (e.g. POST). Applies to clients only.",
915
+ )
916
+ connection.add_argument(
917
+ "--calls-service",
918
+ type=str,
919
+ default=None,
920
+ help=(
921
+ "Narrow to edges involving this other service. Outbound: clients with "
922
+ "target_service == <svc> (producers kept with a warning — no service "
923
+ "target on ASYNC channels). Inbound: callers from microservice == <svc>."
924
+ ),
925
+ )
926
+ connection.set_defaults(handler=_cmd_connection)
927
+
928
+ outline = subparsers.add_parser(
929
+ "outline",
930
+ help="List symbols declared in a file.",
931
+ parents=[_common_parser()],
932
+ description=(
933
+ "List all Symbol nodes whose declared location is in <file>. Calls "
934
+ "find_symbols_in_file_range(graph, filename=<file>, start_line=1, "
935
+ "end_line=2**31-1) — the start_line=1 is required (the backend returns "
936
+ "[] for start_line<1). --limit caps the entry count (the file's "
937
+ "symbol table is otherwise unbounded); truncated is set when more "
938
+ "entries exist. --offset is rejected (the backend takes no offset)."
939
+ ),
940
+ )
941
+ outline.add_argument("file", help="File path as stored in the graph (POSIX-relative to source root).")
942
+ outline.set_defaults(handler=_cmd_outline)
943
+
944
+ imports = subparsers.add_parser(
945
+ "imports",
946
+ help="List imports declared in a file (tree-sitter parse + resolve_v2).",
947
+ parents=[_common_parser()],
948
+ description=(
949
+ "Parse <file> with tree-sitter (ast_java.parse_java), walk its "
950
+ "import_declaration nodes, and resolve each imported FQN via resolve_v2 "
951
+ "against the graph. Returns one node per import: resolved graph Symbol "
952
+ "when resolve_v2 hits, or an unresolved placeholder carrying the raw FQN "
953
+ "otherwise. Static and wildcard imports are included (marked in the row)."
954
+ " --offset is rejected."
955
+ ),
956
+ )
957
+ imports.add_argument("file", help="File path (POSIX-relative to source root, or absolute).")
958
+ imports.set_defaults(handler=_cmd_imports)
959
+
960
+ # ---- Orientation commands (PR-JRAG-4) ----
961
+ microservices = subparsers.add_parser(
962
+ "microservices",
963
+ help="List microservices with resolved type counts.",
964
+ parents=[_core_parser()],
965
+ description=(
966
+ "List every microservice with its resolved type-symbol count. "
967
+ "Calls g.microservice_counts(). Renders as a counts listing. "
968
+ "An aggregate view: --service / --module / --limit are NOT accepted "
969
+ "(rejected at parse time)."
970
+ ),
971
+ )
972
+ microservices.set_defaults(handler=_cmd_microservices, detail="full")
973
+
974
+ map_cmd = subparsers.add_parser(
975
+ "map",
976
+ help="Symbol counts per kind, grouped by service or module.",
977
+ parents=[_common_parser()],
978
+ description=(
979
+ "Count resolved type Symbols (class/interface/enum/record/annotation) "
980
+ "grouped by microservice or module. --by {microservice,module} selects "
981
+ "the grouping axis (default microservice); --service / --module narrow "
982
+ "the count to one service or module (filters, independent of --by)."
983
+ ),
984
+ )
985
+ map_cmd.add_argument(
986
+ "--by",
987
+ dest="by",
988
+ choices=("microservice", "module"),
989
+ default=None,
990
+ help="Grouping axis: microservice (default) or module. When --module is "
991
+ "set without --by, the axis defaults to module (the user's focus is the "
992
+ "module axis); pass --by microservice to keep microservice grouping.",
993
+ )
994
+ map_cmd.set_defaults(handler=_cmd_map, detail="full")
995
+
996
+ conventions = subparsers.add_parser(
997
+ "conventions",
998
+ help="Dominant roles + framework tallies.",
999
+ parents=[_common_parser()],
1000
+ description=(
1001
+ "Report the dominant roles among resolved Symbols and the route framework "
1002
+ "distribution. --service narrows the role tally to one microservice."
1003
+ ),
1004
+ )
1005
+ conventions.set_defaults(handler=_cmd_conventions, detail="full")
1006
+
1007
+ overview = subparsers.add_parser(
1008
+ "overview",
1009
+ help="Bundle for a microservice, route, or topic.",
1010
+ parents=[_common_parser()],
1011
+ description=(
1012
+ "Dispatch on the positional <subject>:\n"
1013
+ " Route path (starts with '/') -> trace_request_flow (same as `flow`).\n"
1014
+ " Microservice name -> routes + clients + producers bundle.\n"
1015
+ " Topic string -> producers + consumers for the topic.\n"
1016
+ "--as {microservice,route,topic} overrides auto-detection.\n"
1017
+ "Auto-detection: starts with '/' -> route; matches a known microservice -> "
1018
+ "microservice; otherwise -> topic."
1019
+ ),
1020
+ )
1021
+ overview.add_argument(
1022
+ "subject",
1023
+ nargs="?",
1024
+ default=None,
1025
+ help="Microservice name, route path (starts with '/'), or topic string.",
1026
+ )
1027
+ overview.add_argument(
1028
+ "--as",
1029
+ dest="as_type",
1030
+ choices=("microservice", "route", "topic"),
1031
+ default=None,
1032
+ help="Override auto-detection of subject type.",
1033
+ )
1034
+ overview.set_defaults(handler=_cmd_overview, detail="full")
1035
+
1036
+ # ---- Search command (PR-JRAG-4) ----
1037
+ search = subparsers.add_parser(
1038
+ "search",
1039
+ help="Semantic search over Lance tables.",
1040
+ parents=[_common_parser()],
1041
+ description=(
1042
+ "Semantic search via search_v2 over the Lance index (java/sql/yaml tables). "
1043
+ "--table all searches all three. --hybrid enables vector+keyword hybrid. "
1044
+ "--offset paginates. --path-contains narrows by file path substring. "
1045
+ "Filters (NodeFilter flags) narrow results.\n\n"
1046
+ "--fuzzy is accepted but rejected IN-HANDLER with status: error (search is "
1047
+ "inherently semantic; --fuzzy is a no-op synonym). Registering the flag "
1048
+ "prevents argparse from exiting 2 before the handler can produce the envelope."
1049
+ ),
1050
+ )
1051
+ search.add_argument("query", help="Natural-language search query.")
1052
+ search.add_argument(
1053
+ "--table",
1054
+ choices=("java", "sql", "yaml", "all"),
1055
+ default="java",
1056
+ help="Lance table to search (default: java; all = java+sql+yaml).",
1057
+ )
1058
+ search.add_argument(
1059
+ "--hybrid", action="store_true", help="Enable vector+keyword hybrid search."
1060
+ )
1061
+ search.add_argument(
1062
+ "--explain", action="store_true", help="Show score breakdown per hit."
1063
+ )
1064
+ search.add_argument(
1065
+ "--path-contains", type=str, default=None, dest="path_contains",
1066
+ help="Narrow to chunks whose filename contains this substring.",
1067
+ )
1068
+ search.add_argument(
1069
+ "--fuzzy", action="store_true",
1070
+ help="Accepted but rejected in-handler (search is semantic; --fuzzy is implicit).",
1071
+ )
1072
+ search.add_argument(
1073
+ "--min-score", type=float, default=0.0, dest="min_score",
1074
+ help=(
1075
+ "Drop hits with a relevance score below this floor. Default 0.0 drops "
1076
+ "negative-score noise (chunks farther than orthogonal to the query); "
1077
+ "raise to tighten precision."
1078
+ ),
1079
+ )
1080
+ # NodeFilter flags (same set as `find` filter mode, minus the query-only ones).
1081
+ search.add_argument("--role", type=str, default=None, help="Filter by role.")
1082
+ search.add_argument("--exclude-role", type=str, default=None, dest="exclude_role", help="Exclude by role.")
1083
+ search.add_argument("--java-kind", type=str, default=None, dest="java_kind", help="Filter by Java symbol kind.")
1084
+ search.add_argument("--annotation", type=str, default=None, help="Filter by annotation.")
1085
+ search.add_argument("--capability", type=str, default=None, help="Filter by capability.")
1086
+ search.add_argument("--framework", type=str, default=None, help="Filter by framework.")
1087
+ search.add_argument("--fqn-contains", type=str, default=None, dest="fqn_contains", help="Filter by FQN substring.")
1088
+ search.add_argument(
1089
+ "--offset",
1090
+ type=int,
1091
+ default=0,
1092
+ help="Page offset (passed to search_v2; paginated via +1-fetch).",
1093
+ )
1094
+ search.add_argument(
1095
+ "--chunks",
1096
+ action="store_true",
1097
+ help="Show every chunk (default collapses to one row per symbol/type).",
1098
+ )
1099
+ search.set_defaults(handler=_cmd_search, auto_scope=True)
1100
+
1101
+ return parser
1102
+
1103
+
1104
+ def _resolve_cfg(args: argparse.Namespace): # type: ignore[no-untyped-def]
1105
+ """Resolve operator config (reuses the operator's cocoindex-free resolver).
1106
+
1107
+ Mirrors ``java_codebase_rag.cli._resolved_from_ns``: pass ``source_root=None``
1108
+ so ``resolve_operator_config`` honors ``JAVA_CODEBASE_RAG_SOURCE_ROOT`` first,
1109
+ then a YAML ``source_root`` field, then walks up from cwd to find a project
1110
+ root. Passing a discovered root explicitly here would OVERRIDE a set env var
1111
+ whenever any ancestor dir has a ``.java-codebase-rag`` marker — silently
1112
+ ignoring the documented subprocess source-root mechanism that
1113
+ ``pipeline.subprocess_env`` sets for the cocoindex child (and that operators
1114
+ set directly).
1115
+
1116
+ When the anchor is an index dir with no YAML beside it, resolution follows
1117
+ that index's ``config_source`` pointer (see ``config._effective_config_dir``)
1118
+ so a config living in a sibling dir is still found from inside a microservice.
1119
+ Applies CLI ``--index-dir`` if given and calls ``apply_to_os_environ`` so
1120
+ downstream modules see a consistent env (critically SBERT_MODEL for ``jrag
1121
+ search``).
1122
+ """
1123
+ from java_codebase_rag.config import resolve_operator_config
1124
+
1125
+ cfg = resolve_operator_config(
1126
+ source_root=None,
1127
+ cli_index_dir=getattr(args, "index_dir", None),
1128
+ )
1129
+ cfg.apply_to_os_environ()
1130
+ return cfg
1131
+
1132
+
1133
+ def _load_graph(cfg): # type: ignore[no-untyped-def]
1134
+ """Load the LadybugGraph with actionable error envelopes.
1135
+
1136
+ * missing index -> ``_IndexNotFound`` (caught in ``main`` -> envelope with
1137
+ a ``java-codebase-rag init --source-root <root>`` remediation).
1138
+ * ontology-mismatch (``RuntimeError`` from ``LadybugGraph.get``) ->
1139
+ ``_IndexStale`` (caught in ``main`` -> envelope with a rebuild hint).
1140
+ """
1141
+ from ladybug_queries import LadybugGraph
1142
+
1143
+ ladybug_path = str(cfg.ladybug_path)
1144
+ if not LadybugGraph.exists(ladybug_path):
1145
+ raise _IndexNotFound(
1146
+ f"No index at {cfg.ladybug_path}. "
1147
+ "Run: java-codebase-rag init --source-root <root>"
1148
+ )
1149
+ try:
1150
+ return LadybugGraph.get(ladybug_path)
1151
+ except RuntimeError as exc:
1152
+ raise _IndexStale(str(exc)) from exc
1153
+
1154
+
1155
+ def _cmd_status(args: argparse.Namespace) -> int:
1156
+ from java_codebase_rag.jrag_envelope import Envelope
1157
+ from java_codebase_rag.jrag_render import render
1158
+
1159
+ cfg = _resolve_cfg(args)
1160
+ try:
1161
+ graph = _load_graph(cfg)
1162
+ except (_IndexNotFound, _IndexStale) as exc:
1163
+ env = Envelope(
1164
+ status="error",
1165
+ message=str(exc),
1166
+ )
1167
+ print(render(env, fmt=args.format, detail=args.detail))
1168
+ return 2
1169
+
1170
+ meta = graph.meta()
1171
+ if "error" in meta:
1172
+ env = Envelope(
1173
+ status="error",
1174
+ message=f"Index meta read failed: {meta['error']}",
1175
+ )
1176
+ print(render(env, fmt=args.format, detail=args.detail))
1177
+ return 2
1178
+
1179
+ counts = meta.get("counts") or {}
1180
+ edge_counts = meta.get("edge_counts") or {}
1181
+ # Single notional "index" node carrying kv fields + nested counts/edges
1182
+ # as top-level dict-valued fields. The renderer's inspect-shape dispatch
1183
+ # fires on ANY dict-typed value (structural signal, not name-based), so
1184
+ # ``counts`` / ``edges`` render as indented alphabetical sections without
1185
+ # abusing ``edge_summary`` (which is reserved for PR-JRAG-3 real edge
1186
+ # data). See jrag_render._render_inspect / _render_text_shape.
1187
+ # --service / --module / --limit are rejected at the argparse layer
1188
+ # (status uses _core_parser), so no no-op warning is needed here.
1189
+ env = Envelope(
1190
+ status="ok",
1191
+ nodes={
1192
+ "index": {
1193
+ "ontology_version": int(meta.get("ontology_version") or 0),
1194
+ "built_at": int(meta.get("built_at") or 0),
1195
+ "source_root": str(meta.get("source_root") or ""),
1196
+ "db_path": str(meta.get("db_path") or ""),
1197
+ "parse_errors": int(meta.get("parse_errors") or 0),
1198
+ "index_dir": str(cfg.index_dir.resolve()),
1199
+ "ladybug_path": str(cfg.ladybug_path.resolve()),
1200
+ "counts": dict(counts),
1201
+ "edges": dict(edge_counts),
1202
+ },
1203
+ },
1204
+ )
1205
+ print(render(env, fmt=args.format, detail=args.detail, noun="status", shape="inspect"))
1206
+ return 0
1207
+
1208
+
1209
+ def _infer_kind(args: argparse.Namespace) -> str | None:
1210
+ """Infer kind from domain flags when --kind is omitted.
1211
+
1212
+ Inference rules (PR-JRAG-1b):
1213
+ - --http-method or --path-contains → route
1214
+ - --client-kind or --calls-service or --calls-path-contains → client
1215
+ - --producer-kind or --topic-contains → producer
1216
+ - else → symbol (default)
1217
+ Returns None if no flags are set (symbol default in callers).
1218
+ """
1219
+ if args.kind is not None:
1220
+ return args.kind
1221
+ if args.http_method or args.path_contains:
1222
+ return "route"
1223
+ if args.client_kind or args.calls_service or args.calls_path_contains:
1224
+ return "client"
1225
+ if args.producer_kind or args.topic_contains:
1226
+ return "producer"
1227
+ return "symbol"
1228
+
1229
+
1230
+ def _check_kind_contradiction(args: argparse.Namespace, inferred: str | None) -> tuple[bool, str | None]:
1231
+ """Check if domain flags contradict explicit --kind.
1232
+
1233
+ Returns (is_contradiction, error_message). Contradiction pairs:
1234
+ - --kind symbol + any route flag (--http-method, --path-contains)
1235
+ - --kind symbol + any client flag (--client-kind, --calls-service, --calls-path-contains)
1236
+ - --kind symbol + any producer flag (--producer-kind, --topic-contains)
1237
+ - (and similarly for route + non-route flags, etc.)
1238
+ """
1239
+ if args.kind is None:
1240
+ return False, None
1241
+ explicit = args.kind
1242
+ route_flags = args.http_method or args.path_contains
1243
+ client_flags = args.client_kind or args.calls_service or args.calls_path_contains
1244
+ producer_flags = args.producer_kind or args.topic_contains
1245
+ if explicit == "symbol" and (route_flags or client_flags or producer_flags):
1246
+ return True, "--kind symbol conflicts with domain flags (route/client/producer flags require matching --kind)"
1247
+ if explicit == "route" and (client_flags or producer_flags):
1248
+ return True, "--kind route conflicts with client/producer flags"
1249
+ if explicit == "client" and (route_flags or producer_flags):
1250
+ return True, "--kind client conflicts with route/producer flags"
1251
+ if explicit == "producer" and (route_flags or client_flags):
1252
+ return True, "--kind producer conflicts with route/client flags"
1253
+ return False, None
1254
+
1255
+
1256
+ def _cmd_find(args: argparse.Namespace) -> int:
1257
+ from java_codebase_rag.jrag_envelope import Envelope
1258
+ from java_codebase_rag.jrag_render import render
1259
+
1260
+ cfg = _resolve_cfg(args)
1261
+ try:
1262
+ graph = _load_graph(cfg)
1263
+ except (_IndexNotFound, _IndexStale) as exc:
1264
+ env = Envelope(status="error", message=str(exc))
1265
+ print(render(env, fmt=args.format, detail=args.detail))
1266
+ return 2
1267
+
1268
+ # find inlines its graph load (not via _load_graph_or_error), so wire the
1269
+ # auto-scope default here too (MCP parity).
1270
+ _apply_auto_scope(args, cfg, graph)
1271
+
1272
+ # Check kind contradiction first (before any backend work)
1273
+ inferred = _infer_kind(args)
1274
+ is_contradiction, error_msg = _check_kind_contradiction(args, inferred)
1275
+ if is_contradiction:
1276
+ env = Envelope(status="error", message=error_msg or "kind contradiction")
1277
+ print(render(env, fmt=args.format, detail=args.detail))
1278
+ return 2
1279
+
1280
+ # Cap at 499 so limit+1 <= 500 (backend clamp)
1281
+ # If args.limit is None, default to 20 (from argparse)
1282
+ raw_limit = args.limit if args.limit is not None else 20
1283
+ limit = min(raw_limit, 499)
1284
+
1285
+ # Query mode: positional <query> present
1286
+ if args.query:
1287
+ # find_by_name_or_fqn is Symbol-only (MATCH (s:Symbol) WHERE s.name=$needle
1288
+ # OR s.fqn=$needle). A positional <query> with a non-symbol kind (explicit
1289
+ # OR inferred from --http-method/--client-kind/--producer-kind/etc.) is a
1290
+ # usage contract violation -> status: error envelope (NOT argparse exit),
1291
+ # telling the user to drop the positional and use filter mode.
1292
+ effective_kind = inferred or "symbol"
1293
+ if effective_kind != "symbol":
1294
+ env = Envelope(
1295
+ status="error",
1296
+ message=(
1297
+ f"query mode (positional <query>) only searches Symbols, but kind "
1298
+ f"'{effective_kind}' was {'inferred from domain flags' if args.kind is None else 'set via --kind'}. "
1299
+ "Drop the positional <query> and use filter mode (the domain flags) "
1300
+ "for route/client/producer searches."
1301
+ ),
1302
+ )
1303
+ print(render(env, fmt=args.format, detail=args.detail))
1304
+ return 2
1305
+ return _cmd_find_query_mode(args, cfg, graph, limit)
1306
+
1307
+ # Filter mode: build NodeFilter and call find_v2
1308
+ return _cmd_find_filter_mode(args, cfg, graph, inferred or "symbol", limit)
1309
+
1310
+
1311
+ def _cmd_find_query_mode(
1312
+ args: argparse.Namespace,
1313
+ cfg,
1314
+ graph,
1315
+ limit: int,
1316
+ ) -> int:
1317
+ """Find query mode: g.find_by_name_or_fqn (Symbol-only, exact name/FQN match).
1318
+
1319
+ ``find_by_name_or_fqn`` runs ``MATCH (s:Symbol) WHERE s.name=$needle OR
1320
+ s.fqn=$needle`` — Symbol-only, exact-only. There is no fuzzy/prefix/contains
1321
+ path; ``--fuzzy`` was deferred (see plans/active/PLAN-JRAG-CLI.md Out of
1322
+ scope). Query mode is gated to ``effective_kind == "symbol"`` upstream in
1323
+ ``_cmd_find``, so the only ``kinds`` filter we may pass is symbol sub-kinds
1324
+ derived from ``--java-kind``.
1325
+ """
1326
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum
1327
+ from java_codebase_rag.jrag_render import render
1328
+
1329
+ query = args.query
1330
+
1331
+ # find_by_name_or_fqn is always Symbol; the only valid kinds filter is the
1332
+ # symbol sub-kind derived from --java-kind (lowercase, matching s.kind).
1333
+ # route/client/producer kinds were removed: they would never match Symbols.
1334
+ if args.java_kind:
1335
+ java_kind_norm = normalize_enum(args.java_kind, kind="java_kind")
1336
+ kinds = [java_kind_norm.lower()]
1337
+ else:
1338
+ kinds = None
1339
+
1340
+ # Call find_by_name_or_fqn (exact name OR fqn match).
1341
+ rows = graph.find_by_name_or_fqn(
1342
+ query,
1343
+ kinds=kinds,
1344
+ module=args.module,
1345
+ microservice=args.service,
1346
+ limit=limit + 1, # +1 for truncated detection
1347
+ )
1348
+ # Truncation is decided by the RAW name/FQN fetch (limit+1), BEFORE
1349
+ # post-filters reduce the set — otherwise a post-filter that drops rows
1350
+ # would silently clear `truncated` even though more name matches may exist
1351
+ # beyond the fetch (silent wrong-results).
1352
+ raw_truncated = len(rows) > limit
1353
+
1354
+ # Post-filter by role/annotation/capability (SymbolHit carries these).
1355
+ post_filter_active = False
1356
+ if args.role:
1357
+ post_filter_active = True
1358
+ role_norm = normalize_enum(args.role, kind="role")
1359
+ rows = [r for r in rows if (r.role or "").upper().replace("-", "_") == role_norm.upper()]
1360
+ if args.exclude_role:
1361
+ post_filter_active = True
1362
+ exclude_role_norm = normalize_enum(args.exclude_role, kind="role")
1363
+ rows = [r for r in rows if (r.role or "").upper().replace("-", "_") != exclude_role_norm.upper()]
1364
+ if args.annotation:
1365
+ post_filter_active = True
1366
+ rows = [r for r in rows if args.annotation in (r.annotations or [])]
1367
+ if args.capability:
1368
+ post_filter_active = True
1369
+ rows = [r for r in rows if args.capability in (r.capabilities or [])]
1370
+
1371
+ # Build warnings for filters that cannot apply in query mode. SymbolHit
1372
+ # carries no framework/source_layer fields; rather than silently dropping
1373
+ # the user's filter, surface a warning so they know to switch to filter mode.
1374
+ warnings: list[str] = []
1375
+ if args.framework:
1376
+ warnings.append(
1377
+ "--framework ignored in query mode (applies to routes/clients/producers; use filter mode)"
1378
+ )
1379
+ if args.source_layer:
1380
+ warnings.append(
1381
+ "--source-layer ignored in query mode (applies to routes; use filter mode)"
1382
+ )
1383
+ # When post-filters apply after a capped fetch, `truncated` reflects the
1384
+ # pre-filter name-match count and cannot know whether MORE filtered matches
1385
+ # exist beyond the fetch — surface that honestly.
1386
+ if raw_truncated and post_filter_active:
1387
+ warnings.append(
1388
+ "results truncated before --role/--annotation/--capability filters; "
1389
+ "additional filtered matches may exist beyond the fetch"
1390
+ )
1391
+
1392
+ # Display at most `limit` of the (post-filtered) rows.
1393
+ display_rows = rows[:limit]
1394
+ nodes = {}
1395
+ for row in display_rows:
1396
+ node_id = row.id
1397
+ # Carry the full SymbolHit field set (signature/annotations/modifiers/
1398
+ # package/raw location columns). The projector trims to the requested
1399
+ # detail level (signature/annotations/... appear only at ``full``),
1400
+ # so populating them here is what makes ``find <fqn> --detail full``
1401
+ # honor the contract (jrag_render keeps signature/annotations at full).
1402
+ # Without this, find --detail full showed only identity+classification
1403
+ # because the node never carried the content fields.
1404
+ nodes[node_id] = {
1405
+ "id": node_id,
1406
+ "kind": "symbol",
1407
+ "fqn": row.fqn,
1408
+ "name": row.name,
1409
+ "symbol_kind": row.kind,
1410
+ "microservice": row.microservice,
1411
+ "module": row.module,
1412
+ "role": row.role,
1413
+ "package": row.package,
1414
+ "signature": row.signature,
1415
+ "annotations": list(row.annotations or []),
1416
+ "capabilities": list(row.capabilities or []),
1417
+ "modifiers": list(row.modifiers or []),
1418
+ "filename": row.filename,
1419
+ "start_line": row.start_line,
1420
+ "end_line": row.end_line,
1421
+ }
1422
+
1423
+ env = Envelope(
1424
+ status="ok", nodes=nodes, truncated=raw_truncated,
1425
+ warnings=warnings + _auto_scope_notice(args),
1426
+ )
1427
+ next_actions_hook(env)
1428
+
1429
+ # Empty-result discoverability: query mode is exact-match only (name OR fqn),
1430
+ # so a partial like `find ChatManagement` legitimately returns 0. Surface a
1431
+ # cross-ref so the agent knows the substring fallback exists instead of
1432
+ # seeing a bare `0 symbol`. Carried as both a `message` (renders inline) and
1433
+ # an `agent_next_action` (renders as `next:` / JSON). A literal FQN-shaped
1434
+ # query (contains '.') almost certainly won't substring-match either, so the
1435
+ # hint applies regardless of shape.
1436
+ if not nodes and query:
1437
+ hint = f"no exact match for {query!r} — try `jrag find --fqn-contains {query}` for substring"
1438
+ env.message = hint
1439
+ env.agent_next_actions = [f"jrag find --fqn-contains {query}"]
1440
+
1441
+ # Offset is not supported in query mode (find_by_name_or_fqn has no offset).
1442
+ print(render(env, fmt=args.format, detail=args.detail, noun="symbol"))
1443
+ return 0
1444
+
1445
+
1446
+ def _build_node_filter_or_error(filter_dict: dict):
1447
+ """Build a ``NodeFilter`` from ``filter_dict``; on pydantic validation
1448
+ failure return ``(None, error_envelope)`` so the caller can render a clean
1449
+ ``status: error`` envelope instead of letting the ValidationError propagate
1450
+ to the top-level handler (which renders "internal error" + a traceback).
1451
+
1452
+ A bad enum (e.g. ``--role FOO``) should be a user-facing validation error,
1453
+ not an internal crash. Returns ``(node_filter, None)`` on success.
1454
+ """
1455
+ import mcp_v2
1456
+
1457
+ from java_codebase_rag.jrag_envelope import Envelope
1458
+ from pydantic import ValidationError
1459
+
1460
+ try:
1461
+ nf = mcp_v2.NodeFilter.model_validate(filter_dict) if filter_dict else mcp_v2.NodeFilter()
1462
+ return nf, None
1463
+ except ValidationError as exc:
1464
+ parts: list[str] = []
1465
+ for err in exc.errors():
1466
+ loc = ".".join(str(x) for x in err.get("loc", []) if x != "")
1467
+ msg = str(err.get("msg") or "").strip()
1468
+ parts.append(f"{loc}: {msg}" if loc else msg)
1469
+ message = "; ".join(parts) if parts else str(exc)
1470
+ return None, Envelope(status="error", message=f"invalid filter: {message}")
1471
+
1472
+
1473
+ def _cmd_find_filter_mode(
1474
+ args: argparse.Namespace,
1475
+ cfg,
1476
+ graph,
1477
+ kind: str,
1478
+ limit: int,
1479
+ ) -> int:
1480
+ """Find filter mode: build NodeFilter and call find_v2."""
1481
+ import mcp_v2
1482
+
1483
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum, to_envelope_rows
1484
+ from java_codebase_rag.jrag_render import render
1485
+
1486
+ # Build NodeFilter from args
1487
+ filter_dict: dict = {}
1488
+ if args.service:
1489
+ filter_dict["microservice"] = args.service
1490
+ if args.module:
1491
+ filter_dict["module"] = args.module
1492
+ if args.role:
1493
+ filter_dict["role"] = normalize_enum(args.role, kind="role")
1494
+ if args.exclude_role:
1495
+ filter_dict["exclude_roles"] = [normalize_enum(args.exclude_role, kind="role")]
1496
+ if args.annotation:
1497
+ filter_dict["annotation"] = args.annotation
1498
+ if args.capability:
1499
+ filter_dict["capability"] = args.capability
1500
+ if args.fqn_contains:
1501
+ filter_dict["fqn_contains"] = args.fqn_contains
1502
+ if args.java_kind:
1503
+ filter_dict["symbol_kind"] = normalize_enum(args.java_kind, kind="java_kind")
1504
+ if args.framework:
1505
+ filter_dict["framework"] = normalize_enum(args.framework, kind="framework")
1506
+ if args.source_layer:
1507
+ filter_dict["source_layer"] = normalize_enum(args.source_layer, kind="source_layer")
1508
+ if args.http_method:
1509
+ filter_dict["http_method"] = args.http_method.upper()
1510
+ if args.path_contains:
1511
+ filter_dict["path_contains"] = args.path_contains
1512
+ if args.client_kind:
1513
+ filter_dict["client_kind"] = normalize_enum(args.client_kind, kind="client_kind")
1514
+ if args.calls_service:
1515
+ filter_dict["target_service"] = args.calls_service
1516
+ if args.calls_path_contains:
1517
+ filter_dict["target_path_contains"] = args.calls_path_contains
1518
+ if args.producer_kind:
1519
+ filter_dict["producer_kind"] = normalize_enum(args.producer_kind, kind="producer_kind")
1520
+ if args.topic_contains:
1521
+ filter_dict["topic_contains"] = args.topic_contains
1522
+
1523
+ node_filter, err_env = _build_node_filter_or_error(filter_dict)
1524
+ if err_env is not None:
1525
+ print(render(err_env, fmt=args.format, detail=args.detail))
1526
+ return 2
1527
+
1528
+ # Call find_v2
1529
+ out = mcp_v2.find_v2(
1530
+ kind=kind,
1531
+ filter=node_filter,
1532
+ limit=limit + 1, # +1 for has_more_results detection
1533
+ offset=args.offset,
1534
+ graph=graph,
1535
+ )
1536
+
1537
+ if not out.success:
1538
+ env = Envelope(status="error", message=out.message)
1539
+ print(render(env, fmt=args.format, detail=args.detail))
1540
+ return 2
1541
+
1542
+ # Convert results to envelope rows. Slice to `limit`: find_v2 was called with
1543
+ # limit+1, so when exactly user_limit+1 matches exist `out.results` carries
1544
+ # one extra row that must be dropped (off-by-one guard). `truncated` is True
1545
+ # when the backend reports more OR the +1 row is present.
1546
+ results = list(out.results)
1547
+ truncated = bool(out.has_more_results) or len(results) > limit
1548
+ display_refs = results[:limit]
1549
+ nodes_dict = {ref.id: to_envelope_rows([ref])[0] for ref in display_refs}
1550
+
1551
+ env = Envelope(
1552
+ status="ok", nodes=nodes_dict, truncated=truncated,
1553
+ warnings=_auto_scope_notice(args),
1554
+ )
1555
+ next_actions_hook(env)
1556
+
1557
+ # Render with offset hint if truncated
1558
+ next_offset = args.offset + limit if truncated else None
1559
+ print(render(env, fmt=args.format, detail=args.detail, noun=kind, next_offset=next_offset))
1560
+ return 0
1561
+
1562
+
1563
+ def _cmd_inspect(args: argparse.Namespace) -> int:
1564
+ import mcp_v2
1565
+
1566
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, resolve_query
1567
+ from java_codebase_rag.jrag_render import render
1568
+
1569
+ cfg = _resolve_cfg(args)
1570
+ try:
1571
+ graph = _load_graph(cfg)
1572
+ except (_IndexNotFound, _IndexStale) as exc:
1573
+ env = Envelope(status="error", message=str(exc))
1574
+ print(render(env, fmt=args.format, detail=args.detail))
1575
+ return 2
1576
+
1577
+ # Resolve the query. Forward --service/--module so an ambiguous name
1578
+ # (same name across services) disambiguates by microservice/module, the
1579
+ # same way find and the traversal commands do. Without this, inspect
1580
+ # silently ignored these inherited flags (resolve_query accepts them).
1581
+ node, env = resolve_query(
1582
+ args.query,
1583
+ hint_kind=args.kind,
1584
+ java_kind=args.java_kind,
1585
+ role=args.role,
1586
+ fqn_contains=args.fqn_contains,
1587
+ cfg=cfg,
1588
+ graph=graph,
1589
+ microservice=args.service or "",
1590
+ module=args.module or "",
1591
+ )
1592
+
1593
+ if env.status != "ok":
1594
+ print(render(env, fmt=args.format, detail=args.detail))
1595
+ return 2 if env.status == "error" else 0
1596
+
1597
+ # Node resolved successfully - call describe_v2
1598
+ desc_out = mcp_v2.describe_v2(id=node.id, graph=graph)
1599
+
1600
+ if not desc_out.success or desc_out.record is None:
1601
+ env = Envelope(status="error", message=desc_out.message or "describe failed")
1602
+ print(render(env, fmt=args.format, detail=args.detail))
1603
+ return 2
1604
+
1605
+ # Convert NodeRecord to envelope format.
1606
+ #
1607
+ # NodeRecord nests the symbol's payload inside a ``data`` sub-dict (kind,
1608
+ # name, package, module, microservice, role, signature, annotations,
1609
+ # capabilities, modifiers, filename, start_line, ...). The envelope
1610
+ # projector's identity/classification keys (``_BRIEF_NODE_KEYS`` /
1611
+ # ``_NORMAL_NODE_KEYS``) live at the TOP level, so without flattening they
1612
+ # never reach the nested data and inspect renders only the outer kind/fqn
1613
+ # at every level (brief == normal, the bug). Flatten ``data`` to the top
1614
+ # level so:
1615
+ # * brief picks up kind/fqn/name/microservice (identity).
1616
+ # * normal additionally picks up module/role/symbol_kind/file
1617
+ # (classification + location).
1618
+ # * full additionally keeps signature/annotations/modifiers/package/
1619
+ # capabilities/edge_summary (content).
1620
+ #
1621
+ # The outer ``kind`` is the node category ("symbol"); the inner
1622
+ # ``data.kind`` is the symbol sub-kind ("class"/"interface"/"method"/...),
1623
+ # renamed ``symbol_kind`` to match find/search/listings (which use
1624
+ # ``symbol_kind`` for the sub-kind and reserve ``kind`` for the category).
1625
+ record_dict = desc_out.record.model_dump()
1626
+ node_id = record_dict.get("id") or node.id
1627
+ data = record_dict.get("data") or {}
1628
+ flat: dict[str, Any] = {
1629
+ "kind": record_dict.get("kind") or "symbol",
1630
+ "fqn": record_dict.get("fqn") or data.get("fqn") or node.fqn,
1631
+ }
1632
+ # Promote inner data fields. Skip ``kind`` here — renamed to symbol_kind.
1633
+ for src_key, dest_key in (
1634
+ ("name", "name"),
1635
+ ("kind", "symbol_kind"),
1636
+ ("package", "package"),
1637
+ ("module", "module"),
1638
+ ("microservice", "microservice"),
1639
+ ("role", "role"),
1640
+ ("signature", "signature"),
1641
+ ("annotations", "annotations"),
1642
+ ("capabilities", "capabilities"),
1643
+ ("modifiers", "modifiers"),
1644
+ ("filename", "filename"),
1645
+ ("start_line", "start_line"),
1646
+ ("end_line", "end_line"),
1647
+ ):
1648
+ val = data.get(src_key)
1649
+ if val not in (None, "", [], {}):
1650
+ flat[dest_key] = val
1651
+ # edge_summary is a top-level field on NodeRecord (not inside data) — keep
1652
+ # it so --detail full renders it as a nested kv-block (brief/normal drop
1653
+ # it via the scalar allow-list since the inspect subject has identity and
1654
+ # so takes the strict-scalar projection branch).
1655
+ if record_dict.get("edge_summary"):
1656
+ flat["edge_summary"] = record_dict["edge_summary"]
1657
+
1658
+ env = Envelope(
1659
+ status="ok",
1660
+ nodes={node_id: flat},
1661
+ root=node_id,
1662
+ file_location=env.file_location, # Preserve file_location from resolve
1663
+ )
1664
+ next_actions_hook(env, root=node_id, edge_summary=record_dict.get("edge_summary"))
1665
+
1666
+ # Render with inspect shape
1667
+ print(render(env, fmt=args.format, detail=args.detail, shape="inspect"))
1668
+ return 0
1669
+
1670
+
1671
+ def _backfill_service_from_filename(row: dict) -> None:
1672
+ """Derive ``microservice`` / ``module`` from ``filename`` when empty.
1673
+
1674
+ Kafka-topic Route nodes are created without ``microservice``/``module`` in
1675
+ the graph builder, so the routes listing rendered them with no ``@service``
1676
+ (or as blank lines when the topic was also empty). The filename carries the
1677
+ info reliably (``<microservice>/<module>/src/...`` or
1678
+ ``<microservice>/src/...``) — the same path-based resolution graph_enrich
1679
+ uses — so backfill from it for display without forcing a reindex.
1680
+ """
1681
+ fn = str(row.get("filename") or "").strip()
1682
+ if not fn:
1683
+ return
1684
+ parts = fn.split("/")
1685
+ if "src" not in parts:
1686
+ return
1687
+ idx = parts.index("src")
1688
+ if idx >= 1 and not (row.get("microservice") or "").strip():
1689
+ row["microservice"] = parts[0]
1690
+ if idx >= 2 and not (row.get("module") or "").strip():
1691
+ row["module"] = parts[1]
1692
+
1693
+
1694
+ def _cmd_routes(args: argparse.Namespace) -> int:
1695
+ from java_codebase_rag.jrag_envelope import normalize_enum
1696
+
1697
+ _, graph, rc = _load_graph_or_error(args)
1698
+ if rc:
1699
+ return rc
1700
+ limit = _clamped_limit(args)
1701
+
1702
+ # Normalize framework if provided
1703
+ framework = normalize_enum(args.framework, kind="framework") if args.framework else None
1704
+
1705
+ rows = graph.list_routes(
1706
+ microservice=args.service,
1707
+ framework=framework,
1708
+ path_contains=args.path_contains,
1709
+ method=args.method,
1710
+ limit=limit + 1, # +1 for truncated detection
1711
+ # `http-routes` is the HTTP-server-route surface (external entrypoints
1712
+ # you'd run `callers` on): exclude kafka topics (→ `topics`) and client
1713
+ # http_endpoint mirrors (call-sites). Pinned to include_kafka=False —
1714
+ # the backend default (True) would re-admit kafka topics.
1715
+ server_exposed=True,
1716
+ include_kafka=False,
1717
+ )
1718
+ for row in rows:
1719
+ _backfill_service_from_filename(row)
1720
+ return _render_listing(rows, limit=limit, args=args, noun="route")
1721
+
1722
+
1723
+ def _cmd_clients(args: argparse.Namespace) -> int:
1724
+ from java_codebase_rag.jrag_envelope import normalize_enum
1725
+
1726
+ _, graph, rc = _load_graph_or_error(args)
1727
+ if rc:
1728
+ return rc
1729
+ limit = _clamped_limit(args)
1730
+
1731
+ # Normalize client_kind via lookup table (feign → feign_method, etc.)
1732
+ client_kind = normalize_enum(args.client_kind, kind="client_kind") if args.client_kind else None
1733
+
1734
+ rows = graph.list_clients(
1735
+ microservice=args.service,
1736
+ client_kind=client_kind,
1737
+ target_service=args.calls_service,
1738
+ path_contains=args.path_contains,
1739
+ limit=limit + 1, # +1 for truncated detection
1740
+ )
1741
+ return _render_listing(rows, limit=limit, args=args, noun="client")
1742
+
1743
+
1744
+ def _cmd_producers(args: argparse.Namespace) -> int:
1745
+ from java_codebase_rag.jrag_envelope import normalize_enum
1746
+
1747
+ _, graph, rc = _load_graph_or_error(args)
1748
+ if rc:
1749
+ return rc
1750
+ limit = _clamped_limit(args)
1751
+
1752
+ # Normalize producer_kind via lookup table (kafka → kafka_send, etc.)
1753
+ producer_kind = normalize_enum(args.producer_kind, kind="producer_kind") if args.producer_kind else None
1754
+
1755
+ rows = graph.list_producers(
1756
+ microservice=args.service,
1757
+ producer_kind=producer_kind,
1758
+ topic_contains=args.topic_contains,
1759
+ limit=limit + 1, # +1 for truncated detection
1760
+ )
1761
+ return _render_listing(rows, limit=limit, args=args, noun="producer")
1762
+
1763
+
1764
+ def _cmd_topics(args: argparse.Namespace) -> int:
1765
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook
1766
+ from java_codebase_rag.jrag_render import render
1767
+
1768
+ _, graph, rc = _load_graph_or_error(args)
1769
+ if rc:
1770
+ return rc
1771
+ limit = _clamped_limit(args)
1772
+
1773
+ # Scope producers by --producer-in if provided (else --service push-down).
1774
+ producer_microservice = args.producer_in or args.service
1775
+
1776
+ # Call list_producers to get producers (grouped by topic)
1777
+ rows = graph.list_producers(
1778
+ microservice=producer_microservice,
1779
+ topic_contains=args.topic_contains,
1780
+ limit=limit + 1, # +1 for truncated detection
1781
+ )
1782
+
1783
+ # Group by topic name. Track no-topic producers so they surface as a
1784
+ # warning (distinguishable from "no producers at all").
1785
+ topics_dict: dict[str, dict] = {}
1786
+ no_topic_count = 0
1787
+ for producer in rows:
1788
+ topic = producer.get("topic") or ""
1789
+ if not topic:
1790
+ no_topic_count += 1
1791
+ continue
1792
+ if topic not in topics_dict:
1793
+ topics_dict[topic] = {
1794
+ "topic": topic,
1795
+ "producers": [],
1796
+ "broker": producer.get("broker") or "",
1797
+ }
1798
+ topics_dict[topic]["producers"].append(producer)
1799
+
1800
+ warnings: list[str] = []
1801
+ if no_topic_count:
1802
+ warnings.append(
1803
+ f"{no_topic_count} producer(s) had no topic and were excluded"
1804
+ )
1805
+ # list_producers has no module kwarg (only microservice/topic_contains); --module
1806
+ # would be silently dropped — surface it (use --producer-in to scope by svc).
1807
+ if getattr(args, "module", None):
1808
+ warnings.append(
1809
+ "--module is not applied on topics (list_producers has no module param; "
1810
+ "use --producer-in to scope producers by microservice)"
1811
+ )
1812
+
1813
+ # If --consumer-in is provided, resolve consumers for each topic group.
1814
+ # A consumer of a topic IS a listener: the edge path is
1815
+ # listener_class -[:DECLARES]-> listener_method -[:EXPOSES]-> Route(topic)
1816
+ # (ASYNC_CALLS run Producer -> Route per java_ontology.py:415-416, so the
1817
+ # inbound-ASYNC_CALLS traversal the original PR shipped returned empty on
1818
+ # every graph — corrected here to use the EXPOSES-based resolver shared
1819
+ # with `listeners --topic-contains`.)
1820
+ if args.consumer_in and topics_dict:
1821
+ for topic_name, topic_group in topics_dict.items():
1822
+ consumers = _resolve_topic_consumers(
1823
+ graph,
1824
+ topic=topic_name,
1825
+ microservice=args.consumer_in,
1826
+ contains=False, # exact match on the producer's topic literal
1827
+ )
1828
+ if consumers:
1829
+ topic_group["consumers"] = consumers
1830
+
1831
+ # Convert to list and apply truncation
1832
+ topic_list = list(topics_dict.values())
1833
+ display_topics_list, truncated = mark_truncated(topic_list, limit)
1834
+
1835
+ # Build envelope with topic nodes
1836
+ nodes = {}
1837
+ for i, topic in enumerate(display_topics_list):
1838
+ node_id = f"topic:{i}"
1839
+ nodes[node_id] = topic
1840
+
1841
+ env = Envelope(
1842
+ status="ok", nodes=nodes, truncated=truncated,
1843
+ warnings=warnings + _auto_scope_notice(args),
1844
+ )
1845
+ next_actions_hook(env, command=getattr(args, "command", None))
1846
+ print(render(env, fmt=args.format, detail=args.detail, noun="topic"))
1847
+ return 0
1848
+
1849
+
1850
+ def _inspect_hints_for_rows(rows: list[dict], *, limit: int = 2) -> list[str]:
1851
+ """Build ``jrag inspect <fqn>`` hints for the first ``limit`` rows that
1852
+ carry an FQN. Used by jobs/listeners/entities to surface a per-row
1853
+ drill-down (text renderer shows up to 2 as ``next:`` lines; JSON carries
1854
+ up to 5 — callers pass ``limit=2`` for the visible cap).
1855
+ """
1856
+ hints: list[str] = []
1857
+ for r in rows:
1858
+ fqn = r.get("fqn") if isinstance(r, dict) else getattr(r, "fqn", None)
1859
+ if fqn:
1860
+ hints.append(f"jrag inspect {fqn}")
1861
+ if len(hints) >= limit:
1862
+ break
1863
+ return hints
1864
+
1865
+
1866
+ def _cmd_jobs(args: argparse.Namespace) -> int:
1867
+ _, graph, rc = _load_graph_or_error(args)
1868
+ if rc:
1869
+ return rc
1870
+ limit = _clamped_limit(args)
1871
+
1872
+ symbol_hits = graph.list_by_capability(
1873
+ capability="SCHEDULED_TASK",
1874
+ module=args.module,
1875
+ microservice=args.service,
1876
+ limit=limit + 1, # +1 for truncated detection
1877
+ )
1878
+ rows = [_symbol_hit_to_dict(h) for h in symbol_hits]
1879
+ # Per-row drill-down: the agent's natural next step on a job/listener/entity
1880
+ # is to inspect it (signature, edges, callers). Cap visible hints at 2 so
1881
+ # text output stays tight; the JSON cap (5) is applied in _render_listing.
1882
+ hints = _inspect_hints_for_rows(rows[:limit], limit=2)
1883
+ return _render_listing(rows, limit=limit, args=args, noun="symbol", extra_hints=hints)
1884
+
1885
+
1886
+ def _resolve_topic_consumers(
1887
+ graph,
1888
+ *,
1889
+ topic: str,
1890
+ microservice: str | None = None,
1891
+ contains: bool = False,
1892
+ ) -> list[dict]:
1893
+ """Resolve listener classes that consume a topic via EXPOSES on Route.
1894
+
1895
+ The graph models the listener→topic edge path as:
1896
+ listener_class -[:DECLARES]-> listener_method -[:EXPOSES]-> Route(topic)
1897
+
1898
+ This is the correct consumer-resolution path for async messaging topics:
1899
+ ``ASYNC_CALLS`` run ``Producer → Route`` (java_ontology.py:415-416), so
1900
+ there is no inbound ``ASYNC_CALLS`` edge into Producer nodes to traverse
1901
+ via ``neighbors_v2(direction="in")``. The ``Route.topic`` property is not
1902
+ projected onto the ``NodeRef`` returned by ``neighbors_v2``, so a
1903
+ single-purpose Cypher lookup is used here — the same pattern as
1904
+ ``jrag_envelope._node_file_location`` (``graph._rows`` for a focused
1905
+ property fetch). This is a CLI-layer compose query, not a reimplementation
1906
+ of backend traversal logic.
1907
+
1908
+ Args:
1909
+ topic: Topic string to match (exact unless ``contains=True``).
1910
+ microservice: Optional microservice filter on the listener class.
1911
+ contains: If True, match topic as a substring (``CONTAINS``);
1912
+ if False (default), exact equality.
1913
+
1914
+ Returns:
1915
+ List of consumer dicts (``id``, ``fqn``, ``kind``, ``microservice``).
1916
+ """
1917
+ if not topic:
1918
+ return []
1919
+ match_clause = "r.topic CONTAINS $topic" if contains else "r.topic = $topic"
1920
+ params: dict = {"topic": topic}
1921
+ ms_clause = ""
1922
+ if microservice:
1923
+ ms_clause = " AND cls.microservice = $ms"
1924
+ params["ms"] = microservice
1925
+ rows = graph._rows( # noqa: SLF001 - focused property lookup (same as _node_file_location)
1926
+ f"MATCH (cls:Symbol)-[:DECLARES]->(mth:Symbol)-[:EXPOSES]->(r:Route) "
1927
+ f"WHERE {match_clause}{ms_clause} "
1928
+ f"RETURN DISTINCT cls.id AS cid, cls.fqn AS cfqn, cls.microservice AS cms",
1929
+ params,
1930
+ )
1931
+ return [
1932
+ {
1933
+ "id": str(r.get("cid") or ""),
1934
+ "fqn": str(r.get("cfqn") or ""),
1935
+ "kind": "symbol",
1936
+ "microservice": str(r.get("cms") or ""),
1937
+ }
1938
+ for r in rows
1939
+ if r.get("cid")
1940
+ ]
1941
+
1942
+
1943
+ def _listener_ids_for_topic_contains(graph, listener_ids: list[str], contains: str) -> set[str]:
1944
+ """Resolve which listener classes consume a topic containing the given substring.
1945
+
1946
+ Thin wrapper over :func:`_resolve_topic_consumers` intersected with the
1947
+ pre-fetched ``listener_ids`` (from ``list_by_capability``). Retained as a
1948
+ separate function so ``_cmd_listeners`` can narrow the SymbolHit list in
1949
+ place (the capability fetch carries SymbolHit fields the resolver does not
1950
+ project). See ``_resolve_topic_consumers`` for the edge-model rationale.
1951
+ """
1952
+ if not listener_ids or not contains:
1953
+ return set(listener_ids)
1954
+ consumers = _resolve_topic_consumers(graph, topic=contains, contains=True)
1955
+ matching = {c["id"] for c in consumers}
1956
+ return {lid for lid in listener_ids if lid in matching}
1957
+
1958
+
1959
+ def _cmd_listeners(args: argparse.Namespace) -> int:
1960
+ _, graph, rc = _load_graph_or_error(args)
1961
+ if rc:
1962
+ return rc
1963
+ limit = _clamped_limit(args)
1964
+
1965
+ symbol_hits = graph.list_by_capability(
1966
+ capability="MESSAGE_LISTENER",
1967
+ module=args.module,
1968
+ microservice=args.service,
1969
+ limit=_CONSUMER_FETCH_LIMIT, # generous pre-filter fetch; truncation applies after
1970
+ )
1971
+
1972
+ # --topic-contains: narrow to listeners consuming a topic containing that substring.
1973
+ # The listener class itself carries no topic; its listener method EXPOSES
1974
+ # a Route whose ``topic`` property holds the consumed topic name (resolved
1975
+ # or as a constant reference). See _listener_ids_for_topic_contains.
1976
+ if args.topic_contains and symbol_hits:
1977
+ matching_ids = _listener_ids_for_topic_contains(
1978
+ graph, [h.id for h in symbol_hits], args.topic_contains
1979
+ )
1980
+ symbol_hits = [h for h in symbol_hits if h.id in matching_ids]
1981
+
1982
+ # Apply the user-facing limit + 1 truncation AFTER the topic filter.
1983
+ capped = symbol_hits[: limit + 1]
1984
+ rows = [_symbol_hit_to_dict(h) for h in capped]
1985
+ hints = _inspect_hints_for_rows(rows[:limit], limit=2)
1986
+ return _render_listing(rows, limit=limit, args=args, noun="symbol", extra_hints=hints)
1987
+
1988
+
1989
+ def _cmd_entities(args: argparse.Namespace) -> int:
1990
+ _, graph, rc = _load_graph_or_error(args)
1991
+ if rc:
1992
+ return rc
1993
+ limit = _clamped_limit(args)
1994
+
1995
+ symbol_hits = graph.list_by_role(
1996
+ role="ENTITY",
1997
+ module=args.module,
1998
+ microservice=args.service,
1999
+ limit=limit + 1, # +1 for truncated detection
2000
+ )
2001
+ rows = [_symbol_hit_to_dict(h) for h in symbol_hits]
2002
+ hints = _inspect_hints_for_rows(rows[:limit], limit=2)
2003
+ return _render_listing(rows, limit=limit, args=args, noun="symbol", extra_hints=hints)
2004
+
2005
+
2006
+ # ============================================================================
2007
+ # PR-JRAG-3a: traversal helpers + 11 traversal command handlers.
2008
+ #
2009
+ # Every traversal is resolve-first (resolve_query), then calls a LadybugGraph
2010
+ # method (or neighbors_v2 for the override axis), then renders via the
2011
+ # traversal shape (envelope.root + edge rows). --offset is NOT supported on
2012
+ # any traversal subparser. --limit uses +1-fetch where the method takes a
2013
+ # limit; client-side slice otherwise.
2014
+ #
2015
+ # Backend signatures verified against source (ladybug_queries.py / mcp_v2.py /
2016
+ # java_ontology.py) at PR-JRAG-3a time. Adaptations from the brief:
2017
+ # * find_implementors / find_subclasses / find_injectors DO accept a
2018
+ # `capability` kwarg (the brief claimed they did not); --capability is
2019
+ # PUSHED DOWN on `implementations` (more efficient + matches the global
2020
+ # principle "pushed down where the method takes it").
2021
+ # * OVERRIDES edge direction confirmed: overrider -> declaration (subtype
2022
+ # method -> supertype method), so `out`=dispatch UP (overrides) and
2023
+ # `in`=dispatch DOWN (overridden-by). Brief was correct.
2024
+ # ============================================================================
2025
+
2026
+
2027
+ def _resolve_traversal_node(
2028
+ args: argparse.Namespace,
2029
+ *,
2030
+ cfg,
2031
+ graph,
2032
+ hint_kind,
2033
+ apply_scope: bool = False,
2034
+ ):
2035
+ """Resolve-first frame shared by every traversal command.
2036
+
2037
+ Returns ``(node, env, rc)``. On resolve failure (ambiguous / not_found /
2038
+ error), renders the envelope and returns ``(None, env, rc)`` with rc=2 on
2039
+ error, 0 on ambiguous/not_found (matches the inspect command convention).
2040
+
2041
+ ``apply_scope`` opts a command into pushing ``--service``/``--module`` down
2042
+ into resolve as resolve-time filters (via :func:`resolve_query`). Most
2043
+ traversal commands keep the default ``False`` to preserve their existing
2044
+ resolve semantics (structural-edge commands warn-and-ignore ``--service``;
2045
+ symbol traversals use ``--service`` as a result filter via find_callers/
2046
+ find_callees). ``callers`` opts in so ``--service`` narrows WHICH route
2047
+ resolves for the cross-service route-caller flow.
2048
+ """
2049
+ from java_codebase_rag.jrag_envelope import resolve_query
2050
+ from java_codebase_rag.jrag_render import render
2051
+
2052
+ node, env = resolve_query(
2053
+ args.query,
2054
+ hint_kind=hint_kind,
2055
+ java_kind=getattr(args, "java_kind", None),
2056
+ role=getattr(args, "role", None),
2057
+ fqn_contains=getattr(args, "fqn_contains", None),
2058
+ cfg=cfg,
2059
+ graph=graph,
2060
+ microservice=(getattr(args, "service", None) or "") if apply_scope else "",
2061
+ module=(getattr(args, "module", None) or "") if apply_scope else "",
2062
+ )
2063
+ if env.status != "ok":
2064
+ print(render(env, fmt=args.format, detail=args.detail))
2065
+ return None, env, 2 if env.status == "error" else 0
2066
+ return node, env, 0
2067
+
2068
+
2069
+ def _noderef_to_node_dict(ref) -> dict:
2070
+ """NodeRef (pydantic, from neighbors_v2 / resolve) -> envelope node dict."""
2071
+ return ref.model_dump()
2072
+
2073
+
2074
+ def _dedupe_traversal_edges(edges: list[dict]) -> list[dict]:
2075
+ """Drop edges with an empty ``other_id`` and dedupe by ``(other_id, edge_type)``.
2076
+
2077
+ ``find_callees`` can emit the same callee twice (a method reached via
2078
+ multiple call sites / strategies), and a CLIENT-role aggregation can surface
2079
+ a Client row whose id never resolved. Both produce noisy traversal rows:
2080
+ duplicates inflate the count, and an empty ``other_id`` becomes a phantom
2081
+ edge the id-free renderer cannot key. Keep the FIRST occurrence (results are
2082
+ confidence-sorted, so the first is the highest-confidence edge).
2083
+ """
2084
+ seen: set[tuple[str, str]] = set()
2085
+ out: list[dict] = []
2086
+ for e in edges:
2087
+ oid = e.get("other_id")
2088
+ if not oid:
2089
+ continue
2090
+ key = (str(oid), str(e.get("edge_type") or ""))
2091
+ if key in seen:
2092
+ continue
2093
+ seen.add(key)
2094
+ out.append(e)
2095
+ return out
2096
+
2097
+
2098
+ def _emit_traversal(
2099
+ args: argparse.Namespace,
2100
+ *,
2101
+ root_id: str,
2102
+ nodes: dict[str, dict],
2103
+ edges: list[dict],
2104
+ noun: str,
2105
+ warnings: list[str] | None = None,
2106
+ truncated: bool = False,
2107
+ is_external_entrypoint: bool = False,
2108
+ extra_hints: list[str] | None = None,
2109
+ ) -> int:
2110
+ """Build the traversal envelope (root + nodes + edges) and render.
2111
+
2112
+ The traversal shape requires ``envelope.root`` so the renderer uses the
2113
+ traversal shape (root + edge rows). ``next_offset`` is left None on every
2114
+ traversal (non-offset -> "truncated: more results - narrow your query").
2115
+ ``is_external_entrypoint`` flags a server-exposed route with zero in-repo
2116
+ callers so the renderer emits an honest "external entrypoint" note instead
2117
+ of a bare, bug-looking ``0 callers`` line.
2118
+
2119
+ ``extra_hints`` are merged into ``agent_next_actions`` AFTER the
2120
+ edge-derived hints (deduped, capped at 5). Used by commands with a known
2121
+ cross-ref for an empty/edge case (e.g. ``subclasses <interface>`` ->
2122
+ ``jrag implementations <fqn>``).
2123
+ """
2124
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
2125
+ from java_codebase_rag.jrag_render import render
2126
+
2127
+ env = Envelope(
2128
+ status="ok",
2129
+ nodes=dict(nodes),
2130
+ edges=list(edges),
2131
+ root=root_id,
2132
+ warnings=(warnings or []) + _auto_scope_notice(args),
2133
+ truncated=truncated,
2134
+ is_external_entrypoint=is_external_entrypoint,
2135
+ )
2136
+ next_actions_hook(env, root=root_id, result_edges=edges, command=getattr(args, "command", None))
2137
+ if extra_hints:
2138
+ seen = set(env.agent_next_actions)
2139
+ for h in extra_hints:
2140
+ if h and h not in seen:
2141
+ seen.add(h)
2142
+ env.agent_next_actions.append(h)
2143
+ env.agent_next_actions = env.agent_next_actions[:5]
2144
+ print(render(env, fmt=args.format, detail=args.detail, noun=noun))
2145
+ return 0
2146
+
2147
+
2148
+ def _require_kind(
2149
+ node,
2150
+ *,
2151
+ expected: str,
2152
+ kinds: tuple[str, ...],
2153
+ args: argparse.Namespace,
2154
+ hint: str = "",
2155
+ java_kinds: tuple[str, ...] | None = None,
2156
+ roles: tuple[str, ...] | None = None,
2157
+ ) -> int | None:
2158
+ """Kind guard shared by traversal handlers (DRY for the 11x guard block).
2159
+
2160
+ Returns ``None`` when ``node.kind`` is in ``kinds`` (caller proceeds). On
2161
+ mismatch, prints a ``status: error`` envelope and returns 2. ``expected``
2162
+ is the human-readable root description (e.g. ``"overrides expects a method
2163
+ Symbol root"``); ``hint`` is an optional trailing suggestion (e.g. ``"Use
2164
+ --kind symbol to narrow resolve."``). Callers whose kind-dispatch is more
2165
+ complex (e.g. ``callers`` accepts Symbol OR Route and routes between them)
2166
+ keep an inline guard.
2167
+
2168
+ ``java_kinds`` / ``roles`` add an OPTIONAL Java-level check applied AFTER
2169
+ the graph-label check passes. Graph ``kind=="symbol"`` covers class,
2170
+ interface, enum, AND method alike, so a label-only guard lets a class
2171
+ through a command that expects an interface (e.g. ``implementations``).
2172
+ When provided, ``node.symbol_kind`` must be in ``java_kinds`` (lowercased,
2173
+ dashes->underscores; e.g. ``("interface",)``) and ``node.role`` in
2174
+ ``roles`` (case-insensitive); otherwise a clear ``status: error`` is
2175
+ emitted instead of the silent empty result the backend returns.
2176
+ """
2177
+ from java_codebase_rag.jrag_envelope import Envelope
2178
+ from java_codebase_rag.jrag_render import render
2179
+
2180
+ def _emit(msg: str) -> int:
2181
+ if hint:
2182
+ msg = f"{msg} {hint}"
2183
+ print(render(Envelope(status="error", message=msg), fmt=args.format, detail=args.detail))
2184
+ return 2
2185
+
2186
+ if node.kind not in kinds:
2187
+ return _emit(f"{expected}; resolved kind is {node.kind!r}.")
2188
+
2189
+ # Java-level guard (optional): symbol_kind / role on the resolved NodeRef.
2190
+ # symbol_kind is stored LOWERCASE (class/method/interface/...); normalize
2191
+ # both sides to lowercase + dashes->underscores before comparing.
2192
+ if java_kinds:
2193
+ actual = (node.symbol_kind or "").lower().replace("-", "_")
2194
+ want = tuple(k.lower().replace("-", "_") for k in java_kinds)
2195
+ if actual not in want:
2196
+ return _emit(
2197
+ f"{expected}; resolved Java kind is {node.symbol_kind!r} "
2198
+ f"(expected {' or '.join(java_kinds)})."
2199
+ )
2200
+ if roles:
2201
+ actual_role = (node.role or "").upper()
2202
+ want_roles = tuple(r.upper() for r in roles)
2203
+ if actual_role not in want_roles:
2204
+ return _emit(
2205
+ f"{expected}; resolved role is {node.role!r} "
2206
+ f"(expected {' or '.join(roles)})."
2207
+ )
2208
+ return None
2209
+
2210
+
2211
+ def _validate_known_microservice(graph, name: str, args: argparse.Namespace) -> int | None:
2212
+ """Return ``None`` when ``name`` is a known microservice; else emit a
2213
+ ``status: error`` envelope and return 2.
2214
+
2215
+ Used by ``connection``/``overview`` so a BOGUS microservice surfaces a clear
2216
+ "unknown microservice 'X'; run `jrag microservices`" error instead of an
2217
+ empty ``status: ok`` (which reads as "this service genuinely has no
2218
+ connections/entries" — a silent wrong answer).
2219
+ """
2220
+ from java_codebase_rag.jrag_envelope import Envelope
2221
+ from java_codebase_rag.jrag_render import render
2222
+
2223
+ try:
2224
+ known = graph.microservice_counts()
2225
+ except Exception:
2226
+ known = {}
2227
+ if name in known:
2228
+ return None
2229
+ msg = f"unknown microservice {name!r}; run `jrag microservices` to list known services"
2230
+ print(render(Envelope(status="error", message=msg), fmt=args.format, detail=args.detail))
2231
+ return 2
2232
+
2233
+
2234
+ def _warn_unapplied_scope(args: argparse.Namespace, *, reason: str) -> list[str]:
2235
+ """Build warnings[] for --service/--module that cannot be applied.
2236
+
2237
+ Used by hierarchy/overrides/overridden-by/flow, where the backend query
2238
+ has no microservice/module predicate (structural edges / index-time data
2239
+ property). The plan principle "inapplicable flags never silently ignored"
2240
+ requires surfacing these as warnings rather than dropping them.
2241
+ """
2242
+ warnings: list[str] = []
2243
+ if args.service:
2244
+ warnings.append(f"--service is not applied on this command ({reason})")
2245
+ if getattr(args, "module", None):
2246
+ warnings.append(f"--module is not applied on this command ({reason})")
2247
+ return warnings
2248
+
2249
+
2250
+ def _warn_inapplicable_common(
2251
+ args: argparse.Namespace, *, service: bool, module: bool, limit: bool
2252
+ ) -> list[str]:
2253
+ """Warn when common flags that don't apply to a command are set.
2254
+
2255
+ Companion to :func:`_warn_unapplied_scope` for the aggregate / orientation
2256
+ commands (status / microservices / map / conventions) which inherit the
2257
+ ``common`` parent parser (``--service`` / ``--module`` / ``--limit``) but
2258
+ don't apply all of them. Each kwarg names whether THAT flag is inapplicable
2259
+ for this command (``True`` -> warn if the user set it). The plan principle
2260
+ "inapplicable flags never silently ignored" requires the warning; with the
2261
+ renderer now printing ``warning:`` lines, this is visible to text consumers
2262
+ too (not just ``--format json``).
2263
+ """
2264
+ warnings: list[str] = []
2265
+ if service and args.service:
2266
+ warnings.append("--service is not applied on this command")
2267
+ if module and getattr(args, "module", None):
2268
+ warnings.append("--module is not applied on this command")
2269
+ if limit and getattr(args, "limit", None) is not None and args.limit != 20:
2270
+ warnings.append("--limit is not applied on this command")
2271
+ return warnings
2272
+
2273
+
2274
+ def _cmd_callers(args: argparse.Namespace) -> int:
2275
+ cfg, graph, rc = _load_graph_or_error(args)
2276
+ if rc:
2277
+ return rc
2278
+ node, _renv, rrc = _resolve_traversal_node(
2279
+ args, cfg=cfg, graph=graph, hint_kind=args.kind, apply_scope=True
2280
+ )
2281
+ if rrc or node is None:
2282
+ return rrc
2283
+ limit = _clamped_limit(args)
2284
+
2285
+ root_dict = _noderef_to_node_dict(node)
2286
+ root_id = node.id
2287
+
2288
+ # Route root -> find_route_callers. Route callers are inherently cross-
2289
+ # service (a Client in microservice A calls a server Route in microservice B),
2290
+ # so --service is NOT applied as a caller-microservice post-filter here;
2291
+ # it has already narrowed resolve (which route was selected) via
2292
+ # _resolve_traversal_node -> resolve_query.
2293
+ if node.kind == "route":
2294
+ route_callers = graph.find_route_callers(route_id=root_id)
2295
+ warnings: list[str] = []
2296
+ # No backend limit on find_route_callers; client-side slice for truncation.
2297
+ truncated = len(route_callers) > limit
2298
+ display = route_callers[:limit]
2299
+ nodes: dict[str, dict] = {}
2300
+ edges: list[dict] = []
2301
+ for rc in display:
2302
+ caller_id = rc.caller_node_id
2303
+ if rc.caller_node_kind == "client":
2304
+ edge_type = "HTTP_CALLS"
2305
+ else:
2306
+ edge_type = "ASYNC_CALLS"
2307
+ # The caller's identity is the declaring Symbol (the method that owns
2308
+ # the Client/Producer), not the call-site path — mirrors
2309
+ # trace_request_flow, which surfaces declaring_symbol_fqn. The
2310
+ # path/topic the caller hits is kept as raw_uri/topic so the agent
2311
+ # sees both WHO calls and WHAT they hit.
2312
+ node = {
2313
+ "id": caller_id,
2314
+ "kind": rc.caller_node_kind,
2315
+ "fqn": rc.declaring_symbol_fqn or caller_id,
2316
+ "microservice": rc.caller_microservice,
2317
+ }
2318
+ if rc.target_service:
2319
+ node["target_service"] = rc.target_service
2320
+ if rc.caller_node_kind == "client" and rc.raw_uri:
2321
+ node["raw_uri"] = rc.raw_uri
2322
+ elif rc.caller_node_kind != "client" and rc.topic:
2323
+ node["topic"] = rc.topic
2324
+ nodes[caller_id] = node
2325
+ edges.append(
2326
+ {"other_id": caller_id, "edge_type": edge_type, "confidence": rc.confidence}
2327
+ )
2328
+ # Include the root (Route) node so the zero-callers rendering surfaces
2329
+ # the route path rather than a bare "0 callers" line.
2330
+ nodes[root_id] = root_dict
2331
+ # External-entrypoint detection: a server-exposed HTTP route (kind
2332
+ # http_endpoint with an inbound EXPOSES edge from a controller Symbol)
2333
+ # genuinely has zero in-repo callers — the route IS the entrypoint. Flag
2334
+ # it so the renderer says so instead of emitting a bug-looking bare
2335
+ # "0 callers". NodeRef.kind is the node label ("route"), not the stored
2336
+ # http_endpoint/kafka_topic property, so fetch the property directly.
2337
+ # Kafka topics are excluded: their empty-callers case has different
2338
+ # semantics (a topic with no producers is not an HTTP entrypoint).
2339
+ is_external_entrypoint = False
2340
+ if not display:
2341
+ kind_row = graph._rows( # noqa: SLF001 - same pattern as jrag_envelope._node_file_location
2342
+ "MATCH (r:Route) WHERE r.id = $rid RETURN r.kind AS kind LIMIT 1",
2343
+ {"rid": root_id},
2344
+ )
2345
+ route_kind = str(kind_row[0].get("kind") or "") if kind_row else ""
2346
+ if route_kind == "http_endpoint" and graph.find_route_handlers(route_id=root_id):
2347
+ is_external_entrypoint = True
2348
+ return _emit_traversal(
2349
+ args, root_id=root_id, nodes=nodes, edges=edges,
2350
+ noun="callers", warnings=warnings, truncated=truncated,
2351
+ is_external_entrypoint=is_external_entrypoint,
2352
+ )
2353
+
2354
+ # Symbol root -> find_callers (push down --service/--module/depth/etc.).
2355
+ if node.kind != "symbol":
2356
+ from java_codebase_rag.jrag_envelope import Envelope
2357
+ from java_codebase_rag.jrag_render import render
2358
+
2359
+ env = Envelope(
2360
+ status="error",
2361
+ message=(
2362
+ f"callers expects a Symbol or Route root; resolved node kind is "
2363
+ f"{node.kind!r}. Use --kind to narrow resolve."
2364
+ ),
2365
+ )
2366
+ print(render(env, fmt=args.format, detail=args.detail))
2367
+ return 2
2368
+
2369
+ depth = getattr(args, "depth", 1)
2370
+ min_conf = getattr(args, "min_confidence", 0.0)
2371
+ exclude_external = not getattr(args, "include_external", False)
2372
+ call_edges = graph.find_callers(
2373
+ node.fqn,
2374
+ depth=depth,
2375
+ limit=limit + 1,
2376
+ min_confidence=min_conf,
2377
+ exclude_external=exclude_external,
2378
+ module=args.module,
2379
+ microservice=args.service,
2380
+ )
2381
+ from java_codebase_rag.jrag_envelope import mark_truncated
2382
+
2383
+ display, truncated = mark_truncated(call_edges, limit)
2384
+ nodes = {}
2385
+ edges = []
2386
+ for ce in display:
2387
+ nodes[ce.src.id] = _symbol_hit_to_dict(ce.src)
2388
+ edges.append(
2389
+ {"other_id": ce.src.id, "edge_type": "CALLS", "confidence": ce.confidence}
2390
+ )
2391
+ nodes[root_id] = root_dict
2392
+ return _emit_traversal(
2393
+ args, root_id=root_id, nodes=nodes, edges=edges,
2394
+ noun="callers", truncated=truncated,
2395
+ )
2396
+
2397
+
2398
+ def _cmd_callees(args: argparse.Namespace) -> int:
2399
+ cfg, graph, rc = _load_graph_or_error(args)
2400
+ if rc:
2401
+ return rc
2402
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2403
+ if rrc or node is None:
2404
+ return rrc
2405
+ limit = _clamped_limit(args)
2406
+
2407
+ # PR-JRAG-3b: accept Symbol (CALLS), Client (HTTP_CALLS), and Producer
2408
+ # (ASYNC_CALLS) roots. The Symbol path is unchanged from PR-JRAG-3a.
2409
+ guard = _require_kind(
2410
+ node,
2411
+ expected="callees expects a Symbol, Client, or Producer root",
2412
+ kinds=("symbol", "client", "producer"),
2413
+ args=args,
2414
+ hint="Use --kind to narrow resolve.",
2415
+ )
2416
+ if guard is not None:
2417
+ return guard
2418
+
2419
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated
2420
+ from java_codebase_rag.jrag_render import render
2421
+
2422
+ # Client root -> HTTP_CALLS out (Client -> :Route).
2423
+ # Producer root -> ASYNC_CALLS out (Producer -> :Route, the kafka_topic
2424
+ # Route this producer publishes to — NOT a :Producer node).
2425
+ if node.kind in ("client", "producer"):
2426
+ import mcp_v2
2427
+
2428
+ edge_types = ["HTTP_CALLS"] if node.kind == "client" else ["ASYNC_CALLS"]
2429
+ out = mcp_v2.neighbors_v2(
2430
+ [node.id], direction="out", edge_types=edge_types,
2431
+ limit=limit + 1, graph=graph,
2432
+ )
2433
+ if not out.success:
2434
+ print(render(Envelope(status="error", message=out.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
2435
+ return 2
2436
+ root_id = node.id
2437
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2438
+ edges: list[dict] = []
2439
+ for e in out.results:
2440
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
2441
+ edges.append(
2442
+ {
2443
+ "other_id": e.other.id,
2444
+ "edge_type": e.edge_type,
2445
+ "confidence": e.attrs.get("confidence"),
2446
+ }
2447
+ )
2448
+ truncated = bool(out.has_more_results) or len(edges) > limit
2449
+ if len(edges) > limit:
2450
+ edges = edges[:limit]
2451
+ # --include-external is accepted but does not apply on Client/Producer
2452
+ # roots (the edges are to :Route, which is always in-graph; there is no
2453
+ # external-exclusion analog). Surface as a warning so the flag is not
2454
+ # silently dropped (plan principle: inapplicable flags never silently ignored).
2455
+ warnings: list[str] = []
2456
+ if getattr(args, "include_external", False):
2457
+ warnings.append(
2458
+ "--include-external does not apply to Client/Producer roots "
2459
+ "(HTTP_CALLS/ASYNC_CALLS reach :Route, which is always in-graph)"
2460
+ )
2461
+ edges = _dedupe_traversal_edges(edges)
2462
+ truncated = truncated or len(edges) > limit
2463
+ edges = edges[:limit]
2464
+ return _emit_traversal(
2465
+ args, root_id=root_id, nodes=nodes, edges=edges,
2466
+ noun="callees", warnings=warnings, truncated=truncated,
2467
+ )
2468
+
2469
+ depth = getattr(args, "depth", 1)
2470
+ min_conf = getattr(args, "min_confidence", 0.0)
2471
+ exclude_external = not getattr(args, "include_external", False)
2472
+ # CLIENT-role type Symbol (e.g. a Feign client interface): its "callees" are
2473
+ # the outbound HTTP endpoints its declared client methods call, NOT CALLS
2474
+ # edges from the interface (a Feign interface declares methods but its
2475
+ # methods' HTTP_CALLS edges carry the real outbound surface). Aggregate the
2476
+ # declared Client nodes and their HTTP_CALLS targets so `jrag callees
2477
+ # 'ChatCoreFeignClient'` shows the routes it hits.
2478
+ if (node.role or "") == "CLIENT":
2479
+ root_id = node.id
2480
+ client_rows = graph._rows( # noqa: SLF001 - one-shot aggregation query
2481
+ "MATCH (iface:Symbol {id: $sid})-[:DECLARES]->(m:Symbol)"
2482
+ "-[:DECLARES_CLIENT]->(c:Client) "
2483
+ "OPTIONAL MATCH (c)-[e:HTTP_CALLS]->(r:Route) "
2484
+ "RETURN c.id AS cid, c.member_fqn AS cfqn, c.path AS cpath, "
2485
+ "c.method AS cmethod, c.microservice AS cms, "
2486
+ "r.id AS rid, r.method AS rmethod, r.path AS rpath, "
2487
+ "r.path_template AS rpt, r.microservice AS rms, "
2488
+ "e.confidence AS conf",
2489
+ {"sid": root_id},
2490
+ )
2491
+ nodes = {root_id: _noderef_to_node_dict(node)}
2492
+ edges: list[dict] = []
2493
+ for row in client_rows:
2494
+ rid = str(row.get("rid") or "")
2495
+ if rid:
2496
+ target_id = rid
2497
+ rmethod = str(row.get("rmethod") or "")
2498
+ rpath = str(row.get("rpt") or row.get("rpath") or "")
2499
+ nodes[target_id] = {
2500
+ "id": target_id,
2501
+ "kind": "route",
2502
+ "fqn": f"{rmethod} {rpath}".strip(),
2503
+ "microservice": str(row.get("rms") or ""),
2504
+ }
2505
+ edge_type = "HTTP_CALLS"
2506
+ else:
2507
+ # Client with no resolved HTTP_CALLS edge: surface the client
2508
+ # node + its declared path so the outbound intent is visible.
2509
+ target_id = str(row.get("cid") or "")
2510
+ if not target_id:
2511
+ continue
2512
+ cmethod = str(row.get("cmethod") or "")
2513
+ cpath = str(row.get("cpath") or "")
2514
+ nodes[target_id] = {
2515
+ "id": target_id,
2516
+ "kind": "client",
2517
+ "fqn": f"{cmethod} {cpath}".strip() or str(row.get("cfqn") or ""),
2518
+ "microservice": str(row.get("cms") or ""),
2519
+ }
2520
+ edge_type = "HTTP_CALLS"
2521
+ edges.append({
2522
+ "other_id": target_id,
2523
+ "edge_type": edge_type,
2524
+ "confidence": float(row.get("conf") or 0.0) or None,
2525
+ })
2526
+ edges = _dedupe_traversal_edges(edges)
2527
+ truncated = len(edges) > limit
2528
+ edges = edges[:limit]
2529
+ return _emit_traversal(
2530
+ args, root_id=root_id, nodes=nodes, edges=edges,
2531
+ noun="callees", truncated=truncated,
2532
+ )
2533
+
2534
+ call_edges = graph.find_callees(
2535
+ node.fqn,
2536
+ depth=depth,
2537
+ limit=limit + 1,
2538
+ min_confidence=min_conf,
2539
+ exclude_external=exclude_external,
2540
+ module=args.module,
2541
+ microservice=args.service,
2542
+ )
2543
+ display, truncated = mark_truncated(call_edges, limit)
2544
+ root_id = node.id
2545
+ nodes = {root_id: _noderef_to_node_dict(node)}
2546
+ edges = []
2547
+ for ce in display:
2548
+ nodes[ce.dst.id] = _symbol_hit_to_dict(ce.dst)
2549
+ edges.append(
2550
+ {"other_id": ce.dst.id, "edge_type": "CALLS", "confidence": ce.confidence}
2551
+ )
2552
+ edges = _dedupe_traversal_edges(edges)
2553
+ truncated = truncated or len(edges) > limit
2554
+ edges = edges[:limit]
2555
+ return _emit_traversal(
2556
+ args, root_id=root_id, nodes=nodes, edges=edges,
2557
+ noun="callees", truncated=truncated,
2558
+ )
2559
+
2560
+
2561
+ def _cmd_hierarchy(args: argparse.Namespace) -> int:
2562
+ import mcp_v2
2563
+
2564
+ cfg, graph, rc = _load_graph_or_error(args)
2565
+ if rc:
2566
+ return rc
2567
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2568
+ if rrc or node is None:
2569
+ return rrc
2570
+ limit = _clamped_limit(args)
2571
+
2572
+ guard = _require_kind(
2573
+ node, expected="hierarchy expects a type Symbol root", kinds=("symbol",), args=args,
2574
+ )
2575
+ if guard is not None:
2576
+ return guard
2577
+
2578
+ warnings = _warn_unapplied_scope(
2579
+ args, reason="neighbors_v2 walks structural EXTENDS/IMPLEMENTS edges with no microservice predicate"
2580
+ )
2581
+
2582
+ root_id = node.id
2583
+ # Fetch both directions with limit+1 for +1-fetch truncation on each axis.
2584
+ fetch = limit + 1
2585
+ up = mcp_v2.neighbors_v2(
2586
+ [root_id], direction="out", edge_types=["EXTENDS", "IMPLEMENTS"],
2587
+ limit=fetch, graph=graph,
2588
+ )
2589
+ dn = mcp_v2.neighbors_v2(
2590
+ [root_id], direction="in", edge_types=["EXTENDS", "IMPLEMENTS"],
2591
+ limit=fetch, graph=graph,
2592
+ )
2593
+ from java_codebase_rag.jrag_envelope import Envelope
2594
+ from java_codebase_rag.jrag_render import render
2595
+
2596
+ if not up.success:
2597
+ print(render(Envelope(status="error", message=up.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
2598
+ return 2
2599
+
2600
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2601
+ # Build up/down edges separately so the limit applies PER DIRECTION
2602
+ # (Fix 5: combined-list truncation could starve `down` behind a full `up`).
2603
+ up_edges: list[dict] = []
2604
+ for e in up.results:
2605
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
2606
+ up_edges.append({"other_id": e.other.id, "edge_type": e.edge_type, "direction": "up"})
2607
+ dn_edges: list[dict] = []
2608
+ for e in dn.results:
2609
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
2610
+ dn_edges.append({"other_id": e.other.id, "edge_type": e.edge_type, "direction": "down"})
2611
+
2612
+ # Per-direction +1-fetch truncation: each side independently drops its
2613
+ # overflow row and flags truncation if it had limit+1 rows.
2614
+ truncated = len(up_edges) > limit or len(dn_edges) > limit
2615
+ up_display = up_edges[:limit]
2616
+ dn_display = dn_edges[:limit]
2617
+ display_edges = up_display + dn_display
2618
+ # Drop nodes no longer referenced after per-direction truncation (keep root).
2619
+ referenced = {root_id} | {e["other_id"] for e in display_edges}
2620
+ nodes = {nid: nd for nid, nd in nodes.items() if nid in referenced}
2621
+ return _emit_traversal(
2622
+ args, root_id=root_id, nodes=nodes, edges=display_edges,
2623
+ noun="hierarchy", warnings=warnings, truncated=truncated,
2624
+ )
2625
+
2626
+
2627
+ def _cmd_implementations(args: argparse.Namespace) -> int:
2628
+ cfg, graph, rc = _load_graph_or_error(args)
2629
+ if rc:
2630
+ return rc
2631
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2632
+ if rrc or node is None:
2633
+ return rrc
2634
+ limit = _clamped_limit(args)
2635
+
2636
+ guard = _require_kind(
2637
+ node, expected="implementations expects an interface Symbol root", kinds=("symbol",),
2638
+ java_kinds=("interface",), args=args,
2639
+ )
2640
+ if guard is not None:
2641
+ return guard
2642
+
2643
+ from java_codebase_rag.jrag_envelope import mark_truncated
2644
+
2645
+ # ADAPTATION: find_implementors DOES accept a `capability` kwarg (brief
2646
+ # claimed otherwise). Push --capability down (matches the global principle
2647
+ # "pushed down where the method takes it"); --service/--module also pushed.
2648
+ impls = graph.find_implementors(
2649
+ node.fqn,
2650
+ microservice=args.service,
2651
+ module=args.module,
2652
+ capability=args.capability,
2653
+ limit=limit + 1,
2654
+ )
2655
+ display, truncated = mark_truncated(impls, limit)
2656
+ root_id = node.id
2657
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2658
+ edges: list[dict] = []
2659
+ for hit in display:
2660
+ nodes[hit.id] = _symbol_hit_to_dict(hit)
2661
+ edges.append({"other_id": hit.id, "edge_type": "IMPLEMENTS"})
2662
+ return _emit_traversal(
2663
+ args, root_id=root_id, nodes=nodes, edges=edges,
2664
+ noun="implementations", truncated=truncated,
2665
+ )
2666
+
2667
+
2668
+ def _cmd_subclasses(args: argparse.Namespace) -> int:
2669
+ cfg, graph, rc = _load_graph_or_error(args)
2670
+ if rc:
2671
+ return rc
2672
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2673
+ if rrc or node is None:
2674
+ return rrc
2675
+ limit = _clamped_limit(args)
2676
+
2677
+ guard = _require_kind(
2678
+ node, expected="subclasses expects a class Symbol root", kinds=("symbol",),
2679
+ java_kinds=("class", "interface"), args=args,
2680
+ )
2681
+ if guard is not None:
2682
+ return guard
2683
+
2684
+ from java_codebase_rag.jrag_envelope import mark_truncated
2685
+
2686
+ subs = graph.find_subclasses(
2687
+ node.fqn,
2688
+ microservice=args.service,
2689
+ module=args.module,
2690
+ limit=limit + 1,
2691
+ )
2692
+ display, truncated = mark_truncated(subs, limit)
2693
+ root_id = node.id
2694
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2695
+ edges: list[dict] = []
2696
+ for hit in display:
2697
+ nodes[hit.id] = _symbol_hit_to_dict(hit)
2698
+ edges.append({"other_id": hit.id, "edge_type": "EXTENDS"})
2699
+ # Cross-ref hint: when the root is an interface, classes implementing it
2700
+ # arrive via IMPLEMENTS (not EXTENDS) — `find_subclasses` (EXTENDS inbound)
2701
+ # only catches sub-interfaces, so the common agent question "what classes
2702
+ # implement this interface?" is answered by `implementations <fqn>`. Surface
2703
+ # that as a `next:` hint whenever the root is an interface (helpful even
2704
+ # when a few sub-interfaces exist, and essential when results are empty).
2705
+ extra_hints: list[str] | None = None
2706
+ if (node.symbol_kind or "").lower() == "interface":
2707
+ extra_hints = [f"jrag implementations {node.fqn}"]
2708
+ return _emit_traversal(
2709
+ args, root_id=root_id, nodes=nodes, edges=edges,
2710
+ noun="subclasses", truncated=truncated, extra_hints=extra_hints,
2711
+ )
2712
+
2713
+
2714
+ def _cmd_overrides(args: argparse.Namespace) -> int:
2715
+ import mcp_v2
2716
+
2717
+ cfg, graph, rc = _load_graph_or_error(args)
2718
+ if rc:
2719
+ return rc
2720
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2721
+ if rrc or node is None:
2722
+ return rrc
2723
+ limit = _clamped_limit(args)
2724
+
2725
+ from java_codebase_rag.jrag_envelope import Envelope
2726
+ from java_codebase_rag.jrag_render import render
2727
+
2728
+ guard = _require_kind(
2729
+ node, expected="overrides expects a method Symbol root", kinds=("symbol",),
2730
+ java_kinds=("method",), args=args,
2731
+ )
2732
+ if guard is not None:
2733
+ return guard
2734
+
2735
+ warnings = _warn_unapplied_scope(
2736
+ args, reason="OVERRIDES is a structural method-to-method edge with no microservice predicate"
2737
+ )
2738
+
2739
+ root_id = node.id
2740
+ # OVERRIDES edge runs overrider -> declaration (subtype -> supertype method).
2741
+ # direction="out" dispatches UP (the declarations this method overrides).
2742
+ out = mcp_v2.neighbors_v2(
2743
+ [root_id], direction="out", edge_types=["OVERRIDES"],
2744
+ limit=limit + 1, graph=graph,
2745
+ )
2746
+ if not out.success:
2747
+ print(render(Envelope(status="error", message=out.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
2748
+ return 2
2749
+
2750
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2751
+ edges: list[dict] = []
2752
+ for e in out.results:
2753
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
2754
+ # No `direction` key: overrides is a flat list, not a tree. Setting
2755
+ # direction="up" would trip the renderer's has_direction guard and
2756
+ # mis-label these rows as `↑ supertypes:` (hierarchy). Flat is correct.
2757
+ edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES"})
2758
+ truncated = bool(out.has_more_results) or len(edges) > limit
2759
+ if len(edges) > limit:
2760
+ edges = edges[:limit]
2761
+ return _emit_traversal(
2762
+ args, root_id=root_id, nodes=nodes, edges=edges,
2763
+ noun="overrides", warnings=warnings, truncated=truncated,
2764
+ )
2765
+
2766
+
2767
+ def _cmd_overridden_by(args: argparse.Namespace) -> int:
2768
+ import mcp_v2
2769
+
2770
+ cfg, graph, rc = _load_graph_or_error(args)
2771
+ if rc:
2772
+ return rc
2773
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2774
+ if rrc or node is None:
2775
+ return rrc
2776
+ limit = _clamped_limit(args)
2777
+
2778
+ from java_codebase_rag.jrag_envelope import Envelope
2779
+ from java_codebase_rag.jrag_render import render
2780
+
2781
+ guard = _require_kind(
2782
+ node, expected="overridden-by expects a method Symbol root", kinds=("symbol",),
2783
+ java_kinds=("method",), args=args,
2784
+ )
2785
+ if guard is not None:
2786
+ return guard
2787
+
2788
+ warnings = _warn_unapplied_scope(
2789
+ args, reason="OVERRIDES is a structural method-to-method edge with no microservice predicate"
2790
+ )
2791
+
2792
+ root_id = node.id
2793
+ # direction="in" on OVERRIDES = virtual OVERRIDDEN_BY out (dispatch DOWN:
2794
+ # from declaration to its overriders).
2795
+ out = mcp_v2.neighbors_v2(
2796
+ [root_id], direction="in", edge_types=["OVERRIDES"],
2797
+ limit=limit + 1, graph=graph,
2798
+ )
2799
+ if not out.success:
2800
+ print(render(Envelope(status="error", message=out.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
2801
+ return 2
2802
+
2803
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2804
+ edges: list[dict] = []
2805
+ for e in out.results:
2806
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
2807
+ # No `direction` key — see _cmd_overrides: a `direction` value would
2808
+ # route these into the hierarchy renderer (`↓ subtypes:`), mis-labeling
2809
+ # a flat overridden-by list.
2810
+ edges.append({"other_id": e.other.id, "edge_type": "OVERRIDES"})
2811
+ truncated = bool(out.has_more_results) or len(edges) > limit
2812
+ if len(edges) > limit:
2813
+ edges = edges[:limit]
2814
+ return _emit_traversal(
2815
+ args, root_id=root_id, nodes=nodes, edges=edges,
2816
+ noun="overridden-by", warnings=warnings, truncated=truncated,
2817
+ )
2818
+
2819
+
2820
+ def _cmd_dependents(args: argparse.Namespace) -> int:
2821
+ cfg, graph, rc = _load_graph_or_error(args)
2822
+ if rc:
2823
+ return rc
2824
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2825
+ if rrc or node is None:
2826
+ return rrc
2827
+ limit = _clamped_limit(args)
2828
+
2829
+ guard = _require_kind(
2830
+ node, expected="dependents expects a type Symbol root", kinds=("symbol",), args=args,
2831
+ )
2832
+ if guard is not None:
2833
+ return guard
2834
+
2835
+ from java_codebase_rag.jrag_envelope import mark_truncated
2836
+
2837
+ inj = graph.find_injectors(
2838
+ node.fqn,
2839
+ microservice=args.service,
2840
+ module=args.module,
2841
+ limit=limit + 1,
2842
+ )
2843
+ display, truncated = mark_truncated(inj, limit)
2844
+ root_id = node.id
2845
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2846
+ edges: list[dict] = []
2847
+ for eh in display:
2848
+ nodes[eh.src.id] = _symbol_hit_to_dict(eh.src)
2849
+ edges.append(
2850
+ {
2851
+ "other_id": eh.src.id,
2852
+ "edge_type": "INJECTS",
2853
+ "mechanism": eh.mechanism,
2854
+ "annotation": eh.annotation,
2855
+ "field_or_param": eh.field_or_param,
2856
+ }
2857
+ )
2858
+ return _emit_traversal(
2859
+ args, root_id=root_id, nodes=nodes, edges=edges,
2860
+ noun="dependents", truncated=truncated,
2861
+ )
2862
+
2863
+
2864
+ def _cmd_impact(args: argparse.Namespace) -> int:
2865
+ cfg, graph, rc = _load_graph_or_error(args)
2866
+ if rc:
2867
+ return rc
2868
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2869
+ if rrc or node is None:
2870
+ return rrc
2871
+ limit = _clamped_limit(args)
2872
+ depth = getattr(args, "depth", 2)
2873
+
2874
+ from java_codebase_rag.jrag_envelope import mark_truncated
2875
+
2876
+ impacts = graph.impact_analysis(node.fqn, depth=depth, limit=limit + 1)
2877
+ warnings: list[str] = []
2878
+ if args.service:
2879
+ # Filter client-side (impact_analysis has no microservice param). The
2880
+ # explanatory warning fires only when the caller EXPLICITLY passed
2881
+ # --service: under cwd-derived auto-scope the filter still applies
2882
+ # (that's the point — keep the blast-radius inside the working
2883
+ # service) but the "post-filter" caveat would be noise the agent
2884
+ # didn't ask for, so it's gated on ``_service_user``.
2885
+ impacts = [h for h in impacts if (h.microservice or "") == args.service]
2886
+ if getattr(args, "_service_user", False):
2887
+ warnings.append(
2888
+ "--service is a post-filter on impact (impact_analysis has no microservice param)"
2889
+ )
2890
+ if getattr(args, "module", None):
2891
+ # impact_analysis has no module param either; warn rather than drop silently.
2892
+ warnings.append(
2893
+ "--module is not applied on impact (impact_analysis has no module param)"
2894
+ )
2895
+ display, truncated = mark_truncated(impacts, limit)
2896
+ root_id = node.id
2897
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2898
+ edges: list[dict] = []
2899
+ for hit in display:
2900
+ nodes[hit.id] = _symbol_hit_to_dict(hit)
2901
+ edges.append({"other_id": hit.id, "edge_type": "IMPACTS"})
2902
+ return _emit_traversal(
2903
+ args, root_id=root_id, nodes=nodes, edges=edges,
2904
+ noun="impact", warnings=warnings, truncated=truncated,
2905
+ )
2906
+
2907
+
2908
+ def _cmd_decompose(args: argparse.Namespace) -> int:
2909
+ cfg, graph, rc = _load_graph_or_error(args)
2910
+ if rc:
2911
+ return rc
2912
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2913
+ if rrc or node is None:
2914
+ return rrc
2915
+
2916
+ guard = _require_kind(
2917
+ node, expected="decompose expects an entrypoint Symbol root", kinds=("symbol",), args=args,
2918
+ )
2919
+ if guard is not None:
2920
+ return guard
2921
+
2922
+ # trace_flow clamps depth internally to 1..3; mirror here for the help text.
2923
+ depth = max(1, min(3, getattr(args, "depth", 2)))
2924
+ # decompose walks a TYPE role-waterfall (CONTROLLER -> SERVICE/COMPONENT ->
2925
+ # CLIENT/REPOSITORY/MAPPER) via INJECTS/EXTENDS/IMPLEMENTS, which are
2926
+ # type-to-type edges. A METHOD seed has no such edges, so trace_flow would
2927
+ # return only stage 0 (the seed itself). Promote a method seed to its owning
2928
+ # type so the waterfall is meaningful; point the agent at `callees` for the
2929
+ # method's direct call chain. (root stays the resolved method node.)
2930
+ seed_fqn = node.fqn
2931
+ warnings: list[str] = []
2932
+ if seed_fqn and "#" in seed_fqn:
2933
+ owning_type = seed_fqn.split("#", 1)[0]
2934
+ warnings.append(
2935
+ f"decompose is a type role-waterfall; promoted method seed "
2936
+ f"'{seed_fqn}' to its owning type '{owning_type}'. "
2937
+ f"Use `jrag callees {seed_fqn}` for the method's direct call chain."
2938
+ )
2939
+ seed_fqn = owning_type
2940
+ stages = graph.trace_flow(
2941
+ seed_fqns=[seed_fqn],
2942
+ depth=depth,
2943
+ follow_calls=getattr(args, "follow_calls", False),
2944
+ stage_limit=getattr(args, "max_stage", 20),
2945
+ min_call_confidence=getattr(args, "min_confidence", 0.0),
2946
+ exclude_external=not getattr(args, "include_external", False),
2947
+ microservice=args.service,
2948
+ module=args.module,
2949
+ )
2950
+ root_id = node.id
2951
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2952
+ edges: list[dict] = []
2953
+ for stage_idx, stage in enumerate(stages):
2954
+ for ss in stage:
2955
+ nodes[ss.symbol.id] = _symbol_hit_to_dict(ss.symbol)
2956
+ via = ss.via[0] if ss.via else None
2957
+ edge_type = via.edge_type if via else ("SEED" if stage_idx == 0 else "STAGE")
2958
+ edge_row = {
2959
+ "other_id": ss.symbol.id,
2960
+ "edge_type": edge_type,
2961
+ "stage": stage_idx,
2962
+ # Role carries through to the renderer so the waterfall can
2963
+ # label each stage with the role allow-list it matched.
2964
+ "role": ss.symbol.role or "",
2965
+ }
2966
+ if via and via.from_fqn:
2967
+ edge_row["from_fqn"] = via.from_fqn
2968
+ edges.append(edge_row)
2969
+ # --limit is inherited from common but does not cap decompose (trace_flow
2970
+ # is stage-limited via --max-stage, not a total edge count). Warn when the
2971
+ # user explicitly set --limit away from the default so they get a signal
2972
+ # rather than a silent multi-stage dump (Fix 4).
2973
+ if args.limit is not None and args.limit != 20:
2974
+ warnings.append(
2975
+ "--limit does not apply to decompose; use --max-stage to cap per-stage breadth"
2976
+ )
2977
+ return _emit_traversal(
2978
+ args, root_id=root_id, nodes=nodes, edges=edges,
2979
+ noun="decompose", warnings=warnings,
2980
+ )
2981
+
2982
+
2983
+ def _cmd_flow(args: argparse.Namespace) -> int:
2984
+ cfg, graph, rc = _load_graph_or_error(args)
2985
+ if rc:
2986
+ return rc
2987
+ # flow requires a Route root; force hint_kind="route".
2988
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind="route")
2989
+ if rrc or node is None:
2990
+ return rrc
2991
+ limit = _clamped_limit(args)
2992
+
2993
+ guard = _require_kind(
2994
+ node, expected="flow requires a Route root", kinds=("route",), args=args,
2995
+ hint="Pass a route path (e.g. /chat/assign).",
2996
+ )
2997
+ if guard is not None:
2998
+ return guard
2999
+
3000
+ warnings = _warn_unapplied_scope(
3001
+ args, reason="trace_request_flow carries no microservice predicate; intra-codebase is an index-time data property"
3002
+ )
3003
+
3004
+ max_hops = max(1, min(8, getattr(args, "max_hops", 5)))
3005
+ flow_data = graph.trace_request_flow(entry_route_id=node.id, max_hops=max_hops)
3006
+
3007
+ root_id = node.id
3008
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
3009
+ edges: list[dict] = []
3010
+ # Inbound: cross-service HTTP/async callers (Client/Producer two-hop).
3011
+ for row in flow_data.get("inbound", []):
3012
+ caller_id = str(row.get("caller_node_id") or "")
3013
+ if not caller_id:
3014
+ continue
3015
+ kind = str(row.get("caller_node_kind") or "")
3016
+ nodes[caller_id] = {
3017
+ "id": caller_id,
3018
+ "kind": kind,
3019
+ "fqn": str(row.get("declaring_symbol_fqn") or ""),
3020
+ "microservice": str(row.get("microservice") or ""),
3021
+ }
3022
+ edges.append(
3023
+ {
3024
+ "other_id": caller_id,
3025
+ "edge_type": "HTTP_CALLS" if kind == "client" else "ASYNC_CALLS",
3026
+ "confidence": float(row.get("confidence") or 0.0),
3027
+ }
3028
+ )
3029
+ # Outbound: CALLS hops from the route handler (intra-service by construction).
3030
+ for row in flow_data.get("outbound", []):
3031
+ next_id = str(row.get("next_symbol_id") or "")
3032
+ if not next_id:
3033
+ continue
3034
+ nodes[next_id] = {
3035
+ "id": next_id,
3036
+ "kind": "symbol",
3037
+ "fqn": str(row.get("next_fqn") or ""),
3038
+ "microservice": str(row.get("next_microservice") or ""),
3039
+ }
3040
+ edges.append({"other_id": next_id, "edge_type": "CALLS"})
3041
+
3042
+ # Client-side slice for truncation (trace_request_flow has no limit param).
3043
+ truncated = len(edges) > limit
3044
+ if truncated:
3045
+ edges = edges[:limit]
3046
+ return _emit_traversal(
3047
+ args, root_id=root_id, nodes=nodes, edges=edges,
3048
+ noun="flow", warnings=warnings, truncated=truncated,
3049
+ )
3050
+
3051
+
3052
+ # ============================================================================
3053
+ # PR-JRAG-3b: compose traversals + connection + outline/imports.
3054
+ #
3055
+ # callees Client/Producer variant (above) re-uses _cmd_callees. The four new
3056
+ # handlers below cover: dependencies (INJECTS out), connection (multi-section
3057
+ # microservice view, resolve-first EXCEPTION), outline (file -> symbols),
3058
+ # imports (file -> tree-sitter parse -> resolve_v2 per FQN).
3059
+ #
3060
+ # Backend signatures verified at PR-JRAG-3b time:
3061
+ # * neighbors_v2(ids, direction, edge_types, limit=25, offset=0, ...) returns
3062
+ # NeighborsOutput.results: list[Edge] where Edge.other: NodeRef,
3063
+ # Edge.edge_type: str, Edge.attrs: dict (mcp_v2.py:1284).
3064
+ # * find_symbols_in_file_range(graph, *, filename, start_line, end_line)
3065
+ # returns list[SymbolHit]; start_line<1 returns [] (ladybug_queries.py:302).
3066
+ # * parse_java(source, *, filename, verbose) -> JavaFileAst with
3067
+ # explicit_imports: dict[str, str] (simple_name -> FQN) (ast_java.py:2612).
3068
+ # * INJECTS is Symbol -> Symbol (java_ontology.py:216); out = types this
3069
+ # symbol injects = direct dependencies.
3070
+ # * HTTP_CALLS is Client -> Route (java_ontology.py:352); ASYNC_CALLS is
3071
+ # Producer -> Route (java_ontology.py:386). Both confirmed.
3072
+ # ============================================================================
3073
+
3074
+
3075
+ def _cmd_dependencies(args: argparse.Namespace) -> int:
3076
+ import mcp_v2
3077
+
3078
+ cfg, graph, rc = _load_graph_or_error(args)
3079
+ if rc:
3080
+ return rc
3081
+ node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
3082
+ if rrc or node is None:
3083
+ return rrc
3084
+ limit = _clamped_limit(args)
3085
+
3086
+ from java_codebase_rag.jrag_envelope import Envelope
3087
+ from java_codebase_rag.jrag_render import render
3088
+
3089
+ # INJECTS is Symbol -> Symbol; Client/Producer/Route roots have no
3090
+ # injection edges (the edge type only fires on type Symbols).
3091
+ guard = _require_kind(
3092
+ node, expected="dependencies expects a Symbol root (INJECTS is Symbol -> Symbol)",
3093
+ kinds=("symbol",), args=args,
3094
+ )
3095
+ if guard is not None:
3096
+ return guard
3097
+
3098
+ warnings = _warn_unapplied_scope(
3099
+ args, reason="neighbors_v2 walks structural INJECTS edges with no microservice predicate"
3100
+ )
3101
+ # --include-external is accepted for surface symmetry with callers/callees
3102
+ # but is a warned no-op here (INJECTS has no external-exclusion analog at
3103
+ # the neighbors_v2 layer; the edge is structural Symbol -> Symbol).
3104
+ if getattr(args, "include_external", False):
3105
+ warnings.append(
3106
+ "--include-external does not apply to dependencies "
3107
+ "(INJECTS is structural Symbol -> Symbol with no external-exclusion analog)"
3108
+ )
3109
+
3110
+ root_id = node.id
3111
+ out = mcp_v2.neighbors_v2(
3112
+ [root_id], direction="out", edge_types=["INJECTS"],
3113
+ limit=limit + 1, graph=graph,
3114
+ )
3115
+ if not out.success:
3116
+ print(render(Envelope(status="error", message=out.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
3117
+ return 2
3118
+
3119
+ nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
3120
+ edges: list[dict] = []
3121
+ for e in out.results:
3122
+ nodes[e.other.id] = _noderef_to_node_dict(e.other)
3123
+ # Carry the injection metadata from the edge attrs (mechanism/annotation/
3124
+ # field_or_param) so the renderer and JSON consumers see how the dep is
3125
+ # injected.
3126
+ edge_row = {"other_id": e.other.id, "edge_type": "INJECTS"}
3127
+ for k in ("mechanism", "annotation", "field_or_param", "dst_fqn", "resolved"):
3128
+ if k in e.attrs:
3129
+ edge_row[k] = e.attrs[k]
3130
+ edges.append(edge_row)
3131
+ truncated = bool(out.has_more_results) or len(edges) > limit
3132
+ if len(edges) > limit:
3133
+ edges = edges[:limit]
3134
+ return _emit_traversal(
3135
+ args, root_id=root_id, nodes=nodes, edges=edges,
3136
+ noun="dependencies", warnings=warnings, truncated=truncated,
3137
+ )
3138
+
3139
+
3140
+ def _client_dict_to_node(c: dict) -> dict:
3141
+ """list_clients dict -> envelope node dict (kind=client)."""
3142
+ return {
3143
+ "id": str(c.get("id") or ""),
3144
+ "kind": "client",
3145
+ "fqn": str(c.get("member_fqn") or c.get("path") or ""),
3146
+ "name": str(c.get("path") or ""),
3147
+ "client_kind": str(c.get("client_kind") or ""),
3148
+ "target_service": str(c.get("target_service") or ""),
3149
+ "method": str(c.get("method") or ""),
3150
+ "path": str(c.get("path") or ""),
3151
+ "microservice": str(c.get("microservice") or ""),
3152
+ "module": str(c.get("module") or ""),
3153
+ }
3154
+
3155
+
3156
+ def _producer_dict_to_node(p: dict) -> dict:
3157
+ """list_producers dict -> envelope node dict (kind=producer)."""
3158
+ return {
3159
+ "id": str(p.get("id") or ""),
3160
+ "kind": "producer",
3161
+ "fqn": str(p.get("member_fqn") or p.get("topic") or ""),
3162
+ "name": str(p.get("topic") or ""),
3163
+ "producer_kind": str(p.get("producer_kind") or ""),
3164
+ "topic": str(p.get("topic") or ""),
3165
+ "broker": str(p.get("broker") or ""),
3166
+ "microservice": str(p.get("microservice") or ""),
3167
+ "module": str(p.get("module") or ""),
3168
+ }
3169
+
3170
+
3171
+ def _cmd_connection(args: argparse.Namespace) -> int:
3172
+ """connection <microservice> — multi-section inbound:/outbound: view.
3173
+
3174
+ RESOLVE-FIRST EXCEPTION: the first positional is a microservice NAME (used
3175
+ literally for list_clients / list_producers / find_route_callers); resolve_v2
3176
+ is NEVER run on it (the agent spec calls this out loudly in --help).
3177
+ """
3178
+ cfg, graph, rc = _load_graph_or_error(args)
3179
+ if rc:
3180
+ return rc
3181
+ limit = _clamped_limit(args)
3182
+
3183
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3184
+ from java_codebase_rag.jrag_render import render
3185
+
3186
+ # Validate the microservice against the known set so a bogus name surfaces a
3187
+ # clear error instead of an empty inbound:/outbound: view (silent wrong
3188
+ # answer). Done AFTER graph load so `jrag connection X --format json` on a
3189
+ # missing index still reports the index error, not the microservice error.
3190
+ rc_ms = _validate_known_microservice(graph, args.microservice, args)
3191
+ if rc_ms is not None:
3192
+ return rc_ms
3193
+
3194
+ microservice = args.microservice
3195
+ # argparse stores --inbound/--outbound/--both into `direction` via
3196
+ # action="store_const"; default is None when no flag is given (-> inbound,
3197
+ # per the brief: --inbound is the default direction).
3198
+ direction = getattr(args, "direction", None) or "both"
3199
+ http_method = (args.http_method or "").upper() or None
3200
+ calls_service = args.calls_service
3201
+
3202
+ show_inbound = direction in ("inbound", "both")
3203
+ show_outbound = direction in ("outbound", "both")
3204
+
3205
+ nodes: dict[str, dict] = {}
3206
+ edges: list[dict] = []
3207
+ warnings: list[str] = []
3208
+
3209
+ # Filter predicates (applied client-side; --module is the only structural
3210
+ # common flag that's a bit meaningful here, but list_clients/list_producers
3211
+ # already take microservice; --module has no analog and is warned).
3212
+ if args.module:
3213
+ warnings.append("--module is not applied on connection (use --calls-service to narrow)")
3214
+
3215
+ # --calls-service on outbound: clients are filtered STRICTLY (target_service
3216
+ # == calls_service); producers have no service target (they target topics),
3217
+ # so they bypass the filter and we emit a single warning so the agent knows
3218
+ # the async channel wasn't narrowed. The previous `or not target_service`
3219
+ # escape hatch matched unresolved clients (empty target_service, e.g.
3220
+ # AuditLogClient#logAssignment) — that was silent-wrong-results.
3221
+ producers_bypass_calls_service = bool(calls_service) and show_outbound
3222
+
3223
+ def _http_method_match(row: dict) -> bool:
3224
+ if not http_method:
3225
+ return True
3226
+ return (str(row.get("method") or "").upper()) == http_method
3227
+
3228
+ def _calls_service_match_out_client(row: dict) -> bool:
3229
+ # STRICT: a client is kept iff target_service == calls_service exactly.
3230
+ # Unresolved clients (empty target_service) are EXCLUDED — they did not
3231
+ # resolve to a specific target service, so we cannot confirm they call
3232
+ # --calls-service and must not surface them as a match.
3233
+ if not calls_service:
3234
+ return True
3235
+ return str(row.get("target_service") or "") == calls_service
3236
+
3237
+ def _calls_service_match_in(caller_microservice: str) -> bool:
3238
+ if not calls_service:
3239
+ return True
3240
+ return caller_microservice == calls_service
3241
+
3242
+ # --- Inbound: clients/producers in OTHER services targeting <microservice> ---
3243
+ if show_inbound:
3244
+ # HTTP: list_clients(target_service=microservice) gives every client
3245
+ # declaring a call into this service. Filter out clients IN this
3246
+ # microservice (those are intra-service, not inbound).
3247
+ http_in = graph.list_clients(target_service=microservice, limit=limit + 1)
3248
+ http_in = [c for c in http_in if (c.get("microservice") or "") != microservice]
3249
+ http_in = [c for c in http_in if _http_method_match(c) and _calls_service_match_in(c.get("microservice") or "")]
3250
+ for c in http_in[:limit + 1]:
3251
+ cid = c["id"]
3252
+ nodes[cid] = _client_dict_to_node(c)
3253
+ edges.append({"other_id": cid, "edge_type": "HTTP_CALLS", "section": "inbound"})
3254
+
3255
+ # Async: topic Routes consumed by this microservice's listeners are
3256
+ # reached by producers in OTHER services via ASYNC_CALLS. The path is
3257
+ # listener_method -[:EXPOSES]-> Route(topic) <-[:ASYNC_CALLS]- Producer
3258
+ # find_route_callers gives both client and producer callers for a route,
3259
+ # so we (a) enumerate this service's listener classes, (b) for each,
3260
+ # resolve the Route(s) it EXPOSES, (c) call find_route_callers on each
3261
+ # topic Route, (d) keep producer callers from other services.
3262
+ try:
3263
+ listener_hits = graph.list_by_capability(
3264
+ capability="MESSAGE_LISTENER",
3265
+ microservice=microservice,
3266
+ limit=_CONSUMER_FETCH_LIMIT,
3267
+ )
3268
+ except Exception as e: # noqa: BLE001 - best-effort multi-section view
3269
+ # Don't swallow silently: surface the failure so an empty async
3270
+ # inbound section is distinguishable from "no listeners". HTTP
3271
+ # inbound above is unaffected; the command still returns its other
3272
+ # sections. (The bare `except: listener_hits = []` this replaces
3273
+ # produced silent wrong-results — status:ok with no async + no clue.)
3274
+ warnings.append(f"listener lookup failed; async inbound section skipped: {e}")
3275
+ listener_hits = []
3276
+ topic_route_ids: set[str] = set()
3277
+ for h in listener_hits:
3278
+ # listener method -> EXPOSES -> Route(topic). Resolve via a focused
3279
+ # Cypher lookup (Route.id for the EXPOSES target).
3280
+ rows = graph._rows( # noqa: SLF001 - focused lookup, same pattern as _node_file_location
3281
+ "MATCH (mth:Symbol)-[:EXPOSES]->(r:Route) WHERE mth.id = $mid RETURN r.id AS rid",
3282
+ {"mid": h.id},
3283
+ )
3284
+ for r in rows:
3285
+ rid = str(r.get("rid") or "")
3286
+ if rid:
3287
+ topic_route_ids.add(rid)
3288
+ # Cache list_producers() per caller_microservice so the inbound-async
3289
+ # loop issues ONE fetch per external service (not one per producer id).
3290
+ producer_cache: dict[str, list[dict]] = {}
3291
+ for rid in topic_route_ids:
3292
+ callers = graph.find_route_callers(route_id=rid)
3293
+ for c in callers:
3294
+ if c.caller_node_kind != "producer":
3295
+ continue
3296
+ if (c.caller_microservice or "") == microservice:
3297
+ continue # intra-service
3298
+ if not _calls_service_match_in(c.caller_microservice or ""):
3299
+ continue
3300
+ pid = c.caller_node_id
3301
+ if pid in nodes:
3302
+ # Already rendered (e.g. duplicated via multiple topic routes)
3303
+ edges.append({"other_id": pid, "edge_type": "ASYNC_CALLS", "section": "inbound", "confidence": c.confidence})
3304
+ continue
3305
+ # Fetch producer dict for richer node data (cached per service).
3306
+ caller_ms = c.caller_microservice or ""
3307
+ if caller_ms not in producer_cache:
3308
+ producer_cache[caller_ms] = graph.list_producers(
3309
+ microservice=caller_ms or None, limit=_CONSUMER_FETCH_LIMIT,
3310
+ )
3311
+ prod_dict = next((p for p in producer_cache[caller_ms] if p.get("id") == pid), None)
3312
+ if prod_dict:
3313
+ nodes[pid] = _producer_dict_to_node(prod_dict)
3314
+ else:
3315
+ nodes[pid] = {
3316
+ "id": pid,
3317
+ "kind": "producer",
3318
+ "fqn": c.topic or "",
3319
+ "name": c.topic or "",
3320
+ "topic": c.topic or "",
3321
+ "broker": c.broker or "",
3322
+ "microservice": c.caller_microservice or "",
3323
+ }
3324
+ edges.append({"other_id": pid, "edge_type": "ASYNC_CALLS", "section": "inbound", "confidence": c.confidence})
3325
+
3326
+ # --- Outbound: clients/producers IN this microservice (calling out) ---
3327
+ if show_outbound:
3328
+ clients_out = graph.list_clients(microservice=microservice, limit=limit + 1)
3329
+ # Clients: apply --http-method AND --calls-service strictly (no empty-
3330
+ # target escape; unresolved clients are EXCLUDED under --calls-service).
3331
+ clients_out = [c for c in clients_out if _http_method_match(c) and _calls_service_match_out_client(c)]
3332
+ for c in clients_out[:limit + 1]:
3333
+ cid = c["id"]
3334
+ nodes[cid] = _client_dict_to_node(c)
3335
+ edges.append({"other_id": cid, "edge_type": "HTTP_CALLS", "section": "outbound"})
3336
+
3337
+ producers_out = graph.list_producers(microservice=microservice, limit=limit + 1)
3338
+ # Producers bypass --calls-service (no service target on ASYNC channels);
3339
+ # emit ONE warning so the agent knows the async channel wasn't narrowed.
3340
+ if producers_bypass_calls_service and producers_out:
3341
+ warnings.append(
3342
+ f"--calls-service does not filter producers (no target_service on "
3343
+ f"ASYNC channels); {len(producers_out)} producer(s) kept visible"
3344
+ )
3345
+ for p in producers_out[:limit + 1]:
3346
+ pid = p["id"]
3347
+ nodes[pid] = _producer_dict_to_node(p)
3348
+ edges.append({"other_id": pid, "edge_type": "ASYNC_CALLS", "section": "outbound"})
3349
+
3350
+ # Synthesize a microservice "root" node so the renderer uses the traversal
3351
+ # shape (root + edges) and the section-grouped rendering fires. The synthetic
3352
+ # id is namespaced to avoid colliding with real node ids.
3353
+ root_id = f"microservice:{microservice}"
3354
+ nodes[root_id] = {
3355
+ "id": root_id,
3356
+ "kind": "microservice",
3357
+ "fqn": microservice,
3358
+ "name": microservice,
3359
+ "microservice": microservice,
3360
+ }
3361
+
3362
+ # Per-section truncation: cap each section at `limit` (drop overflow rows
3363
+ # and flag truncation if either side overflowed). We collected limit+1
3364
+ # rows above; slice here.
3365
+ inbound_edges = [e for e in edges if e.get("section") == "inbound"]
3366
+ outbound_edges = [e for e in edges if e.get("section") == "outbound"]
3367
+ truncated = len(inbound_edges) > limit or len(outbound_edges) > limit
3368
+ inbound_edges = inbound_edges[:limit]
3369
+ outbound_edges = outbound_edges[:limit]
3370
+ display_edges = inbound_edges + outbound_edges
3371
+ # Drop unreferenced node ids (keep the synthetic root).
3372
+ referenced = {root_id} | {e["other_id"] for e in display_edges}
3373
+ nodes = {nid: nd for nid, nd in nodes.items() if nid in referenced}
3374
+
3375
+ env = Envelope(
3376
+ status="ok",
3377
+ nodes=nodes,
3378
+ edges=display_edges,
3379
+ root=root_id,
3380
+ warnings=warnings,
3381
+ truncated=truncated,
3382
+ )
3383
+ next_actions_hook(env, root=root_id, result_edges=display_edges)
3384
+ print(render(env, fmt=args.format, detail=args.detail, noun="connection"))
3385
+ return 0
3386
+
3387
+
3388
+ def _resolve_source_path(cfg, file_arg: str) -> Path | None:
3389
+ """Resolve <file> to an existing path: absolute, else cfg.source_root/<file>.
3390
+
3391
+ Returns None when neither exists (callers render a graceful envelope).
3392
+ """
3393
+ p = Path(file_arg)
3394
+ if p.is_absolute() and p.is_file():
3395
+ return p
3396
+ src = Path(cfg.source_root) if cfg.source_root else Path.cwd()
3397
+ candidate = src / file_arg
3398
+ if candidate.is_file():
3399
+ return candidate
3400
+ return None
3401
+
3402
+
3403
+ def _cmd_outline(args: argparse.Namespace) -> int:
3404
+ """outline <file> — list every Symbol whose declared location is in <file>.
3405
+
3406
+ Calls find_symbols_in_file_range(graph, filename=<file>, start_line=1,
3407
+ end_line=2**31-1). start_line MUST be >=1 (the backend returns [] for
3408
+ start_line<1). ``--limit`` caps the entry count (the file's symbol table
3409
+ is otherwise unbounded); ``truncated`` is set when more entries exist.
3410
+ """
3411
+ from ladybug_queries import find_symbols_in_file_range
3412
+
3413
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook
3414
+ from java_codebase_rag.jrag_render import render
3415
+
3416
+ cfg, graph, rc = _load_graph_or_error(args)
3417
+ if rc:
3418
+ return rc
3419
+
3420
+ # PARITY with `imports`: resolve <file> via _resolve_source_path so a bare
3421
+ # class name or non-existent path yields the SAME "file not found" error
3422
+ # instead of a silent empty success. The graph stores filenames as
3423
+ # POSIX-relative paths from source root, so once the path resolves on disk
3424
+ # we re-derive that relative form for the exact-match query.
3425
+ file_path = _resolve_source_path(cfg, args.file)
3426
+ if file_path is None:
3427
+ env = Envelope(
3428
+ status="error",
3429
+ message=(
3430
+ f"file not found: {args.file!r} (looked at the literal path and at "
3431
+ f"<source_root>/{args.file})"
3432
+ ),
3433
+ )
3434
+ print(render(env, fmt=args.format, detail=args.detail))
3435
+ return 2
3436
+ filename = args.file
3437
+ src_root = Path(cfg.source_root) if cfg.source_root else None
3438
+ if src_root is not None:
3439
+ try:
3440
+ filename = file_path.resolve().relative_to(src_root.resolve()).as_posix()
3441
+ except ValueError:
3442
+ # File lives outside source_root (e.g. an absolute path elsewhere);
3443
+ # fall back to the user's literal input — the graph may still match.
3444
+ filename = args.file
3445
+ try:
3446
+ hits = find_symbols_in_file_range(
3447
+ graph,
3448
+ filename=filename,
3449
+ start_line=1,
3450
+ end_line=2**31 - 1,
3451
+ )
3452
+ except Exception as exc:
3453
+ env = Envelope(status="error", message=f"outline failed: {exc}")
3454
+ print(render(env, fmt=args.format, detail=args.detail))
3455
+ return 2
3456
+
3457
+ rows = [_symbol_hit_to_dict(h) for h in hits]
3458
+ limit = _clamped_limit(args)
3459
+ display, truncated = mark_truncated(rows, limit)
3460
+ nodes = {n["id"]: n for n in display}
3461
+
3462
+ env = Envelope(status="ok", nodes=nodes, truncated=truncated)
3463
+ next_actions_hook(env)
3464
+ # Drill-down: the first declared symbol (class/interface) is the natural
3465
+ # thing to inspect from an outline. Per-row inspect hints for the leading
3466
+ # entries give the agent a concrete next step.
3467
+ env.agent_next_actions = _inspect_hints_for_rows(display, limit=2)
3468
+ print(render(env, fmt=args.format, detail=args.detail, noun="symbol"))
3469
+ return 0
3470
+
3471
+
3472
+ def _cmd_imports(args: argparse.Namespace) -> int:
3473
+ """imports <file> — tree-sitter parse + resolve_v2 per imported FQN.
3474
+
3475
+ Reads <file> from disk (cfg.source_root / <file> for relative paths),
3476
+ parses with ast_java.parse_java, walks explicit_imports (dict: simple_name
3477
+ -> FQN), then resolves each FQN via resolve_v2 against the graph. Returns
3478
+ a node per import: resolved graph Symbol when resolve_v2 hits (status=one),
3479
+ or an unresolved placeholder carrying the raw FQN otherwise.
3480
+ """
3481
+ from ast_java import parse_java
3482
+ from resolve_service import resolve_v2
3483
+
3484
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3485
+ from java_codebase_rag.jrag_render import render
3486
+
3487
+ cfg, graph, rc = _load_graph_or_error(args)
3488
+ if rc:
3489
+ return rc
3490
+
3491
+ file_path = _resolve_source_path(cfg, args.file)
3492
+ if file_path is None:
3493
+ env = Envelope(
3494
+ status="error",
3495
+ message=(
3496
+ f"file not found: {args.file!r} (looked at the literal path and at "
3497
+ f"<source_root>/{args.file})"
3498
+ ),
3499
+ )
3500
+ print(render(env, fmt=args.format, detail=args.detail))
3501
+ return 2
3502
+
3503
+ try:
3504
+ src = file_path.read_bytes()
3505
+ except OSError as exc:
3506
+ env = Envelope(status="error", message=f"could not read {file_path}: {exc}")
3507
+ print(render(env, fmt=args.format, detail=args.detail))
3508
+ return 2
3509
+
3510
+ # parse_java is robust to invalid source (returns an empty JavaFileAst on
3511
+ # parse errors, never raises). It builds imports from the
3512
+ # `import_declaration` tree-sitter nodes via `_import_declaration_is_static`
3513
+ # (ast_java.py:905) and the scoped_identifier child walk (ast_java.py:2658).
3514
+ # explicit_imports: dict[str, str] = simple_name -> FQN (non-wildcard,
3515
+ # non-static); we also surface wildcard/static imports as unresolved rows so
3516
+ # the agent sees the full import block.
3517
+ ast = parse_java(src, filename=args.file)
3518
+ nodes: dict[str, dict] = {}
3519
+ edges: list[dict] = []
3520
+ warnings: list[str] = []
3521
+ # Mirror outline: --limit is accepted (common flag) but imports returns the
3522
+ # full import block; surface a warning when the user explicitly set --limit
3523
+ # away from the default so they know it has no effect.
3524
+ if args.limit is not None and args.limit != 20:
3525
+ warnings.append("--limit does not apply to imports (the full import block is returned)")
3526
+
3527
+ # Static + wildcard imports: rendered as unresolved rows (resolve_v2 only
3528
+ # matches type Symbols, not methods or wildcards).
3529
+ unresolved_imports: list[dict] = []
3530
+ for ident in ast.wildcard_imports:
3531
+ unresolved_imports.append({"fqn": f"{ident}.*", "kind": "wildcard"})
3532
+ for simple, fqn in ast.file_imports.static_methods.items():
3533
+ unresolved_imports.append({"fqn": fqn, "kind": "static_method", "name": simple})
3534
+ for prefix in ast.file_imports.static_wildcards:
3535
+ unresolved_imports.append({"fqn": f"{prefix}.*", "kind": "static_wildcard"})
3536
+
3537
+ # Explicit type imports: resolve each via resolve_v2.
3538
+ resolved_count = 0
3539
+ unresolved_count = 0
3540
+ for simple, fqn in ast.explicit_imports.items():
3541
+ out = resolve_v2(fqn, hint_kind="symbol", graph=graph)
3542
+ if out.status == "one" and out.node is not None:
3543
+ ref = out.node
3544
+ node_dict = _noderef_to_node_dict(ref)
3545
+ node_dict["import_fqn"] = fqn
3546
+ node_dict["import_simple"] = simple
3547
+ nodes[ref.id] = node_dict
3548
+ edges.append({"other_id": ref.id, "edge_type": "IMPORTS", "resolved": True})
3549
+ resolved_count += 1
3550
+ else:
3551
+ # Use a stable synthetic id so unresolved imports round-trip JSON.
3552
+ synthetic_id = f"import:{fqn}"
3553
+ nodes[synthetic_id] = {
3554
+ "id": synthetic_id,
3555
+ "kind": "unresolved_import",
3556
+ "fqn": fqn,
3557
+ "name": simple,
3558
+ "import_simple": simple,
3559
+ "import_fqn": fqn,
3560
+ }
3561
+ edges.append({"other_id": synthetic_id, "edge_type": "IMPORTS", "resolved": False})
3562
+ unresolved_count += 1
3563
+
3564
+ # Append unresolved static/wildcard imports as additional rows.
3565
+ for entry in unresolved_imports:
3566
+ fqn = entry["fqn"]
3567
+ synthetic_id = f"import:{fqn}"
3568
+ nodes[synthetic_id] = {
3569
+ "id": synthetic_id,
3570
+ "kind": "unresolved_import",
3571
+ "fqn": fqn,
3572
+ "name": fqn.rsplit(".", 1)[-1],
3573
+ "import_kind": entry.get("kind", ""),
3574
+ }
3575
+ edges.append({"other_id": synthetic_id, "edge_type": "IMPORTS", "resolved": False})
3576
+
3577
+ if ast.parse_error:
3578
+ warnings.append("tree-sitter reported a parse_error for this file (imports extracted best-effort)")
3579
+
3580
+ env = Envelope(status="ok", nodes=nodes, edges=edges, warnings=warnings)
3581
+ next_actions_hook(env, result_edges=edges)
3582
+ print(render(env, fmt=args.format, detail=args.detail, noun="import"))
3583
+ return 0
3584
+
3585
+
3586
+ # ============================================================================
3587
+ # PR-JRAG-4: orientation commands (microservices / map / conventions / overview)
3588
+ # + semantic search.
3589
+ #
3590
+ # Orientation commands compose counts and listings from LadybugGraph methods
3591
+ # and focused Cypher lookups (graph._rows). They render as inspect-shape
3592
+ # (kv-block + nested dict sections) so the agent sees compact structured data.
3593
+ #
3594
+ # Search dispatches to search_v2 (mcp_v2.search_v2) after building a NodeFilter
3595
+ # from flags. --fuzzy is registered on the parser but rejected IN-HANDLER with
3596
+ # status: error (not argparse exit) so the envelope carries the message.
3597
+ # ============================================================================
3598
+
3599
+
3600
+ def _cmd_microservices(args: argparse.Namespace) -> int:
3601
+ """microservices — list every microservice with its resolved type count."""
3602
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3603
+ from java_codebase_rag.jrag_render import render
3604
+
3605
+ _, graph, rc = _load_graph_or_error(args)
3606
+ if rc:
3607
+ return rc
3608
+
3609
+ counts = graph.microservice_counts()
3610
+ # --service / --module / --limit are rejected at the argparse layer
3611
+ # (microservices uses _core_parser), so no no-op warning is needed here.
3612
+ env = Envelope(
3613
+ status="ok",
3614
+ nodes={"microservices": {"counts": dict(counts)}},
3615
+ )
3616
+ next_actions_hook(env)
3617
+ # Natural follow-ups: drill into one service's structure (map) or its
3618
+ # conventions (role/framework distribution).
3619
+ env.agent_next_actions = ["jrag map", "jrag conventions"][:5]
3620
+ print(render(env, fmt=args.format, detail=args.detail, noun="microservices", shape="inspect"))
3621
+ return 0
3622
+
3623
+
3624
+ def _cmd_map(args: argparse.Namespace) -> int:
3625
+ """map [--by microservice|module] [--service] [--module] — counts per kind.
3626
+
3627
+ ``--by`` selects the grouping axis (default microservice). ``--service`` /
3628
+ ``--module`` narrow the count to one service / module (filters, independent
3629
+ of the axis). Previously ``--module`` was overloaded to also switch the
3630
+ axis, which made "group by ALL modules" unreachable.
3631
+ """
3632
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3633
+ from java_codebase_rag.jrag_render import render
3634
+
3635
+ _, graph, rc = _load_graph_or_error(args)
3636
+ if rc:
3637
+ return rc
3638
+
3639
+ # Grouping axis: explicit --by wins; otherwise --module implies module axis
3640
+ # (the user's focus is the module they named), else default microservice.
3641
+ # This keeps the `group_by` label honest about the actual grouping: before,
3642
+ # `map --module X` labeled `group_by: microservice` while the user was
3643
+ # asking about a module — the label now matches what they see.
3644
+ group_col = args.by or ("module" if args.module else "microservice")
3645
+ scope_clauses: list[str] = []
3646
+ params: dict = {}
3647
+ if args.service:
3648
+ scope_clauses.append("s.microservice = $ms")
3649
+ params["ms"] = args.service
3650
+ if args.module:
3651
+ scope_clauses.append("s.module = $mod")
3652
+ params["mod"] = args.module
3653
+ scope_clause = " AND " + " AND ".join(scope_clauses) if scope_clauses else ""
3654
+
3655
+ rows = graph._rows( # noqa: SLF001 - counts compose query (same pattern as _scope_counts)
3656
+ f"MATCH (s:Symbol) WHERE s.resolved "
3657
+ f"AND s.kind IN ['class','interface','enum','record','annotation']"
3658
+ f"{scope_clause} "
3659
+ f"RETURN s.{group_col} AS scope, s.kind AS kind, count(*) AS n",
3660
+ params,
3661
+ )
3662
+ grouped: dict[str, dict[str, int]] = {}
3663
+ for r in rows:
3664
+ scope = str(r.get("scope") or "(unscoped)")
3665
+ kind = str(r.get("kind") or "(unknown)")
3666
+ grouped.setdefault(scope, {})[kind] = int(r.get("n") or 0)
3667
+
3668
+ # --service/--module are applied above (scope_clauses); --limit is not (this
3669
+ # is an aggregate count, not a row fetch).
3670
+ warnings = _warn_inapplicable_common(args, service=False, module=False, limit=True)
3671
+ env = Envelope(
3672
+ status="ok",
3673
+ nodes={"map": {"group_by": group_col, "counts": grouped}},
3674
+ warnings=warnings,
3675
+ )
3676
+ next_actions_hook(env)
3677
+ # Drill-down: the agent's next step on a count is to inspect the structure
3678
+ # of a specific scope. Suggest `jrag overview <first scope>` (or the explicit
3679
+ # --service scope when present) so the agent has a concrete next command.
3680
+ first_scope = next(iter(grouped), None) if grouped else None
3681
+ drill_scope = args.service or (first_scope if group_col == "microservice" else None)
3682
+ hints: list[str] = []
3683
+ if drill_scope:
3684
+ hints.append(f"jrag overview {drill_scope}")
3685
+ hints.append("jrag conventions")
3686
+ env.agent_next_actions = hints[:5]
3687
+ print(render(env, fmt=args.format, detail=args.detail, noun="map", shape="inspect"))
3688
+ return 0
3689
+
3690
+
3691
+ def _cmd_conventions(args: argparse.Namespace) -> int:
3692
+ """conventions [--service] — dominant roles + framework tallies."""
3693
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3694
+ from java_codebase_rag.jrag_render import render
3695
+
3696
+ _, graph, rc = _load_graph_or_error(args)
3697
+ if rc:
3698
+ return rc
3699
+
3700
+ scope_clause = ""
3701
+ params: dict = {}
3702
+ if args.service:
3703
+ scope_clause = " AND s.microservice = $ms"
3704
+ params["ms"] = args.service
3705
+
3706
+ role_rows = graph._rows( # noqa: SLF001 - counts compose query
3707
+ f"MATCH (s:Symbol) WHERE s.resolved AND s.role IS NOT NULL AND s.role <> ''"
3708
+ f"{scope_clause} "
3709
+ f"RETURN s.role AS role, count(*) AS n ORDER BY n DESC",
3710
+ params,
3711
+ )
3712
+ role_counts: dict[str, int] = {}
3713
+ for r in role_rows:
3714
+ role = str(r.get("role") or "")
3715
+ if role:
3716
+ role_counts[role] = int(r.get("n") or 0)
3717
+
3718
+ # Framework tallies: a direct count of route nodes by framework for
3719
+ # accuracy. --service is forwarded here too (previously the route framework
3720
+ # tally was global even when --service narrowed the role tally — half-scoped
3721
+ # output). Frameworks are NOT hardcoded; they are derived from the data
3722
+ # (r.framework on Route nodes).
3723
+ fw_scope = " AND r.microservice = $ms" if args.service else ""
3724
+ fw_rows = graph._rows( # noqa: SLF001 - counts compose query
3725
+ f"MATCH (r:Route) WHERE r.framework IS NOT NULL AND r.framework <> ''"
3726
+ f"{fw_scope} "
3727
+ f"RETURN r.framework AS framework, count(*) AS n ORDER BY n DESC",
3728
+ params,
3729
+ )
3730
+ framework_counts: dict[str, int] = {}
3731
+ for r in fw_rows:
3732
+ fw = str(r.get("framework") or "")
3733
+ if fw:
3734
+ framework_counts[fw] = int(r.get("n") or 0)
3735
+
3736
+ # --service is applied above; --module/--limit are not (no module clause;
3737
+ # aggregate count).
3738
+ warnings = _warn_inapplicable_common(args, service=False, module=True, limit=True)
3739
+ env = Envelope(
3740
+ status="ok",
3741
+ nodes={"conventions": {"roles": role_counts, "frameworks": framework_counts}},
3742
+ warnings=warnings,
3743
+ )
3744
+ next_actions_hook(env)
3745
+ # Drill-down: list the concrete symbols behind the dominant role so the
3746
+ # agent can inspect one (e.g. the top role's instances).
3747
+ hints: list[str] = []
3748
+ top_role = next(iter(role_counts), None) if role_counts else None
3749
+ drill_scope = args.service or ""
3750
+ if top_role:
3751
+ # Suggest finding symbols of the top role (scoped when --service set).
3752
+ scope_suffix = f" --service {drill_scope}" if drill_scope else ""
3753
+ hints.append(f"jrag find --role {top_role}{scope_suffix}")
3754
+ hints.append("jrag map")
3755
+ env.agent_next_actions = hints[:5]
3756
+ print(render(env, fmt=args.format, detail=args.detail, noun="conventions", shape="inspect"))
3757
+ return 0
3758
+
3759
+
3760
+ def _overview_detect_type(subject: str, graph) -> str:
3761
+ """Auto-detect the subject type for `overview`.
3762
+
3763
+ Returns "route" | "microservice" | "topic". Heuristics:
3764
+ * Starts with '/' → route.
3765
+ * Matches a known microservice name (microservice_counts keys) → microservice.
3766
+ * Else → topic (catch-all for messaging strings).
3767
+ """
3768
+ if subject.startswith("/"):
3769
+ return "route"
3770
+ try:
3771
+ ms_counts = graph.microservice_counts()
3772
+ except Exception:
3773
+ ms_counts = {}
3774
+ if subject in ms_counts:
3775
+ return "microservice"
3776
+ return "topic"
3777
+
3778
+
3779
+ def _overview_microservice(args: argparse.Namespace, graph, microservice: str) -> int:
3780
+ """overview microservice bundle: counts + routes + clients + producers.
3781
+
3782
+ The node is built WITHOUT top-level identity (kind/fqn/name) on purpose:
3783
+ that makes it a rollup to the envelope projector, which then keeps the
3784
+ nested dict/list sections (``bundle`` + sample lists) at every detail
3785
+ level. The command-side sample sizing below is what varies the output by
3786
+ detail: brief = bundle counts only, normal = +3 samples, full = +5 samples.
3787
+ Without both (rollup detection AND command-side sizing), brief/normal/full
3788
+ would all render identically because the projection would either strip the
3789
+ bundle to empty (subject node) or keep all samples equally (rollup node).
3790
+ """
3791
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3792
+ from java_codebase_rag.jrag_render import render
3793
+
3794
+ limit = _clamped_limit(args)
3795
+ routes = graph.list_routes(microservice=microservice, limit=limit + 1)
3796
+ clients = graph.list_clients(microservice=microservice, limit=limit + 1)
3797
+ producers = graph.list_producers(microservice=microservice, limit=limit + 1)
3798
+
3799
+ bundle = {
3800
+ "microservice": microservice,
3801
+ "routes": len(routes),
3802
+ "clients": len(clients),
3803
+ "producers": len(producers),
3804
+ }
3805
+ # Include sample entities (entities + listeners + jobs) for the service.
3806
+ try:
3807
+ entities = graph.list_by_role(
3808
+ role="ENTITY", microservice=microservice, limit=limit + 1
3809
+ )
3810
+ bundle["entities"] = len(entities)
3811
+ except Exception:
3812
+ pass
3813
+
3814
+ # Sample sizing by detail: brief drops samples entirely (counts only);
3815
+ # normal caps at 3 (signal of what's there); full keeps 5 (richer picture).
3816
+ detail = args.detail
3817
+ sample_cap = 0 if detail == "brief" else (3 if detail == "normal" else 5)
3818
+ # No fqn/name/path/topic/member_fqn → project_node treats this as a rollup
3819
+ # and keeps the nested sections (bundle + sample lists) at every detail
3820
+ # level. ``kind`` stays for self-identification (it's a type tag, not in
3821
+ # the rollup-identity check).
3822
+ node: dict = {
3823
+ "kind": "microservice",
3824
+ "microservice": microservice,
3825
+ "bundle": bundle,
3826
+ }
3827
+ if sample_cap:
3828
+ node["route_sample"] = [
3829
+ {"path": r.get("path", ""), "framework": r.get("framework", "")}
3830
+ for r in routes[:sample_cap]
3831
+ ]
3832
+ node["client_sample"] = [
3833
+ {"fqn": c.get("member_fqn", ""), "target_service": c.get("target_service", "")}
3834
+ for c in clients[:sample_cap]
3835
+ ]
3836
+ node["producer_sample"] = [
3837
+ {"topic": p.get("topic", ""), "producer_kind": p.get("producer_kind", "")}
3838
+ for p in producers[:sample_cap]
3839
+ ]
3840
+
3841
+ env = Envelope(
3842
+ status="ok",
3843
+ nodes={f"microservice:{microservice}": node},
3844
+ )
3845
+ next_actions_hook(env)
3846
+ print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect"))
3847
+ return 0
3848
+
3849
+
3850
+ def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> int:
3851
+ """overview route: resolve + trace_request_flow (same as `flow`)."""
3852
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, resolve_query
3853
+ from java_codebase_rag.jrag_render import render
3854
+
3855
+ limit = _clamped_limit(args)
3856
+ node, renv = resolve_query(
3857
+ route_path, hint_kind="route", java_kind=None, role=None, fqn_contains=None,
3858
+ cfg=cfg, graph=graph,
3859
+ )
3860
+ if renv.status != "ok" or node is None:
3861
+ print(render(renv, fmt=args.format, detail=args.detail))
3862
+ return 2 if renv.status == "error" else 0
3863
+
3864
+ if node.kind != "route":
3865
+ env = Envelope(
3866
+ status="error",
3867
+ message=f"overview --as route expects a Route; resolved kind is {node.kind!r}.",
3868
+ )
3869
+ print(render(env, fmt=args.format, detail=args.detail))
3870
+ return 2
3871
+
3872
+ max_hops = max(1, min(8, 5))
3873
+ flow_data = graph.trace_request_flow(entry_route_id=node.id, max_hops=max_hops)
3874
+ root_id = node.id
3875
+ nodes_dict: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
3876
+ edges: list[dict] = []
3877
+ for row in flow_data.get("inbound", []):
3878
+ caller_id = str(row.get("caller_node_id") or "")
3879
+ if not caller_id:
3880
+ continue
3881
+ kind = str(row.get("caller_node_kind") or "")
3882
+ nodes_dict[caller_id] = {
3883
+ "id": caller_id, "kind": kind,
3884
+ "fqn": str(row.get("declaring_symbol_fqn") or ""),
3885
+ "microservice": str(row.get("microservice") or ""),
3886
+ }
3887
+ edges.append({
3888
+ "other_id": caller_id,
3889
+ "edge_type": "HTTP_CALLS" if kind == "client" else "ASYNC_CALLS",
3890
+ "confidence": float(row.get("confidence") or 0.0),
3891
+ })
3892
+ for row in flow_data.get("outbound", []):
3893
+ next_id = str(row.get("next_symbol_id") or "")
3894
+ if not next_id:
3895
+ continue
3896
+ nodes_dict[next_id] = {
3897
+ "id": next_id, "kind": "symbol",
3898
+ "fqn": str(row.get("next_fqn") or ""),
3899
+ "microservice": str(row.get("next_microservice") or ""),
3900
+ }
3901
+ edges.append({"other_id": next_id, "edge_type": "CALLS"})
3902
+ truncated = len(edges) > limit
3903
+ if truncated:
3904
+ edges = edges[:limit]
3905
+ env = Envelope(status="ok", nodes=nodes_dict, edges=edges, root=root_id, truncated=truncated)
3906
+ next_actions_hook(env, root=root_id, result_edges=edges)
3907
+ print(render(env, fmt=args.format, detail=args.detail, noun="overview"))
3908
+ return 0
3909
+
3910
+
3911
+ def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int:
3912
+ """overview topic: producers + consumers for a topic string.
3913
+
3914
+ Built without top-level identity (kind/fqn/name) so the projector treats
3915
+ the node as a rollup and keeps the nested sections (``bundle`` +
3916
+ producers/consumers lists) at every detail level. Command-side sample
3917
+ sizing varies the output by detail: brief = counts only, normal = +3
3918
+ samples, full = +limit samples.
3919
+ """
3920
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3921
+ from java_codebase_rag.jrag_render import render
3922
+
3923
+ limit = _clamped_limit(args)
3924
+ # Producers: exact topic match first, then substring match as fallback.
3925
+ producers = graph.list_producers(topic_contains=topic, limit=limit + 1)
3926
+ if not producers and len(topic) >= 3:
3927
+ # Try a shorter substring if the exact topic yields nothing.
3928
+ producers = graph.list_producers(topic_contains=topic[:3], limit=limit + 1)
3929
+ producers = [p for p in producers if topic in str(p.get("topic") or "")]
3930
+
3931
+ # Consumers: listener classes consuming this topic via EXPOSES on Route.
3932
+ consumers = _resolve_topic_consumers(graph, topic=topic, contains=False)
3933
+ if not consumers:
3934
+ consumers = _resolve_topic_consumers(graph, topic=topic, contains=True)
3935
+
3936
+ detail = args.detail
3937
+ sample_cap = 0 if detail == "brief" else (3 if detail == "normal" else limit)
3938
+ # NOTE: no top-level ``topic``/fqn/name here — ``topic`` IS a rollup-
3939
+ # identity key, so its presence would make project_node treat this as a
3940
+ # subject and strip the bundle/producers/consumers sections at brief/
3941
+ # normal. ``kind`` is fine (type tag, not in the rollup-identity check),
3942
+ # so it stays for self-identification. The topic name travels in the dict
3943
+ # key ("topic:<name>") and inside ``bundle.topic``.
3944
+ topic_node: dict = {
3945
+ "kind": "topic",
3946
+ "bundle": {
3947
+ "topic": topic,
3948
+ "producers": len(producers),
3949
+ "consumers": len(consumers),
3950
+ },
3951
+ }
3952
+ if sample_cap:
3953
+ topic_node["producers"] = [
3954
+ {
3955
+ "fqn": str(p.get("member_fqn") or ""),
3956
+ "topic": str(p.get("topic") or ""),
3957
+ "producer_kind": str(p.get("producer_kind") or ""),
3958
+ "microservice": str(p.get("microservice") or ""),
3959
+ }
3960
+ for p in producers[:sample_cap]
3961
+ ]
3962
+ topic_node["consumers"] = [
3963
+ {
3964
+ "fqn": c.get("fqn", ""),
3965
+ "kind": c.get("kind", "symbol"),
3966
+ "microservice": c.get("microservice", ""),
3967
+ }
3968
+ for c in consumers[:sample_cap]
3969
+ ]
3970
+ env = Envelope(
3971
+ status="ok",
3972
+ nodes={f"topic:{topic}": topic_node},
3973
+ )
3974
+ next_actions_hook(env)
3975
+ print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect"))
3976
+ return 0
3977
+
3978
+
3979
+ def _cmd_overview(args: argparse.Namespace) -> int:
3980
+ """overview <microservice|route-path|topic> [--as ...] — dispatch on type."""
3981
+ cfg, graph, rc = _load_graph_or_error(args)
3982
+ if rc:
3983
+ return rc
3984
+
3985
+ from java_codebase_rag.jrag_envelope import Envelope
3986
+ from java_codebase_rag.jrag_render import render
3987
+
3988
+ subject = args.subject
3989
+ # --service is inherited from the common parser. Treat a provided --service
3990
+ # as the subject when no positional was given (so `overview --service
3991
+ # chat-assign` works like `overview chat-assign`), and ALWAYS validate it
3992
+ # against the known set so a bogus name errors clearly instead of producing
3993
+ # an empty bundle that reads as "service has no entries".
3994
+ if args.service and not subject:
3995
+ subject = args.service
3996
+ if args.service:
3997
+ rc_ms = _validate_known_microservice(graph, args.service, args)
3998
+ if rc_ms is not None:
3999
+ return rc_ms
4000
+
4001
+ if not subject:
4002
+ # Subject is optional on the parser (nargs='?') so we can emit a helpful
4003
+ # explanation instead of argparse's opaque "the following arguments are
4004
+ # required: subject". Prints to stderr (usage guidance) + a status:error
4005
+ # envelope to stdout, exit 2.
4006
+ msg = (
4007
+ "overview requires a <subject>: a microservice name (e.g. 'chat-core'), "
4008
+ "a route path (e.g. '/api/v1/chat/events'), or a topic string "
4009
+ "(e.g. 'banking.chat.audit'). Use --as {microservice,route,topic} to "
4010
+ "override auto-detection."
4011
+ )
4012
+ print(render(Envelope(status="error", message=msg), fmt=args.format, detail=args.detail))
4013
+ return 2
4014
+ as_type = getattr(args, "as_type", None)
4015
+ if as_type is None:
4016
+ as_type = _overview_detect_type(subject, graph)
4017
+
4018
+ # NOTE: we do NOT validate `subject` against the known microservice set here.
4019
+ # Auto-detect only returns "microservice" when the subject IS in
4020
+ # microservice_counts, so that path is already known-good; and an explicit
4021
+ # `--as microservice` is a deliberate force (e.g. on a route-shaped string)
4022
+ # that must NOT be rejected. The bogus-microservice guard for overview is
4023
+ # carried entirely by the --service flag validation above.
4024
+ if as_type == "route":
4025
+ return _overview_route(args, cfg, graph, subject)
4026
+ if as_type == "microservice":
4027
+ return _overview_microservice(args, graph, subject)
4028
+ return _overview_topic(args, graph, subject)
4029
+
4030
+
4031
+ # ============================================================================
4032
+ # Search (PR-JRAG-4)
4033
+ # ============================================================================
4034
+
4035
+
4036
+ def _zero_result_guidance(args: argparse.Namespace, graph) -> str | None:
4037
+ """Hint where matches live when a filtered search returns 0 results.
4038
+
4039
+ Runs ONE cheap unfiltered probe (limit 10) and tallies the filtered
4040
+ dimension across the probe hits, so an agent who filtered to e.g.
4041
+ ``--role SERVICE`` and got nothing learns the matches are under
4042
+ COMPONENT/OTHER instead of guessing. Returns None when no guidance
4043
+ applies: no recognizable single-dimension filter set, the probe is
4044
+ empty (truly no matches for this query), or the probe itself errored
4045
+ (non-fatal — the empty result still renders).
4046
+ """
4047
+ import mcp_v2
4048
+ from collections import Counter
4049
+
4050
+ from java_codebase_rag.jrag_envelope import normalize_enum
4051
+
4052
+ # Only the common single-dimension filters get guidance; first set wins.
4053
+ dims: list[tuple[str, str, str, str]] = []
4054
+ if args.role:
4055
+ dims.append(("role", "role", "roles", normalize_enum(args.role, kind="role")))
4056
+ if args.service:
4057
+ dims.append(("microservice", "service", "services", args.service))
4058
+ if args.module:
4059
+ dims.append(("module", "module", "modules", args.module))
4060
+ if not dims:
4061
+ return None
4062
+ attr, flag, plural, value = dims[0]
4063
+
4064
+ try:
4065
+ probe = mcp_v2.search_v2(
4066
+ args.query,
4067
+ table=args.table,
4068
+ hybrid=args.hybrid,
4069
+ limit=10,
4070
+ offset=0,
4071
+ path_contains=args.path_contains,
4072
+ filter=None,
4073
+ explain=False,
4074
+ graph=graph,
4075
+ )
4076
+ except Exception:
4077
+ return None
4078
+ if not probe.success or not probe.results:
4079
+ return None
4080
+
4081
+ counts: Counter = Counter(getattr(h, attr, None) for h in probe.results)
4082
+ counts.pop(None, None)
4083
+ if not counts:
4084
+ return None
4085
+ total = sum(counts.values())
4086
+ top = counts.most_common(3)
4087
+ alts = ", ".join(f"{v} ({c})" for v, c in top)
4088
+ suggestion = top[0][0]
4089
+ return (
4090
+ f"0 results with --{flag} {value}; {total} matches exist under other {plural}: "
4091
+ f"{alts} — try --{flag} {suggestion}"
4092
+ )
4093
+
4094
+
4095
+ def _cmd_search(args: argparse.Namespace) -> int:
4096
+ """search <query> — semantic search via search_v2 over Lance tables.
4097
+
4098
+ Builds a NodeFilter from flags, calls search_v2 with limit+1 for +1-fetch
4099
+ truncation, and renders. --fuzzy is rejected IN-HANDLER (not argparse-exit)
4100
+ so the error carries the canonical envelope shape.
4101
+ """
4102
+ import mcp_v2
4103
+
4104
+ from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, normalize_enum
4105
+ from java_codebase_rag.jrag_render import render
4106
+
4107
+ # --fuzzy: registered on the parser (so argparse doesn't exit 2), but rejected
4108
+ # IN-HANDLER with status: error (search is inherently semantic; --fuzzy is
4109
+ # a no-op synonym, not a real mode toggle).
4110
+ if getattr(args, "fuzzy", False):
4111
+ env = Envelope(
4112
+ status="error",
4113
+ message="search is semantic; --fuzzy is implicit",
4114
+ )
4115
+ print(render(env, fmt=args.format, detail=args.detail))
4116
+ return 2
4117
+
4118
+ cfg, graph, rc = _load_graph_or_error(args)
4119
+ if rc:
4120
+ return rc
4121
+
4122
+ limit = min(args.limit if args.limit is not None else 20, 499)
4123
+
4124
+ # --limit 0: short-circuit to a clean empty page. mark_truncated(rows, 0)
4125
+ # would otherwise report truncated=True (a unit test pins the helper's
4126
+ # current behavior, so we fix this in the handler, not the helper), and
4127
+ # there is nothing to search — skip the embedding-model load entirely.
4128
+ if limit == 0:
4129
+ env = Envelope(
4130
+ status="ok", nodes={}, truncated=False,
4131
+ warnings=_auto_scope_notice(args),
4132
+ )
4133
+ next_actions_hook(env)
4134
+ print(render(env, fmt=args.format, detail=args.detail, noun="search"))
4135
+ return 0
4136
+
4137
+ # Build NodeFilter from flags (same set as `find` filter mode).
4138
+ filter_dict: dict = {}
4139
+ if args.service:
4140
+ filter_dict["microservice"] = args.service
4141
+ if args.module:
4142
+ filter_dict["module"] = args.module
4143
+ if args.role:
4144
+ filter_dict["role"] = normalize_enum(args.role, kind="role")
4145
+ if args.exclude_role:
4146
+ filter_dict["exclude_roles"] = [normalize_enum(args.exclude_role, kind="role")]
4147
+ if args.annotation:
4148
+ filter_dict["annotation"] = args.annotation
4149
+ if args.capability:
4150
+ filter_dict["capability"] = args.capability
4151
+ if args.fqn_contains:
4152
+ filter_dict["fqn_contains"] = args.fqn_contains
4153
+ if args.java_kind:
4154
+ filter_dict["symbol_kind"] = normalize_enum(args.java_kind, kind="java_kind")
4155
+ # NOTE: --framework is intentionally NOT placed in the NodeFilter. The graph
4156
+ # stores `framework` only on Route nodes (Route.framework), so the
4157
+ # NodeFilter `framework` field is validated against the route-only Framework
4158
+ # Literal AND rejected by the symbol-kind applicability guard. Applying it to
4159
+ # a symbol result set requires mapping the framework tag back onto the
4160
+ # declaring type via its annotations — done as a client-side POST-filter
4161
+ # below (`_framework_post_filter`) after the search hits come back.
4162
+ framework_want = normalize_enum(args.framework, kind="framework") if args.framework else None
4163
+ if framework_want and framework_want not in _FRAMEWORK_ANNOTATIONS:
4164
+ # Catch an unknown framework BEFORE the search runs (saves the embedding
4165
+ # model load + Lance scan). The valid set is the same one NodeFilter
4166
+ # validates against for routes — surfaced as a clean error envelope.
4167
+ valid = ", ".join(sorted(_FRAMEWORK_ANNOTATIONS))
4168
+ env = Envelope(
4169
+ status="error",
4170
+ message=(
4171
+ f"invalid framework: {args.framework!r} (normalized to {framework_want!r}); "
4172
+ f"expected one of: {valid}"
4173
+ ),
4174
+ )
4175
+ print(render(env, fmt=args.format, detail=args.detail))
4176
+ return 2
4177
+ node_filter, err_env = _build_node_filter_or_error(filter_dict)
4178
+ if err_env is not None:
4179
+ print(render(err_env, fmt=args.format, detail=args.detail))
4180
+ return 2
4181
+
4182
+ out = mcp_v2.search_v2(
4183
+ args.query,
4184
+ table=args.table,
4185
+ hybrid=args.hybrid,
4186
+ limit=limit + 1, # +1 for truncated detection
4187
+ offset=args.offset,
4188
+ path_contains=args.path_contains,
4189
+ filter=node_filter,
4190
+ explain=args.explain,
4191
+ graph=graph,
4192
+ dedup=not getattr(args, "chunks", False),
4193
+ )
4194
+
4195
+ if not out.success:
4196
+ env = Envelope(status="error", message=out.message or "search failed")
4197
+ print(render(env, fmt=args.format, detail=args.detail))
4198
+ return 2
4199
+
4200
+ # Convert SearchHit list to envelope node dicts.
4201
+ # Score floor (default 0.0): drop negative-score noise — chunks farther than
4202
+ # orthogonal to the query (l2_distance_to_score < 0) are never a real match.
4203
+ # Applied BEFORE truncation so the floor tightens precision without the +1
4204
+ # row leaking past it. SearchHit now carries filename/start_line; the
4205
+ # envelope projector (_compose_file) folds those into the `file` display
4206
+ # field so each rendered hit shows its file path (filename:start_line).
4207
+ min_score = getattr(args, "min_score", 0.0) or 0.0
4208
+ hit_dicts: list[dict] = []
4209
+ for hit in out.results:
4210
+ if float(getattr(hit, "score", 0.0)) < min_score:
4211
+ continue
4212
+ d = hit.model_dump() if hasattr(hit, "model_dump") else dict(hit)
4213
+ # Ensure an `id` key for envelope nodes (SearchHit carries chunk_id +
4214
+ # optional symbol_id; use chunk_id as the envelope node id).
4215
+ if "id" not in d:
4216
+ d["id"] = d.get("chunk_id") or d.get("symbol_id") or d.get("fqn") or ""
4217
+ if "kind" not in d:
4218
+ d["kind"] = "search_hit"
4219
+ # Add explain token when --explain is set
4220
+ if args.explain:
4221
+ from search_lancedb import explain_score_components
4222
+ comps = d.get("score_components")
4223
+ d["explain"] = explain_score_components(
4224
+ comps,
4225
+ role=d.get("role"),
4226
+ hybrid=bool(args.hybrid),
4227
+ graph_expanded=False,
4228
+ )
4229
+ hit_dicts.append(d)
4230
+
4231
+ # --framework POST-filter: the graph stores `framework` only on Route nodes,
4232
+ # so we map the requested framework tag back onto the symbol's declaring
4233
+ # type via its annotations (e.g. spring_mvc -> @RestController) and keep
4234
+ # only hits whose primary type declares one of those annotations. Applied
4235
+ # BEFORE truncation so the cap bounds the visible (filtered) page.
4236
+ framework_dropped = 0
4237
+ if framework_want and hit_dicts:
4238
+ framework_fqns = _framework_type_fqns(graph, framework_want)
4239
+ kept: list[dict] = []
4240
+ for d in hit_dicts:
4241
+ type_fqn = d.get("fqn") or ""
4242
+ if type_fqn and type_fqn in framework_fqns:
4243
+ kept.append(d)
4244
+ else:
4245
+ framework_dropped += 1
4246
+ hit_dicts = kept
4247
+
4248
+ display, truncated = mark_truncated(hit_dicts, limit)
4249
+ nodes = {n["id"]: n for n in display} if display else {}
4250
+
4251
+ warnings: list[str] = []
4252
+ if framework_want and framework_dropped and not display:
4253
+ warnings.append(
4254
+ f"--framework {framework_want!r} filtered out all {framework_dropped} hit(s); "
4255
+ f"no symbol's declaring type matched the framework's characteristic annotations"
4256
+ )
4257
+ # Zero-result guidance: when a structural filter emptied the page, run one
4258
+ # cheap unfiltered probe and point at where matches actually live (e.g.
4259
+ # "--role SERVICE" returned 0 but matches are under COMPONENT/OTHER).
4260
+ if not hit_dicts and filter_dict:
4261
+ guidance = _zero_result_guidance(args, graph)
4262
+ if guidance:
4263
+ warnings.append(guidance)
4264
+ env = Envelope(
4265
+ status="ok", nodes=nodes, truncated=truncated,
4266
+ warnings=warnings + _auto_scope_notice(args),
4267
+ )
4268
+ next_actions_hook(env)
4269
+ # Per-hit drill-down: the top search hit's primary type is the natural
4270
+ # thing to inspect (signature, edges, callers). Two visible hints in text,
4271
+ # up to 5 in JSON.
4272
+ if display:
4273
+ env.agent_next_actions = _inspect_hints_for_rows(display, limit=2)
4274
+ next_offset = args.offset + limit if truncated else None
4275
+ print(render(env, fmt=args.format, detail=args.detail, noun="search", next_offset=next_offset))
4276
+ return 0
4277
+
4278
+
4279
+ def _suppress_runtime_stderr_noise() -> None:
4280
+ """Silence known-benign stderr noise from the embedding/LanceDB stack.
4281
+
4282
+ The CLI loads sentence_transformers + LanceDB per invocation; both emit
4283
+ benign stderr noise that an agent-facing tool should not dump on the caller:
4284
+
4285
+ * tqdm ``Loading weights`` progress bar (sentence_transformers model load)
4286
+ * HuggingFace hub progress bars / telemetry
4287
+ * torch multiprocessing ``leaked semaphore objects`` ``resource_tracker``
4288
+ UserWarning emitted at shutdown
4289
+
4290
+ Real diagnostics (the top-level handler's ``traceback.format_exc()``) still
4291
+ go to stderr. Env vars are set with ``setdefault`` so an explicit caller
4292
+ override wins. The ``resource_tracker`` warning is raised inside a spawned
4293
+ child process; under the spawn start method (macOS default) the child
4294
+ re-initializes ``warnings`` and does NOT inherit the parent's
4295
+ ``warnings.filterwarnings``, so we route it through ``PYTHONWARNINGS`` (env
4296
+ vars ARE inherited by spawned children) as well as the parent filter.
4297
+ """
4298
+ for key, val in (
4299
+ ("TQDM_DISABLE", "1"),
4300
+ ("TRANSFORMERS_VERBOSITY", "error"),
4301
+ ("HF_HUB_DISABLE_PROGRESS_BARS", "1"),
4302
+ ("HF_HUB_DISABLE_TELEMETRY", "1"),
4303
+ # mcp_v2._log_fail_loud operator diagnostic — the CLI surfaces the same
4304
+ # failure as a clean status:error envelope, so silence the stderr line.
4305
+ ("JAVA_CODEBASE_RAG_FAIL_LOUD", "0"),
4306
+ ):
4307
+ os.environ.setdefault(key, val)
4308
+ existing_pw = os.environ.get("PYTHONWARNINGS", "")
4309
+ extra_pw = "ignore:resource_tracker:UserWarning"
4310
+ if extra_pw not in existing_pw:
4311
+ os.environ["PYTHONWARNINGS"] = f"{existing_pw},{extra_pw}" if existing_pw else extra_pw
4312
+ import warnings
4313
+
4314
+ warnings.filterwarnings("ignore", message=r"resource_tracker.*", category=UserWarning)
4315
+
4316
+
4317
+ def main(argv: list[str] | None = None) -> int:
4318
+ """Process-level entry. Returns the exit code.
4319
+
4320
+ First line raises the FD soft limit (lancedb merge-insert opens many
4321
+ handles; macOS IDE-launched soft limit is 256). Returns 0 on ok, 1 on
4322
+ usage error (argparse rejects argv), 2 on handler exception. The top-level
4323
+ exception handler emits a ``status: error`` envelope to stdout AND
4324
+ ``traceback.format_exc()`` to stderr before returning 2 - this is a
4325
+ deliberate divergence from the operator CLI which swallows tracebacks.
4326
+ """
4327
+ raise_fd_limit()
4328
+ _suppress_runtime_stderr_noise()
4329
+ parser = build_parser()
4330
+ raw = list(argv if argv is not None else sys.argv[1:])
4331
+ try:
4332
+ args = parser.parse_args(raw)
4333
+ except SystemExit as exc:
4334
+ # argparse with exit_on_error=False raises SystemExit on -h/--help
4335
+ # (code 0) and ArgumentError-propagated paths. Treat 0/None as ok and
4336
+ # any other code as usage error (exit 1).
4337
+ if exc.code in (0, None):
4338
+ return 0
4339
+ return 1
4340
+ except argparse.ArgumentError as exc:
4341
+ # exit_on_error=False + _EnvelopeArgumentParser routes argparse usage
4342
+ # errors here (missing required positional, unrecognized flag, bad
4343
+ # choices) WITHOUT dumping usage text. We emit a clean status:error
4344
+ # envelope to STDOUT honoring --format so JSON consumers get a
4345
+ # parseable result (parity with overview/find missing-arg paths), AND
4346
+ # mirror a terse line to STDERR so shell users / `2>&1` pipelines and
4347
+ # the existing "non-empty stderr on usage error" tests still see it.
4348
+ # Exit non-zero: this is a usage error, distinct from a not_found
4349
+ # envelope (exit 0, the resolve found nothing).
4350
+ from java_codebase_rag.jrag_envelope import Envelope
4351
+ from java_codebase_rag.jrag_render import render
4352
+
4353
+ fmt, detail, leftover = _preparse_render_flags(raw)
4354
+ fmt = fmt or "text"
4355
+ detail = detail or "normal"
4356
+ # The subcommand is the first non-dash token in the leftover (flag
4357
+ # values already consumed by the pre-parser), so we don't mis-prefix
4358
+ # with a value like ``json`` from ``--format json``.
4359
+ cmd = next((t for t in leftover if not t.startswith("-")), None)
4360
+ msg = str(exc).strip() or "usage error"
4361
+ if cmd and not msg.startswith(cmd):
4362
+ msg = f"{cmd}: {msg}"
4363
+ env = Envelope(status="error", message=msg)
4364
+ print(render(env, fmt=fmt, detail=detail))
4365
+ print(f"jrag: error: {msg}", file=sys.stderr)
4366
+ return 2
4367
+ handler = getattr(args, "handler", None)
4368
+ if handler is None:
4369
+ # No subcommand: print help to stderr, return usage error.
4370
+ parser.print_help(sys.stderr)
4371
+ return 1
4372
+ try:
4373
+ return int(handler(args))
4374
+ except Exception as exc:
4375
+ from java_codebase_rag.jrag_envelope import Envelope
4376
+ from java_codebase_rag.jrag_render import render
4377
+
4378
+ env = Envelope(
4379
+ status="error",
4380
+ message=f"internal error: {exc}",
4381
+ )
4382
+ print(render(env, fmt=getattr(args, "format", "text")))
4383
+ print(traceback.format_exc(), file=sys.stderr)
4384
+ return 2
4385
+
4386
+
4387
+ def _console_script_main() -> None:
4388
+ """Real CLI entry: terminate without interpreter finalization.
4389
+
4390
+ Mirrors ``java_codebase_rag.cli._console_script_main``: a pyarrow/lance
4391
+ worker thread (loaded via lancedb in lifecycle commands) can outlive CPython
4392
+ finalization in a one-shot CLI subprocess and trip ``PyGILState_Release``
4393
+ (SIGABRT, exit -6). Flushing + ``os._exit`` skips that racy teardown - the
4394
+ command has already done its work and emitted its result. ``main()`` stays
4395
+ return-based so in-process test callers keep working.
4396
+ """
4397
+ force_utf8_stdio()
4398
+ rc = main()
4399
+ sys.stdout.flush()
4400
+ sys.stderr.flush()
4401
+ os._exit(rc)
4402
+
4403
+
4404
+ if __name__ == "__main__":
4405
+ _console_script_main()