Skip to content

fix(instanceof): #7575 — a monomorphized generic class is an instance of the generic it came from - #7631

Merged
proggeramlug merged 6 commits into
mainfrom
fix/7575-generic-class-instanceof
Aug 8, 2026
Merged

fix(instanceof): #7575 — a monomorphized generic class is an instance of the generic it came from#7631
proggeramlug merged 6 commits into
mainfrom
fix/7575-generic-class-instanceof

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7575.

Root cause: it is not Map/Set, and it is not the prototype chain

The issue reads m instanceof MyMap === false as a native-base-subclassing /
class-registry-parentage defect, and points at CLAUDE.md's Native base-class
subclassing
and Two prototype-resolution paths entries. Both are the wrong
tree. The mechanism is monomorphization.

Perry specializes generic classes. class Gen<T> {} plus new Gen<number>()
emits a SECOND class named Gen$num
(crates/perry-hir/src/monomorph/mangle.rs:14, specialize.rs:50) carrying its
own class id, and the instance is stamped with that id — while
x instanceof Gen resolves the RHS through ctx.class_ids to the generic's
id (crates/perry-codegen/src/expr/instance_misc1.rs:346), which appears nowhere
in the specialization's parent chain. js_instanceof's walk therefore answers
false for the class the user actually wrote, and true for the base — exactly
the "only the native base edge survives" symptom.

The bisect that identifies it, measured on pristine main:

class GenMap<K,V> extends Map<K,V> {}  new GenMap<string,number>()  false true
class ConcMap     extends Map<...>  {}  new ConcMap()                true  true
class BareMap     extends Map       {}  new BareMap()                true  true
class GenMap<K,V> extends Map<K,V> {}  new GenMap()   /* no args */  true  true
class GenPlain<T> extends PlainBase {}  new GenPlain<number>()       false true   <-- no Map anywhere
class GenNoBase<T>                  {}  new GenNoBase<number>()      false        <-- no BASE anywhere

The last two rows settle it: a generic class with an ordinary base, and one with
no base at all, fail identically. class MyMap<K, V> extends Map<K, V> is simply
the idiomatic spelling, which is why it surfaced there. constructor.name on the
same instances reports Gen$num — the same leak on a different surface.

