Skip to content

hir: builder fold treats an implicit ToPrimitive as "cannot execute user code" — o.a = "" + weird throws ReferenceError #10357

Description

@proggeramlug

Summary

The builder fold (#6812, crates/perry-hir/src/lower/builder_fold.rs) rewrites

const o: any = {};
o.a = <value>;

into const o: any = { a: <value> }, which moves <value> from after the allocation to before o is initialized. value_is_fold_safe is what makes that sound, and its doc says it admits "only expressions that provably cannot execute user code".

It doesn't. It admits every Expr::Bin operator except in/instanceof, every Expr::Unary except delete, and Expr::Tpl with substitutions. Each of those runs an implicit ToPrimitive/ToNumber/ToString on its operands, and that conversion calls a user valueOf/toString/Symbol.toPrimitive. If that user code reads o, the fold turns a successful read into a TDZ ReferenceError.

Reproduction

function run(): string {
  const weird = { valueOf(): any { return o; } };
  const o: any = {};
  o.a = "" + weird;
  return typeof o.a + ":" + (o.a === "[object Object]" ? "str" : "other");
}
try { console.log(run()); } catch (e: any) { console.log("threw " + e.name); }
output
node --experimental-strip-types string:str
perry main (v0.5.1579, fcd108b) threw ReferenceError

Linux x86_64. Plain perry compile, no flags.

Affected forms

Every value (or, since #10355, every skipped gap statement) that converts an operand:

  • arithmetic/relational/bitwise Bin: a + b, a - b, a < b, a | b, a == b, …
  • numeric Unary: -x, +x, ~x
  • templates with substitutions: `${x}`

Conversion-free forms are fine: literals, identifiers, typeof, void, !, ===/!==, &&/||/??, ?:, and array/object literals built from those.

Fix and its cost

Remove the converting operators from value_is_fold_safe, keeping only the conversion-free forms above. That is a correctness fix, but it narrows #6812's coverage: o.b = r + i, the module doc's own motivating example, would stop folding. It should be measured before landing (benchmarks/object-write-6812, and any real module graph that relies on the fold). Anything smarter needs a type proof that the operands are primitive.

#10355 reuses this predicate for its gap test on purpose, so the two sides stay in sync, and did not change it. Found while validating that PR.

Activity

  1. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Correction to "Affected forms" above: bare identifiers are not fine. A name that resolves to no binding reads the global object, and Object.defineProperty(globalThis, "g", { get() { return typeof o; }, configurable: true }); const o = {}; o.a = g; throws ReferenceError on perry where node prints object. I missed it at first because my probe used declare const g, and perry materializes that as a real undefined binding (#10363). #10361 fixes both the conversions and the identifier reads.

  2. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by 204f9f8065 (changelog.d/10361-builder-fold-toprimitive.md). This issue's exact reproducer returned ReferenceError when filed; it now prints string:str, matching node.

    One caveat worth recording: this was reported on Linux and verified on macOS. The fix commit is named and specific, so I am closing on that rather than on the platform match alone — reopen if it persists on Linux.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions