java-codebase-rag 0.9.7__py3-none-any.whl → 0.10.1__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. java_codebase_rag/analysis/pr_analysis.py +33 -3
  2. java_codebase_rag/ast/ast_java.py +2 -1
  3. java_codebase_rag/cli.py +23 -8
  4. java_codebase_rag/config.py +68 -1
  5. java_codebase_rag/graph/build_ast_graph.py +123 -4
  6. java_codebase_rag/graph/graph_types.py +109 -22
  7. java_codebase_rag/graph/ladybug_queries.py +45 -2
  8. java_codebase_rag/index/java_index_flow_lancedb.py +10 -16
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +3 -1
  10. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +3 -1
  11. java_codebase_rag/jrag.py +627 -661
  12. java_codebase_rag/jrag_render.py +160 -3
  13. java_codebase_rag/lance_optimize.py +11 -12
  14. java_codebase_rag/mcp/mcp_v2.py +2 -1
  15. java_codebase_rag/pipeline.py +47 -1
  16. java_codebase_rag/read_payloads.py +781 -0
  17. java_codebase_rag/search/search_lancedb.py +138 -6
  18. java_codebase_rag/search/search_lexical.py +140 -30
  19. java_codebase_rag/search/search_scoring.py +108 -0
  20. java_codebase_rag/watch/__init__.py +0 -0
  21. java_codebase_rag/watch/client.py +230 -0
  22. java_codebase_rag/watch/daemon.py +368 -0
  23. java_codebase_rag/watch/lock.py +201 -0
  24. java_codebase_rag/watch/paths.py +76 -0
  25. java_codebase_rag/watch/protocol.py +122 -0
  26. java_codebase_rag/watch/server.py +273 -0
  27. java_codebase_rag/watch/warm.py +105 -0
  28. java_codebase_rag/watch/watcher.py +352 -0
  29. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/METADATA +30 -31
  30. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/RECORD +34 -24
  31. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/WHEEL +0 -0
  32. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/entry_points.txt +0 -0
  33. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/licenses/LICENSE +0 -0
  34. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/top_level.txt +0 -0
java_codebase_rag/jrag.py CHANGED
@@ -24,7 +24,9 @@ from __future__ import annotations
24
24
 
25
25
  import argparse
26
26
  import os
27
+ import signal
27
28
  import sys
29
+ import time
28
30
  import traceback
29
31
  from pathlib import Path
30
32
 
@@ -195,6 +197,46 @@ def _clamped_limit(args: argparse.Namespace) -> int:
195
197
  return min(raw_limit, 499)
196
198
 
197
199
 
200
+ def _emit(env, args: argparse.Namespace, *, noun: str = "",
201
+ shape: str | None = None, next_offset: int | None = None) -> int:
202
+ """Final render+print funnel honoring ``--count`` / ``--exists`` / ``--fields``;
203
+ returns the exit code.
204
+
205
+ The single output seam every ok / not_found / ambiguous result routes
206
+ through. The shared helpers (:func:`_render_listing`, :func:`_emit_traversal`)
207
+ delegate their tail here; inline success render sites call it directly. True
208
+ usage / setup errors (missing index, kind guard, ``neighbors_v2`` failure,
209
+ argparse errors) bypass it — those render normally via :func:`render`, since a
210
+ count/exists shape would hide the actionable error message.
211
+
212
+ Exit code: ``--exists`` forces 0 when results are present and 2 otherwise
213
+ (resolve miss AND empty ok both count as absent), computed via
214
+ :func:`jrag_render.has_results` so output and exit code agree. Without
215
+ ``--exists``, rc follows the envelope (error -> 2, else 0); ``--count`` does
216
+ not gate (a zero count on an ok envelope stays exit 0).
217
+
218
+ ``getattr`` defaults keep this safe for parsers that lack the flags
219
+ (``_core_parser`` commands), though only ``_common_parser`` commands are
220
+ routed here today.
221
+ """
222
+ from java_codebase_rag.jrag_render import has_results, render
223
+
224
+ print(render(
225
+ env,
226
+ fmt=getattr(args, "format", "text"),
227
+ detail=getattr(args, "detail", "normal"),
228
+ noun=noun,
229
+ next_offset=next_offset,
230
+ shape=shape,
231
+ count=getattr(args, "count", False),
232
+ exists=getattr(args, "exists", False),
233
+ fields=getattr(args, "fields", None),
234
+ ))
235
+ if getattr(args, "exists", False):
236
+ return 0 if has_results(env, shape) else 2
237
+ return 2 if env.status == "error" else 0
238
+
239
+
198
240
  def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str,
199
241
  extra_hints: list[str] | None = None) -> int:
200
242
  """Apply +1-fetch truncation, build the envelope, render as a listing.
@@ -209,7 +251,6 @@ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str,
209
251
  (jobs / listeners / entities).
210
252
  """
211
253
  from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, to_envelope_rows
212
- from java_codebase_rag.jrag_render import render
213
254
 
214
255
  node_list = to_envelope_rows(rows) if rows and not isinstance(rows[0], dict) else list(rows)
215
256
  display_nodes_list, truncated = mark_truncated(node_list, limit)
@@ -227,8 +268,7 @@ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str,
227
268
  seen.add(h)
228
269
  env.agent_next_actions.append(h)
229
270
  env.agent_next_actions = env.agent_next_actions[:5]
230
- print(render(env, fmt=args.format, detail=args.detail, noun=noun))
231
- return 0
271
+ return _emit(env, args, noun=noun)
232
272
 
233
273
 
234
274
  def _symbol_hit_to_dict(hit) -> dict:
@@ -429,6 +469,43 @@ def build_parser() -> argparse.ArgumentParser:
429
469
  "normal = +module/role/file/score; full = +signature/annotations/snippet."
430
470
  ),
431
471
  )
472
+ # Output-shaping flags (issue #376). NOT on _core_parser: status /
473
+ # microservices are aggregate rollups (a count there is meaningless) and
474
+ # vocab-index prints plain text outside the render path, so adding them
475
+ # there would create silently-ignored flags (violates the
476
+ # "inapplicable flags never silently ignored" principle).
477
+ common.add_argument(
478
+ "--count",
479
+ action="store_true",
480
+ default=False,
481
+ help=(
482
+ "Print only the result count (no rows) — bare int in text, "
483
+ "{\"status\",\"count\"} in json. Counts nodes (listing), edges "
484
+ "(traversal), or 1 (inspect)."
485
+ ),
486
+ )
487
+ common.add_argument(
488
+ "--exists",
489
+ action="store_true",
490
+ default=False,
491
+ help=(
492
+ "Print only an exists boolean (true/false, or "
493
+ "{\"status\",\"exists\"} in json). Exit 0 when results exist, "
494
+ "2 otherwise (incl. resolve miss / empty result)."
495
+ ),
496
+ )
497
+ common.add_argument(
498
+ "--fields",
499
+ type=str,
500
+ default=None,
501
+ metavar="LIST",
502
+ help=(
503
+ "Comma-separated node-field allowlist that overrides --detail "
504
+ "(e.g. fqn,role,signature). Ignored with --count/--exists; "
505
+ "primarily a --format json lever; text still labels rows from "
506
+ "whatever identity fields survive."
507
+ ),
508
+ )
432
509
  return common
433
510
 
434
511
  # Core-only parser for AGGREGATE commands (status / microservices) that have
@@ -484,7 +561,8 @@ def build_parser() -> argparse.ArgumentParser:
484
561
  parents=[_common_parser()],
485
562
  description=(
486
563
  "Find nodes by query or filter. Two modes:\n"
487
- " Query mode (positional <query>): search by exact name/FQN (symbols only).\n"
564
+ " Query mode (positional <query>): search by name/FQN (symbols only); --fuzzy\n"
565
+ " falls back exact -> prefix -> substring when the exact match is empty.\n"
488
566
  " Filter mode (no positional): apply structured filters (NodeFilter flags).\n"
489
567
  "Kind inference: domain flags (--http-method, --client-kind, --producer-kind) imply\n"
490
568
  "route/client/producer when --kind is omitted. Contradiction emits an error envelope.\n"
@@ -507,6 +585,12 @@ def build_parser() -> argparse.ArgumentParser:
507
585
  find.add_argument("--framework", type=_lower_snake, choices=_FRAMEWORK_CHOICES, default=None, help="Filter by framework.")
508
586
  find.add_argument("--source-layer", type=str, default=None, help="Filter by source layer.")
509
587
  find.add_argument("--fqn-contains", type=str, default=None, help="Filter by FQN substring.")
588
+ find.add_argument(
589
+ "--fuzzy",
590
+ action="store_true",
591
+ help="Query mode: fall back from exact name/FQN to prefix then substring "
592
+ "(case-sensitive) when the exact match is empty.",
593
+ )
510
594
  find.add_argument("--http-method", type=str, default=None, help="Filter by HTTP method (route).")
511
595
  find.add_argument("--path-contains", type=str, default=None, help="Filter by path substring (route).")
512
596
  find.add_argument("--client-kind", type=str, default=None, help="Filter by client kind (client).")
@@ -1108,9 +1192,9 @@ def build_parser() -> argparse.ArgumentParser:
1108
1192
  "--table all searches all three. --hybrid enables vector+keyword hybrid. "
1109
1193
  "--offset paginates. --path-contains narrows by file path substring. "
1110
1194
  "Filters (NodeFilter flags) narrow results.\n\n"
1111
- "--fuzzy is accepted but rejected IN-HANDLER with status: error (search is "
1112
- "inherently semantic; --fuzzy is a no-op synonym). Registering the flag "
1113
- "prevents argparse from exiting 2 before the handler can produce the envelope."
1195
+ "--fuzzy is accepted as a no-op (search is inherently semantic; "
1196
+ "--fuzzy is implicit). It is kept registered so callers that pass "
1197
+ "it don't hit an argparse error, and is silently ignored."
1114
1198
  ),
1115
1199
  )
1116
1200
  search.add_argument("query", help="Natural-language search query.")
@@ -1132,7 +1216,7 @@ def build_parser() -> argparse.ArgumentParser:
1132
1216
  )
1133
1217
  search.add_argument(
1134
1218
  "--fuzzy", action="store_true",
1135
- help="Accepted but rejected in-handler (search is semantic; --fuzzy is implicit).",
1219
+ help="Accepted as a no-op (search is always semantic; --fuzzy is implicit).",
1136
1220
  )
1137
1221
  search.add_argument(
1138
1222
  "--min-score", type=float, default=0.0, dest="min_score",
@@ -1177,6 +1261,59 @@ def build_parser() -> argparse.ArgumentParser:
1177
1261
  )
1178
1262
  vocab_index.set_defaults(handler=_cmd_vocab_index, detail="full")
1179
1263
 
1264
+ # ---- watch subparser (jrag watch foreground/detach/stop/status) ----
1265
+ # Uses _core_parser (no auto-scope): watch is a long-lived daemon over a
1266
+ # whole index, not a per-query command, so --service/--module/--limit would
1267
+ # be dishonest on this surface. Keeps --index-dir/--format/--detail so the
1268
+ # daemon anchors and so --status output respects --format.
1269
+ watch = subparsers.add_parser(
1270
+ "watch",
1271
+ help="keep the index fresh and serve warm queries while running",
1272
+ parents=[_core_parser()],
1273
+ description=(
1274
+ "Long-lived daemon: watches the source tree for changes (reindexing "
1275
+ "vectors/graph on a debounce) and serves the read commands (search/find/"
1276
+ "inspect/callers/callees/flow) over a warm Unix socket so queries skip the "
1277
+ "cold-start model/graph load.\n\n"
1278
+ "Lifecycle:\n"
1279
+ " jrag watch run in the foreground (Ctrl+C / SIGTERM to stop)\n"
1280
+ " jrag watch --detach start as a background daemon and return\n"
1281
+ " jrag watch --status report up/down + pid + socket + last reindex\n"
1282
+ " jrag watch --stop SIGTERM a running daemon (SIGKILL after 5s)\n"
1283
+ "Only one daemon may run per index (project lock). --status/--stop do NOT "
1284
+ "acquire the lock."
1285
+ ),
1286
+ )
1287
+ watch.add_argument(
1288
+ "--detach",
1289
+ action="store_true",
1290
+ help="Start the daemon as a detached background process and return.",
1291
+ )
1292
+ watch.add_argument(
1293
+ "--stop",
1294
+ action="store_true",
1295
+ help="Stop a running daemon (SIGTERM; SIGKILL after 5s).",
1296
+ )
1297
+ watch.add_argument(
1298
+ "--status",
1299
+ action="store_true",
1300
+ help="Print whether the daemon is up or down and exit.",
1301
+ )
1302
+ watch.add_argument(
1303
+ "--debounce-ms",
1304
+ type=int,
1305
+ default=None,
1306
+ dest="debounce_ms",
1307
+ help="Reindex debounce window in ms (overrides YAML `watch:debounce_ms`).",
1308
+ )
1309
+ watch.add_argument(
1310
+ "--backend",
1311
+ choices=("auto", "watchdog", "polling"),
1312
+ default=None,
1313
+ help="File-watch backend (overrides YAML `watch:backend`).",
1314
+ )
1315
+ watch.set_defaults(handler=_cmd_watch)
1316
+
1180
1317
  return parser
1181
1318
 
1182
1319
 
@@ -1204,6 +1341,11 @@ def _resolve_cfg(args: argparse.Namespace): # type: ignore[no-untyped-def]
1204
1341
  cfg = resolve_operator_config(
1205
1342
  source_root=None,
1206
1343
  cli_index_dir=getattr(args, "index_dir", None),
1344
+ # ``jrag watch`` CLI overrides for the watch block (absent / None for
1345
+ # every other subcommand via getattr default; resolve_operator_config
1346
+ # treats ``None`` as "not provided" so non-watch commands are unaffected).
1347
+ cli_watch_debounce_ms=getattr(args, "debounce_ms", None),
1348
+ cli_watch_backend=getattr(args, "backend", None),
1207
1349
  )
1208
1350
  cfg.apply_to_os_environ()
1209
1351
  return cfg
@@ -1265,6 +1407,269 @@ def _cmd_vocab_index(args: argparse.Namespace) -> int:
1265
1407
  return 0
1266
1408
 
1267
1409
 
1410
+ # ---------------------------------------------------------------------------
1411
+ # jrag watch — long-lived daemon lifecycle (foreground / --detach / --stop / --status)
1412
+ #
1413
+ # ``--status``/``--stop`` are OUT-OF-PROCESS verbs: they read the lock holder
1414
+ # (``ProjectLock.read_holder``) and never acquire the lock themselves. Only the
1415
+ # running daemon (foreground or detached) holds the lock.
1416
+ # ---------------------------------------------------------------------------
1417
+
1418
+
1419
+ def _watch_child_argv(extra_args: list[str]) -> list[str]:
1420
+ """Build the argv for the detached ``jrag watch`` child process.
1421
+
1422
+ Invokes the daemon via ``python -m java_codebase_rag.jrag`` (NOT the
1423
+ module's file path): running ``python src/.../jrag.py`` directly would put
1424
+ the package directory on ``sys.path[0]`` and shadow the stdlib ``ast``
1425
+ module with the project's ``java_codebase_rag.ast`` package (breaking
1426
+ ``inspect``). ``-m`` runs the module as ``__main__`` within its package
1427
+ context, so stdlib imports resolve correctly. A separate function (rather
1428
+ than inline) so a test can swap in a stub child.
1429
+ """
1430
+ return [sys.executable, "-m", "java_codebase_rag.jrag", "watch"] + list(extra_args)
1431
+
1432
+
1433
+ def _watch_passthrough_args(args: argparse.Namespace) -> list[str]:
1434
+ """Reconstruct the watch flags to pass through to a detached child.
1435
+
1436
+ Only re-emits the flags that influence the daemon's behavior; --index-dir is
1437
+ included so the child anchors on the same index without re-discovering it.
1438
+ """
1439
+ out: list[str] = []
1440
+ if getattr(args, "index_dir", None):
1441
+ out += ["--index-dir", str(args.index_dir)]
1442
+ if getattr(args, "debounce_ms", None) is not None:
1443
+ out += ["--debounce-ms", str(args.debounce_ms)]
1444
+ if getattr(args, "backend", None) is not None:
1445
+ out += ["--backend", str(args.backend)]
1446
+ return out
1447
+
1448
+
1449
+ def _cmd_watch_status(cfg) -> int:
1450
+ """``jrag watch --status``: print up/down + pid + socket + last reindex.
1451
+
1452
+ Does NOT acquire the lock. Returns 0 if a daemon is alive, 1 otherwise.
1453
+ """
1454
+ from java_codebase_rag.watch import paths
1455
+ from java_codebase_rag.watch.client import is_daemon_alive
1456
+ from java_codebase_rag.watch.lock import ProjectLock
1457
+
1458
+ sock = paths.socket_path(cfg.index_dir)
1459
+ alive = is_daemon_alive(cfg.index_dir)
1460
+ pid = ProjectLock.read_holder(cfg.index_dir)
1461
+ state = _read_state_file(cfg.index_dir)
1462
+ if alive:
1463
+ print(f"jrag watch: up (pid {pid}, socket {sock})")
1464
+ if state:
1465
+ kind = state.get("last_reindex_kind")
1466
+ at = state.get("last_reindex_at")
1467
+ count = state.get("reindex_count", 0)
1468
+ if kind and at:
1469
+ when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(at))
1470
+ print(f" last reindex: {kind} at {when} (total {count})")
1471
+ else:
1472
+ print(f" last reindex: none (total {count})")
1473
+ return 0
1474
+ print(f"jrag watch: down (no daemon at {sock})")
1475
+ return 1
1476
+
1477
+
1478
+ def _cmd_watch_stop(cfg) -> int:
1479
+ """``jrag watch --stop``: SIGTERM the daemon, SIGKILL after 5s if needed.
1480
+
1481
+ Polls for socket removal (the daemon's own shutdown unlinks it). Always
1482
+ cleans a leftover socket/state so a fresh start isn't blocked by a corpse.
1483
+ Returns 0 if a daemon was stopped, 1 if none was running.
1484
+ """
1485
+ from java_codebase_rag.watch import paths
1486
+ from java_codebase_rag.watch.lock import ProjectLock
1487
+
1488
+ sock = paths.socket_path(cfg.index_dir)
1489
+ state_path = paths.state_path(cfg.index_dir)
1490
+ pid = ProjectLock.read_holder(cfg.index_dir)
1491
+ if pid is None:
1492
+ print("jrag watch: not running")
1493
+ _watch_unlink(sock)
1494
+ _watch_unlink(state_path)
1495
+ return 1
1496
+
1497
+ _watch_signal(pid, signal.SIGTERM)
1498
+ # Poll for the socket's removal (the daemon unlinks it on clean shutdown).
1499
+ deadline = time.monotonic() + _WATCH_STOP_TIMEOUT_S
1500
+ while time.monotonic() < deadline:
1501
+ if not sock.exists():
1502
+ break
1503
+ if not _watch_pid_alive(pid):
1504
+ break
1505
+ time.sleep(0.05)
1506
+ # If still alive after the timeout, escalate to SIGKILL.
1507
+ if _watch_pid_alive(pid):
1508
+ _watch_signal(pid, signal.SIGKILL)
1509
+ _watch_unlink(sock)
1510
+ _watch_unlink(state_path)
1511
+ print(f"jrag watch: stopped (pid {pid})")
1512
+ return 0
1513
+
1514
+
1515
+ def _cmd_watch_detach(args: argparse.Namespace, cfg) -> int:
1516
+ """``jrag watch --detach``: spawn the daemon detached and wait until it serves.
1517
+
1518
+ ``start_new_session=True`` detaches the child from the controlling terminal
1519
+ (setsid); stdio is redirected to a per-index log under ``paths.runtime_dir``
1520
+ so the parent can return. Waits until ``is_daemon_alive`` (socket bound AND a
1521
+ live holder pid) or a timeout, then prints the socket path + pid. Returns 0
1522
+ on success, 2 on timeout / child exit.
1523
+ """
1524
+ import subprocess
1525
+
1526
+ from java_codebase_rag.watch import paths
1527
+ from java_codebase_rag.watch.client import is_daemon_alive
1528
+ from java_codebase_rag.watch.lock import ProjectLock
1529
+
1530
+ child_argv = _watch_child_argv(_watch_passthrough_args(args))
1531
+ log_path = paths.runtime_dir() / f"jrag-watch-{paths.project_key(cfg.index_dir)}.log"
1532
+ try:
1533
+ log_fh = open(log_path, "ab")
1534
+ except OSError:
1535
+ log_fh = None
1536
+ try:
1537
+ proc = subprocess.Popen(
1538
+ child_argv,
1539
+ stdin=subprocess.DEVNULL,
1540
+ stdout=log_fh,
1541
+ stderr=log_fh,
1542
+ start_new_session=True, # setsid: detach from the controlling terminal
1543
+ close_fds=True,
1544
+ )
1545
+ finally:
1546
+ if log_fh is not None:
1547
+ log_fh.close()
1548
+
1549
+ deadline = time.monotonic() + _WATCH_DETACH_TIMEOUT_S
1550
+ child_exited = False
1551
+ while time.monotonic() < deadline:
1552
+ if is_daemon_alive(cfg.index_dir):
1553
+ break
1554
+ # Fail fast: a child that crashes on startup (model-load/import failure)
1555
+ # should not make the parent wait the whole timeout. proc.poll() is None
1556
+ # while the child lives; a non-None return code means it has exited.
1557
+ if proc.poll() is not None:
1558
+ child_exited = True
1559
+ break
1560
+ time.sleep(0.1)
1561
+ if is_daemon_alive(cfg.index_dir):
1562
+ pid = ProjectLock.read_holder(cfg.index_dir)
1563
+ print(
1564
+ f"jrag watch: detached (pid {pid}, socket "
1565
+ f"{paths.socket_path(cfg.index_dir)}, log {log_path})"
1566
+ )
1567
+ return 0
1568
+ if child_exited:
1569
+ print(
1570
+ f"jrag watch: child exited before serving (see {log_path})",
1571
+ file=sys.stderr,
1572
+ )
1573
+ else:
1574
+ print(
1575
+ f"jrag watch: failed to start within {_WATCH_DETACH_TIMEOUT_S}s "
1576
+ f"(see {log_path})",
1577
+ file=sys.stderr,
1578
+ )
1579
+ return 2
1580
+
1581
+
1582
+ def _cmd_watch(args: argparse.Namespace) -> int:
1583
+ """Dispatch ``jrag watch`` to its lifecycle verb (or the foreground daemon).
1584
+
1585
+ The lightweight probe verbs (``--status``/``--stop``/``--detach``) must NOT
1586
+ import the daemon module: that import eagerly pulls torch/
1587
+ sentence_transformers/lancedb/pyarrow (~2.5s + ~1GB), defeating their purpose.
1588
+ ``WatchDaemon`` is therefore imported inline ONLY on the foreground path below.
1589
+ """
1590
+ cfg = _resolve_cfg(args)
1591
+ if args.status:
1592
+ return _cmd_watch_status(cfg)
1593
+ if args.stop:
1594
+ return _cmd_watch_stop(cfg)
1595
+ if args.detach:
1596
+ return _cmd_watch_detach(args, cfg)
1597
+ # default: run the daemon in the foreground. Ends with os._exit(0) on the
1598
+ # serving path; only the early-failure returns (lock held / model load) come
1599
+ # back here with a non-zero int.
1600
+ from java_codebase_rag.watch.daemon import WatchDaemon
1601
+
1602
+ return WatchDaemon(cfg).run_foreground()
1603
+
1604
+
1605
+ # Small lifecycle helpers (kept here, not in daemon.py, so --stop/--status have
1606
+ # zero coupling to the heavy daemon import path).
1607
+
1608
+
1609
+ def _watch_pid_alive(pid: int) -> bool:
1610
+ """True iff ``pid`` is currently a live process (best-effort signal-0 probe)."""
1611
+ try:
1612
+ os.kill(pid, 0)
1613
+ except ProcessLookupError:
1614
+ return False
1615
+ except PermissionError:
1616
+ return True # exists, just not signalable by us
1617
+ except OSError:
1618
+ return False
1619
+ return True
1620
+
1621
+
1622
+ def _watch_signal(pid: int, sig: int) -> None:
1623
+ """Send ``sig`` to ``pid``, swallowing ProcessLookupError (already gone)."""
1624
+ try:
1625
+ os.kill(pid, sig)
1626
+ except ProcessLookupError:
1627
+ pass
1628
+
1629
+
1630
+ def _watch_unlink(path) -> None:
1631
+ """Idempotent, best-effort unlink."""
1632
+ try:
1633
+ path.unlink()
1634
+ except FileNotFoundError:
1635
+ pass
1636
+ except OSError:
1637
+ pass
1638
+
1639
+
1640
+ def _read_state_file(index_dir) -> dict | None:
1641
+ """Return the parsed daemon state JSON, or ``None`` if missing/unreadable.
1642
+
1643
+ Kept HERE (not in ``watch.daemon``) so ``jrag watch --status`` can read the
1644
+ last reindex WITHOUT importing the daemon module — that import eagerly pulls
1645
+ torch/sentence_transformers/lancedb/pyarrow (~2.5s + ~1GB). A corrupt/partial
1646
+ file yields ``None`` rather than raising.
1647
+ """
1648
+ import json
1649
+
1650
+ from java_codebase_rag.watch import paths
1651
+
1652
+ path = paths.state_path(index_dir)
1653
+ try:
1654
+ raw = path.read_text()
1655
+ except (FileNotFoundError, OSError):
1656
+ return None
1657
+ try:
1658
+ obj = json.loads(raw)
1659
+ except (ValueError, OSError):
1660
+ return None
1661
+ return obj if isinstance(obj, dict) else None
1662
+
1663
+
1664
+ # The daemon's shutdown (watcher.stop joins the debounce thread up to 10s, then
1665
+ # server.shutdown joins the accept thread up to 2s) is well under this on an
1666
+ # idle watcher; 5s is the brief's prescribed SIGTERM->SIGKILL grace window.
1667
+ _WATCH_STOP_TIMEOUT_S = 5.0
1668
+ # Model warm-up dominates the detach readiness window on a cold cache; generous
1669
+ # so a fresh start isn't reported as a failure while the SBERT model loads.
1670
+ _WATCH_DETACH_TIMEOUT_S = 60.0
1671
+
1672
+
1268
1673
  def _cmd_status(args: argparse.Namespace) -> int:
1269
1674
  from java_codebase_rag.jrag_envelope import Envelope
1270
1675
  from java_codebase_rag.jrag_render import render
@@ -1369,6 +1774,8 @@ def _check_kind_contradiction(args: argparse.Namespace, inferred: str | None) ->
1369
1774
  def _cmd_find(args: argparse.Namespace) -> int:
1370
1775
  from java_codebase_rag.jrag_envelope import Envelope
1371
1776
  from java_codebase_rag.jrag_render import render
1777
+ from java_codebase_rag.read_payloads import PayloadError, find_payload
1778
+ from java_codebase_rag.watch.client import get_payload
1372
1779
 
1373
1780
  cfg = _resolve_cfg(args)
1374
1781
  try:
@@ -1382,104 +1789,40 @@ def _cmd_find(args: argparse.Namespace) -> int:
1382
1789
  # auto-scope default here too (MCP parity).
1383
1790
  _apply_auto_scope(args, cfg, graph)
1384
1791
 
1385
- # Check kind contradiction first (before any backend work)
1386
- inferred = _infer_kind(args)
1387
- is_contradiction, error_msg = _check_kind_contradiction(args, inferred)
1388
- if is_contradiction:
1389
- env = Envelope(status="error", message=error_msg or "kind contradiction")
1390
- print(render(env, fmt=args.format, detail=args.detail))
1391
- return 2
1792
+ # find_payload does the mode selection (query vs filter), kind-contradiction
1793
+ # check, and the backend call (find_by_name_or_fqn + post-filters, or find_v2).
1794
+ # Rendering (nodes/warnings/empty-result hint/offset) is split into the two
1795
+ # render helpers below, branch by payload["mode"].
1796
+ try:
1797
+ payload = get_payload("find", vars(args), cfg, cold_core=find_payload)
1798
+ except PayloadError as pe:
1799
+ print(render(pe.env, fmt=args.format, detail=args.detail))
1800
+ return pe.rc
1392
1801
 
1393
- # Cap at 499 so limit+1 <= 500 (backend clamp)
1394
- # If args.limit is None, default to 20 (from argparse)
1395
- raw_limit = args.limit if args.limit is not None else 20
1396
- limit = min(raw_limit, 499)
1397
-
1398
- # Query mode: positional <query> present
1399
- if args.query:
1400
- # find_by_name_or_fqn is Symbol-only (MATCH (s:Symbol) WHERE s.name=$needle
1401
- # OR s.fqn=$needle). A positional <query> with a non-symbol kind (explicit
1402
- # OR inferred from --http-method/--client-kind/--producer-kind/etc.) is a
1403
- # usage contract violation -> status: error envelope (NOT argparse exit),
1404
- # telling the user to drop the positional and use filter mode.
1405
- effective_kind = inferred or "symbol"
1406
- if effective_kind != "symbol":
1407
- env = Envelope(
1408
- status="error",
1409
- message=(
1410
- f"query mode (positional <query>) only searches Symbols, but kind "
1411
- f"'{effective_kind}' was {'inferred from domain flags' if args.kind is None else 'set via --kind'}. "
1412
- "Drop the positional <query> and use filter mode (the domain flags) "
1413
- "for route/client/producer searches."
1414
- ),
1415
- )
1416
- print(render(env, fmt=args.format, detail=args.detail))
1417
- return 2
1418
- return _cmd_find_query_mode(args, cfg, graph, limit)
1802
+ if payload["mode"] == "query":
1803
+ return _render_find_query(args, payload)
1804
+ return _render_find_filter(args, payload)
1419
1805
 
1420
- # Filter mode: build NodeFilter and call find_v2
1421
- return _cmd_find_filter_mode(args, cfg, graph, inferred or "symbol", limit)
1422
1806
 
1807
+ def _render_find_query(args: argparse.Namespace, payload) -> int:
1808
+ """Render find query-mode payload (rows from find_by_name_or_fqn + post-filters).
1423
1809
 
1424
- def _cmd_find_query_mode(
1425
- args: argparse.Namespace,
1426
- cfg,
1427
- graph,
1428
- limit: int,
1429
- ) -> int:
1430
- """Find query mode: g.find_by_name_or_fqn (Symbol-only, exact name/FQN match).
1431
-
1432
- ``find_by_name_or_fqn`` runs ``MATCH (s:Symbol) WHERE s.name=$needle OR
1433
- s.fqn=$needle`` — Symbol-only, exact-only. There is no fuzzy/prefix/contains
1434
- path; ``--fuzzy`` was deferred (see plans/active/PLAN-JRAG-CLI.md Out of
1435
- scope). Query mode is gated to ``effective_kind == "symbol"`` upstream in
1436
- ``_cmd_find``, so the only ``kinds`` filter we may pass is symbol sub-kinds
1437
- derived from ``--java-kind``.
1810
+ The backend call + post-filters live in ``read_payloads.find_payload``; this
1811
+ builds the envelope node dicts, warnings, and empty-result hint, then renders.
1812
+ With ``--fuzzy``, ``find_payload`` widens an empty exact result to prefix then
1813
+ substring (issue #375) and reports the matched tier via ``payload["matched_mode"]``
1814
+ (exact/prefix/contains) plus ``payload["identifier_matched"]`` for the hint.
1438
1815
  """
1439
- from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum
1816
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
1440
1817
  from java_codebase_rag.jrag_render import render
1441
1818
 
1442
- query = args.query
1443
-
1444
- # find_by_name_or_fqn is always Symbol; the only valid kinds filter is the
1445
- # symbol sub-kind derived from --java-kind (lowercase, matching s.kind).
1446
- # route/client/producer kinds were removed: they would never match Symbols.
1447
- if args.java_kind:
1448
- java_kind_norm = normalize_enum(args.java_kind, kind="java_kind")
1449
- kinds = [java_kind_norm.lower()]
1450
- else:
1451
- kinds = None
1452
-
1453
- # Call find_by_name_or_fqn (exact name OR fqn match).
1454
- rows = graph.find_by_name_or_fqn(
1455
- query,
1456
- kinds=kinds,
1457
- module=args.module,
1458
- microservice=args.service,
1459
- limit=limit + 1, # +1 for truncated detection
1460
- )
1461
- # Truncation is decided by the RAW name/FQN fetch (limit+1), BEFORE
1462
- # post-filters reduce the set — otherwise a post-filter that drops rows
1463
- # would silently clear `truncated` even though more name matches may exist
1464
- # beyond the fetch (silent wrong-results).
1465
- raw_truncated = len(rows) > limit
1466
-
1467
- # Post-filter by role/annotation/capability (SymbolHit carries these).
1468
- post_filter_active = False
1469
- if args.role:
1470
- post_filter_active = True
1471
- role_norm = normalize_enum(args.role, kind="role")
1472
- rows = [r for r in rows if (r.role or "").upper().replace("-", "_") == role_norm.upper()]
1473
- if args.exclude_role:
1474
- post_filter_active = True
1475
- exclude_role_norm = normalize_enum(args.exclude_role, kind="role")
1476
- rows = [r for r in rows if (r.role or "").upper().replace("-", "_") != exclude_role_norm.upper()]
1477
- if args.annotation:
1478
- post_filter_active = True
1479
- rows = [r for r in rows if args.annotation in (r.annotations or [])]
1480
- if args.capability:
1481
- post_filter_active = True
1482
- rows = [r for r in rows if args.capability in (r.capabilities or [])]
1819
+ rows = payload["rows"]
1820
+ raw_truncated = payload["raw_truncated"]
1821
+ post_filter_active = payload["post_filter_active"]
1822
+ limit = payload["limit"]
1823
+ query = payload["query"]
1824
+ matched_mode = payload["matched_mode"]
1825
+ identifier_matched = payload["identifier_matched"]
1483
1826
 
1484
1827
  # Build warnings for filters that cannot apply in query mode. SymbolHit
1485
1828
  # carries no framework/source_layer fields; rather than silently dropping
@@ -1504,6 +1847,12 @@ def _cmd_find_query_mode(
1504
1847
 
1505
1848
  # Display at most `limit` of the (post-filtered) rows.
1506
1849
  display_rows = rows[:limit]
1850
+ # Map internal mode -> user-facing term (help/empty-hint say "substring").
1851
+ mode_label = "substring" if matched_mode == "contains" else matched_mode
1852
+ if matched_mode != "exact" and display_rows:
1853
+ warnings.append(
1854
+ f"no exact name/FQN match; --fuzzy matched via {mode_label}"
1855
+ )
1507
1856
  nodes = {}
1508
1857
  for row in display_rows:
1509
1858
  node_id = row.id
@@ -1539,21 +1888,29 @@ def _cmd_find_query_mode(
1539
1888
  )
1540
1889
  next_actions_hook(env)
1541
1890
 
1542
- # Empty-result discoverability: query mode is exact-match only (name OR fqn),
1543
- # so a partial like `find ChatManagement` legitimately returns 0. Surface a
1544
- # cross-ref so the agent knows the substring fallback exists instead of
1545
- # seeing a bare `0 symbol`. Carried as both a `message` (renders inline) and
1546
- # an `agent_next_action` (renders as `next:` / JSON). A literal FQN-shaped
1547
- # query (contains '.') almost certainly won't substring-match either, so the
1548
- # hint applies regardless of shape.
1891
+ # Empty-result discoverability: a partial like `find ChatManag` returns 0
1892
+ # under exact match. Three cases: (1) some tier matched the identifier but
1893
+ # --role/--exclude-role/--annotation/--capability removed every hit — blame
1894
+ # the filter, not the query; (2) --fuzzy widened to prefix/substring and
1895
+ # still found nothing; (3) no --fuzzy, so suggest it. Carried as `message`
1896
+ # (inline) + `agent_next_actions` (`next:`/JSON).
1549
1897
  if not nodes and query:
1550
- hint = f"no exact match for {query!r} — try `jrag find --fqn-contains {query}` for substring"
1898
+ if identifier_matched and post_filter_active:
1899
+ hint = (
1900
+ f"matched {query!r} (via {mode_label}), but "
1901
+ "--role/--exclude-role/--annotation/--capability removed all hits"
1902
+ )
1903
+ elif getattr(args, "fuzzy", False):
1904
+ hint = f"no match for {query!r} (tried exact, prefix, substring)"
1905
+ env.agent_next_actions = [f"jrag find --fqn-contains {query}"]
1906
+ else:
1907
+ hint = f"no exact match for {query!r} — try `jrag find {query} --fuzzy`"
1908
+ env.agent_next_actions = [f"jrag find {query} --fuzzy"]
1551
1909
  env.message = hint
1552
- env.agent_next_actions = [f"jrag find --fqn-contains {query}"]
1553
1910
 
1554
1911
  # Offset is not supported in query mode (find_by_name_or_fqn has no offset).
1555
- print(render(env, fmt=args.format, detail=args.detail, noun="symbol"))
1556
- return 0
1912
+ return _emit(env, args, noun="symbol")
1913
+
1557
1914
 
1558
1915
 
1559
1916
  def _build_node_filter_or_error(filter_dict: dict):
@@ -1583,69 +1940,20 @@ def _build_node_filter_or_error(filter_dict: dict):
1583
1940
  return None, Envelope(status="error", message=f"invalid filter: {message}")
1584
1941
 
1585
1942
 
1586
- def _cmd_find_filter_mode(
1587
- args: argparse.Namespace,
1588
- cfg,
1589
- graph,
1590
- kind: str,
1591
- limit: int,
1592
- ) -> int:
1593
- """Find filter mode: build NodeFilter and call find_v2."""
1594
- from java_codebase_rag.mcp import mcp_v2
1943
+ def _render_find_filter(args: argparse.Namespace, payload) -> int:
1944
+ """Render find filter-mode payload (a FindOutput from find_v2).
1595
1945
 
1596
- from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, normalize_enum, to_envelope_rows
1946
+ The backend call + NodeFilter construction live in
1947
+ ``read_payloads.find_payload``; this slices to the limit, builds the envelope
1948
+ node dicts, and renders — verbatim from the original
1949
+ ``_cmd_find_filter_mode`` render portion.
1950
+ """
1951
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, to_envelope_rows
1597
1952
  from java_codebase_rag.jrag_render import render
1598
1953
 
1599
- # Build NodeFilter from args
1600
- filter_dict: dict = {}
1601
- if args.service:
1602
- filter_dict["microservice"] = args.service
1603
- if args.module:
1604
- filter_dict["module"] = args.module
1605
- if args.role:
1606
- filter_dict["role"] = normalize_enum(args.role, kind="role")
1607
- if args.exclude_role:
1608
- filter_dict["exclude_roles"] = [normalize_enum(args.exclude_role, kind="role")]
1609
- if args.annotation:
1610
- filter_dict["annotation"] = args.annotation
1611
- if args.capability:
1612
- filter_dict["capability"] = args.capability
1613
- if args.fqn_contains:
1614
- filter_dict["fqn_contains"] = args.fqn_contains
1615
- if args.java_kind:
1616
- filter_dict["symbol_kind"] = normalize_enum(args.java_kind, kind="java_kind")
1617
- if args.framework:
1618
- filter_dict["framework"] = normalize_enum(args.framework, kind="framework")
1619
- if args.source_layer:
1620
- filter_dict["source_layer"] = normalize_enum(args.source_layer, kind="source_layer")
1621
- if args.http_method:
1622
- filter_dict["http_method"] = args.http_method.upper()
1623
- if args.path_contains:
1624
- filter_dict["path_contains"] = args.path_contains
1625
- if args.client_kind:
1626
- filter_dict["client_kind"] = normalize_enum(args.client_kind, kind="client_kind")
1627
- if args.calls_service:
1628
- filter_dict["target_service"] = args.calls_service
1629
- if args.calls_path_contains:
1630
- filter_dict["target_path_contains"] = args.calls_path_contains
1631
- if args.producer_kind:
1632
- filter_dict["producer_kind"] = normalize_enum(args.producer_kind, kind="producer_kind")
1633
- if args.topic_contains:
1634
- filter_dict["topic_contains"] = args.topic_contains
1635
-
1636
- node_filter, err_env = _build_node_filter_or_error(filter_dict)
1637
- if err_env is not None:
1638
- print(render(err_env, fmt=args.format, detail=args.detail))
1639
- return 2
1640
-
1641
- # Call find_v2
1642
- out = mcp_v2.find_v2(
1643
- kind=kind,
1644
- filter=node_filter,
1645
- limit=limit + 1, # +1 for has_more_results detection
1646
- offset=args.offset,
1647
- graph=graph,
1648
- )
1954
+ out = payload["out"]
1955
+ kind = payload["kind"]
1956
+ limit = payload["limit"]
1649
1957
 
1650
1958
  if not out.success:
1651
1959
  env = Envelope(status="error", message=out.message)
@@ -1669,14 +1977,12 @@ def _cmd_find_filter_mode(
1669
1977
 
1670
1978
  # Render with offset hint if truncated
1671
1979
  next_offset = args.offset + limit if truncated else None
1672
- print(render(env, fmt=args.format, detail=args.detail, noun=kind, next_offset=next_offset))
1673
- return 0
1980
+ return _emit(env, args, noun=kind, next_offset=next_offset)
1674
1981
 
1675
1982
 
1676
- def _cmd_inspect(args: argparse.Namespace) -> int:
1677
- from java_codebase_rag.mcp import mcp_v2
1678
1983
 
1679
- from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook, resolve_query
1984
+ def _cmd_inspect(args: argparse.Namespace) -> int:
1985
+ from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
1680
1986
  from java_codebase_rag.jrag_render import render
1681
1987
 
1682
1988
  cfg = _resolve_cfg(args)
@@ -1687,28 +1993,22 @@ def _cmd_inspect(args: argparse.Namespace) -> int:
1687
1993
  print(render(env, fmt=args.format, detail=args.detail))
1688
1994
  return 2
1689
1995
 
1690
- # Resolve the query. Forward --service/--module so an ambiguous name
1691
- # (same name across services) disambiguates by microservice/module, the
1692
- # same way find and the traversal commands do. Without this, inspect
1693
- # silently ignored these inherited flags (resolve_query accepts them).
1694
- node, env = resolve_query(
1695
- args.query,
1696
- hint_kind=args.kind,
1697
- java_kind=args.java_kind,
1698
- role=args.role,
1699
- fqn_contains=args.fqn_contains,
1700
- cfg=cfg,
1701
- graph=graph,
1702
- microservice=args.service or "",
1703
- module=args.module or "",
1704
- )
1705
-
1706
- if env.status != "ok":
1707
- print(render(env, fmt=args.format, detail=args.detail))
1708
- return 2 if env.status == "error" else 0
1996
+ # inspect_payload resolves the query (forwarding --service/--module, same as
1997
+ # before) and calls describe_v2. It returns the DescribeOutput plus the
1998
+ # resolve-derived node id/fqn and file_location the flatten+render below
1999
+ # needs (file_location lives on the resolve Envelope, not on DescribeOutput).
2000
+ # On resolve failure it raises PayloadError carrying that Envelope + rc.
2001
+ from java_codebase_rag.read_payloads import PayloadError, inspect_payload
2002
+ from java_codebase_rag.watch.client import get_payload
1709
2003
 
1710
- # Node resolved successfully - call describe_v2
1711
- desc_out = mcp_v2.describe_v2(id=node.id, graph=graph)
2004
+ try:
2005
+ payload = get_payload("inspect", vars(args), cfg, cold_core=inspect_payload)
2006
+ except PayloadError as pe:
2007
+ # Resolve-miss: route through _emit so --exists/--count shape it
2008
+ # (inspect Missing --exists -> false, rc 2), mirroring master's inspect
2009
+ # resolve-miss path. No flag -> rc matches the prior 2-if-error-else-0.
2010
+ return _emit(pe.env, args)
2011
+ desc_out = payload["describe"]
1712
2012
 
1713
2013
  if not desc_out.success or desc_out.record is None:
1714
2014
  env = Envelope(status="error", message=desc_out.message or "describe failed")
@@ -1736,11 +2036,11 @@ def _cmd_inspect(args: argparse.Namespace) -> int:
1736
2036
  # renamed ``symbol_kind`` to match find/search/listings (which use
1737
2037
  # ``symbol_kind`` for the sub-kind and reserve ``kind`` for the category).
1738
2038
  record_dict = desc_out.record.model_dump()
1739
- node_id = record_dict.get("id") or node.id
2039
+ node_id = record_dict.get("id") or payload["node_id"]
1740
2040
  data = record_dict.get("data") or {}
1741
2041
  flat: dict[str, Any] = {
1742
2042
  "kind": record_dict.get("kind") or "symbol",
1743
- "fqn": record_dict.get("fqn") or data.get("fqn") or node.fqn,
2043
+ "fqn": record_dict.get("fqn") or data.get("fqn") or payload["node_fqn"],
1744
2044
  }
1745
2045
  # Promote inner data fields. Skip ``kind`` here — renamed to symbol_kind.
1746
2046
  for src_key, dest_key in (
@@ -1772,13 +2072,12 @@ def _cmd_inspect(args: argparse.Namespace) -> int:
1772
2072
  status="ok",
1773
2073
  nodes={node_id: flat},
1774
2074
  root=node_id,
1775
- file_location=env.file_location, # Preserve file_location from resolve
2075
+ file_location=payload["file_location"], # Preserve file_location from resolve
1776
2076
  )
1777
2077
  next_actions_hook(env, root=node_id, edge_summary=record_dict.get("edge_summary"))
1778
2078
 
1779
2079
  # Render with inspect shape
1780
- print(render(env, fmt=args.format, detail=args.detail, shape="inspect"))
1781
- return 0
2080
+ return _emit(env, args, shape="inspect")
1782
2081
 
1783
2082
 
1784
2083
  def _backfill_service_from_filename(row: dict) -> None:
@@ -1874,9 +2173,45 @@ def _cmd_producers(args: argparse.Namespace) -> int:
1874
2173
  return _render_listing(rows, limit=limit, args=args, noun="producer")
1875
2174
 
1876
2175
 
2176
+ def _producer_summary(producer: dict) -> dict:
2177
+ """Display-oriented producer dict for the ``topics`` grouping.
2178
+
2179
+ The raw ``list_producers`` row carries 11 fields (incl. empty ``broker``,
2180
+ raw ``filename``/``start_line``/``end_line``, ``direction``, ``source_layer``)
2181
+ which the text renderer used to collapse into one unreadable comma-line.
2182
+ This folds location into a single ``file`` and keeps only the fields an
2183
+ operator needs to identify the producer under a topic header (the topic
2184
+ itself is the group header, so it's dropped here). Empty values omitted.
2185
+ """
2186
+ member_fqn = str(producer.get("member_fqn") or "")
2187
+ member_simple = member_fqn.rsplit(".", 1)[-1] if member_fqn else ""
2188
+ filename = str(producer.get("filename") or "")
2189
+ start_line = producer.get("start_line")
2190
+ try:
2191
+ sl = int(start_line) if start_line not in (None, "") else None
2192
+ except (TypeError, ValueError):
2193
+ sl = None
2194
+ file_loc = f"{filename}:{sl}" if filename and sl else filename
2195
+ out: dict = {}
2196
+ if member_simple:
2197
+ out["member"] = member_simple
2198
+ if file_loc:
2199
+ out["file"] = file_loc
2200
+ # ``direction`` is intentionally omitted: every Producer is built with
2201
+ # direction="producer" (build_ast_graph), so it's a constant that only
2202
+ # inflates each block with zero information.
2203
+ for k in ("microservice", "module", "producer_kind", "broker"):
2204
+ v = producer.get(k)
2205
+ if v not in (None, "", []):
2206
+ out[k] = v
2207
+ resolved = producer.get("resolved")
2208
+ if resolved is not None:
2209
+ out["resolved"] = bool(resolved)
2210
+ return out
2211
+
2212
+
1877
2213
  def _cmd_topics(args: argparse.Namespace) -> int:
1878
2214
  from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook
1879
- from java_codebase_rag.jrag_render import render
1880
2215
 
1881
2216
  _, graph, rc = _load_graph_or_error(args)
1882
2217
  if rc:
@@ -1908,7 +2243,7 @@ def _cmd_topics(args: argparse.Namespace) -> int:
1908
2243
  "producers": [],
1909
2244
  "broker": producer.get("broker") or "",
1910
2245
  }
1911
- topics_dict[topic]["producers"].append(producer)
2246
+ topics_dict[topic]["producers"].append(_producer_summary(producer))
1912
2247
 
1913
2248
  warnings: list[str] = []
1914
2249
  if no_topic_count:
@@ -1956,8 +2291,7 @@ def _cmd_topics(args: argparse.Namespace) -> int:
1956
2291
  warnings=warnings + _auto_scope_notice(args),
1957
2292
  )
1958
2293
  next_actions_hook(env, command=getattr(args, "command", None))
1959
- print(render(env, fmt=args.format, detail=args.detail, noun="topic"))
1960
- return 0
2294
+ return _emit(env, args, noun="topic")
1961
2295
 
1962
2296
 
1963
2297
  def _inspect_hints_for_rows(rows: list[dict], *, limit: int = 2) -> list[str]:
@@ -2160,7 +2494,6 @@ def _resolve_traversal_node(
2160
2494
  resolves for the cross-service route-caller flow.
2161
2495
  """
2162
2496
  from java_codebase_rag.jrag_envelope import resolve_query
2163
- from java_codebase_rag.jrag_render import render
2164
2497
 
2165
2498
  node, env = resolve_query(
2166
2499
  args.query,
@@ -2174,8 +2507,10 @@ def _resolve_traversal_node(
2174
2507
  module=(getattr(args, "module", None) or "") if apply_scope else "",
2175
2508
  )
2176
2509
  if env.status != "ok":
2177
- print(render(env, fmt=args.format, detail=args.detail))
2178
- return None, env, 2 if env.status == "error" else 0
2510
+ # Route through _emit so --exists/--count shape a resolve miss (e.g.
2511
+ # `callers Missing --exists` -> false, rc 2) instead of rendering the
2512
+ # not_found body. rc matches the prior 2-if-error-else-0 when no flag.
2513
+ return None, env, _emit(env, args)
2179
2514
  return node, env, 0
2180
2515
 
2181
2516
 
@@ -2235,7 +2570,6 @@ def _emit_traversal(
2235
2570
  ``jrag implementations <fqn>``).
2236
2571
  """
2237
2572
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
2238
- from java_codebase_rag.jrag_render import render
2239
2573
 
2240
2574
  env = Envelope(
2241
2575
  status="ok",
@@ -2254,8 +2588,7 @@ def _emit_traversal(
2254
2588
  seen.add(h)
2255
2589
  env.agent_next_actions.append(h)
2256
2590
  env.agent_next_actions = env.agent_next_actions[:5]
2257
- print(render(env, fmt=args.format, detail=args.detail, noun=noun))
2258
- return 0
2591
+ return _emit(env, args, noun=noun)
2259
2592
 
2260
2593
 
2261
2594
  def _require_kind(
@@ -2388,161 +2721,20 @@ def _cmd_callers(args: argparse.Namespace) -> int:
2388
2721
  cfg, graph, rc = _load_graph_or_error(args)
2389
2722
  if rc:
2390
2723
  return rc
2391
- node, _renv, rrc = _resolve_traversal_node(
2392
- args, cfg=cfg, graph=graph, hint_kind=args.kind, apply_scope=True
2393
- )
2394
- if rrc or node is None:
2395
- return rrc
2396
- limit = _clamped_limit(args)
2397
-
2398
- root_dict = _noderef_to_node_dict(node)
2399
- root_id = node.id
2400
-
2401
- # Route root -> find_route_callers. Route callers are inherently cross-
2402
- # service (a Client in microservice A calls a server Route in microservice B),
2403
- # so --service is NOT applied as a caller-microservice post-filter here;
2404
- # it has already narrowed resolve (which route was selected) via
2405
- # _resolve_traversal_node -> resolve_query.
2406
- if node.kind == "route":
2407
- route_callers = graph.find_route_callers(route_id=root_id)
2408
- warnings: list[str] = []
2409
- # No backend limit on find_route_callers; client-side slice for truncation.
2410
- truncated = len(route_callers) > limit
2411
- display = route_callers[:limit]
2412
- nodes: dict[str, dict] = {}
2413
- edges: list[dict] = []
2414
- for rc in display:
2415
- caller_id = rc.caller_node_id
2416
- if rc.caller_node_kind == "client":
2417
- edge_type = "HTTP_CALLS"
2418
- else:
2419
- edge_type = "ASYNC_CALLS"
2420
- # The caller's identity is the declaring Symbol (the method that owns
2421
- # the Client/Producer), not the call-site path — mirrors
2422
- # trace_request_flow, which surfaces declaring_symbol_fqn. The
2423
- # path/topic the caller hits is kept as raw_uri/topic so the agent
2424
- # sees both WHO calls and WHAT they hit.
2425
- node = {
2426
- "id": caller_id,
2427
- "kind": rc.caller_node_kind,
2428
- "fqn": rc.declaring_symbol_fqn or caller_id,
2429
- "microservice": rc.caller_microservice,
2430
- }
2431
- if rc.target_service:
2432
- node["target_service"] = rc.target_service
2433
- if rc.caller_node_kind == "client" and rc.raw_uri:
2434
- node["raw_uri"] = rc.raw_uri
2435
- elif rc.caller_node_kind != "client" and rc.topic:
2436
- node["topic"] = rc.topic
2437
- nodes[caller_id] = node
2438
- edges.append(
2439
- {"other_id": caller_id, "edge_type": edge_type, "confidence": rc.confidence}
2440
- )
2441
- # Include the root (Route) node so the zero-callers rendering surfaces
2442
- # the route path rather than a bare "0 callers" line.
2443
- nodes[root_id] = root_dict
2444
- # External-entrypoint detection: a server-exposed HTTP route (kind
2445
- # http_endpoint with an inbound EXPOSES edge from a controller Symbol)
2446
- # genuinely has zero in-repo callers — the route IS the entrypoint. Flag
2447
- # it so the renderer says so instead of emitting a bug-looking bare
2448
- # "0 callers". NodeRef.kind is the node label ("route"), not the stored
2449
- # http_endpoint/kafka_topic property, so fetch the property directly.
2450
- # Kafka topics are excluded: their empty-callers case has different
2451
- # semantics (a topic with no producers is not an HTTP entrypoint).
2452
- is_external_entrypoint = False
2453
- if not display:
2454
- kind_row = graph._rows( # noqa: SLF001 - same pattern as jrag_envelope._node_file_location
2455
- "MATCH (r:Route) WHERE r.id = $rid RETURN r.kind AS kind LIMIT 1",
2456
- {"rid": root_id},
2457
- )
2458
- route_kind = str(kind_row[0].get("kind") or "") if kind_row else ""
2459
- if route_kind == "http_endpoint" and graph.find_route_handlers(route_id=root_id):
2460
- is_external_entrypoint = True
2461
- return _emit_traversal(
2462
- args, root_id=root_id, nodes=nodes, edges=edges,
2463
- noun="callers", warnings=warnings, truncated=truncated,
2464
- is_external_entrypoint=is_external_entrypoint,
2465
- )
2466
-
2467
- # Symbol root -> find_callers (push down --service/--module/depth/etc.).
2468
- if node.kind != "symbol":
2469
- from java_codebase_rag.jrag_envelope import Envelope
2470
- from java_codebase_rag.jrag_render import render
2471
-
2472
- env = Envelope(
2473
- status="error",
2474
- message=(
2475
- f"callers expects a Symbol or Route root; resolved node kind is "
2476
- f"{node.kind!r}. Use --kind to narrow resolve."
2477
- ),
2478
- )
2479
- print(render(env, fmt=args.format, detail=args.detail))
2480
- return 2
2724
+ from java_codebase_rag.read_payloads import PayloadError, callers_payload
2725
+ from java_codebase_rag.watch.client import get_payload
2481
2726
 
2482
- depth = getattr(args, "depth", 1)
2483
- min_conf = getattr(args, "min_confidence", 0.0)
2484
- exclude_external = not getattr(args, "include_external", False)
2485
- call_edges = graph.find_callers(
2486
- node.fqn,
2487
- depth=depth,
2488
- limit=limit + 1,
2489
- min_confidence=min_conf,
2490
- exclude_external=exclude_external,
2491
- module=args.module,
2492
- microservice=args.service,
2493
- )
2494
- from java_codebase_rag.jrag_envelope import mark_truncated
2495
-
2496
- display, truncated = mark_truncated(call_edges, limit)
2497
- nodes = {}
2498
- edges = []
2499
- for ce in display:
2500
- nodes[ce.src.id] = _symbol_hit_to_dict(ce.src)
2501
- edges.append(
2502
- {"other_id": ce.src.id, "edge_type": "CALLS", "confidence": ce.confidence}
2503
- )
2504
- # Entry-point awareness. A controller / messaging-listener type is invoked
2505
- # via the routes its methods EXPOSE (Controller -[:DECLARES]-> method
2506
- # -[:EXPOSES]-> Route), NOT via in-repo CALLS edges — so find_callers is
2507
- # typically empty for HTTP handlers. Without this fold, `callers
2508
- # <Controller>` returns a bug-looking empty list when the controller is the
2509
- # very thing the agent is investigating. The routes ARE its inbound callers,
2510
- # so surface them as additional EXPOSES rows alongside any CALLS-in edges.
2511
- # Gated on having DECLARES.EXPOSES out-edges (covers any entry-point holder,
2512
- # not just role=CONTROLLER). Routes are additive and usually few, so they do
2513
- # not count against the CALLS --limit (cf. the callees client/producer path,
2514
- # which likewise emits its own targets without sharing the CALLS budget).
2515
- expose_rows = graph._rows( # noqa: SLF001 - one-shot aggregation, cf. _cmd_callees client path
2516
- "MATCH (t:Symbol {id: $tid})-[:DECLARES]->(m:Symbol)-[e:EXPOSES]->(r:Route) "
2517
- "RETURN r.id AS rid, r.method AS rmethod, r.path AS rpath, "
2518
- "r.path_template AS rpt, r.microservice AS rms, "
2519
- "m.fqn AS via_fqn, e.confidence AS conf",
2520
- {"tid": root_id},
2521
- )
2522
- for row in expose_rows:
2523
- rid = str(row.get("rid") or "")
2524
- if not rid or rid in nodes:
2525
- continue
2526
- rmethod = str(row.get("rmethod") or "")
2527
- rpath = str(row.get("rpt") or row.get("rpath") or "")
2528
- nodes[rid] = {
2529
- "id": rid,
2530
- "kind": "route",
2531
- "fqn": f"{rmethod} {rpath}".strip(),
2532
- "method": rmethod,
2533
- "path": rpath,
2534
- "microservice": str(row.get("rms") or ""),
2535
- }
2536
- edge_row: dict = {"other_id": rid, "edge_type": "EXPOSES"}
2537
- via_fqn = str(row.get("via_fqn") or "")
2538
- if via_fqn:
2539
- # Declaring method that exposes the route; rendered at --detail full.
2540
- edge_row["from_fqn"] = via_fqn
2541
- edges.append(edge_row)
2542
- nodes[root_id] = root_dict
2727
+ try:
2728
+ payload = get_payload("callers", vars(args), cfg, cold_core=callers_payload)
2729
+ except PayloadError as pe:
2730
+ # Resolve-miss: route through _emit so --exists/--count shape it
2731
+ # (callers Missing --exists -> false, rc 2), mirroring master's
2732
+ # _resolve_traversal_node resolve-miss path.
2733
+ return _emit(pe.env, args)
2543
2734
  return _emit_traversal(
2544
- args, root_id=root_id, nodes=nodes, edges=edges,
2545
- noun="callers", truncated=truncated,
2735
+ args, root_id=payload["root_id"], nodes=payload["nodes"], edges=payload["edges"],
2736
+ noun=payload["noun"], warnings=payload["warnings"], truncated=payload["truncated"],
2737
+ is_external_entrypoint=payload["is_external_entrypoint"],
2546
2738
  )
2547
2739
 
2548
2740
 
@@ -2550,165 +2742,24 @@ def _cmd_callees(args: argparse.Namespace) -> int:
2550
2742
  cfg, graph, rc = _load_graph_or_error(args)
2551
2743
  if rc:
2552
2744
  return rc
2553
- node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind=args.kind)
2554
- if rrc or node is None:
2555
- return rrc
2556
- limit = _clamped_limit(args)
2745
+ from java_codebase_rag.read_payloads import PayloadError, callees_payload
2746
+ from java_codebase_rag.watch.client import get_payload
2557
2747
 
2558
- # PR-JRAG-3b: accept Symbol (CALLS), Client (HTTP_CALLS), and Producer
2559
- # (ASYNC_CALLS) roots. The Symbol path is unchanged from PR-JRAG-3a.
2560
- guard = _require_kind(
2561
- node,
2562
- expected="callees expects a Symbol, Client, or Producer root",
2563
- kinds=("symbol", "client", "producer"),
2564
- args=args,
2565
- hint="Use --kind to narrow resolve.",
2566
- )
2567
- if guard is not None:
2568
- return guard
2569
-
2570
- from java_codebase_rag.jrag_envelope import Envelope, mark_truncated
2571
- from java_codebase_rag.jrag_render import render
2572
-
2573
- # Client root -> HTTP_CALLS out (Client -> :Route).
2574
- # Producer root -> ASYNC_CALLS out (Producer -> :Route, the kafka_topic
2575
- # Route this producer publishes to — NOT a :Producer node).
2576
- if node.kind in ("client", "producer"):
2577
- from java_codebase_rag.mcp import mcp_v2
2578
-
2579
- edge_types = ["HTTP_CALLS"] if node.kind == "client" else ["ASYNC_CALLS"]
2580
- out = mcp_v2.neighbors_v2(
2581
- [node.id], direction="out", edge_types=edge_types,
2582
- limit=limit + 1, graph=graph,
2583
- )
2584
- if not out.success:
2585
- print(render(Envelope(status="error", message=out.message or "neighbors_v2 failed"), fmt=args.format, detail=args.detail))
2586
- return 2
2587
- root_id = node.id
2588
- nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
2589
- edges: list[dict] = []
2590
- for e in out.results:
2591
- nodes[e.other.id] = _noderef_to_node_dict(e.other)
2592
- edges.append(
2593
- {
2594
- "other_id": e.other.id,
2595
- "edge_type": e.edge_type,
2596
- "confidence": e.attrs.get("confidence"),
2597
- }
2598
- )
2599
- truncated = bool(out.has_more_results) or len(edges) > limit
2600
- if len(edges) > limit:
2601
- edges = edges[:limit]
2602
- # --include-external is accepted but does not apply on Client/Producer
2603
- # roots (the edges are to :Route, which is always in-graph; there is no
2604
- # external-exclusion analog). Surface as a warning so the flag is not
2605
- # silently dropped (plan principle: inapplicable flags never silently ignored).
2606
- warnings: list[str] = []
2607
- if getattr(args, "include_external", False):
2608
- warnings.append(
2609
- "--include-external does not apply to Client/Producer roots "
2610
- "(HTTP_CALLS/ASYNC_CALLS reach :Route, which is always in-graph)"
2611
- )
2612
- edges = _dedupe_traversal_edges(edges)
2613
- truncated = truncated or len(edges) > limit
2614
- edges = edges[:limit]
2615
- return _emit_traversal(
2616
- args, root_id=root_id, nodes=nodes, edges=edges,
2617
- noun="callees", warnings=warnings, truncated=truncated,
2618
- )
2619
-
2620
- depth = getattr(args, "depth", 1)
2621
- min_conf = getattr(args, "min_confidence", 0.0)
2622
- exclude_external = not getattr(args, "include_external", False)
2623
- # CLIENT-role type Symbol (e.g. a Feign client interface): its "callees" are
2624
- # the outbound HTTP endpoints its declared client methods call, NOT CALLS
2625
- # edges from the interface (a Feign interface declares methods but its
2626
- # methods' HTTP_CALLS edges carry the real outbound surface). Aggregate the
2627
- # declared Client nodes and their HTTP_CALLS targets so `jrag callees
2628
- # 'ChatCoreFeignClient'` shows the routes it hits.
2629
- if (node.role or "") == "CLIENT":
2630
- root_id = node.id
2631
- client_rows = graph._rows( # noqa: SLF001 - one-shot aggregation query
2632
- "MATCH (iface:Symbol {id: $sid})-[:DECLARES]->(m:Symbol)"
2633
- "-[:DECLARES_CLIENT]->(c:Client) "
2634
- "OPTIONAL MATCH (c)-[e:HTTP_CALLS]->(r:Route) "
2635
- "RETURN c.id AS cid, c.member_fqn AS cfqn, c.path AS cpath, "
2636
- "c.method AS cmethod, c.microservice AS cms, "
2637
- "r.id AS rid, r.method AS rmethod, r.path AS rpath, "
2638
- "r.path_template AS rpt, r.microservice AS rms, "
2639
- "e.confidence AS conf",
2640
- {"sid": root_id},
2641
- )
2642
- nodes = {root_id: _noderef_to_node_dict(node)}
2643
- edges: list[dict] = []
2644
- for row in client_rows:
2645
- rid = str(row.get("rid") or "")
2646
- if rid:
2647
- target_id = rid
2648
- rmethod = str(row.get("rmethod") or "")
2649
- rpath = str(row.get("rpt") or row.get("rpath") or "")
2650
- nodes[target_id] = {
2651
- "id": target_id,
2652
- "kind": "route",
2653
- "fqn": f"{rmethod} {rpath}".strip(),
2654
- "microservice": str(row.get("rms") or ""),
2655
- }
2656
- edge_type = "HTTP_CALLS"
2657
- else:
2658
- # Client with no resolved HTTP_CALLS edge: surface the client
2659
- # node + its declared path so the outbound intent is visible.
2660
- target_id = str(row.get("cid") or "")
2661
- if not target_id:
2662
- continue
2663
- cmethod = str(row.get("cmethod") or "")
2664
- cpath = str(row.get("cpath") or "")
2665
- nodes[target_id] = {
2666
- "id": target_id,
2667
- "kind": "client",
2668
- "fqn": f"{cmethod} {cpath}".strip() or str(row.get("cfqn") or ""),
2669
- "microservice": str(row.get("cms") or ""),
2670
- }
2671
- edge_type = "HTTP_CALLS"
2672
- edges.append({
2673
- "other_id": target_id,
2674
- "edge_type": edge_type,
2675
- "confidence": float(row.get("conf") or 0.0) or None,
2676
- })
2677
- edges = _dedupe_traversal_edges(edges)
2678
- truncated = len(edges) > limit
2679
- edges = edges[:limit]
2680
- return _emit_traversal(
2681
- args, root_id=root_id, nodes=nodes, edges=edges,
2682
- noun="callees", truncated=truncated,
2683
- )
2684
-
2685
- call_edges = graph.find_callees(
2686
- node.fqn,
2687
- depth=depth,
2688
- limit=limit + 1,
2689
- min_confidence=min_conf,
2690
- exclude_external=exclude_external,
2691
- module=args.module,
2692
- microservice=args.service,
2693
- )
2694
- display, truncated = mark_truncated(call_edges, limit)
2695
- root_id = node.id
2696
- nodes = {root_id: _noderef_to_node_dict(node)}
2697
- edges = []
2698
- for ce in display:
2699
- nodes[ce.dst.id] = _symbol_hit_to_dict(ce.dst)
2700
- edges.append(
2701
- {"other_id": ce.dst.id, "edge_type": "CALLS", "confidence": ce.confidence}
2702
- )
2703
- edges = _dedupe_traversal_edges(edges)
2704
- truncated = truncated or len(edges) > limit
2705
- edges = edges[:limit]
2748
+ try:
2749
+ payload = get_payload("callees", vars(args), cfg, cold_core=callees_payload)
2750
+ except PayloadError as pe:
2751
+ # Resolve-miss: route through _emit so --exists/--count shape it
2752
+ # (callees Missing --exists -> false, rc 2), mirroring master's
2753
+ # _resolve_traversal_node resolve-miss path.
2754
+ return _emit(pe.env, args)
2706
2755
  return _emit_traversal(
2707
- args, root_id=root_id, nodes=nodes, edges=edges,
2708
- noun="callees", truncated=truncated,
2756
+ args, root_id=payload["root_id"], nodes=payload["nodes"], edges=payload["edges"],
2757
+ noun=payload["noun"], warnings=payload["warnings"], truncated=payload["truncated"],
2758
+ is_external_entrypoint=payload["is_external_entrypoint"],
2709
2759
  )
2710
2760
 
2711
2761
 
2762
+
2712
2763
  def _cmd_hierarchy(args: argparse.Namespace) -> int:
2713
2764
  from java_codebase_rag.mcp import mcp_v2
2714
2765
 
@@ -3135,93 +3186,23 @@ def _cmd_flow(args: argparse.Namespace) -> int:
3135
3186
  cfg, graph, rc = _load_graph_or_error(args)
3136
3187
  if rc:
3137
3188
  return rc
3138
- # flow requires a Route root; force hint_kind="route".
3139
- node, _renv, rrc = _resolve_traversal_node(args, cfg=cfg, graph=graph, hint_kind="route")
3140
- if rrc or node is None:
3141
- return rrc
3142
- limit = _clamped_limit(args)
3189
+ from java_codebase_rag.read_payloads import PayloadError, flow_payload
3190
+ from java_codebase_rag.watch.client import get_payload
3143
3191
 
3144
- guard = _require_kind(
3145
- node, expected="flow requires a Route root", kinds=("route",), args=args,
3146
- hint="Pass a route path (e.g. /chat/assign).",
3147
- )
3148
- if guard is not None:
3149
- return guard
3150
-
3151
- warnings = _warn_unapplied_scope(
3152
- args, reason="trace_request_flow carries no microservice predicate; intra-codebase is an index-time data property"
3153
- )
3154
-
3155
- max_hops = max(1, min(8, getattr(args, "depth", 5)))
3156
- flow_data = graph.trace_request_flow(entry_route_id=node.id, max_hops=max_hops)
3157
-
3158
- root_id = node.id
3159
- nodes: dict[str, dict] = {root_id: _noderef_to_node_dict(node)}
3160
- edges: list[dict] = []
3161
- # Inbound: cross-service HTTP/async callers (Client/Producer two-hop).
3162
- for row in flow_data.get("inbound", []):
3163
- caller_id = str(row.get("caller_node_id") or "")
3164
- if not caller_id:
3165
- continue
3166
- kind = str(row.get("caller_node_kind") or "")
3167
- nodes[caller_id] = {
3168
- "id": caller_id,
3169
- "kind": kind,
3170
- "fqn": str(row.get("declaring_symbol_fqn") or ""),
3171
- "microservice": str(row.get("microservice") or ""),
3172
- }
3173
- edges.append(
3174
- {
3175
- "other_id": caller_id,
3176
- "edge_type": "HTTP_CALLS" if kind == "client" else "ASYNC_CALLS",
3177
- "confidence": float(row.get("confidence") or 0.0),
3178
- }
3179
- )
3180
- # Outbound: CALLS hops from the route handler (intra-service by construction).
3181
- for row in flow_data.get("outbound", []):
3182
- next_id = str(row.get("next_symbol_id") or "")
3183
- if not next_id:
3184
- continue
3185
- nodes[next_id] = {
3186
- "id": next_id,
3187
- "kind": "symbol",
3188
- "fqn": str(row.get("next_fqn") or ""),
3189
- "microservice": str(row.get("next_microservice") or ""),
3190
- }
3191
- edges.append({"other_id": next_id, "edge_type": "CALLS"})
3192
-
3193
- # Client-side slice for truncation (trace_request_flow has no limit param).
3194
- truncated = len(edges) > limit
3195
- if truncated:
3196
- edges = edges[:limit]
3192
+ try:
3193
+ payload = get_payload("flow", vars(args), cfg, cold_core=flow_payload)
3194
+ except PayloadError as pe:
3195
+ # Resolve-miss: route through _emit so --exists/--count shape it
3196
+ # (flow Missing --exists -> false, rc 2), mirroring master's
3197
+ # _resolve_traversal_node resolve-miss path.
3198
+ return _emit(pe.env, args)
3197
3199
  return _emit_traversal(
3198
- args, root_id=root_id, nodes=nodes, edges=edges,
3199
- noun="flow", warnings=warnings, truncated=truncated,
3200
+ args, root_id=payload["root_id"], nodes=payload["nodes"], edges=payload["edges"],
3201
+ noun=payload["noun"], warnings=payload["warnings"], truncated=payload["truncated"],
3202
+ is_external_entrypoint=payload["is_external_entrypoint"],
3200
3203
  )
3201
3204
 
3202
3205
 
3203
- # ============================================================================
3204
- # PR-JRAG-3b: compose traversals + connection + outline/imports.
3205
- #
3206
- # callees Client/Producer variant (above) re-uses _cmd_callees. The four new
3207
- # handlers below cover: dependencies (INJECTS out), connection (multi-section
3208
- # microservice view, resolve-first EXCEPTION), outline (file -> symbols),
3209
- # imports (file -> tree-sitter parse -> resolve_v2 per FQN).
3210
- #
3211
- # Backend signatures verified at PR-JRAG-3b time:
3212
- # * neighbors_v2(ids, direction, edge_types, limit=25, offset=0, ...) returns
3213
- # NeighborsOutput.results: list[Edge] where Edge.other: NodeRef,
3214
- # Edge.edge_type: str, Edge.attrs: dict (mcp_v2.py:1284).
3215
- # * find_symbols_in_file_range(graph, *, filename, start_line, end_line)
3216
- # returns list[SymbolHit]; start_line<1 returns [] (ladybug_queries.py:302).
3217
- # * parse_java(source, *, filename, verbose) -> JavaFileAst with
3218
- # explicit_imports: dict[str, str] (simple_name -> FQN) (ast_java.py:2612).
3219
- # * INJECTS is Symbol -> Symbol (java_ontology.py:216); out = types this
3220
- # symbol injects = direct dependencies.
3221
- # * HTTP_CALLS is Client -> Route (java_ontology.py:352); ASYNC_CALLS is
3222
- # Producer -> Route (java_ontology.py:386). Both confirmed.
3223
- # ============================================================================
3224
-
3225
3206
 
3226
3207
  def _cmd_dependencies(args: argparse.Namespace) -> int:
3227
3208
  from java_codebase_rag.mcp import mcp_v2
@@ -3332,7 +3313,6 @@ def _cmd_connection(args: argparse.Namespace) -> int:
3332
3313
  limit = _clamped_limit(args)
3333
3314
 
3334
3315
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3335
- from java_codebase_rag.jrag_render import render
3336
3316
 
3337
3317
  # Validate the microservice against the known set so a bogus name surfaces a
3338
3318
  # clear error instead of an empty inbound:/outbound: view (silent wrong
@@ -3532,8 +3512,7 @@ def _cmd_connection(args: argparse.Namespace) -> int:
3532
3512
  truncated=truncated,
3533
3513
  )
3534
3514
  next_actions_hook(env, root=root_id, result_edges=display_edges)
3535
- print(render(env, fmt=args.format, detail=args.detail, noun="connection"))
3536
- return 0
3515
+ return _emit(env, args, noun="connection")
3537
3516
 
3538
3517
 
3539
3518
  def _resolve_source_path(cfg, file_arg: str) -> Path | None:
@@ -3616,8 +3595,7 @@ def _cmd_outline(args: argparse.Namespace) -> int:
3616
3595
  # thing to inspect from an outline. Per-row inspect hints for the leading
3617
3596
  # entries give the agent a concrete next step.
3618
3597
  env.agent_next_actions = _inspect_hints_for_rows(display, limit=2)
3619
- print(render(env, fmt=args.format, detail=args.detail, noun="symbol"))
3620
- return 0
3598
+ return _emit(env, args, noun="symbol")
3621
3599
 
3622
3600
 
3623
3601
  def _cmd_imports(args: argparse.Namespace) -> int:
@@ -3730,8 +3708,7 @@ def _cmd_imports(args: argparse.Namespace) -> int:
3730
3708
 
3731
3709
  env = Envelope(status="ok", nodes=nodes, edges=edges, warnings=warnings)
3732
3710
  next_actions_hook(env, result_edges=edges)
3733
- print(render(env, fmt=args.format, detail=args.detail, noun="import"))
3734
- return 0
3711
+ return _emit(env, args, noun="import")
3735
3712
 
3736
3713
 
3737
3714
  # ============================================================================
@@ -3743,8 +3720,8 @@ def _cmd_imports(args: argparse.Namespace) -> int:
3743
3720
  # (kv-block + nested dict sections) so the agent sees compact structured data.
3744
3721
  #
3745
3722
  # Search dispatches to search_v2 (mcp_v2.search_v2) after building a NodeFilter
3746
- # from flags. --fuzzy is registered on the parser but rejected IN-HANDLER with
3747
- # status: error (not argparse exit) so the envelope carries the message.
3723
+ # from flags. --fuzzy is registered on the parser and accepted as a silent
3724
+ # no-op (search is inherently semantic; --fuzzy is implicit).
3748
3725
  # ============================================================================
3749
3726
 
3750
3727
 
@@ -3781,7 +3758,6 @@ def _cmd_map(args: argparse.Namespace) -> int:
3781
3758
  axis, which made "group by ALL modules" unreachable.
3782
3759
  """
3783
3760
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3784
- from java_codebase_rag.jrag_render import render
3785
3761
 
3786
3762
  _, graph, rc = _load_graph_or_error(args)
3787
3763
  if rc:
@@ -3835,14 +3811,12 @@ def _cmd_map(args: argparse.Namespace) -> int:
3835
3811
  hints.append(f"jrag overview {drill_scope}")
3836
3812
  hints.append("jrag conventions")
3837
3813
  env.agent_next_actions = hints[:5]
3838
- print(render(env, fmt=args.format, detail=args.detail, noun="map", shape="inspect"))
3839
- return 0
3814
+ return _emit(env, args, noun="map", shape="inspect")
3840
3815
 
3841
3816
 
3842
3817
  def _cmd_conventions(args: argparse.Namespace) -> int:
3843
3818
  """conventions [--service] — dominant roles + framework tallies."""
3844
3819
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3845
- from java_codebase_rag.jrag_render import render
3846
3820
 
3847
3821
  _, graph, rc = _load_graph_or_error(args)
3848
3822
  if rc:
@@ -3904,8 +3878,7 @@ def _cmd_conventions(args: argparse.Namespace) -> int:
3904
3878
  hints.append(f"jrag find --role {top_role}{scope_suffix}")
3905
3879
  hints.append("jrag map")
3906
3880
  env.agent_next_actions = hints[:5]
3907
- print(render(env, fmt=args.format, detail=args.detail, noun="conventions", shape="inspect"))
3908
- return 0
3881
+ return _emit(env, args, noun="conventions", shape="inspect")
3909
3882
 
3910
3883
 
3911
3884
  def _overview_detect_type(subject: str, graph) -> str:
@@ -3940,7 +3913,6 @@ def _overview_microservice(args: argparse.Namespace, graph, microservice: str) -
3940
3913
  bundle to empty (subject node) or keep all samples equally (rollup node).
3941
3914
  """
3942
3915
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
3943
- from java_codebase_rag.jrag_render import render
3944
3916
 
3945
3917
  limit = _clamped_limit(args)
3946
3918
  routes = graph.list_routes(microservice=microservice, limit=limit + 1)
@@ -3994,8 +3966,7 @@ def _overview_microservice(args: argparse.Namespace, graph, microservice: str) -
3994
3966
  nodes={f"microservice:{microservice}": node},
3995
3967
  )
3996
3968
  next_actions_hook(env)
3997
- print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect"))
3998
- return 0
3969
+ return _emit(env, args, noun="overview", shape="inspect")
3999
3970
 
4000
3971
 
4001
3972
  def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> int:
@@ -4009,8 +3980,7 @@ def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> in
4009
3980
  cfg=cfg, graph=graph,
4010
3981
  )
4011
3982
  if renv.status != "ok" or node is None:
4012
- print(render(renv, fmt=args.format, detail=args.detail))
4013
- return 2 if renv.status == "error" else 0
3983
+ return _emit(renv, args)
4014
3984
 
4015
3985
  if node.kind != "route":
4016
3986
  env = Envelope(
@@ -4055,8 +4025,7 @@ def _overview_route(args: argparse.Namespace, cfg, graph, route_path: str) -> in
4055
4025
  edges = edges[:limit]
4056
4026
  env = Envelope(status="ok", nodes=nodes_dict, edges=edges, root=root_id, truncated=truncated)
4057
4027
  next_actions_hook(env, root=root_id, result_edges=edges)
4058
- print(render(env, fmt=args.format, detail=args.detail, noun="overview"))
4059
- return 0
4028
+ return _emit(env, args, noun="overview")
4060
4029
 
4061
4030
 
4062
4031
  def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int:
@@ -4069,7 +4038,6 @@ def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int:
4069
4038
  samples, full = +limit samples.
4070
4039
  """
4071
4040
  from java_codebase_rag.jrag_envelope import Envelope, next_actions_hook
4072
- from java_codebase_rag.jrag_render import render
4073
4041
 
4074
4042
  limit = _clamped_limit(args)
4075
4043
  # Producers: exact topic match first, then substring match as fallback.
@@ -4123,8 +4091,7 @@ def _overview_topic(args: argparse.Namespace, graph, topic: str) -> int:
4123
4091
  nodes={f"topic:{topic}": topic_node},
4124
4092
  )
4125
4093
  next_actions_hook(env)
4126
- print(render(env, fmt=args.format, detail=args.detail, noun="overview", shape="inspect"))
4127
- return 0
4094
+ return _emit(env, args, noun="overview", shape="inspect")
4128
4095
 
4129
4096
 
4130
4097
  def _cmd_overview(args: argparse.Namespace) -> int:
@@ -4247,24 +4214,17 @@ def _cmd_search(args: argparse.Namespace) -> int:
4247
4214
  """search <query> — semantic search via search_v2 over Lance tables.
4248
4215
 
4249
4216
  Builds a NodeFilter from flags, calls search_v2 with limit+1 for +1-fetch
4250
- truncation, and renders. --fuzzy is rejected IN-HANDLER (not argparse-exit)
4251
- so the error carries the canonical envelope shape.
4217
+ truncation, and renders. --fuzzy is accepted as a silent no-op (search is
4218
+ always semantic; --fuzzy is implicit).
4252
4219
  """
4253
- from java_codebase_rag.mcp import mcp_v2
4254
-
4255
4220
  from java_codebase_rag.jrag_envelope import Envelope, mark_truncated, next_actions_hook, normalize_enum
4256
4221
  from java_codebase_rag.jrag_render import render
4257
4222
 
4258
- # --fuzzy: registered on the parser (so argparse doesn't exit 2), but rejected
4259
- # IN-HANDLER with status: error (search is inherently semantic; --fuzzy is
4260
- # a no-op synonym, not a real mode toggle).
4261
- if getattr(args, "fuzzy", False):
4262
- env = Envelope(
4263
- status="error",
4264
- message="search is semantic; --fuzzy is implicit",
4265
- )
4266
- print(render(env, fmt=args.format, detail=args.detail))
4267
- return 2
4223
+ # --fuzzy: accepted as a silent no-op. Search is inherently semantic
4224
+ # (vector + lexical), so --fuzzy is implicit; the flag is kept registered
4225
+ # so callers/agents that pass it don't hit an argparse or envelope error,
4226
+ # and is simply ignored here.
4227
+ _ = getattr(args, "fuzzy", False)
4268
4228
 
4269
4229
  cfg, graph, rc = _load_graph_or_error(args)
4270
4230
  if rc:
@@ -4282,8 +4242,7 @@ def _cmd_search(args: argparse.Namespace) -> int:
4282
4242
  warnings=_auto_scope_notice(args),
4283
4243
  )
4284
4244
  next_actions_hook(env)
4285
- print(render(env, fmt=args.format, detail=args.detail, noun="search"))
4286
- return 0
4245
+ return _emit(env, args, noun="search")
4287
4246
 
4288
4247
  # Build NodeFilter from flags (same set as `find` filter mode).
4289
4248
  filter_dict: dict = {}
@@ -4325,23 +4284,21 @@ def _cmd_search(args: argparse.Namespace) -> int:
4325
4284
  )
4326
4285
  print(render(env, fmt=args.format, detail=args.detail))
4327
4286
  return 2
4328
- node_filter, err_env = _build_node_filter_or_error(filter_dict)
4329
- if err_env is not None:
4330
- print(render(err_env, fmt=args.format, detail=args.detail))
4331
- return 2
4332
-
4333
- out = mcp_v2.search_v2(
4334
- args.query,
4335
- table=args.table,
4336
- hybrid=args.hybrid,
4337
- limit=limit + 1, # +1 for truncated detection
4338
- offset=args.offset,
4339
- path_contains=args.path_contains,
4340
- filter=node_filter,
4341
- explain=args.explain,
4342
- graph=graph,
4343
- dedup=not getattr(args, "chunks", False),
4344
- )
4287
+ from java_codebase_rag.read_payloads import PayloadError, search_payload
4288
+ from java_codebase_rag.watch.client import get_payload
4289
+
4290
+ # search_payload builds the NodeFilter from args (same filter_dict set above)
4291
+ # and calls search_v2 with limit+1. On filter-validation failure it raises
4292
+ # PayloadError carrying the error Envelope (rendered identically to before).
4293
+ # get_payload tries the watch daemon first (hot), cold-falling-back to the
4294
+ # identical search_payload core when no daemon is alive (every non-watch jrag
4295
+ # invocation). Reconstructs the SearchOutput object on the hot path so the
4296
+ # downstream render is unchanged.
4297
+ try:
4298
+ out = get_payload("search", vars(args), cfg, cold_core=search_payload)
4299
+ except PayloadError as pe:
4300
+ print(render(pe.env, fmt=args.format, detail=args.detail))
4301
+ return pe.rc
4345
4302
 
4346
4303
  if not out.success:
4347
4304
  env = Envelope(status="error", message=out.message or "search failed")
@@ -4430,8 +4387,7 @@ def _cmd_search(args: argparse.Namespace) -> int:
4430
4387
  if display:
4431
4388
  env.agent_next_actions = _inspect_hints_for_rows(display, limit=2)
4432
4389
  next_offset = args.offset + limit if truncated else None
4433
- print(render(env, fmt=args.format, detail=args.detail, noun="search", next_offset=next_offset))
4434
- return 0
4390
+ return _emit(env, args, noun="search", next_offset=next_offset)
4435
4391
 
4436
4392
 
4437
4393
  def _suppress_runtime_stderr_noise() -> None:
@@ -4551,9 +4507,19 @@ def _console_script_main() -> None:
4551
4507
  (SIGABRT, exit -6). Flushing + ``os._exit`` skips that racy teardown - the
4552
4508
  command has already done its work and emitted its result. ``main()`` stays
4553
4509
  return-based so in-process test callers keep working.
4510
+
4511
+ ``KeyboardInterrupt`` (Ctrl+C during a long indexing step) is caught here so
4512
+ it routes through the same flush + ``os._exit`` path — clean, immediate exit
4513
+ (code 130, no traceback) and no finalization-time SIGABRT — instead of
4514
+ propagating past this function.
4554
4515
  """
4555
4516
  force_utf8_stdio()
4556
- rc = main()
4517
+ try:
4518
+ rc = main()
4519
+ except KeyboardInterrupt:
4520
+ sys.stderr.write("\nInterrupted.\n")
4521
+ sys.stderr.flush()
4522
+ rc = 130
4557
4523
  sys.stdout.flush()
4558
4524
  sys.stderr.flush()
4559
4525
  os._exit(rc)