@@ -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
98168def _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