The fix

  • HIR records Class::specialized_from (the generic's name), set by
    specialize_class.
  • Codegen emits one js_register_class_generic_origin(spec, generic) per
    specialization in the module-init prelude, next to the existing
    js_register_class_parent edges.
  • instanceof's chain walk — now a single shared, depth-bounded
    class_chain_reaches, used by both the static and the dynamic-RHS path, which
    previously had two hand-rolled copies (one of them uncapped) — follows the
    origin edge as well as extends.

It is deliberately a separate edge, not a parent edge. CLASS_REGISTRY's
chain also resolves super() construction
(object/class_constructors.rs:652), static-method lookup and vtable dispatch,
so splicing Gen in between Gen$num and its real base would re-run the wrong
constructor. Only instanceof may follow this one, and the runtime module says
so at the declaration.

Did the fix need rooting discipline?

No. The whole change is u32 -> u32 class-id bookkeeping — no heap pointers, no
new cache of a *mut, so nothing to register with
gc_register_mutable_root_scanner and nothing for raw_handle_debt.py to count
(it is unchanged at 998). The gap test is nevertheless byte-identical under
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1.

The Array-side sibling #7603 left unfixed

One mechanism covers both families, and the note about it was stale. Measured
on pristine main:

new MyArr()                  instanceof MyArr    true    (already worked)
new Indirect()               instanceof MyArr    true    (already worked)
MyArr.from([1,2,3])          instanceof MyArr    true    (already worked)
new GenArr<number>()         instanceof GenArr   FALSE   <-- fixed here

So the only broken Array case was the generic spelling, and this PR fixes it. The
comment in test_gap_7541_array_subclass_inherited_statics.ts that named
sub instanceof MyArr as a pre-existing gap was wrong; it is corrected in place
rather than left to mislead the next reader. Nothing is left to file on the
Array side.

Sabotage, both ways

  • Making class_generic_origin return None turns two of the four new runtime
    unit tests red and turns the gap test red at 8 lines
    (1 unannotated: false true, 6 seeded: false true, 8 dynamic, 9/10 ...).
  • The other two unit tests stay green under that sabotage by design — they
    assert the edge is directional and does not widen matches (sibling
    specializations must not match each other, a generic is not an instance of its
    own specialization), so they would catch the opposite mistake.

Validation (local; CI backlog is deep, so this is the evidence)

  • test-files/test_gap_7575_map_set_subclass_instanceof.ts — byte-identical to
    node --experimental-strip-types on the pinned 26.5.1, exit 0. Covers
    instanceof against the subclass, the native base, an unrelated class, a
    multi-level chain (class A extends Map {}; class B extends A {}; class C extends B {}), an explicit-super() subclass and a subclass of it, an
    iterable-seeded instance, Symbol.hasInstance in both directions (it still
    takes precedence over the chain walk), the dynamic RHS, instanceof over an
    untyped parameter, and the generic-over-plain / generic-over-nothing /
    generic-over-Array shapes plus their non-generic controls and sibling
    negatives.
  • Same test byte-identical under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, compiled with
    PERRY_GC_MOVING_LOOP_POLLS=1.
  • test_gap_6325_map_set_subclass.ts and
    test_gap_7570_map_set_declared_base_type.ts tightened to assert the
    subclass edge, as instanceof a Map/Set SUBCLASS is false (m instanceof MyMap); only the native base edge survives #7575 asked — both still byte-identical.
  • No regressions: test_gap_7541, test_gap_7574, test_gap_7563,
    test_gap_6232, test_gap_4099, test_gap_5592, test_edge_generics,
    test_generic_class, test_edge_class_advanced, test_edge_complex_patterns,
    test_edge_interfaces, test_data_pipeline,
    test_gap_repsel_element_shape_loop_clone,
    test_gap_intl_rtf_auto_instanceof_6960 all byte-identical to node.
  • 4 new perry-runtime unit tests over the walk. cargo test -p perry-runtime
    1890 passed / 0 failed; -p perry-hir and -p perry-transform all green.
  • Lint: cargo fmt --all -- --check, check_file_size.sh,
    addr_class_inventory.py, raw_handle_debt.py (998, unchanged),
    class_id_collisions.py, check_test_registration.py,
    gc_store_site_inventory.py, workspace_architecture.py,
    gap_snapshot.py --self-test all pass.

Pre-existing failures untouched by this PR, each A/B'd against pristine
origin/main in this worktree:

  • perry-codegen's integration suites (crates/perry-codegen/tests/*.rs, which
    do not run per-PR) fail the same 23 tests, byte-identical list, on both
    arms.
  • test_harness_class_mixins, test_issue_562_stream_subclass and
    test_issue_806_curried_factory_extends_capture produce byte-identical Perry
    output
    on both arms.
  • cargo clippy errors in crates/perry-ffi/src/jsvalue.rs (approx_constant
    on 3.14) are pre-existing in a file this PR does not touch.

Known, deliberately not folded in

constructor.name still reports the mangled Gen$num rather than Gen. Same
root cause (monomorphized identity leaking to a user-visible surface), different
surface (the class display-name registry), and it can move error-message text —
so it belongs in its own change with its own parity sweep, not bundled into an
instanceof fix. Filed as #7632.


Rebased onto v0.5.1357 (#7627) — clean as text, but it did NOT compile

Rebasing this onto current main produced zero conflicts, and
git merge-tree origin/main <this> exits 0. That verdict was wrong in the way
that matters: the merge does not build.

#7627 added a new perry_hir::Class literal in
crates/perry-codegen/src/codegen/emission_order_tests.rs, and this PR widens
that struct with Class::specialized_from. Neither side touches a line the
other side touches, so there is nothing for a textual merge to flag — but the
result is E0063: missing field specialized_from. Fixed here in its own commit.

Worth stating plainly because it generalises: textual mergeability is not a
merge check when one branch widens a struct another branch constructs.
The
only reliable check is cargo check --all-targets on the merge result, and
--all-targets is load-bearing — the offending literal is in a #[cfg(test)]
fixture, so a plain cargo check stays green and the break surfaces later, in a
job that does build tests.

This PR does not touch expr/instance_misc1.rs, logical_collections.rs or
map_set.rs, so it has no overlap with #7627's rooting migration itself.

Re-verified after the rebase, not before

  • test_gap_7575_map_set_subclass_instanceof.ts byte-identical to node 26.5.1,
    exit 0; byte-identical again under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, compiled
    with PERRY_GC_MOVING_LOOP_POLLS=1.
  • test_gap_7570, test_gap_6325 (both tightened by this PR) and
    test_gap_7541 all byte-identical.
  • cargo check --all-targets over the workspace: clean.
  • 4 runtime unit tests over the walk green; -p perry-runtime 1890 passed / 0
    failed; -p perry-codegen --lib 694 / 0 (ledger tests included);
    -p perry-hir 281 / 0; -p perry-transform 56 / 0.
  • Root-dominance corpus, both gated modes (this PR changes codegen emission,
    so it was re-run rather than reasoned about): 129/129 sources, 149 .ll,
    2452 functions / 9846 root stores, 0 violations in dominance mode with
    40/40 seeded caught, and 0 --unrooted-allocas violations.
  • Full lint set from .github/workflows/test.yml green, including
    raw_handle_debt.py at 998 (unchanged — this PR is u32 → u32
    bookkeeping, no heap pointers).

Conflict-free against #7626 (git merge-tree of the two heads exits 0), and
#7626 adds no Class literal, so this PR's struct widening cannot break it in
either merge order.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed instanceof checks for monomorphized generic classes and their inheritance chains.
    • Improved behavior for generic Map, Set, and other specialized subclasses, including dynamic constructor checks.
    • Preserved correct behavior for negative matches, sibling specializations, and custom Symbol.hasInstance implementations.
  • Tests

    • Added comprehensive regression coverage for generic inheritance and built-in collection subclasses.
  • Chores

    • Updated the application version to 0.5.1359.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0847962d-506f-4366-aa2a-86a337fa4db8

📥 Commits

Reviewing files that changed from the base of the PR and between 02cebe1 and ea253f0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-codegen/src/runtime_decls/strings.rs

📝 Walkthrough

Walkthrough

The change records generic class origins in HIR, registers specialization-to-origin links at runtime, and extends instanceof traversal to follow those links. Regression tests cover generic subclasses, inheritance, native collections, dynamic constructors, and negative cases.

Changes

Generic instanceof resolution

Layer / File(s) Summary
Class specialization metadata
crates/perry-hir/..., crates/perry-codegen/..., crates/perry-transform/..., crates/perry/src/commands/compile/helpers.rs, Cargo.toml, CLAUDE.md
Class stores specialized_from. Specialization records the generic origin. Lowering, hashing, imported stubs, fixtures, and version metadata are updated.
Generic-origin registration
crates/perry-codegen/src/codegen/string_pool.rs, crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-runtime/src/object/...
Code generation emits deterministic specialization-to-origin registrations. The runtime stores these links in a separate registry and exposes the registration API.
Runtime matching and regression coverage
crates/perry-runtime/src/object/instanceof.rs, test-files/test_gap_*.ts, changelog.d/7631-generic-class-instanceof.md
instanceof uses bounded traversal across inheritance and generic-origin links. Tests cover generic Map and Set subclasses, multi-level inheritance, dynamic RHS constructors, Symbol.hasInstance, sibling specializations, and negative matches.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • PerryTS/perry#7573: Related Map/Set subclass handling and overlapping instanceof regression tests.

Suggested labels: bug, parity

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #7575 by fixing generic Map and Set subclass instanceof checks while preserving base matching and ordinary inheritance.
Out of Scope Changes check ✅ Passed The additional generic-shape tests, runtime tests, and stale Array comment correction support the stated instanceof fix and remain in scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the fix for instanceof checks on monomorphized generic classes and references the related issue.
Description check ✅ Passed The description provides the root cause, implementation changes, related issue, extensive validation results, regression coverage, and known limitation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7575-generic-class-instanceof

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/codegen/emission_order_tests.rs (1)

242-245: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify that the complete construct is emitted before comparing IR.

The closure check proves only that one registration exists. The tower check proves only that some @perry_method_ symbol exists. This fixture also emits per-class method wrappers with that prefix. An incomplete dispatch tower can therefore pass the determinism test if both compilations produce the same incomplete IR.

Use registered_closure_ids(&first).len() == N as usize and tower_arm_classes(&first).len() == N as usize for the two liveness checks.

Proposed test-oracle fix
-    assert!(
-        first.contains("call void `@js_register_function_name`("),
-        "liveness: fixture emitted no function-name registrations"
-    );
+    assert_eq!(
+        registered_closure_ids(&first).len(),
+        N as usize,
+        "liveness: expected one function-name registration per closure"
+    );

-    assert!(
-        first.contains("`@perry_method_`"),
-        "liveness: fixture emitted no class methods"
-    );
+    assert_eq!(
+        tower_arm_classes(&first).len(),
+        N as usize,
+        "liveness: expected one dispatch-tower arm per implementing class"
+    );

Also applies to: 426-429

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/codegen/emission_order_tests.rs` around lines 242 -
245, Strengthen the liveness assertions in the emission-order determinism test:
replace the single function-name registration check with
registered_closure_ids(&first).len() == N as usize, and replace the broad
`@perry_method_` symbol check with tower_arm_classes(&first).len() == N as usize.
Apply the same updates to the corresponding assertions in the second location.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-codegen/src/codegen/emission_order_tests.rs`:
- Around line 242-245: Strengthen the liveness assertions in the emission-order
determinism test: replace the single function-name registration check with
registered_closure_ids(&first).len() == N as usize, and replace the broad
`@perry_method_` symbol check with tower_arm_classes(&first).len() == N as usize.
Apply the same updates to the corresponding assertions in the second location.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b60e723-d110-49cf-a863-d15e7fd015b6

📥 Commits

Reviewing files that changed from the base of the PR and between 72994ab and 02cebe1.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/codegen/emission_order_tests.rs
  • crates/perry-codegen/src/codegen/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/codegen/mod.rs

Ralph Küpper added 6 commits August 8, 2026 11:38
… of the generic

`m instanceof MyMap` was false for a `class MyMap<K, V> extends Map<K, V>`
instance while `m instanceof Map` was true. The issue read this as a Map/Set
subclass / prototype-chain defect. It is neither: the mechanism is
MONOMORPHIZATION, and Map/Set had nothing to do with it.

Perry specializes generic classes. `class Gen<T> {}` plus `new Gen<number>()`
emits a SECOND class named `Gen$num` (monomorph::mangle::generate_specialized_name)
with its own class id, and the instance is stamped with that id — while
`x instanceof Gen` resolves the RHS to the GENERIC's id, which appears nowhere
in the specialization's parent chain. The bisect that pins it:

  class Gen<T> extends Base {}   new Gen<number>()  ->  instanceof Gen  false
  class Gen<T> extends Base {}   new Gen()          ->  instanceof Gen  true
  class Conc  extends Base {}    new Conc()         ->  instanceof Conc true
  class GenNoExtends<T> {}       new G<number>()    ->  instanceof G    false

The last row has no base class at all, so this was never about `super()`-to-a-
native-base wiring. `class MyMap<K, V> extends Map<K, V>` is simply the
idiomatic spelling, which is why it surfaced there.

HIR now records `Class::specialized_from`; codegen emits one
`js_register_class_generic_origin(spec, generic)` per specialization next to the
parent edges; and `instanceof`'s chain walk (now one shared, depth-bounded
`class_chain_reaches`, used by both the static and the dynamic-RHS path) follows
that edge as well as `extends`.

It is deliberately a SEPARATE edge, not a CLASS_REGISTRY parent edge: that chain
also resolves `super()` construction, static-method lookup and vtable dispatch,
so splicing the generic in between a specialization and its real base would
re-run the wrong constructor.

The Array-side sibling #7603 left unfixed is covered by the same mechanism —
`new GenArr<number>() instanceof GenArr` now holds. `constructor.name` still
reports the mangled `Gen$num`; that is the same root cause on a different
surface and is filed separately rather than folded in here.

Validated locally: new gap test byte-identical to node 26.5.1 and byte-identical
again under PERRY_GC_ZEAL=1 + PERRY_GC_PROTECT_FROMSPACE=1; 4 new runtime unit
tests over the walk (including that the edge stays directional and does not make
sibling specializations match); test_gap_6325 and test_gap_7570 tightened to
assert the subclass edge the issue asked for.
…, and the remaining Class construction sites

Expands the gap test past the Map/Set framing the issue was filed under: a
generic class over a PLAIN base, over NO base, and over Array all failed
identically before the fix, which is what identifies the mechanism as
monomorphization rather than native-base wiring. Adds the sibling-specialization
negatives so the new edge is shown to be directional rather than a widening.

Measured on pristine origin/main: every NON-generic Array-subclass instanceof
already held (new MyArr(), new Indirect(), MyArr.from([...])) and only the
generic spelling was broken, so the note in
test_gap_7541_array_subclass_inherited_statics.ts claiming the non-generic form
as a gap was stale; corrected in place.

Also threads specialized_from through the remaining Class construction sites
(test fixtures and CJS/anon-shape scaffolding) that only --all-targets sees.
Rebasing #7575 onto v0.5.1357 merges CLEANLY as text but does not compile:
#7627 added a `perry_hir::Class` literal in codegen/emission_order_tests.rs,
and #7575 adds a field to that struct. Textual mergeability is not a merge
check when one side widens a struct the other side constructs.
@proggeramlug
proggeramlug force-pushed the fix/7575-generic-class-instanceof branch from 02cebe1 to ea253f0 Compare August 8, 2026 09:45
@proggeramlug
proggeramlug merged commit 925dcfc into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7575-generic-class-instanceof branch August 8, 2026 09:45
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1359

The corrected diagnosis is right, and I reproduced the bisect that proves
it
— the issue's filed explanation (native-base wiring / the
CLASS_PROTOTYPE_OBJECTS split) was wrong, and my brief repeated it:

case main this PR node
MyMap<K,V> extends Map instanceof itself false true true
GenPlain<T> — no Map, no base at all false true true
GenExt<T> extends Base false true true
non-generic Bee extends A extends Map true true true
negatives (sibling specializations) false false false

A generic class with no Map anywhere fails identically; a non-generic Map chain
works. That settles it as monomorphization — new MyMap<string,number>()
constructs MyMap$str_num while instanceof MyMap resolves the generic's id,
which is in no parent chain. Map/Set was just the idiomatic spelling in the bug
report.

Following the origin edge instead of adding a parent edge is the load-bearing
design choice
, and the reason is stated: CLASS_REGISTRY's chain also
resolves super(), static lookup and vtable dispatch, so a parent edge would
have changed three unrelated behaviours to fix one. Collapsing two hand-rolled
chain walks — one of them uncapped — into a single depth-bounded
class_chain_reaches is the right cleanup to take along.

Gates: runtime 1,894/0, codegen 694/0, hir 281/0, all five lint scripts +
file-size + fmt clean, cargo check --all-targets clean.

The merge-order lesson here is worth generalising

This branch was textually conflict-free and still did not compile: #7627
added a perry_hir::Class literal in a #[cfg(test)] fixture and this PR
widens that struct, so neither side touched the other's lines and
git merge-tree exited 0 — then E0063: missing field specialized_from.

Two things follow, and I'd like both treated as standing practice for the
remaining ~85 Layer-1 slices, where struct widening and fixture-heavy modules
will keep meeting:

  1. git merge-tree exiting 0 is not a merge check when one branch widens a
    type another constructs.
  2. cargo check --all-targets is load-bearing, because a plain cargo check stays green when the only constructor is in a test fixture — the
    break then surfaces in a later job rather than at the merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

instanceof a Map/Set SUBCLASS is false (m instanceof MyMap); only the native base edge survives

1 participant