Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions nominal_refactor_advisor/class_overlap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions tests/test_missing_mixin_unblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading