From 75b2219f98103d73e7a354d72c067972ce18b09d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:45:51 +0000 Subject: [PATCH] ExtractMixin tolerates keyword-only construction difference For ExtractMixin moves, a DIFFERENT_CONSTRUCTION guard that fires only because class keywords differ (e.g. affix vs nothing) is downgraded to SAME_CONSTRUCTION. The mixin is a new class that doesn't inherit the composers' keywords. Dataclass declaration, metaclass, and decorator differences still guard. Unblocks 3 more missing_mixin verdicts on agent-comms where one class is a DeclaredFamily member (with affix) and the other is a plain dataclass: HistoricalNativeInput/WakeClaim, Channel/ChannelPreferences, InstalledEvidence/ReportedProblemOrder. Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01RnH6dR7JdknxC8XJJtW8Uh --- nominal_refactor_advisor/class_overlap.py | 20 +++++++-- tests/test_missing_mixin_unblock.py | 50 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/nominal_refactor_advisor/class_overlap.py b/nominal_refactor_advisor/class_overlap.py index 36fd904a..b6da9605 100644 --- a/nominal_refactor_advisor/class_overlap.py +++ b/nominal_refactor_advisor/class_overlap.py @@ -841,7 +841,7 @@ def verdict(self, overlap: ClassBodyOverlap) -> ClassOverlapCandidate | None: *self._kept_difference_receipts(overlap), *self._consumer_receipts(first, second), *self._family_receipts(first, second), - *self._construction_receipts(first, second), + *self._construction_receipts(first, second, move), *self._identity_receipts(owner, restating, move), *self._positional_construction_receipts( move, (first, second) @@ -1226,7 +1226,9 @@ def _family_receipts( @staticmethod def _construction_receipts( - first: CompactIndexedClass, second: CompactIndexedClass + first: CompactIndexedClass, + second: CompactIndexedClass, + move: type[ClassOverlapMove] | None = None, ) -> tuple[Receipt, ...]: if _construction(first) == _construction(second): return ( @@ -1256,7 +1258,19 @@ def _construction_receipts( != second.class_decorators_are_promotion_safe ): differences.append("decorator safety") - detail = ", ".join(differences) if differences else "class creation" + if not differences: + differences.append("class creation") + if move is ExtractMixin and all( + d.startswith("class keywords") for d in differences + ): + return ( + Receipt( + OverlapReceiptKind.SAME_CONSTRUCTION, + "class keywords differ but a new mixin does not inherit them", + (_location(first), _location(second)), + ), + ) + detail = ", ".join(differences) return ( Receipt( OverlapReceiptKind.DIFFERENT_CONSTRUCTION, diff --git a/tests/test_missing_mixin_unblock.py b/tests/test_missing_mixin_unblock.py index 706066f6..fef09128 100644 --- a/tests/test_missing_mixin_unblock.py +++ b/tests/test_missing_mixin_unblock.py @@ -171,6 +171,56 @@ def test_extract_mixin_verdict_not_blocked_by_inherited( assert c.inherited_names, "inherited_names still available via overlap" +KEYWORD_MISMATCH = ( + "from dataclasses import dataclass\n" + "from nominal_refactor_advisor.nominal.declared_family import DeclaredFamily\n\n\n" + "@dataclass(frozen=True)\n" + "class FamilyMember(DeclaredFamily, affix='Member'):\n" + " alpha: str\n" + " beta: int\n" + " gamma: float\n" + " own_family: str = 'x'\n\n\n" + "@dataclass(frozen=True)\n" + "class PlainRecord:\n" + " alpha: str\n" + " beta: int\n" + " gamma: float\n" + " own_plain: str = 'y'\n" +) + + +def test_keyword_only_construction_diff_does_not_block_extract_mixin( + tmp_path: Path, +) -> None: + """When two classes differ only in class keywords (affix vs nothing), + ExtractMixin should not be blocked by DIFFERENT_CONSTRUCTION: the + mixin does not inherit the keywords.""" + + from nominal_refactor_advisor.class_overlap import ExtractMixin + from nominal_refactor_advisor.overlap_receipts import OverlapReceiptKind + + package = tmp_path / "pkg" + package.mkdir() + (package / "__init__.py").write_text("") + (package / "classes.py").write_text(KEYWORD_MISMATCH) + snapshot = CodemodSourceSnapshot.from_modules(parse_python_modules(tmp_path)) + + verdicts = snapshot.class_overlap_verdicts + extract_mixins = [ + c for c in verdicts.candidates if c.move is ExtractMixin + ] + assert extract_mixins, "fixture must produce an ExtractMixin verdict" + for c in extract_mixins: + construction_refusals = [ + r for r in c.refusals + if r.kind is OverlapReceiptKind.DIFFERENT_CONSTRUCTION + ] + assert not construction_refusals, ( + f"ExtractMixin should not be blocked by keyword-only construction diff, " + f"got {[r.detail for r in construction_refusals]}" + ) + + def test_descendant_without_projected_fields_does_not_block_carrier( tmp_path: Path, ) -> None: