Jia Chen
90d · built 2026-08-09
90-day totals
- Commits
- 88
- Grow
- 5.1
- Maintenance
- 3.5
- Fixes
- 4.5
- Total ETV
- 13.1
30-day trajectory
Last 30 days vs. the 30 days before. Up arrows on Growth and ETV mean improvement; up arrow on Fixes share means more time on fixes (worse).
↓-40.4 %
vs 47 prior
↓-20.0 pp
recent vs prior
↑+25.7 pp
recent vs prior
Daily performance
Daily ETV, stacked by Growth, Maintenance and Fixes.
Work-mix over time
Share of Growth / Maintenance / Fixes over a rolling 7-day window. Reads as 'where is effort flowing right now'.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 20 by ETV in the 90-day window.
- 1.6ETVUpgrade typeshed Summary: Refresh the typeshed stubs bundled into the Pyrefly binary so the type checker resolves against current stdlib and third-party type information. The bundled snapshot was pinned to typeshed `1d3abc4` (2026-05-28); this bumps it to upstream `main` HEAD `a7bcce8` (2026-07-07). Fixes https://github.com/facebook/pyrefly/issues/4059 Reviewed By: yangdanny97 Differential Revision: D110970459 fbshipit-source-id: 363b39d2def8493b9e52c342ddf2aef094bb5922github.com-facebook-pyrefly · 2e1819d8 · 2026-07-08
- 0.9ETVExtend receiver-constrained semantics to multi-target and unpacking assignments Summary: The receiver-constrained semantics introduced in this stack only flowed through single-target `Real = Dummy` assignments. Multi-target chains (`other = Real = Dummy`) and unpacking forms (`Real, _ = (Dummy, 0)`) silently bypassed the check, leaving the post-join type as `type[Dummy] | type[Real]` and producing spurious argument errors at downstream call sites against `Dummy.__init__`. The fix mirrors the single-target plumbing: `Binding::MultiTargetAssign` and `Binding::UnpackedValue` now carry an optional `MultiTargetReceiver` describing the canonical class key and the rebound name. `bind_target_name` consults the same `canonical_class_receiver_idx` lookup as `NameAssign` and threads the receiver through the new binding field; the solver shares a `check_against_receiver` helper that runs the rebind RHS through `check_and_return_type` against the original `type[Real]`, emitting a single `bad-assignment` error and keeping the visible type pinned to `type[Real]`. The previously bug-marked `test_class_rebind_multi_target` and `test_class_rebind_unpacked` now exercise the corrected behavior. Reviewed By: stroxler Differential Revision: D102405597 fbshipit-source-id: 7956311e1cebe5169a07b7be63b55341c9f7e3f9github.com-facebook-pyrefly · d6ad8487 · 2026-05-21
- 0.9ETVMove reusable module resolver to `pyrefly_build` crate (1/2) Summary: justmovingthingsaround Bazel check, and other build-system integrations, need to resolve dependency imports directly from search roots using the same package, stub, namespace, and compiled-module lookup semantics as Pyrefly's checker. That logic previously lived inside the checker's `finder.rs`, coupled to checker command code, so it could not be reused outside the checker. This moves the filesystem module-resolution core into a new `module_resolver` module in the `pyrefly_build` crate (registered in `lib.rs`). The module owns the low-level package/stub/namespace/compiled walk -- `find_one_part`, `continue_find_module`, `find_module_components`, `find_module_results`, `is_pkgutil_namespace`, and the `FindResult` type -- plus a `ModuleResolver` entry point for build-system callers. The extraction is a pure move of the filesystem-walking logic with no checker-specific policy attached: bundled stubs, typeshed ordering, import-ignore handling, and py.typed diagnostics all stay in the checker. As a result `pyrefly_build` can depend on the resolver without pulling in checker command code, and the resolver's own unit tests move with it so the crate that owns the internals owns their tests. This diff only copies the current logic into `pyrefly_build`. The next diff will complete the moving by deleting pre-existing logic, and adjust downstream logic to point to the new code. Reviewed By: ndmitchell Differential Revision: D110825229 fbshipit-source-id: e4ed786a1be2535b1c2cd9470fe89dd3e9466631github.com-facebook-pyrefly · 840c8098 · 2026-07-08
- 0.6ETVPromote `@dataclass(slots=True)` to PEP 800 disjoint base Summary: When using Pyrefly, there was a confusing behavior around classes that use `__slots__`—especially those created with `dataclass(slots=True)`: - **Explicit `__slots__`**: If you defined `__slots__` directly in your class, Pyrefly correctly treated the class as a "disjoint base" (meaning, it prevents certain types of multiple inheritance and helps with type narrowing). - **Dataclass-generated `__slots__`**: If your class got its `__slots__` from `dataclass(slots=True)`, Pyrefly did NOT treat it as a disjoint base yet. This led to: - Multiple inheritance of two `dataclass(slots=True)` bases (or one such base plus an explicit-`__slots__` base) was allowed, even though it should not be. - Type narrowing with `isinstance` did not recognize these classes as disjoint, which could cause bugs. ## Why Was This Happening? - The information about whether a class should be a disjoint base was stored in `ClassMetadata`. - However, the actual `__slots__` for dataclasses are only created later, during field solving (`ClassField`), so Pyrefly couldn't check them during metadata construction without causing a dependency cycle. - Inheriting `kws.slots = true` from a base would incorrectly promote every subclass of a slotted dataclass, which is not what CPython does. ## What Is the Fix? The new approach models CPython's behavior more closely: 1. **Promotion to Disjoint Base**: A class is promoted to a new disjoint-base representative only if: - It has a fresh, local `dataclass(slots=True)`-like decorator applied. - There is NOT an explicit class-body `__slots__` present (having both is a runtime error and disables dataclass slot synthesis). - When walking the class's inheritance chain (MRO), at least one instance field (local or inherited) is not already covered by an ancestor's slot. 2. **Precise Detection**: The code now tracks whether the most recent local dataclass-like decorator requested `slots=True`, separate from whether the final `kws.slots` is true (important for inheritance cases). 3. **Explicit vs. Synthesized Slots**: `SlotsInfo` only refers to explicit class-body `__slots__`. Dataclass field synthesis is unchanged. 4. **Tests**: Tests that previously failed now pass. ## Known Follow-Up - If both an explicit `dataclass(...)` decorator and a `dataclass_transform`-derived spec apply to the same class, the transform branch overwrites both the dataclass metadata and the slots flag. The boolean stays in sync with the final `kws.slots`, but the merge policy will be addressed in a future change. Reviewed By: samwgoldman Differential Revision: D108108047 fbshipit-source-id: e67c8cd3f006a4e1826630ff7c9f06cde7a20df6github.com-facebook-pyrefly · 8eb21bce · 2026-06-11
- 0.6ETVAdd path model for Bazel short-path to Pyrefly import resolution Summary: The `bazel-check` command currently receives raw JSON from the Bazel aspect with short paths, path overlays, and search path metadata, but had no typed model to turn those into something Pyrefly check can easily understand. This diff introduce a path model in `bazel_check.rs` and validate it before subsequent processing. The command still writes empty output, but now exercises parsing invariants so the next diff can plug the model into the module finder without changing the input contract. We keeps validation at the input boundary and makes the conversion helpers operate only on already-validated paths. Short paths and overlay physical paths now reject absolute paths, parent traversal, current-directory components, empty components from repeated separators, and trailing slashes. Logical-to-physical import conversion is component-aware, so workspace-prefixed imports are stripped as path components rather than by raw string prefix matching. Follow-up diff will wire `BazelFileInput` and `BazelSearchRoot` into Pyrefly's module finder and state initialization to actually type-check Bazel targets. No user-visible behavior change yet; `bazel-check` still outputs empty diagnostics but now fails fast on malformed input, keeping invariants exercised. Reviewed By: maggiemoss Differential Revision: D110555018 fbshipit-source-id: 8e5c2be3bfdc889a9cc096424464484a3f2fb172github.com-facebook-pyrefly · 1ec5114f · 2026-07-09
- 0.5ETVStop completing TypedDict keys that are not identifiers Summary: Python's functional TypedDict syntax allows TypedDicts to declare keys that aren't valid Python identifiers, and Pyrefly intentionally supports these keys. Values of such types can still be constructed and unpacked as mappings, making the keys meaningful even though they can't be written literally in code. Previously, when a TypedDict was used to type a `**kwargs` parameter or when its constructor was called, the IDE suggested every key as a possible keyword argument—even those that couldn't be parsed. This issue arose from two separate sources: unpacked keyword arguments and the constructor generated for the TypedDict. Fixing just one would have left the broken suggestion accessible via the other, so now both routes funnel through a single location where keyword-argument suggestions are built, and the validation check is centralized there. Determining whether a name can be written in source requires two checks: whether it's a valid identifier and whether it's a reserved keyword. The codebase previously had three partial solutions: one checked keywords but only accepted ASCII identifiers, while the other two accepted any identifier but ignored keywords. These have now been replaced by a unified predicate that checks both conditions, aligning with Python's own rules. Soft keywords are intentionally not treated as reserved, so names like `match` remain valid identifiers and can be used as keyword arguments. This stricter, more accurate predicate also resolves two smaller issues: - Functional class definitions now accept non-ASCII member names, which Python has always allowed but the old ASCII-only check rejected. - The dict-to-TypedDict quick fix now avoids firing when a key is a reserved keyword, preventing the generation of class bodies that won't parse. Reviewed By: yangdanny97 Differential Revision: D114975982 fbshipit-source-id: 49c5bdbf87961cddfda91c95c558997f7c1e6effgithub.com-facebook-pyrefly · 604d0e04 · 2026-08-06
- 0.4ETVAdd KeyClassDisjointBase scaffolding Summary: Introduce a new class-scoped solver key, `KeyClassDisjointBase`, which is intended to eventually handle PEP 800 disjoint-base propagation. No functional changes intended: the solver continues to return the same representative as computed by `ClassMetadata::is_disjoint_base()` and `ClassMro::nearest_disjoint_base()`. Currently, no caller uses the new key, and `ClassMro` still maintains its cached disjoint-base field. The purpose of landing this plumbing separately is to ensure that the upcoming behavioral diff remains focused solely on the algorithm migration, without mixing in table, binding, or state boilerplate changes. Reviewed By: yangdanny97 Differential Revision: D108246271 fbshipit-source-id: c38e6c930a64e9523e304b80d168269e3d296c12github.com-facebook-pyrefly · 4ebb45d0 · 2026-06-12
- 0.4ETVHonor Pydantic `populate_by_name` Summary: Pydantic models configured with an alias generator and `populate_by_name=True` accept both generated aliases and declared field names. Pyrefly recognized the newer `validate_by_name` and `validate_by_alias` flags but ignored the legacy `populate_by_name` option, so alias-generator support synthesized only the alias keyword and incorrectly rejected the field name. This also affected inherited model configuration. Teach Pydantic config parsing to retain `populate_by_name` and normalize it using runtime semantics: when `validate_by_name` is unset, use the `populate_by_name` value and force `validate_by_alias=True`. Preserve unresolved validation settings as `Option<bool>` in class metadata so subclasses distinguish an absent option from an explicit `False`; existing `__init__` synthesis then emits the correct keyword forms. Tests cover `ConfigDict`, class-keyword, precedence, and inheritance cases. Fixes https://github.com/facebook/pyrefly/issues/4365 Reviewed By: yangdanny97 Differential Revision: D114140809 fbshipit-source-id: 440a6558fedafbf4ea570fc37fefc50d4a5a4249github.com-facebook-pyrefly · faed8eb7 · 2026-07-30
- 0.4ETVAdd source database for bazel-check import resolution Summary: The previous diff made `bazel-check` parse its Bazel-provided input into file entries and search roots, but it still discarded them without building anything the type checker could use. This diff transforms those intermediate structures into a dedicated `SourceDatabase`: a resolver that answers “which file backs module `foo.bar.baz`?” and “which modules is this target actually responsible for checking?”. This is the last data-massaging step before we can hand data to Pyrefly and run a real check. The core difficulty is that Bazel describes each file in two coordinate systems at once: a logical path, where Python’s import system sees it and which determines the module name, and a physical path, the actual on-disk artifact, often generated and living elsewhere. Compounding this, one module name can be served by several files: a runtime `.py`, a `.pyi` stub, or a PEP 561 `-stubs` package. Imports also resolve through an ordered set of search roots mirroring Bazel’s runfiles and repository layout. The source database’s job is to collapse all of this into deterministic module candidates and lookup behavior. The workflow is to derive a module name for each explicit file input by matching its logical path against the search roots in priority order, stripping the `-stubs` suffix so a stub package types the package it stands in for. Each file is registered as a ranked candidate for its module, and lookups resolve by rank. The rank encodes Bazel’s precedence rules: stub packages first, then earlier search roots, then interface files over executable ones. A caller-supplied style filter is treated as a preference among a module’s candidates rather than a hard constraint, falling back to the best candidate when nothing matches. Files the target owns (aka its "check roots") are separated from files present only so imports resolve. A check root that cannot be named as a module is a hard error, while a non-importable overlay is skipped. The resulting shape deliberately matches the existing `buck-check` source database contract, so both build systems drive Pyrefly through the same interface. Reviewed By: maggiemoss Differential Revision: D110555020 fbshipit-source-id: 75c8260a512930b5c52d324e1632fa6c2ea25498github.com-facebook-pyrefly · fb3e1775 · 2026-07-09
- 0.4ETVDefine `bazel-check` command input parsing Summary: Flesh out the `bazel-check` command to parse JSON inputs that Bazel may hand to us. The JSON format currently coms from the following design: https://www.internalfb.com/phabricator/paste/view/P2402942225?view=markdown This is obviously subject to lots of changes as I gradually flesh out the implementation of `rules_pyrefly`. But to bootstrap the implementation iterations we need a concrete place (i.e. the current design) to start from. Reviewed By: ndmitchell Differential Revision: D110249180 fbshipit-source-id: 2f30cf5b76e360177134d27e89e2b6fdb4d434d3github.com-facebook-pyrefly · addc551a · 2026-07-01
- 0.4ETVMake reference intent explicit Summary: Reference collection previously exposed a single yes-or-no choice for including the declaration. It could not express whether implicit protocol edges such as `Foo()` reaching `Foo.__init__` were appropriate, even though read-only features and source-rewriting features have fundamentally different needs. This refactor introduces an explicit request policy for declarations and implicit constructor calls. Every consumer initially requests the same complete result as before, so this diff changes no behavior; it only makes each consumer state what kind of references it needs. We are doing this now to prepare D114976461. With the policy already available, that follow-up can be a small, reviewable behavior change instead of mixing API plumbing with the rename fix. This entire stack is a rewrite of D114572202, which I do not think meets the quality bar. Reviewed By: kinto0 Differential Revision: D114976467 fbshipit-source-id: 43e05292387c10cd84e66a19750c322dae7845adgithub.com-facebook-pyrefly · d4370516 · 2026-08-06
- 0.3ETVSupport PEP 561 partial stub packages in imports Summary: Per PEP 561, a stub package (e.g. `foo-stubs`) whose `py.typed` file contains `partial` is an incomplete stub, and type checkers must merge it with the runtime package so that modules the stub omits still resolve from the runtime distribution. Pyrefly previously treated any `-stubs` package as authoritative: once `foo-stubs` was found, the runtime `foo` package was discarded and the `py.typed` marker was never read. Importing a module that a partial stub omits (e.g. `from foo import a` when `foo-stubs` only ships `foo/bar.pyi` and no `foo/__init__.pyi`) therefore failed with a spurious `missing-import`. This reads the `partial` marker from a stub package's `py.typed` and, when a partial stub resolves only to a bare namespace directory (meaning it does not itself provide that module file), defers to the runtime package instead of shadowing it. Modules the partial stub does provide still win, preserving the normal `.pyi`-before-`.py` precedence. This matches the typing spec's directory-merge model — functionally equivalent to overlaying the stub package onto the runtime package and type checking the combined tree. The merge is at module/file granularity, not symbol granularity: a complete (non-`partial`) stub, and a partial stub that ships its own `__init__.pyi`, remain authoritative for that package as before. Fixes https://github.com/facebook/pyrefly/issues/3811 Reviewed By: rchen152 Differential Revision: D113868455 fbshipit-source-id: 936395006a73c9a37ea90b54c17990eaf94defcdgithub.com-facebook-pyrefly · cf2cb34d · 2026-07-28
- 0.3ETVMerge class-level diagnostic check bindings Summary: `KeyVarianceCheck` and `KeyConsistentOverrideCheck` were both pure side-effect bindings: they forced class-level diagnostics and returned `EmptyAnswer`. Merge them into `KeyClassChecks` so each class gets one diagnostic binding/table entry, while leaving exported `KeyVariance` as the separate answer used by downstream variance lookups. NOTE: I was considering merging in `KeyAbstractClassCheck` as well, but then I realized that despite the name of that key, the calculation actually produces some data (i.e. the actual abstract members) that could be depended on by downstream key/bindings. So it's not a pure diagnostics-producing calculation. Reviewed By: rchen152 Differential Revision: D108324112 fbshipit-source-id: 3616338f46d705afb8b62f6de20221a065b06e0egithub.com-facebook-pyrefly · 24eb9c3e · 2026-06-12
- 0.3ETVFix spurious override errors on generic methods with *args: *Ts Summary: Pyrefly represents specialized `TypeVarTuple` arguments as tuple carriers in some class-specialization paths. That means two equivalent varargs annotations can reach callable subtyping in different internal forms: `*args: *Ts` on one side and `*args: *tuple[*Ts]` on the other. Before this diff, that representation mismatch caused Pyrefly to reject valid method overrides and callable assignments involving inherited generic `*args`. This diff normalizes the pure tuple-carrier form when comparing two unpacked varargs, so `*Ts` and `*tuple[*Ts]` are treated as the same variadic parameter sequence. The normalization is intentionally narrow: it only applies to pure carriers with no prefix or suffix elements, preserving the existing behavior for forms like `tuple[int, *Ts]` or `tuple[*Ts, str]`. Tests cover both the inherited-method override case and the callable varargs case that protects `tuple[*Ts]` against homogeneous tuple packs. Fixes https://github.com/facebook/pyrefly/issues/4073 Reviewed By: kinto0 Differential Revision: D110970933 fbshipit-source-id: 852ca320ff076fe45b592e96511ba18c7b372366github.com-facebook-pyrefly · be2b8f1e · 2026-07-10
- 0.3ETVNarrow anon-TypedDict opt-in under bare partial-Var hints Summary: D104256983 dropped bare partial-Var hints in `dict_infer` so that dict literals like `{"start": d, "tasks": []}` assigned through a `{}` accumulator could form anonymous TypedDicts and preserve their inner shape (so `val["tasks"]` stays a `list[?L]` that can later be pinned by use). mypy_primer surfaced ~29 regressions: ordinary accumulator patterns where the inner literal has only concrete values — e.g. `d[k] = {"x": 1}` followed by `d[k] = {"y": 1}` — were now being pinned to per-write anonymous TypedDicts on the first write, causing later writes with conflicting field types to fail per-field instead of widening to `dict[str, V1 | V2]`. This commit replaces the upstream "drop the hint" with a narrower downstream gate inside `dict_items_infer_inner`. The `can_create_anonymous_typed_dict` predicate is extended to allow a bare partial-Var hint as if it were no hint, but the final commit point also requires that at least one literal value still has an unpinned placeholder Var. So: - `{"start": d, "tasks": []}` — `tasks` is `list[?L]` with an unpinned element Var → anon TypedDict, preserving the open subcontainer for later pinning. - `{"x": 1}` then `{"y": 1}` — all fields concrete, no placeholder Vars → falls through to `dict[str, int]` and widens cleanly across writes. - Top-level literals with no hint — unchanged. The discriminator (`collect_maybe_placeholder_vars` + `var_is_partial`) reuses the existing pattern at `solve.rs:2114` and `overload.rs:785`. The upstream filter from D104256983 in `dict_infer` is removed. Reviewed By: rchen152 Differential Revision: D104320990 fbshipit-source-id: 9102eaf6b524e253243aed998765f7c044131154github.com-facebook-pyrefly · b08be3ee · 2026-05-14
- 0.3ETVDon't emit spurious disjoint-base errors on dataclasses with dynamic __slots__ Summary: When a user writes `dataclass(slots=True)` on a class that also declares `__slots__` directly in its body, pyrefly correctly flags the conflict between the two. But if the `__slots__` value was something pyrefly couldn't read statically (`__slots__ = get_slots()`, `__slots__ = SOME_CONST`, a conditional expression, etc.), pyrefly reported the real conflict *and* an extra, misleading "incompatible disjoint base" error pointing at the class's subclasses, with no actionable fix. Pyrefly now treats any class-body `__slots__` declaration as preventing disjoint-base promotion, regardless of whether the value can be read at type-check time. This matches what actually happens at runtime — no slots get synthesized for the dataclass — so the disjoint-base treatment is consistent across all `__slots__` shapes. Users see only the real, actionable conflict error. Reviewed By: samwgoldman Differential Revision: D108108048 fbshipit-source-id: fcfe22a19953fce77d9610fb6c4d25f67eb08255github.com-facebook-pyrefly · d444273a · 2026-06-11
- 0.2ETVEmit proper `@disjoint_base` decorator misuse errors Summary: PEP 800 restricts disjoint_base to nominal classes and requires a type checker error when it is applied to a function, TypedDict, or Protocol. Previously Pyrefly treated disjoint_base as a generic decorator on functions, producing a confusing bad-specialization diagnostic, and it marked invalid Protocol and TypedDict targets as disjoint bases. This caused unsound narrowing: intersecting two disjoint bases would narrow to Never, silently accepting assert_never on what should be a valid type. This change adds SpecialDecorator::DisjointBase handling to emit InvalidDecorator diagnostics for invalid targets and filters the decorator out of the generic pipeline. For classes, we now check is_typed_dict and protocol_metadata before setting is_disjoint_base, ensuring invalid targets are not marked and do not affect narrowing. Conformance improves for directives_disjoint_base.py (6 to 4 differences, total 19 to 17), and a new testcase verifies the error messages and that invalid Protocols are excluded from disjoint-base narrowing. Reviewed By: yangdanny97 Differential Revision: D107463825 fbshipit-source-id: 7cd2559de8bb9519e831d8d91a87b0dd334fb8b2github.com-facebook-pyrefly · 9d1d8703 · 2026-06-04
- 0.2ETVAdd `nearest_disjoint_base` field to `ClassMro` Summary: Introduce a `nearest_disjoint_base` cache slot on `ClassMro::Resolved` so subclasses can read each direct base's nearest explicit `disjoint_base` ancestor in O(1) instead of re-walking the parent chain. This diff only adds the slot, the accessor, and threads `None` through; the next diff will populate it in `calculate_class_mro` and emit the incompatible-disjoint-bases diagnostic. The constructor takes the already-computed cache value (rather than deriving it from `bases_with_mro`) so we never need a mutating setter and so the disjoint-base scan stays out of `Linearization`. `ClassMro::Cyclic` deliberately drops the cache value, matching the existing convention that cyclic MROs do not propagate inherited facts. Downstream impact: Direct `ClassMro::Resolved` pattern matches in non-disjoint-base consumers (`commands/report.rs`, `lsp/non_wasm/server.rs`, `report/pysa/is_test_module.rs`) are routed through `ancestors_no_object()` where possible. `report/pysa/class.rs` keeps an explicit match because it must preserve the `Cyclic => PysaClassMro::Cyclic` mapping. Reviewed By: yangdanny97 Differential Revision: D107617251 fbshipit-source-id: 24c115fd83c0f2e51a80a5b3179f279aa80a49c0github.com-facebook-pyrefly · 213d1b71 · 2026-06-06
- 0.2ETVFix metaclass lookup for class object __class__ Summary: Pyrefly could infer `C.__class__` as `type[type[C]]` when `C` was represented as a `ClassDef`, but attribute-base conversion had no case for `type[ClassDef]`. That caused ordinary metaclass method lookups such as `C.__class__.__setattr__` to report an internal `attribute base undefined` error. Treat `type[ClassDef(C)]` as `C`'s metaclass viewed as a class object, preserving custom metaclass method signatures while leaving the broader `type[ClassType]` / generic alias behavior as the existing known bug. Fixes https://github.com/facebook/pyrefly/issues/4093 Reviewed By: kinto0 Differential Revision: D111341385 fbshipit-source-id: b610104a8e39887b7cda66f3c608832d00b2cd72github.com-facebook-pyrefly · a45d7f08 · 2026-07-10
- 0.2ETVMove disjoint-base semantics out of `ClassMro` into `KeyClassDisjointBase` Summary: `ClassMro` previously owned two separate responsibilities: C3 linearization of ancestors and PEP 800 disjoint-base propagation. That made `dataclass(slots=True)` promotion awkward because deciding whether generated slots are non-empty needs the class's solved MRO for inherited-slot dedup, but metadata construction cannot read its own MRO without a binding cycle. As mentioned in my response in D108108047, the clean way to resolve this cycle is to move disjoint base calc to a separate key/binding. `ClassMetadata` now records only local class-body facts (`is_local_disjoint_base`, `has_local_dataclass_slots_request`), while `ClassMro` records ancestors plus `linearization_complete` so callers can tell whether `ancestors_no_object` is exact or only a recovery prefix after nonlinearizable inheritance. `calculate_class_disjoint_base` combines direct bases' representatives, emits incompatible-disjoint-base diagnostics, and uses a complete MRO to model CPython dataclass slot dedup before promoting generated slots. Partial MROs still support best-effort inherited propagation, but generated-slot promotion does not run a separate approximate ancestor walk. This removes the duplicated MRO walk mentioned in D108108047. Narrowing for `Self`, `ClassType`, and bounded `TypeVar`s now reads `KeyClassDisjointBase` directly. Because that key is exported, cross-module dependency tracking sees the new per-base demands; the laziness snapshot is updated accordingly. Reviewed By: samwgoldman Differential Revision: D108246272 fbshipit-source-id: 4fc9a9ce92ef04ec0f103d98cad2df6fdbdfe57dgithub.com-facebook-pyrefly · 603d4700 · 2026-06-12