fix(es5): bound-function prototype walk (#4563) + own length seed (#4562) - #4672
Merged
js2-merge-queue-bot[bot] merged 23 commits intoAug 21, 2026
Merged
Conversation
…s its prototype walk
`__closure_prop_get` consulted the carrier's own-property bag and `return`ed
UNCONDITIONALLY once that bag was non-null, so the §8.10.5 inherited-read
fallback two lines below it became unreachable the moment ANY own property was
defined on a closure or a `$__bound_fn`:
var b = foo.bind({});
Function.prototype.p = 12;
b.p // 12 — bag still null
Object.defineProperty(b, "zz", {value: 1});
b.p // was undefined — want 12
Not bound-specific: a plain closure with one own property lost it the same way,
while an ordinary object with a prototype kept inheriting through the identical
sequence — which is what isolates it to the carrier bag rather than the define.
The bag answer is now taken only for a key the bag OWNS. The discriminator has
to be `hasOwn`, not "is the read undefined": a bag entry whose stored value IS
`undefined` is a real own property and must still beat the prototype, exactly
as `f.prototype = undefined` already does through the loopdive#2660 proto edge above.
Without `__hasOwnProperty` resolvable the emitted body is byte-identical to the
pre-loopdive#4563 one.
This is a pure ENABLER — it moves no conformance row by itself. It is what
makes the §20.2.3.2 bound-function `length`/`name` seed viable: seeding those
own properties put every bound function into the broken state, which is why
that seed measured +2/-2 and was reverted.
Verification, both lanes, all relative to the merge base:
standalone guard 551/551 -> 551/551
standalone lane (loopdive#4555) 18/75 -> 18/75
standalone Function/prototype/bind 73/100 -> 73/100, 0 newly failing
standalone Object/defineProperty 1066/1131 -> 1066/1131, 0 newly failing
js-host Function/prototype/bind 86/100 -> 86/100
unit suites (132, incl. GC-lane) 55 failing -> 55 failing, 0 newly failing
prototype-write corpus (per-test) 120/121 -> 120/121, same single failure
tests/issue-4563-carrier-bag-prototype-walk.test.ts: 2 of 6 fail without the fix, 6/6 with
✓
…r shadows its prototype walk ✓
…teps 5-8) bind/ 73/100 -> 75/100 on the integration tree, target=standalone. Zero regressions: set difference both directions gives 2 fixed (instance-length-prop-desc, instance-length-remaining-args), 0 broken. An earlier cut measured +2/-2 and was REVERTED rather than shipped as a wash: seeding an own property put every bound function into the loopdive#4563 state, where a non-empty carrier bag shadowed the prototype walk, so 15.3.4.5-11-1 / -6-2 broke as fast as the length rows fixed. loopdive#4563 has since landed; the A/B confirms the trade is gone and both shapes now pass together. The value is computed at RUNTIME because 20.2.3.2 reads `length` off the TARGET, an arbitrary runtime value. Only the argument count is static. Budget gates earned, then granted at the floor: LOC on calls.ts went +9 -> +2 by moving the local plumbing into the subsystem module behind seedBoundFunctionLengthOnStack (one import, one call). The func-budget entry on loopdive#4563 is for fillClosurePropHelpers, which that merge pushed over the threshold. Recovered from a lane that hit its usage limit with this work uncommitted. ✓
… (standalone) Math.sin(0.5) always worked; derivative(Math.sin, dx) and [1,4,9].map(Math.sqrt) threw "Math.sin is not yet implemented in --target standalone". The direct call has a dedicated lowering onto the self-hosted Math_<name> provider; the reified VALUE got the generic refusal body. Identity, .name and .length were already correct — only invoking the extracted value failed, which is why it presented as a missing implementation rather than a missing property. S13.2.1_A5_T2 and S12.9_A4 both FAIL -> PASS, guard 551/551, target=standalone. Late minting is safe here: emitInlineMathFunctions appends DEFINED functions, whose indices come after the import block, so it cannot shift an existing index — unlike a late import, which is what addUnionImports exists to manage. Arguments run the engine ToNumber pipeline (__any_from_extern -> __any_to_f64), the same one Math.max/min use (loopdive#2933), so an object argument with a valueOf coerces identically through a direct call and through an extracted value. Result boxed with __box_number, not __any_box_f64, for the reason the max fold already documents. Anything without a self-hosted provider keeps the refusal — a miss, never a wrong answer. Both budget gates earned to the floor (+2 LOC: one import, one condition; +1 func: the condition), with the body and rationale in the new module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH ✓
…typeOf(a) is false
Both lanes. `function A(){}; var a = new A(); A.prototype.isPrototypeOf(a)`
answers false; node answers true. No throw, no refusal, no compile error — just
the wrong boolean, which makes it worse than the loopdive#4580 refusal it was found next
to.
It is a method bug, not a linkage bug: Object.getPrototypeOf(a) === A.prototype,
a instanceof A, and Object.prototype.isPrototypeOf(a) are ALL correct on the same
two objects. Only isPrototypeOf with a user prototype as receiver is wrong.
Context-dependent, which is why it is easy to miss: a module that first evaluates
Object.getPrototypeOf(a) === A.prototype then gets `true` from the same call.
Warming with `a instanceof A` does not. Any repro must be a BARE module, and the
acceptance criteria say so.
Prime suspect is the loopdive#2994 static fold proving false instead of declining, which
would explain both lanes answering identically and the context-dependence, and
would short-circuit the correct native chain walk that instanceof already uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH
✓
… broken chain walk
My first version named tryStaticIsPrototypeOf as the prime suspect "proving false
instead of declining". Wrong, and wrong in a misleading direction: the fold
declines for user prototypes, and it is the one thing producing a CORRECT answer.
Measured: the native chain walk answers false for EVERYTHING, including the
Object.prototype receiver that answers true when written directly —
`Object.prototype.isPrototypeOf.call(Object.prototype, a)` is false while
`Object.prototype.isPrototypeOf(a)` is true. The direct form is right only
because the fold short-circuits it to true before the walk runs.
So the fold is a mask, not the bug. That also explains the context-dependence:
what changes between probes is whether the fold can prove its precondition, not
what the walk computes.
Mechanism to check first: __isPrototypeOf ref.tests both operands against $Object
and returns 0 when either fails ("Non-$Object obj/candidate -> 0" in its own
comment). native-user-instanceof.ts calls the SAME helper and `a instanceof A` is
correct, so the difference in how that path prepares its operands is the shortest
route to the fix.
Corrected in place rather than rewritten away — an issue carrying a plausible
wrong suspect costs the next reader more than no suspect at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH
✓
… SPELLING is the trigger Supersedes my "the walk answers false for everything". Measured: proto.isPrototypeOf(o) TRUE (plain objects) Object.getPrototypeOf(a).isPrototypeOf(a) TRUE A.prototype.isPrototypeOf(a) FALSE Object.getPrototypeOf(a) === A.prototype true Three readings of the same object, two right and one wrong — so the chain walk is correct whenever the receiver is a genuine $Object, and the defect is in how <UserFn>.prototype is lowered in METHOD-RECEIVER position. The earlier .call-spelled readings belong to loopdive#4580's value-read gap, not here. Likely mechanism, from native-user-instanceof.ts's own comment: instanceof feeds __isPrototypeOf the canonical per-fnctor $Object via emitFnctorProtoGet — "the same global the loopdive#2660 S3a new F() reconstruct seeds $proto from, so object identity holds". A plain A.prototype read yields something ===-equal that is not the object the instance's $proto points at, so ref.eq never matches. Shortest fix is to route <UserFn>.prototype receivers through emitFnctorProtoGet too. Third correction to this file's diagnosis, each from a measurement rather than a re-reading — recorded in place so the narrowing sequence stays visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH ✓
…gration Co-Authored-By: Claude <noreply@anthropic.com>
…e of R4; carry its js-host finding across loopdive#4581 is loopdive#4480 R4. That residual already documents this exact shape, with the same instrumented measurement (struct=108 resolve=undefined vs struct=17 resolve=F), and records that the ref.test arm was written, measured unreachable, and removed rather than shipped as dead code — the blocker is the loopdive#2660 escape gate, not the walk. Filing it was my miss: the mechanism is documented IN THE SOURCE, in a 15-line comment at the exact call site ("(loopdive#4480 S2, NOT taken)"), which I should have read before opening an issue. One finding is NOT covered by R4 and is carried to loopdive#4480: it reproduces on the JS-HOST lane too, with the plain-object and getPrototypeOf controls correct on both lanes. R4 is framed as a standalone escape-gate problem, so either there is a second host-side cause or the shared cause sits above both lanes — and a fix validated only on standalone would leave js-host silently wrong. R4's successor now carries a two-lane acceptance requirement, plus the note that any regression test must be a BARE module because the wrong answer is context-dependent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH ✓
… record a reverted fix attempt Not the harness, as loopdive#4580 guessed. Merely mentioning `Boolean.prototype` anywhere in the module switches a working `Object(true).valueOf()` onto the reflective makeGlue path, every member of which is a refusal stub for the boxed brands. Isolated by truncating built-ins/Object/S9.9_A3 to its first assertion, which passes. I attempted the fix — wiring __unbox_boolean/__box_boolean etc. into makeGlue — and it got past the refusal and then answered FALSE for Object(true).valueOf(). That is strictly worse than the refusal it replaced: a loud TypeError became a silent wrong boolean. Reverted rather than shipped, and the issue records the two things the next attempt needs that mine lacked: verify what param 1 actually holds on THAT glue (the documented "this is param 1" applies to the String/Array/Date factories), and note that String never reaches the shared fallback at all because emitStringProtoMemberBody claims it first and refuses valueOf. Acceptance criteria say a wrong value is a failure, not a partial win — my attempt would have passed a "does not throw" test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH ✓
…ion-sites gate `quality` was red on `check:coercion-sites`: this change-set adds one `__unbox_number` call in the new `src/codegen/bound-fn-meta.ts` (`codegen/bound-fn-meta.ts: 0 -> 1`), and the gate is a net-per-vocabulary ratchet, so any un-declared growth fails. The site is not a hand-rolled ToNumber. §20.2.3.2 step 6.b is answered first by `__typeof_number` — a non-coercing `typeof x === "number"` test — and the unbox runs only on the branch where the value is already known to be a Number primitive. Routing it through the coercion engine would be wrong rather than merely redundant: a coercing read answers 1 for `new Number(1)` and `"1"` and throws on a Symbol, where the spec wants 0 in all three cases (`built-ins/Function/prototype/bind/instance-length-default-value`). So this is declared, not rewritten: a `coercion-sites-allow:` key in this change-set's own issue file, per loopdive#3131 — the committed `scripts/coercion-sites-baseline.json` is untouched. Co-Authored-By: Claude <noreply@anthropic.com>
Another lane pushed loopdive#4580 / loopdive#4576 / loopdive#4581-loopdive#4582 onto this same branch while the CI reproduction was running. Merged, not rebased, per repo policy. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
…read work Two gates went red once loopdive#4580's src/codegen/math-value-read.ts joined this change-set: 1. check:coercion-sites — '__any_to_f64 +1' in math-value-read.ts. This is the gate seeing the fix do the RIGHT thing: the externref arguments run the engine ToNumber pipeline (__any_from_extern -> __any_to_f64), which is what the gate exists to push work towards. The counter is per-vocabulary-token and cannot tell an engine call from a hand-roll, so it is declared via coercion-sites-allow: in loopdive#4580's own issue file rather than rewritten. 2. check:dead-exports — 'mathSelfHostedArity' was exported but referenced from nowhere in src, tests or scripts (verified by grep). Deleted rather than banked into scripts/dead-export-baseline.json: the arity data it wrapped is already read directly from MATH_SELF_HOSTED_F64 by emitMathValueReadBody. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
upstream/main advanced 11 commits (test262 baseline summary sync, npm-compat + landing benchmark refreshes, the loopdive#4577 standalone Calendar/clock/DOM work and the loopdive#4663 object-rest fix). Merged, never rebased, per repo policy. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
…e requires `quality` was red on the Issue->probe coverage gate: 'loopdive#4580 flipped to done with NO probe/test reference'. That gate hard-fails a done-flip whose issue cites neither a tests/*test*.ts file nor a test262/ path, and loopdive#4580's measurement table named its two rows WITHOUT the test262/test/ prefix, so the reference existed in prose but not in a form the gate could see. Rather than assert the rows, I re-ran them on the merged tree: TEST262_TARGET=standalone with a TEST262_PATH_FILTER scoped to the two files reports 2 pass / 2 total. The table now carries their full test262/test/... paths and the command that produced that result. Also recorded the probe trap that made this expensive to check: the fix is reached through the builtin value-read path, so a hand-written 'const f: any = Math.cos; f(0)' does NOT reach it and still throws — a bespoke probe in that shape reads as 'the fix does nothing' while the real conformance rows pass. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
Another lane pushed loopdive#4673/loopdive#4674 (init-firewall re-run safety, ttyd PATH) onto this branch. Everything else it carried was already in via the earlier upstream/main catch-up, so the effective diff is .devcontainer/ only — no source, no gate surface. Merged, never rebased. Pre-commit checklist done. ✓ Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ES5 standalone: bound-function prototype walk +
lengthseedFollow-up to #4658. Two fixes that had to land in this order, plus the
measurement showing why.
#4563 — a callable carrier's expando bag shadowed its prototype walk
Defining any own property on a bound function stopped it inheriting from
Function.prototype: the$__bound_fncarrier's expando bag (#4241) shadowedthe prototype walk once non-empty, answering
undefinedon a bag miss instead ofcontinuing up the chain. Pre-existing, and a plain-JS idiom broken outright.
#4562 — seed a bound function's own
length(§20.2.3.2 steps 5-8)built-ins/Function/prototype/bind: 73/100 → 75/100,target=standalone.Zero regressions — set difference in both directions gives 2 fixed
(
instance-length-prop-desc,instance-length-remaining-args), 0 broken.The ordering is the point. An earlier cut of this seed measured +2 / −2
and was reverted rather than shipped as a wash: giving every bound function
an own property drove them all into the #4563 state, so
15.3.4.5-11-1/15.3.4.5-6-2(a bound function must inherit fromFunction.prototype) broke asfast as the
lengthrows were fixed. With #4563 landed first, the trade is goneand both shapes pass together.
The value is computed at runtime, because §20.2.3.2 reads
lengthoff thetarget — an arbitrary runtime value that
definePropertycan change betweendeclaration and
bind, and the target may itself be bound. Only the argumentcount is static.
Budget gates: earned first, granted at the floor
calls.tswent +9 → +2 by moving the local plumbing into the newsubsystem module behind
seedBoundFunctionLengthOnStack. The remaining +2 isone import and one call — the floor — and is granted on fix(#4454): register __make_getter_callback for spread+method object literals on the host plain-object path #4562.
func-budgetentry sits on fix(codegen): match-vec structurally excluded from closed-struct array-like arms (#4443) #4563, where the growth actually came from(
fillClosurePropHelpers), not on the issue that happened to trip it.Provenance
The #4562 implementation was recovered uncommitted from a lane that hit its
usage limit mid-task — a 161-line module plus a two-line hook that existed only
in a worktree. Rescued, re-based onto current
main, re-measured, and landed.Corpus status
The 523-row ES5 standalone residue corpus reads 120 converted on current
main(up from 109 at #4658, asmainadvanced). The twobindrows above sitoutside that ES5-classified denominator, so they are a real gain that this
particular number does not move.
🤖 Generated with Claude Code
https://claude.ai/code/session_015vAL9KZvTPwwBcFovPJ8aH