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