Skip to content

Commit 20ce65a

Browse files
committed
Flag local boundary wrapper collapses
1 parent fd07498 commit 20ce65a

3 files changed

Lines changed: 343 additions & 7 deletions

File tree

nominal_refactor_advisor/detectors/_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,10 @@ class DetectorConfig:
495495
4,
496496
"Minimum declaration/forward/projection sites before surfacing distributed boundary fanout.",
497497
)
498+
min_local_wrapper_fanout_sites: int = detector_config_option(
499+
4,
500+
"Minimum live carrier-boundary sites before surfacing a local-wrapper containment failure.",
501+
)
498502
min_orchestration_function_lines: int = 150
499503
min_orchestration_branches: int = 15
500504
min_orchestration_calls: int = 50

nominal_refactor_advisor/detectors/_surface.py

Lines changed: 301 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
9141027
def _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+
11911397
class 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+
12301524
def default_detectors() -> tuple[IssueDetector, ...]:
12311525
"""Instantiate all registered detectors in deterministic priority order."""
12321526
return tuple(

0 commit comments

Comments
 (0)