Skip to content

Commit 910e3d4

Browse files
HumanBean17claude
andcommitted
fix(jrag): auto --service scope from cwd (parity with MCP ScopeManager)
Consumers can build the index at the system level (above microservices) but work inside one microservice. MCP silently defaults `--service` to the cwd-derived microservice (server.py ScopeManager -> find/search/ neighbors); the CLI did not, so cross-service results leaked in (e.g. `search "audit log"` from chat-assign/ returned 14 chat-core + 2 chat-assign hits; `find Client` returned the chat-core entity). Mirror MCP rather than reinvent: - _apply_auto_scope reuses graph_enrich.detect_microservice_from_path + the indexed-set validation guard (keep candidate on empty/error, None at system root); defaults args.service to the cwd microservice on the 16 commands where --service is a result filter (find/search, 7 listings, 7 traversals). Orientation/structural/subject/file commands stay unscoped. Explicit --service always wins. - --no-auto-scope flag + JRAG_NO_AUTO_SCOPE env escape hatch. - Transparency: stderr line + envelope warnings[] notice (6 seams, incl. topics which builds its own envelope, not via _render_listing). - impact post-filter warning gated on _service_user so auto-injected scope filters results without the caveat noise the user didn't ask for. - Resolve semantics unchanged (matches MCP, which also doesn't auto-scope resolve); cross-service resolve ambiguity on inspect/ callees is a pre-existing, separate gap. Verified: tests/test_jrag_auto_scope.py (10 cases) + full suite (1063 passed, 14 skipped). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7a2a0ef commit 910e3d4

2 files changed

Lines changed: 400 additions & 27 deletions

File tree

java_codebase_rag/jrag.py

Lines changed: 134 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,76 @@ def _framework_type_fqns(graph, framework: str) -> set[str]:
9393
return {str(r.get("fqn") or "") for r in rows if r.get("fqn")}
9494

9595

96+
def _apply_auto_scope(args: argparse.Namespace, cfg, graph) -> None:
97+
"""Default ``args.service`` to the microservice implied by cwd (MCP parity).
98+
99+
Mirrors ``server.py`` ``ScopeManager``: when cwd sits inside one
100+
microservice of a system-level index, behave as if the agent had typed
101+
``--service <that microservice>`` so the other services' results do not
102+
leak in. No-op unless the command opted in via
103+
``set_defaults(auto_scope=True)`` and the caller did not pass ``--service``.
104+
105+
Detection reuses ``graph_enrich.detect_microservice_from_path``; the
106+
candidate is validated against ``graph.microservice_counts()`` and dropped
107+
if absent (a mislabeled non-microservice dir would otherwise yield zero
108+
matches). When the known set is empty/unreadable we KEEP the candidate
109+
(transient graph error) — same as ``server.py:130-132``. Detection returns
110+
``None`` at the system root or outside it, so auto-scope never fires for
111+
estate-wide work.
112+
113+
Records ``args._service_user`` (caller passed ``--service``) for warning
114+
distinction and ``args._service_auto`` (detected name) for the
115+
transparency notice.
116+
117+
NOTE: three commands inline their graph load and bypass
118+
``_load_graph_or_error`` — ``find``, ``inspect``, ``status``. ``find`` is
119+
opted in and calls this helper itself; ``inspect``/``status`` are NOT
120+
opted in (they don't use ``--service`` as a result filter today). If
121+
either is ever opted in, it must call this helper in its own load path.
122+
"""
123+
# ``--service`` lives on the ``_common_parser``; a few commands (status,
124+
# microservices) use a bare ``_core_parser`` without it. Gate on opt-in
125+
# FIRST so those never reach the ``args.service`` read below.
126+
if not getattr(args, "auto_scope", False):
127+
return
128+
args._service_user = getattr(args, "service", None) is not None
129+
if getattr(args, "service", None) is not None: # explicit --service wins
130+
return
131+
if getattr(args, "no_auto_scope", False) or os.environ.get("JRAG_NO_AUTO_SCOPE"):
132+
return
133+
source_root = cfg.source_root if cfg.source_root else None
134+
if not source_root:
135+
return
136+
from graph_enrich import detect_microservice_from_path
137+
138+
candidate = detect_microservice_from_path(Path.cwd(), Path(source_root))
139+
if not candidate:
140+
return
141+
try:
142+
known = {name for name in (graph.microservice_counts() or {}) if name}
143+
except Exception:
144+
known = set()
145+
if known and candidate not in known:
146+
return
147+
args.service = candidate
148+
args._service_auto = candidate
149+
print(f"[jrag] auto-scope: --service {candidate} (cwd)", file=sys.stderr)
150+
151+
152+
def _auto_scope_notice(args: argparse.Namespace) -> list[str]:
153+
"""Envelope ``warnings[]`` line telling the agent results are auto-scoped.
154+
155+
Models often do not see stderr (where the ``[jrag] auto-scope`` line goes),
156+
so this also surfaces the scope in the rendered output. Returns ``[]`` when
157+
auto-scope did not fire (no detected service) or the command opted out.
158+
"""
159+
svc = getattr(args, "_service_auto", None)
160+
if not svc:
161+
return []
162+
return [
163+
f"auto-scope: --service {svc} (inferred from cwd; "
164+
f"pass --no-auto-scope to disable)"
165+
]
96166

