[mypyc] Preserve inherited attribute defaults under separate=True (#21547)

Fixes #21542

Under `separate=True`, when a subclass is recompiled while its parent is
loaded from mypy's incremental cache, parent default-attribute
assignments are silently dropped from the subclass's
`__mypyc_defaults_setup`. The first read of an inherited default-attr
then raises:

```
    AttributeError: attribute '<name>' of '<Parent>' undefined
```

`find_attr_initializers` walks `cdef.info.mro` and reads
`info.defn.defs.body` for `AssignmentStmt`s. `ClassDef.serialize`
(mypy/nodes.py) does not serialize `defs`, so a cache-loaded parent has
`defs = Block([])`; the MRO walk collects no parent assignments and the
subclass's emitted setup leaves inherited slots in the
undefined-sentinel state.

This PR implements the fix discussed in the linked issue.
diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py
index ea3e2dd..db94f1d 100644
--- a/mypyc/codegen/emitclass.py
+++ b/mypyc/codegen/emitclass.py
@@ -29,6 +29,7 @@
     BITMAP_BITS,
     BITMAP_TYPE,
     CPYFUNCTION_NAME,
+    MYPYC_DEFAULTS_SETUP,
     NATIVE_PREFIX,
     PREFIX,
     REG_PREFIX,
@@ -285,7 +286,7 @@
 
     # If the class has a method to initialize default attribute
     # values, we need to call it during initialization.
-    defaults_fn = cl.get_method("__mypyc_defaults_setup")
+    defaults_fn = cl.get_method(MYPYC_DEFAULTS_SETUP)
 
     # If there is a __init__ method, we'll use it in the native constructor.
     init_fn = cl.get_method("__init__")
diff --git a/mypyc/common.py b/mypyc/common.py
index 64fe812..382d640 100644
--- a/mypyc/common.py
+++ b/mypyc/common.py
@@ -24,6 +24,7 @@
 LAMBDA_NAME: Final = "__mypyc_lambda__"
 PROPSET_PREFIX: Final = "__mypyc_setter__"
 SELF_NAME: Final = "__mypyc_self__"
+MYPYC_DEFAULTS_SETUP: Final = "__mypyc_defaults_setup"
 GENERATOR_ATTRIBUTE_PREFIX: Final = "__mypyc_generator_attribute__"
 CPYFUNCTION_NAME = "__cpyfunction__"
 
diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py
index 3f95cf8..5bc19c9 100644
--- a/mypyc/irbuild/classdef.py
+++ b/mypyc/irbuild/classdef.py
@@ -7,6 +7,7 @@
 from typing import Final
 
 from mypy.nodes import (
+    ARG_POS,
     EXCLUDED_ENUM_ATTRIBUTES,
     TYPE_VAR_TUPLE_KIND,
     AssignmentStmt,
@@ -21,7 +22,6 @@
     NameExpr,
     OverloadedFuncDef,
     PassStmt,
-    RefExpr,
     StrExpr,
     TempNode,
     TypeInfo,
@@ -29,7 +29,7 @@
     is_class_var,
 )
 from mypy.types import Instance, UnboundType, get_proper_type
-from mypyc.common import PROPSET_PREFIX
+from mypyc.common import MYPYC_DEFAULTS_SETUP, PROPSET_PREFIX
 from mypyc.ir.class_ir import ClassIR, NonExtClassInfo
 from mypyc.ir.func_ir import FuncDecl, FuncSignature
 from mypyc.ir.ops import (
@@ -48,15 +48,7 @@
     TupleSet,
     Value,
 )
-from mypyc.ir.rtypes import (
-    RType,
-    bool_rprimitive,
-    dict_rprimitive,
-    is_none_rprimitive,
-    is_object_rprimitive,
-    is_optional_type,
-    object_rprimitive,
-)
+from mypyc.ir.rtypes import RType, bool_rprimitive, dict_rprimitive, object_rprimitive
 from mypyc.irbuild.builder import IRBuilder, create_type_params
 from mypyc.irbuild.function import (
     gen_property_getter_ir,
@@ -66,7 +58,13 @@
     load_type,
 )
 from mypyc.irbuild.prepare import GENERATOR_HELPER_NAME
-from mypyc.irbuild.util import dataclass_type, get_func_def, is_constant, is_dataclass_decorator
+from mypyc.irbuild.util import (
+    dataclass_type,
+    default_attr_name,
+    get_func_def,
+    is_constant,
+    is_dataclass_decorator,
+)
 from mypyc.primitives.dict_ops import dict_new_op, exact_dict_set_item_op
 from mypyc.primitives.generic_ops import (
     iter_op,
@@ -322,10 +320,6 @@
     def class_body_obj(self) -> Value | None:
         return self.type_obj
 
-    def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
-        """Controls whether to skip generating a default for an attribute."""
-        return False
-
     def add_method(self, fdef: FuncDef) -> None:
         handle_ext_method(self.builder, self.cdef, fdef)
 
@@ -348,11 +342,18 @@
         # Call __init_subclass__ after class attributes have been set
         self.builder.call_c(py_init_subclass_op, [self.type_obj], self.cdef.line)
 
-        attrs_with_defaults, default_assignments = find_attr_initializers(
-            self.builder, self.cdef, self.skip_attr_default
-        )
-        ir.attrs_with_defaults.update(attrs_with_defaults)
-        generate_attr_defaults_init(self.builder, self.cdef, default_assignments)
+        # Under separate compilation, prepare.py pre-registers the decl iff
+        # the class has its own default attribute assignments to emit, so we
+        # can skip the body walk entirely when it isn't present. Without
+        # separate compilation, find_attr_initializers walks the MRO so that
+        # inherited defaults are reflected in ir.attrs_with_defaults (relied
+        # on by the attribute-definedness analysis), so we always run it.
+        if not self.builder.options.separate or MYPYC_DEFAULTS_SETUP in ir.method_decls:
+            attrs_with_defaults, default_assignments = find_attr_initializers(
+                self.builder, self.cdef
+            )
+            ir.attrs_with_defaults.update(attrs_with_defaults)
+            generate_attr_defaults_init(self.builder, self.cdef, default_assignments)
         create_ne_from_eq(self.builder, self.cdef)
 
 
@@ -380,9 +381,6 @@
             self.builder.add(LoadAddress(type_object_op.type, type_object_op.src, self.cdef.line)),
         )
 
-    def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
-        return stmt.type is not None
-
     def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
         # We populate __annotations__ because dataclasses uses it to determine
         # which attributes to compute on.
@@ -445,9 +443,6 @@
 
     add_annotations_to_dict = False
 
-    def skip_attr_default(self, name: str, stmt: AssignmentStmt) -> bool:
-        return True
-
     def get_type_annotation(self, stmt: AssignmentStmt) -> TypeInfo | None:
         if isinstance(stmt.rvalue, CallExpr):
             # find the type arg in `attr.ib(type=str)`
@@ -741,58 +736,50 @@
 
 
 def find_attr_initializers(
-    builder: IRBuilder, cdef: ClassDef, skip: Callable[[str, AssignmentStmt], bool] | None = None
+    builder: IRBuilder, cdef: ClassDef
 ) -> tuple[set[str], list[tuple[AssignmentStmt, str]]]:
     """Find initializers of attributes in a class body.
 
-    If provided, the skip arg should be a callable which will return whether
-    to skip generating a default for an attribute.  It will be passed the name of
-    the attribute and the corresponding AssignmentStmt.
+    Under separate compilation, only this class's own body is walked, and
+    generate_attr_defaults_init emits a runtime call to the parent's
+    __mypyc_defaults_setup so inherited defaults are produced by chaining,
+    not by inlining. Walking the MRO here would break under separate=True
+    with mypy's incremental cache: a base class loaded from the cache has
+    an empty ClassDef.defs.body (mypy/nodes.py::ClassDef.serialize doesn't
+    serialize the class body), so inherited assignments would be silently
+    dropped and the subclass's __mypyc_defaults_setup would leave inherited
+    slots in the "undefined" state at runtime.
+
+    Without separate compilation, all modules are parsed in the same pass
+    and the MRO walk is safe; we keep the original inline-all behavior
+    there as an optimization (no chain call needed for instance creation).
     """
     cls = builder.mapper.type_to_ir[cdef.info]
     if cls.builtin_base:
         return set(), []
 
-    attrs_with_defaults = set()
-
-    # Pull out all assignments in classes in the mro so we can initialize them
-    # TODO: Support nested statements
+    cls_type = dataclass_type(cdef)
+    attrs_with_defaults: set[str] = set()
     default_assignments: list[tuple[AssignmentStmt, str]] = []
-    for info in reversed(cdef.info.mro):
-        if info not in builder.mapper.type_to_ir:
+
+    # TODO: Support nested statements
+    if builder.options.separate:
+        infos: list[TypeInfo] = [cdef.info]
+    else:
+        infos = list(reversed(cdef.info.mro))
+
+    for info in infos:
+        info_ir = builder.mapper.type_to_ir.get(info)
+        if info_ir is None:
             continue
         for stmt in info.defn.defs.body:
-            if (
-                isinstance(stmt, AssignmentStmt)
-                and isinstance(stmt.lvalues[0], NameExpr)
-                and not is_class_var(stmt.lvalues[0])
-                and not isinstance(stmt.rvalue, TempNode)
-            ):
-                name = stmt.lvalues[0].name
-                if name == "__slots__":
-                    continue
-
-                if name == "__deletable__":
-                    check_deletable_declaration(builder, cls, stmt.line)
-                    continue
-
-                if skip is not None and skip(name, stmt):
-                    continue
-
-                attr_type = cls.attr_type(name)
-
-                # If the attribute is initialized to None and type isn't optional,
-                # doesn't initialize it to anything (special case for "# type:" comments).
-                if isinstance(stmt.rvalue, RefExpr) and stmt.rvalue.fullname == "builtins.None":
-                    if (
-                        not is_optional_type(attr_type)
-                        and not is_object_rprimitive(attr_type)
-                        and not is_none_rprimitive(attr_type)
-                    ):
-                        continue
-
-                attrs_with_defaults.add(name)
-                default_assignments.append((stmt, info.module_name))
+            if not isinstance(stmt, AssignmentStmt):
+                continue
+            name = default_attr_name(stmt, info_ir, cls_type)
+            if name is None:
+                continue
+            attrs_with_defaults.add(name)
+            default_assignments.append((stmt, info.module_name))
 
     return attrs_with_defaults, default_assignments
 
@@ -800,15 +787,49 @@
 def generate_attr_defaults_init(
     builder: IRBuilder, cdef: ClassDef, default_assignments: list[tuple[AssignmentStmt, str]]
 ) -> None:
-    """Generate an initialization method for default attr values (from class vars)."""
-    if not default_assignments:
-        return
+    """Generate an initialization method for default attr values (from class vars).
+
+    Under separate compilation, the emitted __mypyc_defaults_setup chains to
+    the nearest ancestor that has the method (Python __init__ style), then
+    sets only this class's own defaults; inherited defaults are produced by
+    the chain at runtime. The ancestor lookup uses cls.mro[1:] and relies on
+    prepare.py having registered the FuncDecl on every class that needs one
+    before any IR build runs. IR build within a compilation group proceeds
+    in filename order, so this class may be IR-built before its base, and a
+    method_decls lookup that depended on the base having been IR-built first
+    would miss. Without separate compilation, find_attr_initializers has
+    already collected the full MRO's defaults into default_assignments, so
+    we inline them all as before.
+    """
     cls = builder.mapper.type_to_ir[cdef.info]
     if cls.builtin_base:
         return
 
-    with builder.enter_method(cls, "__mypyc_defaults_setup", bool_rprimitive):
+    parent_with_defaults: ClassIR | None = None
+    if builder.options.separate:
+        for ancestor in cls.mro[1:]:
+            if MYPYC_DEFAULTS_SETUP in ancestor.method_decls:
+                parent_with_defaults = ancestor
+                break
+
+    if not default_assignments and parent_with_defaults is None:
+        return
+
+    with builder.enter_method(cls, MYPYC_DEFAULTS_SETUP, bool_rprimitive):
         self_var = builder.self()
+
+        # Chain to parent's setup so inherited defaults run first; propagate
+        # its False return so a parent default that raised still aborts
+        # instance creation rather than being silently swallowed here.
+        if parent_with_defaults is not None:
+            decl = parent_with_defaults.method_decl(MYPYC_DEFAULTS_SETUP)
+            parent_ok = builder.builder.call(decl, [self_var], [ARG_POS], [None], cdef.line)
+            fail_block, continue_block = BasicBlock(), BasicBlock()
+            builder.add(Branch(parent_ok, continue_block, fail_block, Branch.BOOL))
+            builder.activate_block(fail_block)
+            builder.add(Return(builder.false()))
+            builder.activate_block(continue_block)
+
         for stmt, origin_module in default_assignments:
             lvalue = stmt.lvalues[0]
             assert isinstance(lvalue, NameExpr), lvalue
@@ -833,26 +854,6 @@
         builder.add(Return(builder.true()))
 
 
-def check_deletable_declaration(builder: IRBuilder, cl: ClassIR, line: int) -> None:
-    for attr in cl.deletable:
-        if attr not in cl.attributes:
-            if not cl.has_attr(attr):
-                builder.error(f'Attribute "{attr}" not defined', line)
-                continue
-            for base in cl.mro:
-                if attr in base.property_types:
-                    builder.error(f'Cannot make property "{attr}" deletable', line)
-                    break
-            else:
-                _, base = cl.attr_details(attr)
-                builder.error(
-                    ('Attribute "{}" not defined in "{}" ' + '(defined in "{}")').format(
-                        attr, cl.name, base.name
-                    ),
-                    line,
-                )
-
-
 def create_ne_from_eq(builder: IRBuilder, cdef: ClassDef) -> None:
     """Create a "__ne__" method from a "__eq__" method (if only latter exists)."""
     cls = builder.mapper.type_to_ir[cdef.info]
diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py
index f143ce1..8b73b10 100644
--- a/mypyc/irbuild/prepare.py
+++ b/mypyc/irbuild/prepare.py
@@ -21,6 +21,7 @@
 from mypy.nodes import (
     ARG_STAR,
     ARG_STAR2,
+    AssignmentStmt,
     CallExpr,
     ClassDef,
     Decorator,
@@ -39,7 +40,13 @@
 from mypy.semanal import refers_to_fullname
 from mypy.traverser import TraverserVisitor
 from mypy.types import Instance, Type, get_proper_type
-from mypyc.common import FAST_PREFIX, PROPSET_PREFIX, SELF_NAME, get_id_from_name
+from mypyc.common import (
+    FAST_PREFIX,
+    MYPYC_DEFAULTS_SETUP,
+    PROPSET_PREFIX,
+    SELF_NAME,
+    get_id_from_name,
+)
 from mypyc.crash import catch_errors
 from mypyc.errors import Errors
 from mypyc.ir.class_ir import ClassIR
@@ -55,6 +62,7 @@
 from mypyc.ir.rtypes import (
     RInstance,
     RType,
+    bool_rprimitive,
     dict_rprimitive,
     none_rprimitive,
     object_pointer_rprimitive,
@@ -63,6 +71,8 @@
 )
 from mypyc.irbuild.mapper import Mapper
 from mypyc.irbuild.util import (
+    dataclass_type,
+    default_attr_name,
     get_func_def,
     get_mypyc_attrs,
     is_dataclass,
@@ -131,6 +141,24 @@
         if class_ir.is_ext_class:
             prepare_implicit_property_accessors(cdef.info, class_ir, module.fullname, mapper)
 
+    # Register __mypyc_defaults_setup FuncDecls on classes that have their own
+    # class-level default attribute assignments. Done here, before any IR build
+    # runs, so that the cross-class lookup in generate_attr_defaults_init is
+    # order-independent: IR build within a compilation group proceeds in
+    # filename order, so a subclass may be IR-built before its base.
+    for module, cdef in classes:
+        class_ir = mapper.type_to_ir[cdef.info]
+        if class_ir.is_ext_class and _has_own_default_attrs(cdef, class_ir):
+            _register_defaults_setup_decl(class_ir, module.fullname)
+
+    # Validate __deletable__ declarations. Done here so the compiler exits
+    # early on invalid input before any IR is built.
+    for module, cdef in classes:
+        class_ir = mapper.type_to_ir[cdef.info]
+        if class_ir.is_ext_class:
+            with catch_errors(module.path, cdef.line):
+                _check_deletable_declarations(module.path, cdef, class_ir, errors)
+
     # Collect all the functions also. We collect from the symbol table
     # so that we can easily pick out the right copy of a function that
     # is conditionally defined. This doesn't include nested functions!
@@ -408,6 +436,68 @@
         )
 
 
+def _has_own_default_attrs(cdef: ClassDef, ir: ClassIR) -> bool:
+    """Whether this class's own body has any default attribute assignment
+    that would be emitted into __mypyc_defaults_setup.
+
+    Used during prepare to decide whether to register a
+    __mypyc_defaults_setup FuncDecl ahead of IR build.
+    """
+    if ir.builtin_base or ir.is_trait:
+        return False
+    cls_type = dataclass_type(cdef)
+    return any(
+        default_attr_name(stmt, ir, cls_type) is not None
+        for stmt in cdef.info.defn.defs.body
+        if isinstance(stmt, AssignmentStmt)
+    )
+
+
+def _register_defaults_setup_decl(ir: ClassIR, module_name: str) -> None:
+    sig = FuncSignature([RuntimeArg(SELF_NAME, RInstance(ir))], bool_rprimitive)
+    ir.method_decls[MYPYC_DEFAULTS_SETUP] = FuncDecl(
+        MYPYC_DEFAULTS_SETUP, ir.name, module_name, sig
+    )
+
+
+def _check_deletable_declarations(path: str, cdef: ClassDef, ir: ClassIR, errors: Errors) -> None:
+    """Validate that attributes listed in __deletable__ refer to definable
+    attributes on the class.
+
+    Runs in the prepare phase so we exit early on invalid programs before
+    any IR is built.
+    """
+    if not ir.deletable:
+        return
+    line = next(
+        (
+            stmt.line
+            for stmt in cdef.info.defn.defs.body
+            if isinstance(stmt, AssignmentStmt)
+            and isinstance(stmt.lvalues[0], NameExpr)
+            and stmt.lvalues[0].name == "__deletable__"
+        ),
+        cdef.line,
+    )
+    for attr in ir.deletable:
+        if attr not in ir.attributes:
+            if not ir.has_attr(attr):
+                errors.error(f'Attribute "{attr}" not defined', path, line)
+                continue
+            for base in ir.mro:
+                if attr in base.property_types:
+                    errors.error(f'Cannot make property "{attr}" deletable', path, line)
+                    break
+            else:
+                _, base = ir.attr_details(attr)
+                errors.error(
+                    f'Attribute "{attr}" not defined in "{ir.name}" '
+                    f'(defined in "{base.name}")',
+                    path,
+                    line,
+                )
+
+
 def prepare_class_def(
     path: str,
     module_name: str,
diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py
index 5eda51a..a6f793c 100644
--- a/mypyc/irbuild/util.py
+++ b/mypyc/irbuild/util.py
@@ -12,6 +12,7 @@
     ARG_POS,
     GDEF,
     ArgKind,
+    AssignmentStmt,
     BytesExpr,
     CallExpr,
     ClassDef,
@@ -24,13 +25,17 @@
     OverloadedFuncDef,
     RefExpr,
     StrExpr,
+    TempNode,
     TupleExpr,
     UnaryExpr,
     Var,
+    is_class_var,
 )
 from mypy.semanal import refers_to_fullname
 from mypy.types import FINAL_DECORATOR_NAMES
 from mypyc.errors import Errors
+from mypyc.ir.class_ir import ClassIR
+from mypyc.ir.rtypes import is_none_rprimitive, is_object_rprimitive, is_optional_type
 
 MYPYC_ATTRS: Final[frozenset[MypycAttr]] = frozenset(
     ["native_class", "allow_interpreted_subclasses", "serializable", "free_list_len", "acyclic"]
@@ -102,6 +107,50 @@
     return None
 
 
+def _defaults_skip(stmt: AssignmentStmt, cls_type: str | None) -> bool:
+    """Whether a class-level default assignment is skipped when emitting
+    __mypyc_defaults_setup, based on class type.
+
+    - attr (auto_attribs=False): skip all (handled by attr.ib machinery).
+    - dataclasses / attr-auto: skip annotated assignments.
+    - regular extension class: skip nothing.
+    """
+    if cls_type == "attr":
+        return True
+    if cls_type in ("dataclasses", "attr-auto"):
+        return stmt.type is not None
+    return False
+
+
+def default_attr_name(stmt: AssignmentStmt, ir: ClassIR, cls_type: str | None) -> str | None:
+    """Return the attribute name if `stmt` is a class-level default assignment
+    that __mypyc_defaults_setup should emit; otherwise None.
+
+    Single source of truth for the predicate used by both
+    mypyc.irbuild.classdef.find_attr_initializers (IR build) and
+    mypyc.irbuild.prepare._has_own_default_attrs (prepare-phase decl registration).
+    """
+    lvalue = stmt.lvalues[0]
+    if not isinstance(lvalue, NameExpr) or is_class_var(lvalue):
+        return None
+    if isinstance(stmt.rvalue, TempNode):
+        return None
+    name = lvalue.name
+    if name in ("__slots__", "__deletable__") or name not in ir.attributes:
+        return None
+    if _defaults_skip(stmt, cls_type):
+        return None
+    if isinstance(stmt.rvalue, RefExpr) and stmt.rvalue.fullname == "builtins.None":
+        attr_type = ir.attributes[name]
+        if (
+            not is_optional_type(attr_type)
+            and not is_object_rprimitive(attr_type)
+            and not is_none_rprimitive(attr_type)
+        ):
+            return None
+    return name
+
+
 def get_mypyc_attr_literal(e: Expression) -> Any:
     """Convert an expression from a mypyc_attr decorator to a value.
 
diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test
index d13bd95..a7fdc09 100644
--- a/mypyc/test-data/irbuild-classes.test
+++ b/mypyc/test-data/irbuild-classes.test
@@ -1135,7 +1135,7 @@
     __deletable__ = ['x']
     x: int
 
-[case testInvalidDeletableAttribute]
+[case testDeleteNonDeletableAttribute]
 class NotDeletable:
     __deletable__ = ['x']
     x: int
@@ -1146,6 +1146,7 @@
     del o.y  # E: "y" cannot be deleted \
              # N: Using "__deletable__ = ['<attr>']" in the class body enables "del obj.<attr>"
 
+[case testInvalidDeletableAttribute]
 class Base:
     x: int
 
diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test
index ace1ab9..e586a3c 100644
--- a/mypyc/test-data/run-multimodule.test
+++ b/mypyc/test-data/run-multimodule.test
@@ -1813,3 +1813,86 @@
 [out2]
 empty
 hello
+
+[case testIncrementalCrossModuleInheritedAttrDefaultsWithOverride]
+# Regression: same shape as testIncrementalCrossModuleInheritedAttrDefaults,
+# but the subclass adds an attribute of its own, so generate_attr_defaults_init
+# emits a __mypyc_defaults_setup for it. Before the fix, the recompiled
+# subclass walked the parent's ClassDef.defs.body to collect inherited
+# defaults; when the parent was loaded from mypy's incremental cache that
+# body was empty, so the inherited initialization was dropped and any
+# access to an inherited attribute through compiled code raised
+# "AttributeError: attribute '<name>' of '<base>' undefined".
+import other_a
+
+def test() -> None:
+    c = other_a.Child()
+    # Inherited attributes must still be initialized after the subclass
+    # has been recompiled against a cache-loaded parent.
+    assert c.x == 1
+    assert c.y == "hello"
+    # Own override is set by the subclass's own __mypyc_defaults_setup.
+    assert c.z is True
+    # Method defined on the parent reads an inherited attribute through
+    # the compiled path; this is what crashes pre-fix.
+    assert c.use() == 1
+
+[file other_b.py]
+class Parent:
+    x: int = 1
+    y: str = "hello"
+    z: bool = False
+
+    def use(self) -> int:
+        if self.x:
+            return 1
+        return 0
+
+[file other_a.py]
+from other_b import Parent
+
+class Child(Parent):
+    z: bool = True
+
+[file other_a.py.2]
+from other_b import Parent
+
+class Child(Parent):
+    z: bool = True
+
+def _force_recompile() -> int:
+    return 1
+
+[file driver.py]
+from native import test
+test()
+
+[case testCrossModuleInheritedAttrDefaultsSameGroup]
+# separate: [(["native.py"], "grp1"), (["other_a.py", "other_b.py"], "grp2")]
+# Regression: with the subclass (other_a) and base (other_b) in the same
+# compilation group, IR build runs alphabetically within the group, so
+# the subclass is IR-built before the base. The decision to emit
+# __mypyc_defaults_setup (and a chained call to the ancestor's) must be
+# set up in the prepare phase, before any IR build runs; otherwise the
+# subclass's lookup of the parent's setup decl misses and inherited
+# defaults are lost on a fresh build.
+import other_a
+
+def test() -> None:
+    c = other_a.Child()
+    assert c.x == 1
+    assert c.z is True
+
+[file other_b.py]
+class Parent:
+    x: int = 1
+
+[file other_a.py]
+from other_b import Parent
+
+class Child(Parent):
+    z: bool = True
+
+[file driver.py]
+from native import test
+test()