Skip to content

Commit 7d8ebff

Browse files
committed
Detect private object runtime boundary fields
Add a runtime advisor detector for private dataclass fields annotated as object when the field name signals executable/runtime boundary semantics such as impl, callback, provider, resolver, handler, predicate, or runtime. This catches the class of bug where a request record transports an untyped private closure across a boundary, hiding both ownership and callable shape from the advisor. Add a regression test covering a private _handler_impl: object field while leaving typed public runtime fields clean.
1 parent 31b7e52 commit 7d8ebff

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

nominal_refactor_advisor/detectors/_runtime.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,138 @@ def _fallback_owner(
324324
return ".".join(owner_parts) if owner_parts else "module"
325325

326326

327+
_PRIVATE_OBJECT_BOUNDARY_FIELD_TOKENS = frozenset(
328+
(
329+
"callback",
330+
"callable",
331+
"executor",
332+
"function",
333+
"handler",
334+
"impl",
335+
"materializer",
336+
"predicate",
337+
"provider",
338+
"resolver",
339+
"runtime",
340+
)
341+
)
342+
343+
344+
def _private_boundary_identifier_tokens(text: str) -> tuple[str, ...]:
345+
normalized = "".join(
346+
(character.lower() if character.isalnum() else "_")
347+
for character in text
348+
)
349+
return tuple((token for token in normalized.split("_") if token))
350+
351+
352+
def _is_exact_object_annotation(annotation: ast.AST) -> bool:
353+
return (
354+
isinstance(annotation, ast.Name)
355+
and annotation.id == "object"
356+
) or (
357+
isinstance(annotation, ast.Attribute)
358+
and annotation.attr == "object"
359+
)
360+
361+
362+
def _is_dataclass_declaration(node: ast.ClassDef) -> bool:
363+
return any(
364+
(
365+
SYNTAX_PROJECTION_AUTHORITY.is_dataclass_decorator(decorator)
366+
or (
367+
isinstance(decorator, ast.Call)
368+
and SYNTAX_PROJECTION_AUTHORITY.is_dataclass_decorator(
369+
decorator.func
370+
)
371+
)
372+
)
373+
for decorator in node.decorator_list
374+
)
375+
376+
377+
def _private_object_boundary_fields(
378+
module: ParsedModule,
379+
) -> dict[str, list[tuple[int, str]]]:
380+
fields_by_class: dict[str, list[tuple[int, str]]] = {}
381+
for node in module.module.body:
382+
if not isinstance(node, ast.ClassDef) or not _is_dataclass_declaration(node):
383+
continue
384+
for statement in node.body:
385+
if not isinstance(statement, ast.AnnAssign) or not isinstance(
386+
statement.target,
387+
ast.Name,
388+
):
389+
continue
390+
field_name = statement.target.id
391+
if not field_name.startswith("_"):
392+
continue
393+
if not _is_exact_object_annotation(statement.annotation):
394+
continue
395+
field_tokens = frozenset(_private_boundary_identifier_tokens(field_name))
396+
if not (field_tokens & _PRIVATE_OBJECT_BOUNDARY_FIELD_TOKENS):
397+
continue
398+
fields_by_class.setdefault(node.name, []).append(
399+
(int(getattr(statement, "lineno", node.lineno)), field_name)
400+
)
401+
return fields_by_class
402+
403+
404+
class PrivateObjectBoundaryFieldDetector(PerModuleIssueDetector):
405+
detector_id = "private_object_boundary_field"
406+
finding_spec = high_confidence_spec(
407+
PatternId.AUTHORITATIVE_SCHEMA,
408+
"Private object-typed boundary field should become a typed authority",
409+
"A private dataclass field annotated as `object` and named like an executable/runtime boundary hides both ownership and callable shape. That lets local Python closures cross request boundaries without static evidence.",
410+
"nominal typed authority or protocol field for each executable/runtime boundary",
411+
"dataclass request boundary stores a private executable/runtime field as `object`",
412+
_AUTHORITATIVE_PROVENANCE_NOMINAL_IDENTITY_CAPABILITY_TAGS,
413+
_KEYWORD_BUILDER_CALL_DATAFLOW_ROOT_OBSERVATION_TAGS,
414+
)
415+
416+
def _findings_for_module(
417+
self,
418+
module: ParsedModule,
419+
config: DetectorConfig,
420+
) -> list[RefactorFinding]:
421+
del config
422+
findings: list[RefactorFinding] = []
423+
for class_name, fields in sorted(_private_object_boundary_fields(module).items()):
424+
field_names = tuple(field_name for _line, field_name in fields)
425+
evidence = tuple(
426+
SourceLocation(str(module.path), line, f"{class_name}.{field_name}")
427+
for line, field_name in fields
428+
)
429+
findings.append(
430+
self.build_finding(
431+
(
432+
f"`{class_name}` stores private runtime boundary field(s) "
433+
f"{field_names} as untyped `object`."
434+
),
435+
evidence,
436+
scaffold=(
437+
"@dataclass(frozen=True)\n"
438+
"class BoundaryRuntime:\n"
439+
" def execute(self, request: BoundaryRequest) -> BoundaryResult: ...\n\n"
440+
"@dataclass(frozen=True)\n"
441+
"class Request:\n"
442+
" boundary_runtime: BoundaryRuntime"
443+
),
444+
codemod_patch=(
445+
f"# Replace private object boundary fields on `{class_name}` "
446+
"with a named typed authority/protocol field. Do not pass "
447+
"private closures through request dataclasses."
448+
),
449+
metrics=MappingMetrics.from_field_names(
450+
mapping_site_count=len(field_names),
451+
mapping_name=class_name,
452+
field_names=field_names,
453+
),
454+
)
455+
)
456+
return findings
457+
458+
327459
class UnclassifiedRuntimeFallbackDetector(PerModuleIssueDetector):
328460
detector_id = "unclassified_runtime_fallback"
329461
finding_spec = high_confidence_spec(

tests/test_refactor_advisor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def _test_scan_economics_proof(
189189
FAIL_SOFT_EFFECT_PIPELINE_DETECTOR_ID = "fail_soft_effect_pipeline"
190190
IDENTITY_KEYWORD_FORWARDING_SHELL_DETECTOR_ID = "identity_keyword_forwarding_shell"
191191
OPTIONAL_PARAMETER_BRANCH_DETECTOR_ID = "optional_parameter_branch"
192+
PRIVATE_OBJECT_BOUNDARY_FIELD_DETECTOR_ID = "private_object_boundary_field"
192193
UNDER_AMORTIZED_INFRASTRUCTURE_DETECTOR_ID = "under_amortized_infrastructure"
193194
MANUAL_CONCRETE_SUBCLASS_ROSTER_DETECTOR_ID = "manual_concrete_subclass_roster"
194195
PRIVATE_COHORT_SHOULD_BE_MODULE_DETECTOR_ID = "private_cohort_should_be_module"
@@ -3408,6 +3409,27 @@ def test_detects_fail_soft_effect_pipeline(tmp_path: Path) -> None:
34083409
assert "nominal `EffectStep` subclasses" in (finding.codemod_patch or "")
34093410

34103411

3412+
def test_detects_private_object_boundary_field(tmp_path: Path) -> None:
3413+
_write_module(
3414+
tmp_path,
3415+
"pkg/mod.py",
3416+
"\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass UnsafeRequest:\n _handler_impl: object\n payload: object\n\n\n@dataclass(frozen=True)\nclass SafeRequest:\n handler_runtime: HandlerRuntime\n",
3417+
)
3418+
3419+
findings = analyze_path(tmp_path)
3420+
finding = next(
3421+
(
3422+
finding
3423+
for finding in findings
3424+
if finding.detector_id == PRIVATE_OBJECT_BOUNDARY_FIELD_DETECTOR_ID
3425+
)
3426+
)
3427+
3428+
assert "UnsafeRequest" in finding.summary
3429+
assert "_handler_impl" in finding.summary
3430+
assert "SafeRequest" not in finding.summary
3431+
3432+
34113433
def test_detects_short_fail_soft_effect_pipeline(tmp_path: Path) -> None:
34123434
_write_module(
34133435
tmp_path,

0 commit comments

Comments
 (0)