97167

98168
def _load_graph_or_error(args: argparse.Namespace):
@@ -112,6 +182,9 @@ def _load_graph_or_error(args: argparse.Namespace):
112182
env = Envelope(status="error", message=str(exc))
113183
print(render(env, fmt=args.format, detail=args.detail))
114184
return cfg, None, 2
185+
# Default --service from cwd before the handler reads it (MCP parity).
186+
# No-op unless the command opted in via set_defaults(auto_scope=True).
187+
_apply_auto_scope(args, cfg, graph)
115188
return cfg, graph, 0
116189

117190

@@ -141,7 +214,10 @@ def _render_listing(rows, *, limit: int, args: argparse.Namespace, noun: str,
141214
display_nodes_list, truncated = mark_truncated(node_list, limit)
142215
display_nodes = {node["id"]: node for node in display_nodes_list}
143216

144-
env = Envelope(status="ok", nodes=display_nodes, truncated=truncated)
217+
env = Envelope(
218+
status="ok", nodes=display_nodes, truncated=truncated,
219+
warnings=_auto_scope_notice(args),
220+
)
145221
next_actions_hook(env, command=getattr(args, "command", None))
146222
if extra_hints:
147223
seen = set(env.agent_next_actions)
@@ -273,6 +349,16 @@ def _common_parser() -> argparse.ArgumentParser:
273349
common = argparse.ArgumentParser(add_help=False)
274350
common.add_argument("--service", type=str, default=None, help="Filter by microservice.")
275351
common.add_argument("--module", type=str, default=None, help="Filter by module.")
352+
common.add_argument(
353+
"--no-auto-scope",
354+
dest="no_auto_scope",
355+
action="store_true",
356+
default=False,
357+
help=(
358+
"Disable cwd-derived auto --service scoping so cross-service "
359+
"results are visible (also disabled via JRAG_NO_AUTO_SCOPE=1)."
360+
),
361+
)
276362
common.add_argument(
277363
"--limit", type=int, default=20, help="Cap on results (default 20; 10 for fan-out)."
278364
)
@@ -390,7 +476,7 @@ def _core_parser() -> argparse.ArgumentParser:
390476
default=0,
391477
help="Page offset (filter mode only; ignored in query mode).",
392478
)
393-
find.set_defaults(handler=_cmd_find)
479+
find.set_defaults(handler=_cmd_find, auto_scope=True)
394480

