@@ -791,13 +791,81 @@ def _finding_for_candidate(self, candidate: object) -> RefactorFinding:
791791)
792792
793793
794+ _BOUNDARY_LOCAL_WRAPPER_TOKENS = frozenset (
795+ {
796+ "boundary" ,
797+ "boundaries" ,
798+ "carrier" ,
799+ "carriers" ,
800+ "context" ,
801+ "contexts" ,
802+ "query" ,
803+ "queries" ,
804+ "record" ,
805+ "records" ,
806+ "request" ,
807+ "requests" ,
808+ "scope" ,
809+ "scopes" ,
810+ "wrapper" ,
811+ "wrappers" ,
812+ }
813+ )
814+
815+ _BOUNDARY_IDENTITY_DETAIL_TOKENS = frozenset (
816+ {
817+ "id" ,
818+ "ids" ,
819+ "identity" ,
820+ "identities" ,
821+ "value" ,
822+ "values" ,
823+ }
824+ )
825+
826+ _BOUNDARY_OWNER_CLASS_TOKENS = frozenset (
827+ {
828+ "adapter" ,
829+ "authority" ,
830+ "context" ,
831+ "coordinator" ,
832+ "manager" ,
833+ "orchestrator" ,
834+ "request" ,
835+ "resolver" ,
836+ "runtime" ,
837+ "scope" ,
838+ "service" ,
839+ "session" ,
840+ }
841+ )
842+
843+ _BOUNDARY_TRANSPORT_CLASS_TOKENS = frozenset (
844+ {
845+ "cache" ,
846+ "key" ,
847+ "keys" ,
848+ "query" ,
849+ "queries" ,
850+ "record" ,
851+ "records" ,
852+ "request" ,
853+ "requests" ,
854+ }
855+ )
856+
857+
794858@dataclass (frozen = True )
795- class DistributedBoundaryDeclaration :
859+ class DistributedBoundarySurface :
796860 file_path : str
797861 line : int
798- class_name : str
799862 field_name : str
800863
864+
865+ @dataclass (frozen = True )
866+ class DistributedBoundaryDeclaration (DistributedBoundarySurface ):
867+ class_name : str
868+
801869 @property
802870 def evidence (self ) -> SourceLocation :
803871 return SourceLocation (
@@ -808,11 +876,8 @@ def evidence(self) -> SourceLocation:
808876
809877
810878@dataclass (frozen = True )
811- class DistributedBoundaryUse :
812- file_path : str
813- line : int
879+ class DistributedBoundaryUse (DistributedBoundarySurface ):
814880 symbol : str
815- field_name : str
816881 use_kind : str
817882 context_tokens : tuple [str , ...]
818883
@@ -846,6 +911,29 @@ def evidence(self) -> tuple[SourceLocation, ...]:
846911 * (use_site .evidence for use_site in self .projection_sites [:3 ]),
847912 )
848913
914+ @property
915+ def site_count (self ) -> int :
916+ return (
917+ len (self .declarations )
918+ + len (self .forwarding_sites )
919+ + len (self .projection_sites )
920+ )
921+
922+
923+ @dataclass (frozen = True )
924+ class BoundaryLocalWrapperCollapseCandidate :
925+ original : DistributedBoundaryFanoutCandidate
926+ wrapper : DistributedBoundaryFanoutCandidate
927+ core_tokens : tuple [str , ...]
928+ owner_class_names : tuple [str , ...]
929+
930+ @property
931+ def evidence (self ) -> tuple [SourceLocation , ...]:
932+ return (
933+ * self .original .evidence [:4 ],
934+ * self .wrapper .evidence [:4 ],
935+ )
936+
849937
850938_BOUNDARY_PROJECTION_CONTEXT_TOKENS = frozenset (
851939 {
@@ -911,6 +999,31 @@ def _boundary_identifier_tokens(name: str) -> tuple[str, ...]:
911999 )
9121000
9131001
1002+ def _boundary_raw_identifier_tokens (name : str ) -> tuple [str , ...]:
1003+ return tuple (
1004+ token
1005+ for token in re .sub (r"(?<!^)(?=[A-Z])" , "_" , name ).lower ().split ("_" )
1006+ if token
1007+ )
1008+
1009+
1010+ def _boundary_core_semantic_tokens (name : str ) -> tuple [str , ...]:
1011+ return tuple (
1012+ token
1013+ for token in _boundary_raw_identifier_tokens (name )
1014+ if token
1015+ and token not in _BOUNDARY_FANOUT_STOPWORDS
1016+ and token not in _BOUNDARY_LOCAL_WRAPPER_TOKENS
1017+ and token not in _BOUNDARY_IDENTITY_DETAIL_TOKENS
1018+ )
1019+
1020+
1021+ def _boundary_has_local_wrapper_token (name : str ) -> bool :
1022+ return bool (
1023+ set (_boundary_raw_identifier_tokens (name )) & _BOUNDARY_LOCAL_WRAPPER_TOKENS
1024+ )
1025+
1026+
9141027def _boundary_node_tokens (node : ast .AST ) -> tuple [str , ...]:
9151028 tokens : set [str ] = set ()
9161029 for child in ast .walk (node ):
@@ -1055,7 +1168,7 @@ def visit_keyword(self, node: ast.keyword) -> None:
10551168 None ,
10561169 )
10571170 self ._record (
1058- line = getattr ( node , " lineno" , 0 ) ,
1171+ line = node . lineno ,
10591172 field_name = cast (str , node .arg ),
10601173 use_kind = "keyword_forwarded" ,
10611174 context_tokens = (
@@ -1188,6 +1301,99 @@ def _distributed_boundary_fanout_candidates(
11881301 return tuple (candidates )
11891302
11901303
1304+ def _boundary_owner_class_names (
1305+ original : DistributedBoundaryFanoutCandidate ,
1306+ wrapper : DistributedBoundaryFanoutCandidate ,
1307+ ) -> tuple [str , ...]:
1308+ owner_names : list [tuple [str , bool ]] = []
1309+ seen : set [str ] = set ()
1310+ declarations = (* original .declarations , * wrapper .declarations )
1311+ for declaration in declarations :
1312+ class_tokens = set (_boundary_raw_identifier_tokens (declaration .class_name ))
1313+ if not (class_tokens & _BOUNDARY_OWNER_CLASS_TOKENS ):
1314+ continue
1315+ if declaration .class_name in seen :
1316+ continue
1317+ seen .add (declaration .class_name )
1318+ owner_names .append (
1319+ (
1320+ declaration .class_name ,
1321+ bool (class_tokens & _BOUNDARY_TRANSPORT_CLASS_TOKENS ),
1322+ )
1323+ )
1324+ if owner_names :
1325+ non_transport_names = tuple (
1326+ sorted (name for name , is_transport in owner_names if not is_transport )
1327+ )
1328+ if non_transport_names :
1329+ return non_transport_names
1330+ return tuple (sorted (name for name , _ in owner_names ))
1331+ return tuple (
1332+ sorted (
1333+ {
1334+ declaration .class_name
1335+ for declaration in declarations
1336+ }
1337+ )
1338+ )
1339+
1340+
1341+ def _boundary_local_wrapper_pairs (
1342+ candidates : tuple [DistributedBoundaryFanoutCandidate , ...],
1343+ config : DetectorConfig ,
1344+ ) -> tuple [BoundaryLocalWrapperCollapseCandidate , ...]:
1345+ candidates_by_core : dict [
1346+ tuple [str , ...], list [DistributedBoundaryFanoutCandidate ]
1347+ ] = defaultdict (list )
1348+ for candidate in candidates :
1349+ core_tokens = _boundary_core_semantic_tokens (candidate .field_name )
1350+ if not core_tokens :
1351+ continue
1352+ candidates_by_core [core_tokens ].append (candidate )
1353+
1354+ wrapper_candidates : list [BoundaryLocalWrapperCollapseCandidate ] = []
1355+ seen_pairs : set [tuple [str , str , tuple [str , ...]]] = set ()
1356+ for core_tokens , core_candidates in sorted (candidates_by_core .items ()):
1357+ if len (core_candidates ) < 2 :
1358+ continue
1359+ for wrapper in core_candidates :
1360+ if not _boundary_has_local_wrapper_token (wrapper .field_name ):
1361+ continue
1362+ if wrapper .site_count < config .min_local_wrapper_fanout_sites :
1363+ continue
1364+ for original in core_candidates :
1365+ if original is wrapper :
1366+ continue
1367+ if original .site_count < config .min_boundary_fanout_sites :
1368+ continue
1369+ pair_key = (original .field_name , wrapper .field_name , core_tokens )
1370+ if pair_key in seen_pairs :
1371+ continue
1372+ seen_pairs .add (pair_key )
1373+ wrapper_candidates .append (
1374+ BoundaryLocalWrapperCollapseCandidate (
1375+ original = original ,
1376+ wrapper = wrapper ,
1377+ core_tokens = core_tokens ,
1378+ owner_class_names = _boundary_owner_class_names (
1379+ original ,
1380+ wrapper ,
1381+ ),
1382+ )
1383+ )
1384+ return tuple (wrapper_candidates )
1385+
1386+
1387+ def _boundary_local_wrapper_collapse_candidates (
1388+ modules : Sequence [ParsedModule ],
1389+ config : DetectorConfig ,
1390+ ) -> tuple [BoundaryLocalWrapperCollapseCandidate , ...]:
1391+ return _boundary_local_wrapper_pairs (
1392+ _distributed_boundary_fanout_candidates (modules , config ),
1393+ config ,
1394+ )
1395+
1396+
11911397class DistributedBoundaryFanoutDetector (
11921398 ConfiguredCrossModuleCollectorCandidateDetector [DistributedBoundaryFanoutCandidate ]
11931399):
@@ -1227,6 +1433,94 @@ def _finding_for_candidate(
12271433 )
12281434
12291435
1436+ @dataclass (frozen = True )
1437+ class BoundaryLocalWrapperFindingRenderer :
1438+ """Render local-wrapper compliance findings from one semantic authority."""
1439+
1440+ def summary (self , candidate : BoundaryLocalWrapperCollapseCandidate ) -> str :
1441+ core = ", " .join (candidate .core_tokens )
1442+ owners = ", " .join (candidate .owner_class_names [:6 ])
1443+ return (
1444+ f"`{ candidate .wrapper .field_name } ` appears to locally wrap "
1445+ f"`{ candidate .original .field_name } ` for semantic core { core !r} , but "
1446+ f"the original still has { candidate .original .site_count } fanout sites "
1447+ f"and the wrapper has { candidate .wrapper .site_count } ; candidate owner "
1448+ f"boundary: { owners } ."
1449+ )
1450+
1451+ def evidence (
1452+ self ,
1453+ candidate : BoundaryLocalWrapperCollapseCandidate ,
1454+ ) -> tuple [SourceLocation , ...]:
1455+ return candidate .evidence [:8 ]
1456+
1457+ def scaffold (self , candidate : BoundaryLocalWrapperCollapseCandidate ) -> str :
1458+ core_name = _boundary_pascal_name ("_" .join (candidate .core_tokens ))
1459+ owner_hint = ", " .join (candidate .owner_class_names [:4 ]) or "the execution owner"
1460+ return (
1461+ "@dataclass(frozen=True)\n "
1462+ f"class { core_name } ExecutionScope:\n "
1463+ " # Own the complete co-varying semantic family here.\n "
1464+ " ...\n \n "
1465+ f"# Candidate authority boundary: { owner_hint } .\n "
1466+ f"# Move `{ candidate .original .field_name } ` and "
1467+ f"`{ candidate .wrapper .field_name } ` consumers to this owner-level scope;\n "
1468+ "# do not keep a carrier field threaded through transport records."
1469+ )
1470+
1471+ def codemod_patch (self , candidate : BoundaryLocalWrapperCollapseCandidate ) -> str :
1472+ owner_hint = ", " .join (candidate .owner_class_names [:4 ]) or "the least common owner"
1473+ return (
1474+ f"# `{ candidate .wrapper .field_name } ` is a local wrapper around the still-live "
1475+ f"`{ candidate .original .field_name } ` boundary.\n "
1476+ f"# Move the boundary to { owner_hint } , then delete the wrapper field from "
1477+ "intermediate request/cache/query records.\n "
1478+ "# Success condition: the before/after fanout graph no longer has sibling "
1479+ f"`{ candidate .original .field_name } ` and `{ candidate .wrapper .field_name } ` "
1480+ "Pattern 16 findings for the same semantic core."
1481+ )
1482+
1483+ def metrics (self , candidate : BoundaryLocalWrapperCollapseCandidate ) -> MappingMetrics :
1484+ return MappingMetrics .from_field_names (
1485+ mapping_site_count = (
1486+ candidate .original .site_count + candidate .wrapper .site_count
1487+ ),
1488+ mapping_name = candidate .wrapper .field_name ,
1489+ field_names = (
1490+ candidate .original .field_name ,
1491+ candidate .wrapper .field_name ,
1492+ * candidate .core_tokens ,
1493+ ),
1494+ source_name = "boundary_local_wrapper_collapse" ,
1495+ identity_field_names = candidate .core_tokens ,
1496+ )
1497+
1498+
1499+ BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER = BoundaryLocalWrapperFindingRenderer ()
1500+
1501+
1502+ declare_candidate_rule_detector (
1503+ BoundaryLocalWrapperCollapseCandidate ,
1504+ high_confidence_certified_spec (
1505+ PatternId .AUTHORITATIVE_CONTEXT ,
1506+ "Local boundary wrapper should move to the real authority boundary" ,
1507+ "A carrier-style field was introduced around an existing distributed boundary, but both the original primitive boundary and the wrapper boundary still fan out through declarations, forwarding, or projections. That is a local containment failure, not the authoritative context collapse requested by Pattern 16." ,
1508+ "one owner-level execution/context record that consumes the full semantic family directly" ,
1509+ "a wrapper-name fanout coexists with the original boundary fanout for the same semantic core" ,
1510+ _AUTHORITATIVE_NOMINAL_IDENTITY_PROVENANCE_CAPABILITY_TAGS ,
1511+ _CLASS_FAMILY_KEYWORD_MANUAL_SYNCHRONIZATION_OBSERVATION_TAGS ,
1512+ ),
1513+ summary = BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER .summary ,
1514+ evidence = BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER .evidence ,
1515+ scaffold = BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER .scaffold ,
1516+ codemod_patch = BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER .codemod_patch ,
1517+ metrics = BOUNDARY_LOCAL_WRAPPER_FINDING_RENDERER .metrics ,
1518+ detector_base = ConfiguredCrossModuleCollectorCandidateDetector ,
1519+ candidate_collector = _boundary_local_wrapper_collapse_candidates ,
1520+ detector_priority = - 1 ,
1521+ )
1522+
1523+
12301524def default_detectors () -> tuple [IssueDetector , ...]:
12311525 """Instantiate all registered detectors in deterministic priority order."""
12321526 return tuple (
0 commit comments