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.
Summary
The builder fold (#6812,
crates/perry-hir/src/lower/builder_fold.rs) rewritesinto
const o: any = { a: <value> }, which moves<value>from after the allocation to beforeois initialized.value_is_fold_safeis 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::Binoperator exceptin/instanceof, everyExpr::Unaryexceptdelete, andExpr::Tplwith substitutions. Each of those runs an implicitToPrimitive/ToNumber/ToStringon its operands, and that conversion calls a uservalueOf/toString/Symbol.toPrimitive. If that user code readso, the fold turns a successful read into a TDZReferenceError.Reproduction
node --experimental-strip-typesstring:strmain(v0.5.1579, fcd108b)threw ReferenceErrorLinux x86_64. Plain
perry compile, no flags.Affected forms
Every value (or, since #10355, every skipped gap statement) that converts an operand:
Bin:a + b,a - b,a < b,a | b,a == b, …Unary:-x,+x,~x`${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.