395481
# inspect subparser (PR-JRAG-1b)
396482
inspect = subparsers.add_parser(
@@ -429,7 +515,7 @@ def _core_parser() -> argparse.ArgumentParser:
429515
http_routes.add_argument("--framework", type=str, default=None, help="Filter by framework.")
430516
http_routes.add_argument("--path-contains", type=str, default=None, help="Filter by path substring.")
431517
http_routes.add_argument("--method", type=str, default=None, help="Filter by HTTP method.")
432-
http_routes.set_defaults(handler=_cmd_routes, detail="full")
518+
http_routes.set_defaults(handler=_cmd_routes, detail="full", auto_scope=True)
433519

434520
# http-clients subparser (PR-JRAG-2)
435521
http_clients = subparsers.add_parser(
@@ -444,7 +530,7 @@ def _core_parser() -> argparse.ArgumentParser:
444530
http_clients.add_argument("--client-kind", type=str, default=None, help="Filter by client kind.")
445531
http_clients.add_argument("--calls-service", type=str, default=None, help="Filter by target service.")
446532
http_clients.add_argument("--path-contains", type=str, default=None, help="Filter by path substring.")
447-
http_clients.set_defaults(handler=_cmd_clients, detail="full")
533+
http_clients.set_defaults(handler=_cmd_clients, detail="full", auto_scope=True)
448534

449535
# producers subparser (PR-JRAG-2)
450536
producers = subparsers.add_parser(
@@ -458,7 +544,7 @@ def _core_parser() -> argparse.ArgumentParser:
458544
)
459545
producers.add_argument("--producer-kind", type=str, default=None, help="Filter by producer kind.")
460546
producers.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring.")
461-
producers.set_defaults(handler=_cmd_producers, detail="full")
547+
producers.set_defaults(handler=_cmd_producers, detail="full", auto_scope=True)
462548

463549
# topics subparser (PR-JRAG-2)
464550
topics = subparsers.add_parser(
@@ -474,7 +560,7 @@ def _core_parser() -> argparse.ArgumentParser:
474560
topics.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring.")
475561
topics.add_argument("--producer-in", type=str, default=None, help="Scope producers to this microservice.")
476562
topics.add_argument("--consumer-in", type=str, default=None, help="Show consumers from this microservice.")
477-
topics.set_defaults(handler=_cmd_topics, detail="full")
563+
topics.set_defaults(handler=_cmd_topics, detail="full", auto_scope=True)
478564

479565
# jobs subparser (PR-JRAG-2)
480566
jobs = subparsers.add_parser(
@@ -486,7 +572,7 @@ def _core_parser() -> argparse.ArgumentParser:
486572
"Returns Symbol nodes with the SCHEDULED_TASK capability."
487573
),
488574
)
489-
jobs.set_defaults(handler=_cmd_jobs, detail="full")
575+
jobs.set_defaults(handler=_cmd_jobs, detail="full", auto_scope=True)
490576

491577
# listeners subparser (PR-JRAG-2)
492578
listeners = subparsers.add_parser(
@@ -499,7 +585,7 @@ def _core_parser() -> argparse.ArgumentParser:
499585
),
500586
)
501587
listeners.add_argument("--topic-contains", type=str, default=None, help="Filter by topic substring (on producer member).")
502-
listeners.set_defaults(handler=_cmd_listeners, detail="full")
588+
listeners.set_defaults(handler=_cmd_listeners, detail="full", auto_scope=True)
503589

504590
# entities subparser (PR-JRAG-2)
505591
entities = subparsers.add_parser(
@@ -511,7 +597,7 @@ def _core_parser() -> argparse.ArgumentParser:
511597
"Returns Symbol nodes with the ENTITY role."
512598
),
513599
)
514-
entities.set_defaults(handler=_cmd_entities, detail="full")
600+
entities.set_defaults(handler=_cmd_entities, detail="full", auto_scope=True)
515601

516602
# ---- Traversal commands (PR-JRAG-3a) ----
517603
# Shared resolve-disambiguation flags (PR-JRAG-1a contract: only --kind is a
@@ -557,7 +643,7 @@ def _core_parser() -> argparse.ArgumentParser:
557643
action="store_true",
558644
help="Include external (JDK/Spring/Lombok) callers/callees (default excluded).",
559645
)
560-
callers.set_defaults(handler=_cmd_callers)
646+
callers.set_defaults(handler=_cmd_callers, auto_scope=True)
561647

562648
callees = subparsers.add_parser(
563649
"callees",
@@ -582,7 +668,7 @@ def _core_parser() -> argparse.ArgumentParser:
582668
action="store_true",
583669
help="Include external (JDK/Spring/Lombok) callees (default excluded).",
584670
)
585-
callees.set_defaults(handler=_cmd_callees)
671+
callees.set_defaults(handler=_cmd_callees, auto_scope=True)
586672

587673
hierarchy = subparsers.add_parser(
588674
"hierarchy",
@@ -609,7 +695,7 @@ def _core_parser() -> argparse.ArgumentParser:
609695
)
610696
implementations.add_argument("query", help="Interface FQN or name.")
611697
implementations.add_argument("--capability", type=str, default=None, help="Filter implementors by capability.")
612-
implementations.set_defaults(handler=_cmd_implementations)
698+
implementations.set_defaults(handler=_cmd_implementations, auto_scope=True)
613699

614700
subclasses = subparsers.add_parser(
615701
"subclasses",
@@ -621,7 +707,7 @@ def _core_parser() -> argparse.ArgumentParser:
621707
),
622708
)
623709
subclasses.add_argument("query", help="Class FQN or name.")
624-
subclasses.set_defaults(handler=_cmd_subclasses)
710+
subclasses.set_defaults(handler=_cmd_subclasses, auto_scope=True)
625711

626712
overrides = subparsers.add_parser(
627713
"overrides",
@@ -659,7 +745,7 @@ def _core_parser() -> argparse.ArgumentParser:
659745
),
660746
)
661747
dependents.add_argument("query", help="Type FQN or name.")
662-
dependents.set_defaults(handler=_cmd_dependents)
748+
dependents.set_defaults(handler=_cmd_dependents, auto_scope=True)
663749

664750
impact = subparsers.add_parser(
665751
"impact",
@@ -674,7 +760,7 @@ def _core_parser() -> argparse.ArgumentParser:
674760
)
675761
impact.add_argument("query", help="Symbol FQN or name.")
676762
impact.add_argument("--depth", type=int, default=2, help="Closure depth (default 2).")
677-
impact.set_defaults(handler=_cmd_impact)
763+
impact.set_defaults(handler=_cmd_impact, auto_scope=True)
678764

679765
decompose = subparsers.add_parser(
680766
"decompose",
@@ -714,7 +800,7 @@ def _core_parser() -> argparse.ArgumentParser:
714800
action="store_true",
715801
help="Include external types reached via the CALLS hop (default excluded).",
716802
)
717-
decompose.set_defaults(handler=_cmd_decompose)
803+
decompose.set_defaults(handler=_cmd_decompose, auto_scope=True)
718804

719805
flow = subparsers.add_parser(
720806
"flow",
@@ -1002,7 +1088,7 @@ def _core_parser() -> argparse.ArgumentParser:
10021088
default=0,
10031089
help="Page offset (passed to search_v2; paginated via +1-fetch).",
10041090
)
1005-
search.set_defaults(handler=_cmd_search)
1091+
search.set_defaults(handler=_cmd_search, auto_scope=True)
10061092

10071093
return parser
10081094

@@ -1161,6 +1247,10 @@ def _cmd_find(args: argparse.Namespace) -> int:
11611247
print(render(env, fmt=args.format, detail=args.detail))
11621248
return 2
11631249

1250+
# find inlines its graph load (not via _load_graph_or_error), so wire the
1251+
# auto-scope default here too (MCP parity).
1252+
_apply_auto_scope(args, cfg, graph)
1253+
11641254
# Check kind contradiction first (before any backend work)
11651255
inferred = _infer_kind(args)
11661256
is_contradiction, error_msg = _check_kind_contradiction(args, inferred)
@@ -1312,7 +1402,10 @@ def _cmd_find_query_mode(
13121402
"end_line": row.end_line,
13131403
}
13141404

1315-
env = Envelope(status="ok", nodes=nodes, truncated=raw_truncated, warnings=warnings)
1405+
env = Envelope(
1406+
status="ok", nodes=nodes, truncated=raw_truncated,
1407+
warnings=warnings + _auto_scope_notice(args),
1408+
)
13161409
next_actions_hook(env)
13171410

13181411
# Empty-result discoverability: query mode is exact-match only (name OR fqn),
@@ -1437,7 +1530,10 @@ def _cmd_find_filter_mode(
14371530
display_refs = results[:limit]
14381531
nodes_dict = {ref.id: to_envelope_rows([ref])[0] for ref in display_refs}
14391532

1440-
env = Envelope(status="ok", nodes=nodes_dict, truncated=truncated)
1533+
env = Envelope(
1534+
status="ok", nodes=nodes_dict, truncated=truncated,
1535+
warnings=_auto_scope_notice(args),
1536+
)
14411537
next_actions_hook(env)
14421538

14431539
# Render with offset hint if truncated
@@ -1719,7 +1815,10 @@ def _cmd_topics(args: argparse.Namespace) -> int:
17191815
node_id = f"topic:{i}"
17201816
nodes[node_id] = topic
17211817

1722-
env = Envelope(status="ok", nodes=nodes, truncated=truncated, warnings=warnings)
1818+
env = Envelope(
1819+
status="ok", nodes=nodes, truncated=truncated,
1820+
warnings=warnings + _auto_scope_notice(args),
1821+
)
17231822
next_actions_hook(env, command=getattr(args, "command", None))
17241823
print(render(env, fmt=args.format, detail=args.detail, noun="topic"))
17251824
return 0
@@ -2007,7 +2106,7 @@ def _emit_traversal(
20072106
nodes=dict(nodes),
20082107
edges=list(edges),
20092108
root=root_id,
2010-
warnings=warnings or [],
2109+
warnings=(warnings or []) + _auto_scope_notice(args),
20112110
truncated=truncated,
20122111
is_external_entrypoint=is_external_entrypoint,
20132112
)
@@ -2754,12 +2853,17 @@ def _cmd_impact(args: argparse.Namespace) -> int:
27542853
impacts = graph.impact_analysis(node.fqn, depth=depth, limit=limit + 1)
27552854
warnings: list[str] = []
27562855
if args.service:
2757-
# impact_analysis has no microservice param (verified); filter
2758-
# client-side and surface a warning so the user knows.
2759-
warnings.append(
2760-
"--service is a post-filter on impact (impact_analysis has no microservice param)"
2761-
)
2856+
# Filter client-side (impact_analysis has no microservice param). The
2857+
# explanatory warning fires only when the caller EXPLICITLY passed
2858+
# --service: under cwd-derived auto-scope the filter still applies
2859+
# (that's the point — keep the blast-radius inside the working
2860+
# service) but the "post-filter" caveat would be noise the agent
2861+
# didn't ask for, so it's gated on ``_service_user``.
27622862
impacts = [h for h in impacts if (h.microservice or "") == args.service]
2863+
if getattr(args, "_service_user", False):
2864+
warnings.append(
2865+
"--service is a post-filter on impact (impact_analysis has no microservice param)"
2866+
)
27632867
if getattr(args, "module", None):
27642868
# impact_analysis has no module param either; warn rather than drop silently.
27652869
warnings.append(
@@ -4043,7 +4147,10 @@ def _cmd_search(args: argparse.Namespace) -> int:
40434147
f"--framework {framework_want!r} filtered out all {framework_dropped} hit(s); "
40444148
f"no symbol's declaring type matched the framework's characteristic annotations"
40454149
)
4046-
env = Envelope(status="ok", nodes=nodes, truncated=truncated, warnings=warnings)
4150+
env = Envelope(
4151+
status="ok", nodes=nodes, truncated=truncated,
4152+
warnings=warnings + _auto_scope_notice(args),
4153+
)
40474154
next_actions_hook(env)
40484155
# Per-hit drill-down: the top search hit's primary type is the natural
40494156
# thing to inspect (signature, edges, callers). Two visible hints in text,

0 commit comments

Comments
 (0)