Skip to content

fix(es5): bound-function prototype walk (#4563) + own length seed (#4562) - #4672

Merged
js2-merge-queue-bot[bot] merged 23 commits into
loopdive:mainfrom
ttraenkler:es5-standalone-integration
Aug 21, 2026
Merged

fix(es5): bound-function prototype walk (#4563) + own length seed (#4562)#4672
js2-merge-queue-bot[bot] merged 23 commits into
loopdive:mainfrom
ttraenkler:es5-standalone-integration

Conversation

@ttraenkler

Copy link
Copy Markdown
Contributor

ES5 standalone: bound-function prototype walk + length seed

Follow-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

var b = foo.bind({});
Object.defineProperty(b, "zz", { value: 1, configurable: true });
Function.prototype.property = 12;
b.property;   // was undefined — want 12

Defining any own property on a bound function stopped it inheriting from
Function.prototype: the $__bound_fn carrier's expando bag (#4241) shadowed
the prototype walk once non-empty, answering undefined on a bag miss instead of
continuing 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 from Function.prototype) broke as
fast as the length rows were fixed. With #4563 landed first, the trade is gone
and both shapes pass together.

The value is computed at runtime, because §20.2.3.2 reads length off the
target — an arbitrary runtime value that defineProperty can change between
declaration and bind, and the target may itself be bound. Only the argument
count is static.

Budget gates: earned first, granted at the floor

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, as main advanced). The two bind rows above sit
outside 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

ttraenkler and others added 23 commits August 19, 2026 10:35
…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

✓
…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>
@js2-merge-queue-bot
js2-merge-queue-bot Bot added this pull request to the merge queue Aug 21, 2026
Merged via the queue into loopdive:main with commit 4a9362c Aug 21, 2026
35 checks passed
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.

1 participant