Skip to content

fix(class-refs): static method extracted as a value must not resolve the same-named instance method (#7689) - #7691

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7689-marked-pedantic
Aug 9, 2026
Merged

fix(class-refs): static method extracted as a value must not resolve the same-named instance method (#7689)#7691
proggeramlug merged 4 commits into
mainfrom
fix/7689-marked-pedantic

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #7689.

What broke

marked.parse("# hi") threw TypeError: Cannot read properties of undefined (reading 'pedantic') on every parse, both with and without perry.compilePackages.

marked's parseMarkdown extracts a static method into a variable and calls it unbound:

const lexer2 = blockType ? _Lexer.lex : _Lexer.lexInline;
let tokens = lexer2(src, opt);

Lexer declares both static lex(src, options) and an instance method lex(src) (Parser has the same parse collision). Reading C.lex off the constructor routes through js_class_method_bind, whose #446 method-identity canonicalization resolves the name against the instance vtable: class_id_from_method_receiver treats an INT32-tagged constructor ref exactly like an instance receiver, and method_owner_class_id only consults instance methods. The extracted value was therefore the instance lex; invoked bare, its this.options read produced undefined, and blockTokens' first access (this.options.pedantic) threw.

In JS, C.m never exposes prototype methods (they live on C.prototype) — the same semantics the NestJS fix already established for the read path in get_field_by_name.rs ("Instance (prototype) methods must only resolve when reading off the prototype ref").

Fix

In js_class_method_bind, skip the instance-vtable canonicalization when the receiver is a constructor class ref (class_ref_id matches, class_prototype_ref_id does not). The read then falls through to build_bound_method_closure, whose call-time dispatch (js_native_call_method's 0x7FFE arm) already resolves statics-first for constructor refs — the exact path that made the same extraction work when no name collision existed. Prototype refs (C.prototype.m) keep the canonical instance-method path unchanged.

Validation

  • New runtime unit test constructor_ref_method_value_resolves_static_over_instance_method registers a class with both a static and an instance lex and asserts the extracted constructor-ref value dispatches the static while the prototype-ref value still dispatches the instance method. Verified it fails without the fix (dispatches the instance method) and passes with it.
  • New gap test test_gap_static_method_value_name_collision.ts covers the marked shape end-to-end (class expression + declaration, ternary + plain extraction, direct calls, prototype read); byte-identical to node --experimental-strip-types 26.5.1. It passes, so gap_snapshot.json needs no entry.
  • The issue's repro: mdmin.ts now prints 12 (== Node), and the issue's mdapp.ts at 10 documents prints 20105 60 (== Node).
  • Full perry-runtime --lib suite: 1931 passed, 0 failed.
  • Full gap suite run locally; every reported divergence was A/B'd against a baseline build of the merge-base with only this fix reverted: outputs are identical (or the same panic/timeout) on both arms — all are host-environment artifacts (socket binds under the sandbox, the known zlib link flake, npm-import tests whose oracle behaves differently outside CI), none attributable to this change.

Follow-up (separate issue)

The full 300-document mdapp.ts workload is severely superlinear under Perry: 10 docs run in 0.16 s, but 100 docs did not finish within 5 minutes (Node: ~1 s for 300). That is a scaling defect independent of this correctness fix — I'll file it separately so this comparison workload can actually be run to completion.

No version bump per the contribution flow; maintainer bumps at merge.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed static method extraction when a class has an instance method with the same name.
    • Constructor references now correctly invoke static methods, while prototype references continue to invoke instance methods.
    • Prevented accidental calls to unconstructed instance methods in this scenario.
  • Tests

    • Added regression coverage for direct and indirect method extraction, class expressions, class declarations, static calls, and prototype access.

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime preserves static dispatch when extracted constructor methods share names with instance methods. Prototype extraction keeps instance dispatch. Regression tests cover both paths and the marked failure case. The workspace version changes to 0.5.1401.

Changes

Method dispatch

Layer / File(s) Summary
Constructor and prototype method binding
crates/perry-runtime/src/object/native_module.rs, crates/perry-runtime/src/object/tests.rs, test-files/test_gap_static_method_value_name_collision.ts, changelog.d/7691-static-method-value-name-collision.md
js_class_method_bind skips instance-method canonicalization for constructor references. Call-time dispatch selects static methods first. Prototype references retain instance-method canonicalization. Tests cover colliding methods, class forms, extracted calls, direct calls, and prototype access.
Workspace version update
Cargo.toml, CLAUDE.md
The workspace package version and documented current version change from 0.5.1400 to 0.5.1401.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested labels: bug, parity

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but does not follow the required template, and its no-version-bump claim conflicts with the changes. Use the required sections and checklist, and accurately report or remove the prohibited Cargo.toml and CLAUDE.md version changes.
Out of Scope Changes check ⚠️ Warning Cargo.toml and CLAUDE.md version updates are unrelated to #7689 and violate the repository template. Remove the Cargo.toml version bump and CLAUDE.md version edit; leave release metadata for the maintainer at merge.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the static method dispatch fix for same-named instance methods.
Linked Issues check ✅ Passed The runtime fix and regression tests address #7689 by preserving static dispatch for constructor references and instance dispatch for prototype references.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7689-marked-pedantic

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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@crates/perry-runtime/src/object/native_module.rs`:
- Around line 1054-1069: Update js_class_method_bind so constructor static
references that fall through to build_bound_method_closure are stored in and
retrieved from the existing canonical value cache using the static method name.
Preserve the current prototype-reference path and ensure repeated reads such as
C.m === C.m return the same callable value.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bee335bc-4c70-4e57-91e6-a16304606de6

📥 Commits

Reviewing files that changed from the base of the PR and between fc0c1d6 and 1d6e9f4.

📒 Files selected for processing (4)
  • changelog.d/7691-static-method-value-name-collision.md
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/tests.rs
  • test-files/test_gap_static_method_value_name_collision.ts

Comment on lines +1054 to +1069
// #7689: a CONSTRUCTOR class-ref receiver (`const f = C.m`) must
// never canonicalize to the INSTANCE vtable method of the same
// name — in JS `C.m` sees only statics (`class C { static lex(){}
// lex(){} }` has `C.lex` === the static; the instance `lex` lives
// on `C.prototype`). `class_id_from_method_receiver` treats a
// class ref like an instance, so marked's `const lexer2 =
// _Lexer.lex; lexer2(src, opt)` extracted the instance `lex`,
// whose bare invocation read `this.options` off an unconstructed
// receiver. Fall through to `build_bound_method_closure`: its
// call-time dispatch (`js_native_call_method`'s 0x7FFE arm)
// resolves statics-first for constructor refs. PROTOTYPE refs
// (`C.prototype.m`) keep the canonical path — the instance method
// is exactly what they name.
let receiver_is_constructor_ref =
class_ref_id(instance).is_some() && class_prototype_ref_id(instance).is_none();
if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'build_bound_method_closure|class_prototype_method_value_for_name|js_class_method_bind|static.*method|method.*cache' \
  crates/perry-runtime/src/object/native_module.rs \
  crates/perry-runtime/src/object/tests.rs

Repository: PerryTS/perry

Length of output: 39242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l crates/perry-runtime/src/object/native_module.rs crates/perry-runtime/src/object/tests.rs
sed -n '1020,1120p' crates/perry-runtime/src/object/native_module.rs
sed -n '1440,1505p' crates/perry-runtime/src/object/tests.rs
sed -n '1136,1175p' crates/perry-runtime/src/object/native_module.rs
rg -n "class_prototype_method_values|class_prototype_method_value_cache_root_store|class.*ref.*method|C\.m|CLASS_PROTOTYPE_METHOD_VALUES|js_native_call_method|0x7FFE|static" crates/perry-runtime/src/object/native_module.rs | sed -n '1,220p'

Repository: PerryTS/perry

Length of output: 15970


Cache static method values from constructor refs.

js_class_method_bind falls through to build_bound_method_closure for constructor refs, which allocates a fresh BOUND_METHOD closure on every C.m read. This violates method identity: C.m === C.m becomes false even though static method reads should return one shared callable value. Keep the existing canonical value cache for constructor static refs by adding a static-name cache entry for the built closure instead of minting a new closure each time.

🤖 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-runtime/src/object/native_module.rs` around lines 1054 - 1069,
Update js_class_method_bind so constructor static references that fall through
to build_bound_method_closure are stored in and retrieved from the existing
canonical value cache using the static method name. Preserve the current
prototype-reference path and ensure repeated reads such as C.m === C.m return
the same callable value.

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@proggeramlug
proggeramlug force-pushed the fix/7689-marked-pedantic branch from 1d6e9f4 to c2f6e3b Compare August 9, 2026 13:44
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1401

Verified independently against node 26.5.1, on a class with the exact static lex / instance lex collision marked has:

output
node static:hi:opt / static:yo:opt / instance:zz:false
this PR byte-identical

Including the ternary form (blockType ? _Lexer.lex : _Lexer.lexInline) that is what marked actually writes.

The root cause is the interesting part: #446's method-identity canonicalization resolves the name against the instance vtable, because class_id_from_method_receiver treats an INT32-tagged constructor ref exactly like an instance receiver, and method_owner_class_id only consults instance methods. So the extracted value was the instance lex, and invoked bare its this.options read produced undefined — surfacing three frames later as Cannot read properties of undefined (reading 'pedantic'), which is why this reads as a marked bug rather than a dispatch bug.

A static and an instance method sharing a name is unusual enough to have gone unnoticed and common enough that marked does it twice (Lexer.lex, Parser.parse).

Gates: 24/24 via the full lint extraction, perry-runtime --lib 1941 passed. The gap test needs no registry entry — check_test_registration gates the four named suites (gc-repsel-corpus, feature-matrix-probes, compiler-output-workloads, rust-test-modules) and a plain parity test is picked up by glob; I checked rather than assumed, since a dark test is how #7612 slipped.

@proggeramlug
proggeramlug force-pushed the fix/7689-marked-pedantic branch from c2f6e3b to 6309718 Compare August 9, 2026 13:47
@proggeramlug
proggeramlug merged commit 69c5435 into main Aug 9, 2026
10 of 13 checks passed
@proggeramlug
proggeramlug deleted the fix/7689-marked-pedantic branch August 9, 2026 13:48

@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)
CLAUDE.md (1)

133-133: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep CLAUDE.md changes limited to the version line.

Revert the changes at Lines 133, 154, and 253. They add content outside **Current Version:**.

As per coding guidelines, update only the **Current Version:** line in CLAUDE.md; do not add changelog entries or detailed history here.

Also applies to: 154-154, 253-253

🤖 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 `@CLAUDE.md` at line 133, Revert the added content at the referenced lines in
CLAUDE.md, including the escape-hatches text and other changelog/history
entries. Keep only the existing **Current Version:** line change, with no
additional documentation outside that version line.

Source: Coding guidelines

🤖 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 `@CLAUDE.md`:
- Line 133: Revert the added content at the referenced lines in CLAUDE.md,
including the escape-hatches text and other changelog/history entries. Keep only
the existing **Current Version:** line change, with no additional documentation
outside that version line.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42b8be82-c8a1-432c-9924-c166e38a3fba

📥 Commits

Reviewing files that changed from the base of the PR and between 1d6e9f4 and 6309718.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…7664)

Re-verifying the checker fix found 9 real+false hits, not the 8 the prior
snapshot recorded -- test_gap_static_method_value_name_collision joined the
population after #7691 without the budget being re-measured. Of the 9: 4 were
the checker's own phi-edge false positives (fixed in the prior commit), 3
were unrooted:global (2 already fixed upstream by #7719, 1 fixed in the prior
commit's static_dispatch.rs change), and 2 are unrooted:capture -- real,
diagnosed, and tracked as this budget's referent rather than rushed.

Measured on the native corpus, both arms of --moving-only, stale still 0.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
…--max-unrooted to 2 (#7664) (#7724)

* gc: fix the phi-edge checker false positives and the static-dispatch receiver hazard (#7664)

scripts/gc_root_dominance_check.py: the native/--statepoints chain treated a
phi as unconditionally transparent, so one tainted incoming edge blanket-
tainted the phi's result and a downstream use was checked against ANY CFG
path between source and use (between_blocks is deliberately path-insensitive,
sound for an ordinary register but not for a phi, whose dynamic value depends
on which edge was actually taken). All four reported unmasked hits were the
same &&/|| short-circuit join: the tainted edge never crosses a safepoint,
the OTHER edge does, and the checker reported that.

_cast_closure gains phi_all_edges: a phi joins `chain` only once every
incoming edge is independently in it. That closes the false positive and
deliberately excludes the case of a single tainted edge with its own
intervening safepoint before its predecessor's terminator; _phi_edge_hazard
covers that separately, checking each edge's own window. Two new self-test
fixtures (phi_safe_edge / phi_hazard_edge) pin both directions, each verified
against a sabotaged copy of the checker to confirm it can still fail.

lower_call/property_get/static_dispatch.rs: (Lexer as any).lex(...) reads a
module-global receiver, then held it raw across arg-bundling logic that can
allocate (a rest-param bundle always allocates; an object-literal argument
can too) before implicit_this_save/js_static_this_arm_value read the stale
copy -- the same #6969/#6986 shape #7719 just fixed in lower_call/builtin.rs,
here on the receiver. Wrapped it in RootedGroup::adopt/reread.

Re-verified against the current corpus: the checker fix eliminates exactly
the four phi false positives with nothing else changing. The static-dispatch
fix was not yet re-verified against a fresh corpus run after this rebase
(disk pressure and box load made prior corpus runs unreliable) -- see the PR
description for exactly what is and isn't confirmed.

* gate(gc): lower gc-root-dominance-statepoints' --max-unrooted to 2 (#7664)

Re-verifying the checker fix found 9 real+false hits, not the 8 the prior
snapshot recorded -- test_gap_static_method_value_name_collision joined the
population after #7691 without the budget being re-measured. Of the 9: 4 were
the checker's own phi-edge false positives (fixed in the prior commit), 3
were unrooted:global (2 already fixed upstream by #7719, 1 fixed in the prior
commit's static_dispatch.rs change), and 2 are unrooted:capture -- real,
diagnosed, and tracked as this budget's referent rather than rushed.

Measured on the native corpus, both arms of --moving-only, stale still 0.

* chore: key the changelog fragment on PR #7724

* chore: point the budget referent at the split-out #7725

* chore: bump version to 0.5.1420

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* style: cargo fmt

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

npm: marked compiles natively but throws at runtime — "Cannot read properties of undefined (reading 'pedantic')"

1 participant