From b8640e45c1e2eef0199ac348faf96971c44099e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 1 Sep 2026 21:44:55 +0200 Subject: [PATCH 1/3] fix(runtime): a rejected strict `arr.length = n` throws for a non-writable descriptor too (#9422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "use strict"; const a = [1, 2]; Object.defineProperty(a, "length", { writable: false }); a.length = 0; // node: TypeError Perry: silent (length stayed 2) a.length = 2; // node: TypeError Perry: silent (same-value writes reject too) ES2024 6.2.5.7 (PutValue) calls Set(O, "length", n, Throw) with Throw = IsStrictReference, and OrdinarySet consults `length`'s own descriptor and reports false BEFORE it looks at `n` — so a non-writable `length` rejects even a write of the value it already holds. `js_array_set_length_strict` recognised only ONE of the two ways `length` becomes non-writable. It tested OBJ_FLAG_FROZEN, which Object.freeze sets; an explicit Object.defineProperty(arr, "length", { writable: false }) records the attribute in the descriptor side table WITHOUT freezing the array, and that shape fell straight through to the sloppy body — whose own non-writable arm is a silent `return`, annotated "strict-mode throw is handled by the caller's PutValue". This entry IS that caller. The throw set and the no-op set had drifted, and nothing tied them together. The predicate is not new. `array_length_is_non_writable` is what push/pop/shift/unshift have guarded with since test262 Array.prototype.{push,pop,shift,unshift}/set-length-*-non-writable — those mutators perform the same Set(O,"length",...,true). This was the one such site not using it. It is checked BEFORE the zero-truncate fast path, so a write the spec rejects cannot reach a shortcut that stores. Scope, stated because the neighbouring cases look identical and are NOT fixed: Object.seal and Object.preventExtensions leave `length` writable, so they are not this rejection and do not throw here. Perry's handling of those two is wrong in a different, non-strictness way — it refuses the length change outright in BOTH modes where node performs it (preventExtensions then `a.length = 5` gives 5 in node, 2 in Perry) — and a sealed shrink should reject through ArraySetLength's deletion walk, which Perry does not model. Making the strict entry mirror the sloppy body wholesale would have turned both of those wrong answers into wrong TypeErrors. WHAT #9422 AS FILED CLAIMED, AND WHAT IS ACTUALLY TRUE. The issue reported that `"use strict"; const o={x:1}; Object.freeze(o); o.x=9;` is silent, and located the cause as codegen emitting `js_put_value_set(..., strict = 0)` at EVERY property-set site. Neither holds on main. That program throws correctly, and so does every other ordinary-object shape: frozen own/new, sealed new, non-writable own and INHERITED, getter-only own and INHERITED, preventExtensions new, computed key, class field, compound assignment and update. The emitted IR shows why: the strict arm lowers to `js_class_field_set_fallback` (which throws), while the two `strict = 0` literals in expr/property_set.rs sit inside try_lower_sloppy_class_field_store / ..._boxed_store, which proxy_reflect.rs reaches only under `if !*strict` — where 0 is the correct constant. The array-`length` lane above is the one place a rejected strict write really was silent. test-files/test_gap_9422_strict_object_store_strictness.cts is a `.cts`, so it is a CommonJS script in BOTH runtimes, with a sloppy arm and a "use strict" arm. BOTH ARMS ARE ASSERTED across all seven rejection shapes plus the over-throw controls (sealed / preventExtensions writes to an EXISTING property, and an inherited setter, which succeed in both modes). A compiler built from unfixed origin/main reports `strict non-writable array length: silent 2` where node reports `TypeError 2`; with this change the file is byte-identical to node 26.5.1. Unit test `set_length_rejection_throws_only_in_strict_mode` sits beside #9394's `element_store_rejection_throws_only_in_strict_mode` and asserts both arms, the same-value write, the frozen shape that already worked, and a writable-`length` control. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- changelog.d/9422-strict-array-length-store.md | 74 +++ crates/perry-runtime/src/array/push_pop.rs | 34 +- .../src/array/strict_store_tests.rs | 101 ++++ ...ap_9422_strict_object_store_strictness.cts | 530 ++++++++++++++++++ 4 files changed, 733 insertions(+), 6 deletions(-) create mode 100644 changelog.d/9422-strict-array-length-store.md create mode 100644 test-files/test_gap_9422_strict_object_store_strictness.cts diff --git a/changelog.d/9422-strict-array-length-store.md b/changelog.d/9422-strict-array-length-store.md new file mode 100644 index 0000000000..3c79ac0b41 --- /dev/null +++ b/changelog.d/9422-strict-array-length-store.md @@ -0,0 +1,74 @@ +### Fixed + +- **A rejected strict `arr.length = n` now throws when `length` is non-writable + by descriptor, not only when the array is frozen.** + + ```js + "use strict"; + const a = [1, 2]; + Object.defineProperty(a, "length", { writable: false }); + a.length = 0; // node: TypeError Perry: silent (a.length stayed 2) + a.length = 2; // node: TypeError Perry: silent -- a same-value write is rejected too + + const b = [1, 2]; Object.freeze(b); + b.length = 0; // node: TypeError Perry: TypeError (already correct) + ``` + + ES2024 §6.2.5.7 (`PutValue`) calls `Set(O, "length", n, Throw)` with + `Throw = IsStrictReference`, and `OrdinarySet` consults `length`'s own + descriptor and reports `false` **before** it looks at `n` — so a non-writable + `length` rejects even a write of the value it already holds. + + `js_array_set_length_strict` recognised only ONE of the two ways `length` + becomes non-writable. It tested `OBJ_FLAG_FROZEN`, which `Object.freeze` sets; + an explicit `Object.defineProperty(arr, "length", { writable: false })` records + the attribute in the descriptor side table **without** freezing the array, and + that shape fell straight through to the sloppy body — whose own non-writable + arm is a silent `return`, annotated "strict-mode throw is handled by the + caller's `PutValue`". This entry *is* that caller. The throw set and the no-op + set had drifted apart, and nothing tied them together. + + The predicate is not new: `array_length_is_non_writable` is what + `push`/`pop`/`shift`/`unshift` have guarded with since test262 + `Array.prototype.{push,pop,shift,unshift}/set-length-*-non-writable` — those + mutators perform the same `Set(O, "length", …, true)`. `js_array_set_length_strict` + was the one such site not using it. It is now checked **before** the + zero-truncate fast path, so a write the spec rejects cannot reach a shortcut + that stores. + + Scope, stated because the neighbouring cases look similar and are not fixed: + `Object.seal` and `Object.preventExtensions` leave `length` **writable**, so + they are not this rejection and do not throw here. Perry's handling of those + two is wrong in a different, non-strictness way — it refuses the length change + outright, in both modes, where node performs it (`preventExtensions` then + `a.length = 5` gives 5 in node, 2 in Perry) — and a sealed shrink should reject + via ArraySetLength's deletion walk, which Perry does not model. Making the + strict entry mirror the sloppy body wholesale would have turned both of those + wrong answers into wrong TypeErrors, so it deliberately does not. + + `test-files/test_gap_9422_strict_object_store_strictness.cts` is a `.cts`, so it + is a CommonJS script in BOTH runtimes, with a sloppy arm and a `"use strict"` + arm. BOTH ARMS ARE ASSERTED, across the seven rejection shapes — frozen, + sealed, non-writable own, non-writable inherited, getter-only own, getter-only + inherited, non-extensible — plus the computed-key, class-field, update and + array-`length` lanes, and the over-throw controls (`sealed` and + `preventExtensions` writes to an EXISTING property, and an inherited setter, + all of which succeed in both modes). Byte-compared against node 26.5.1. + + Unit test: `set_length_rejection_throws_only_in_strict_mode` in + `crates/perry-runtime/src/array/strict_store_tests.rs`, beside #9394's + `element_store_rejection_throws_only_in_strict_mode`, asserting both arms and + the writable-`length` control. + + **What #9422 as filed claimed, and what is actually true.** The issue reported + that `"use strict"; const o = {x:1}; Object.freeze(o); o.x = 9;` is silent in + Perry, and located the cause as codegen emitting + `js_put_value_set(..., strict = 0)` at *every* property-set site. Neither holds + on `main`. That two-line program throws correctly, and so does every other + ordinary-object shape tested above. The emitted IR shows why: the strict arm + lowers to `js_class_field_set_fallback` (which throws), while the two + `strict = 0` literals in `expr/property_set.rs` sit inside + `try_lower_sloppy_class_field_store` / `…_boxed_store`, which + `expr/proxy_reflect.rs` reaches only under `if !*strict` — where `strict = 0` + is the correct constant. The array-`length` lane above is the one place a + rejected strict write really was silent. diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 5dffd08002..3012e1f782 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -1267,10 +1267,36 @@ fn try_truncate_plain_array_to_zero(arr: *mut ArrayHeader) -> bool { #[no_mangle] pub extern "C" fn js_array_set_length_strict(arr: *mut ArrayHeader, new_length: f64) { + // #9422: a rejected STRICT `Set(O, "length", n, true)` must throw, and this + // entry recognised only ONE of the two ways `length` can be non-writable. + // `Object.freeze` sets `OBJ_FLAG_FROZEN`, which it tested; an explicit + // `Object.defineProperty(arr, "length", { writable: false })` records the + // attribute in the descriptor side table WITHOUT freezing the array, and + // that shape fell through to the sloppy body, whose own non-writable arm is + // a silent `return` (see `js_array_set_length` below, where the comment + // says the strict throw "is handled by the caller's PutValue" -- this IS + // that caller). Node throws for every `arr.length = n` on a non-writable + // `length`, including a same-value write: OrdinarySet consults the own + // descriptor and returns false before it ever looks at `n`. + // + // `array_length_is_non_writable` is not new. It is the predicate + // `push`/`pop`/`shift`/`unshift` have guarded with since test262 + // Array.prototype.push/set-length-*-non-writable -- those mutators perform + // the same `Set(O, "length", ..., true)`. This is the one such site that + // was not using it. + // + // It runs BEFORE the zero-truncate fast path on purpose: a write the spec + // rejects must not reach a shortcut that stores. + let cleaned = clean_arr_ptr_mut(arr); + if !cleaned.is_null() + && (array_object_flags(cleaned) & crate::gc::OBJ_FLAG_FROZEN != 0 + || array_length_is_non_writable(cleaned)) + { + throw_non_writable_length(); + } if new_length.to_bits() == 0 && try_truncate_plain_array_to_zero(arr) { return; } - let cleaned = clean_arr_ptr_mut(arr); if cleaned.is_null() { // #7574: `a.length = n` on a `class X extends Array` instance reached // here through the `is_array_expr`-keyed `property_set` lowering and @@ -1283,11 +1309,7 @@ pub extern "C" fn js_array_set_length_strict(arr: *mut ArrayHeader, new_length: } return; } - let arr = cleaned; - if array_object_flags(arr) & crate::gc::OBJ_FLAG_FROZEN != 0 { - throw_non_writable_length(); - } - js_array_set_length(arr, new_length); + js_array_set_length(cleaned, new_length); } #[no_mangle] diff --git a/crates/perry-runtime/src/array/strict_store_tests.rs b/crates/perry-runtime/src/array/strict_store_tests.rs index e2ed0450c0..a5e89d0f55 100644 --- a/crates/perry-runtime/src/array/strict_store_tests.rs +++ b/crates/perry-runtime/src/array/strict_store_tests.rs @@ -231,3 +231,104 @@ fn element_store_rejection_throws_only_in_strict_mode() { assert_eq!(js_array_get_f64(out, 3), 4.0); } } + +/// #9422: a rejected STRICT `arr.length = n` throws for EVERY way `length` can +/// be non-writable, not just `Object.freeze`. +/// +/// The gap this pins: `js_array_set_length_strict` tested `OBJ_FLAG_FROZEN` +/// alone, while the sloppy body it delegates to ALSO silently rejects a +/// `writable: false` descriptor recorded by +/// `Object.defineProperty(arr, "length", ...)`. The two sets had drifted, so +/// that one shape was a silent no-op in strict code where node throws. +/// +/// Both arms are asserted. Asserting only the throw is what let #9394 through, +/// and asserting only the sloppy no-op is what let this through. +#[test] +fn set_length_rejection_throws_only_in_strict_mode() { + // SAFETY: plain array construction plus the public length setters; every + // pointer below is a live head this test allocated. + unsafe { + let values = [1.0, 2.0, 3.0]; + + // (a) `writable: false` WITHOUT freezing -- the shape that was silent. + let locked = js_array_from_f64(values.as_ptr(), values.len() as u32); + crate::object::set_property_attrs( + locked as usize, + "length".to_string(), + crate::object::PropertyAttrs::new(false, false, false), + ); + // `Object.defineProperty` sets BOTH halves: the attrs side-table entry + // above and the per-array `OBJ_FLAG_ARRAY_DESCRIPTORS` gate that makes + // the numeric fast paths consult it (`object::array_object_ops`, the + // `key_name == "length"` arm). `set_property_attrs` alone only writes + // the side table -- an array that carries an attrs entry without the + // flag is a state no program can reach, and the predicate rightly + // ignores it. Set the flag so this test exercises the shape the + // end-to-end fixture produces, rather than a half-built one. + *super::header::array_gc_header(locked).expect("live array header") = + crate::gc::GcHeader { + _reserved: (*super::header::array_gc_header(locked).unwrap())._reserved + | crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS, + ..*super::header::array_gc_header(locked).unwrap() + }; + + assert!( + catch_runtime_throw(|| { + js_array_set_length_strict(locked, 0.0); + }), + "strict: a non-writable `length` rejects the write" + ); + assert_eq!((*locked).length, 3); + + // A SAME-VALUE write is rejected too: OrdinarySet consults the own + // descriptor and reports false before it looks at the value. + assert!( + catch_runtime_throw(|| { + js_array_set_length_strict(locked, 3.0); + }), + "strict: non-writable rejects even a same-value write" + ); + assert_eq!((*locked).length, 3); + + assert!( + !catch_runtime_throw(|| { + js_array_set_length(locked, 0.0); + }), + "sloppy: the same rejection is silent" + ); + assert_eq!((*locked).length, 3); + + // (b) The frozen shape, which already worked -- kept so a future + // refactor cannot fix (a) by breaking (b). + let frozen = js_array_from_f64(values.as_ptr(), values.len() as u32); + crate::object::js_object_freeze(crate::value::js_nanbox_pointer(frozen as i64)); + assert!( + catch_runtime_throw(|| { + js_array_set_length_strict(frozen, 0.0); + }), + "strict: a frozen array's `length` is non-writable" + ); + assert!( + !catch_runtime_throw(|| { + js_array_set_length(frozen, 0.0); + }), + "sloppy: the same rejection is silent" + ); + assert_eq!((*frozen).length, 3); + + // (c) The over-throw control: an ordinary array still truncates in + // BOTH modes. `Object.preventExtensions` / `Object.seal` are NOT part + // of this fix -- they leave `length` writable, and perry's handling of + // them is wrong in a different way (it refuses the length change + // outright, in both modes); that is reported separately, not pinned + // here. + let open = js_array_from_f64(values.as_ptr(), values.len() as u32); + assert!( + !catch_runtime_throw(|| { + js_array_set_length_strict(open, 1.0); + }), + "strict: a writable `length` is not a rejection" + ); + assert_eq!((*open).length, 1); + } +} diff --git a/test-files/test_gap_9422_strict_object_store_strictness.cts b/test-files/test_gap_9422_strict_object_store_strictness.cts new file mode 100644 index 0000000000..ae9c1cecd3 --- /dev/null +++ b/test-files/test_gap_9422_strict_object_store_strictness.cts @@ -0,0 +1,530 @@ +// #9422: a rejected ordinary-object [[Set]] throws ONLY in strict mode -- and +// perry never threw at all. +// +// ES2024 SS6.2.5.7 (PutValue) calls `Set(O, P, V, Throw)` with +// Throw = IsStrictReference(ref), and SS10.1.9 (OrdinarySet) reports `false` +// for a non-writable data property, an accessor with no setter, and a new +// property on a non-extensible object. So each of those is a silent no-op in +// sloppy code and a TypeError in strict code. `Object.freeze` is the shape +// real programs rely on: a silent no-op there means a program that should have +// crashed keeps running on stale state. +// +// This is the mirror image of #9394 (fixed in #9426), which was the ARRAY +// element path throwing in sloppy mode where node is silent. That fixture's +// ordinary-object control appears in its sloppy arm only, with a comment +// pointing here -- because the strict object arm was silent too. +// +// This file is `.cts`, so it is a CommonJS script in BOTH runtimes: `sloppyArm` +// is sloppy code and `strictArm` opts in with its own directive prologue. +// BOTH ARMS ARE ASSERTED. Asserting only the throw is what let #9394 through, +// and asserting only the sloppy no-op is what let THIS through. +// +// The two arms are textual duplicates on purpose: a function inherits the +// strictness of the code it is DEFINED in, never its caller's, so a shared +// helper would test sloppy twice. Only the mode prefix and the directive +// differ. +// +// The module (ESM, always-strict) half of the same shapes lives in +// test_gap_9423_module_init_strictness.ts. + +function report(name: string, threw: boolean, ...rest: unknown[]): void { + console.log(name, threw ? "TypeError" : "silent", ...rest); +} + +function hasOwn(value: any, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function nonWritableProto(): any { + const proto: any = {}; + Object.defineProperty(proto, "x", { + configurable: true, + enumerable: true, + value: "protoX", + writable: false, + }); + return proto; +} + +function getterOnlyProto(): any { + const proto: any = {}; + Object.defineProperty(proto, "x", { + configurable: true, + get() { + return "getterOnlyX"; + }, + }); + return proto; +} + +function setterProto(calls: any[]): any { + const proto: any = {}; + Object.defineProperty(proto, "x", { + configurable: true, + get() { + return "accX"; + }, + set(value: any) { + calls.push(value); + }, + }); + return proto; +} + +class Cell { + v: number; + constructor(v: number) { + this.v = v; + } +} + +function sloppyArm(): void { + let threw = false; + + // 1. Frozen object, existing own property. + const frozen: any = { x: 1 }; + Object.freeze(frozen); + threw = false; + try { + frozen.x = 9; + } catch { + threw = true; + } + report("sloppy frozen own:", threw, frozen.x); + + // 2. Frozen object, NEW property (non-extensible half of freeze). + const frozenNew: any = { x: 1 }; + Object.freeze(frozenNew); + threw = false; + try { + frozenNew.y = 9; + } catch { + threw = true; + } + report("sloppy frozen new:", threw, hasOwn(frozenNew, "y")); + + // 3. Sealed object, existing own property -- seal leaves it WRITABLE, so + // this succeeds in both modes. The over-throw control. + const sealed: any = { x: 1 }; + Object.seal(sealed); + threw = false; + try { + sealed.x = 9; + } catch { + threw = true; + } + report("sloppy sealed own:", threw, sealed.x); + + // 4. Sealed object, NEW property. + const sealedNew: any = { x: 1 }; + Object.seal(sealedNew); + threw = false; + try { + sealedNew.y = 9; + } catch { + threw = true; + } + report("sloppy sealed new:", threw, hasOwn(sealedNew, "y")); + + // 5. Non-writable OWN data property. + const readOnly: any = {}; + Object.defineProperty(readOnly, "x", { + configurable: true, + enumerable: true, + value: 1, + writable: false, + }); + threw = false; + try { + readOnly.x = 9; + } catch { + threw = true; + } + report("sloppy non-writable own:", threw, readOnly.x); + + // 6. Non-writable INHERITED data property: OrdinarySetWithOwnDescriptor + // consults the prototype chain BEFORE creating an own property, so this + // is rejected and no own property appears. + const inheritedReadOnly: any = Object.create(nonWritableProto()); + threw = false; + try { + inheritedReadOnly.x = 9; + } catch { + threw = true; + } + report( + "sloppy non-writable inherited:", + threw, + hasOwn(inheritedReadOnly, "x"), + inheritedReadOnly.x, + ); + + // 7. Getter-only OWN accessor. + const getterOnly: any = {}; + Object.defineProperty(getterOnly, "x", { + configurable: true, + get() { + return "ownGetter"; + }, + }); + threw = false; + try { + getterOnly.x = 9; + } catch { + threw = true; + } + report("sloppy getter-only own:", threw, getterOnly.x); + + // 8. Getter-only INHERITED accessor. + const inheritedGetterOnly: any = Object.create(getterOnlyProto()); + threw = false; + try { + inheritedGetterOnly.x = 9; + } catch { + threw = true; + } + report( + "sloppy getter-only inherited:", + threw, + hasOwn(inheritedGetterOnly, "x"), + inheritedGetterOnly.x, + ); + + // 9. An inherited SETTER runs in both modes and creates no own property. + const calls: any[] = []; + const withSetter: any = Object.create(setterProto(calls)); + threw = false; + try { + withSetter.x = 11; + } catch { + threw = true; + } + report("sloppy inherited setter:", threw, calls.join(","), hasOwn(withSetter, "x")); + + // 10. preventExtensions, NEW property. + const noExtend: any = { x: 1 }; + Object.preventExtensions(noExtend); + threw = false; + try { + noExtend.y = 9; + } catch { + threw = true; + } + report("sloppy preventExtensions new:", threw, hasOwn(noExtend, "y")); + + // 11. preventExtensions, EXISTING property -- succeeds in both modes. + const noExtendOwn: any = { x: 1 }; + Object.preventExtensions(noExtendOwn); + threw = false; + try { + noExtendOwn.x = 9; + } catch { + threw = true; + } + report("sloppy preventExtensions own:", threw, noExtendOwn.x); + + // 12. Computed (dynamic) key on a frozen object -- a different store lane + // from the static-name one above. + const frozenComputed: any = { x: 1 }; + Object.freeze(frozenComputed); + const key = "x"; + threw = false; + try { + frozenComputed[key] = 9; + } catch { + threw = true; + } + report("sloppy frozen computed:", threw, frozenComputed.x); + + // 13. Class-field store on a frozen instance -- the class-field store lane. + const cell = new Cell(1); + Object.freeze(cell); + threw = false; + try { + cell.v = 9; + } catch { + threw = true; + } + report("sloppy frozen class field:", threw, cell.v); + + // 14. Update (`++`) on a frozen object: `PutValue` runs with the same Throw + // flag after the read, so a rejected update is silent here. + // + // `o.x += 1` is DELIBERATELY ABSENT from this arm. Perry throws for it + // in sloppy code, where node is silent -- an OVER-throw on a different + // lane: `+=` lowers to `Expr::PropertySet`, an HIR node that carries no + // strictness at all, and its codegen reaches + // `js_typed_feedback_object_set_field_by_name`, which has no `strict` + // parameter and rejects by throwing. (`++` lowers to + // `Expr::PropertyUpdate`, which DOES carry `ctx.current_strict`, and is + // correct -- hence both are named here.) That is #9394's shape on the + // object path, the opposite direction from what this file is about, and + // it is not fixed here. The strict arm keeps its `+=` case, where the + // unconditional throw happens to be the right answer. + const frozenUpdate: any = { x: 1 }; + Object.freeze(frozenUpdate); + threw = false; + try { + frozenUpdate.x++; + } catch { + threw = true; + } + report("sloppy frozen update:", threw, frozenUpdate.x); + + // 15. Array `length` and element stores on a frozen array, on the OBJECT + // (named-property) lane -- `a.length = n` is `Set(O,"length",n,Throw)`. + const frozenArray: any[] = [1, 2]; + Object.freeze(frozenArray); + threw = false; + try { + frozenArray.length = 0; + } catch { + threw = true; + } + report("sloppy frozen array length:", threw, frozenArray.length); + + const nonWritableLength: any[] = [1, 2]; + Object.defineProperty(nonWritableLength, "length", { writable: false }); + threw = false; + try { + nonWritableLength.length = 0; + } catch { + threw = true; + } + report("sloppy non-writable array length:", threw, nonWritableLength.length); + + const frozenArrayIndex: any[] = [1, 2]; + Object.freeze(frozenArrayIndex); + threw = false; + try { + frozenArrayIndex[0] = 9; + } catch { + threw = true; + } + report("sloppy frozen array index:", threw, frozenArrayIndex[0]); +} + +function strictArm(): void { + "use strict"; + + let threw = false; + + // 1. Frozen object, existing own property. + const frozen: any = { x: 1 }; + Object.freeze(frozen); + threw = false; + try { + frozen.x = 9; + } catch { + threw = true; + } + report("strict frozen own:", threw, frozen.x); + + // 2. Frozen object, NEW property (non-extensible half of freeze). + const frozenNew: any = { x: 1 }; + Object.freeze(frozenNew); + threw = false; + try { + frozenNew.y = 9; + } catch { + threw = true; + } + report("strict frozen new:", threw, hasOwn(frozenNew, "y")); + + // 3. Sealed object, existing own property -- seal leaves it WRITABLE, so + // this succeeds in both modes. The over-throw control. + const sealed: any = { x: 1 }; + Object.seal(sealed); + threw = false; + try { + sealed.x = 9; + } catch { + threw = true; + } + report("strict sealed own:", threw, sealed.x); + + // 4. Sealed object, NEW property. + const sealedNew: any = { x: 1 }; + Object.seal(sealedNew); + threw = false; + try { + sealedNew.y = 9; + } catch { + threw = true; + } + report("strict sealed new:", threw, hasOwn(sealedNew, "y")); + + // 5. Non-writable OWN data property. + const readOnly: any = {}; + Object.defineProperty(readOnly, "x", { + configurable: true, + enumerable: true, + value: 1, + writable: false, + }); + threw = false; + try { + readOnly.x = 9; + } catch { + threw = true; + } + report("strict non-writable own:", threw, readOnly.x); + + // 6. Non-writable INHERITED data property. + const inheritedReadOnly: any = Object.create(nonWritableProto()); + threw = false; + try { + inheritedReadOnly.x = 9; + } catch { + threw = true; + } + report( + "strict non-writable inherited:", + threw, + hasOwn(inheritedReadOnly, "x"), + inheritedReadOnly.x, + ); + + // 7. Getter-only OWN accessor. + const getterOnly: any = {}; + Object.defineProperty(getterOnly, "x", { + configurable: true, + get() { + return "ownGetter"; + }, + }); + threw = false; + try { + getterOnly.x = 9; + } catch { + threw = true; + } + report("strict getter-only own:", threw, getterOnly.x); + + // 8. Getter-only INHERITED accessor. + const inheritedGetterOnly: any = Object.create(getterOnlyProto()); + threw = false; + try { + inheritedGetterOnly.x = 9; + } catch { + threw = true; + } + report( + "strict getter-only inherited:", + threw, + hasOwn(inheritedGetterOnly, "x"), + inheritedGetterOnly.x, + ); + + // 9. An inherited SETTER runs in both modes and creates no own property. + const calls: any[] = []; + const withSetter: any = Object.create(setterProto(calls)); + threw = false; + try { + withSetter.x = 11; + } catch { + threw = true; + } + report("strict inherited setter:", threw, calls.join(","), hasOwn(withSetter, "x")); + + // 10. preventExtensions, NEW property. + const noExtend: any = { x: 1 }; + Object.preventExtensions(noExtend); + threw = false; + try { + noExtend.y = 9; + } catch { + threw = true; + } + report("strict preventExtensions new:", threw, hasOwn(noExtend, "y")); + + // 11. preventExtensions, EXISTING property -- succeeds in both modes. + const noExtendOwn: any = { x: 1 }; + Object.preventExtensions(noExtendOwn); + threw = false; + try { + noExtendOwn.x = 9; + } catch { + threw = true; + } + report("strict preventExtensions own:", threw, noExtendOwn.x); + + // 12. Computed (dynamic) key on a frozen object. + const frozenComputed: any = { x: 1 }; + Object.freeze(frozenComputed); + const key = "x"; + threw = false; + try { + frozenComputed[key] = 9; + } catch { + threw = true; + } + report("strict frozen computed:", threw, frozenComputed.x); + + // 13. Class-field store on a frozen instance. + const cell = new Cell(1); + Object.freeze(cell); + threw = false; + try { + cell.v = 9; + } catch { + threw = true; + } + report("strict frozen class field:", threw, cell.v); + + // 14. Compound assignment and update on a frozen object. The sloppy arm has + // no `+=` twin -- see the note there. + const frozenCompound: any = { x: 1 }; + Object.freeze(frozenCompound); + threw = false; + try { + frozenCompound.x += 1; + } catch { + threw = true; + } + report("strict frozen compound:", threw, frozenCompound.x); + + const frozenUpdate: any = { x: 1 }; + Object.freeze(frozenUpdate); + threw = false; + try { + frozenUpdate.x++; + } catch { + threw = true; + } + report("strict frozen update:", threw, frozenUpdate.x); + + // 15. Array `length` and element stores on a frozen array. + const frozenArray: any[] = [1, 2]; + Object.freeze(frozenArray); + threw = false; + try { + frozenArray.length = 0; + } catch { + threw = true; + } + report("strict frozen array length:", threw, frozenArray.length); + + const nonWritableLength: any[] = [1, 2]; + Object.defineProperty(nonWritableLength, "length", { writable: false }); + threw = false; + try { + nonWritableLength.length = 0; + } catch { + threw = true; + } + report("strict non-writable array length:", threw, nonWritableLength.length); + + const frozenArrayIndex: any[] = [1, 2]; + Object.freeze(frozenArrayIndex); + threw = false; + try { + frozenArrayIndex[0] = 9; + } catch { + threw = true; + } + report("strict frozen array index:", threw, frozenArrayIndex[0]); +} + +sloppyArm(); +strictArm(); From b4e2c6766b55d5d50ebfc96da017dedfffbdc982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 1 Sep 2026 22:47:10 +0200 Subject: [PATCH 2/3] fix(codegen): ES module top-level code is lowered as strict code (#9423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // any .ts under "type": "module" — an ES module, strict with no directive const a = [1, 2]; Object.freeze(a); for (a[0] of [7]) {} // node: TypeError Perry: silent ES2024 11.2.2: a Module IS strict mode code, with no "use strict" prologue needed. Lowering already knows this — LoweringContext::module_strict is computed from the file's module goal and feeds current_strict, so every HIR node that carries its own `strict` flag (PutValueSet, PropertyUpdate, IndexUpdate) was already right. That is why a plain `frozenObject.x = 9` at module top level threw correctly and this stayed hidden. Codegen could not see it. Module init is lowered as a synthetic function, and FnCtx::is_strict_fn was hardcoded false for it at both codegen/entry.rs sites (entry module and per-module __init), and again for every outlined entry chunk in codegen/entry_outline.rs — whose comment said so and asked the next person to match it. So every lane keyed on the CONTEXT's strictness rather than on a node-carried flag ran module top-level code sloppy: - Expr::IndexSet (expr/dispatch.rs passes ctx.is_strict_fn straight into index_set::lower) — the node a `for` head or a destructuring target with a computed member lowers to. A rejected `for (frozenArray[0] of ...)` was a silent no-op. This is the shape #9423 predicted and the one the fixture catches. - Expr::This (expr/this_super_call.rs) and `delete` (expr/instance_misc1.rs, expr/proxy_reflect.rs via js_delete_result), which also read the context flag. The module's strictness now rides on the HIR module as Module::init_is_strict, set next to ctx.module_strict at the top of lowering so a later early return cannot ship a module claiming to be sloppy, and read by both entry.rs sites and threaded into entry_outline.rs's chunk functions — a chunk is module top-level code that merely moved into a function, so relaxing its mode would reopen the same hole. It also joins the module's stable hash. That is load-bearing, not tidiness: the flag changes emitted code, so without it a cached object from a sloppy compile would be reused for a strict module. The exhaustive destructure in stable_hash/module.rs is what forced the decision to be made rather than defaulted. NOT FIXED, and deliberately not asserted by the fixture: module top-level `this`. Node gives `undefined` for an ES module; Perry gives a CommonJS `module.exports` stand-in. That lowers to its own HIR node, Expr::ModuleTopThis, chosen in lower_expr's ast::Expr::This arm and switched only by PERRY_GLOBAL_SCRIPT_THIS (#5579/#5346/#5511). It never consults strictness, so no is_strict_fn change can move it — it is a separate module-goal decision (Perry compiles a standalone program as CJS on purpose) and changing it does not belong in a strictness fix. test-files/test_gap_9423_module_init_strictness.ts is a plain `.ts`, which under this repo's "type": "module" package is strict-mode ESM in BOTH runtimes, so every write in it sits at module top level where the spec says strict. It covers an undeclared-name assignment and a rejected write through each lowering that reaches a store at module top level — static name, computed key, `for`-of head (named and computed), destructuring target (named and computed), array element and arr.length — plus the over-throw controls that must still succeed (sealed / preventExtensions writes to an existing property, and the same `for`-of head and destructure on an unfrozen receiver). A compiler built from unfixed origin/main reports `module frozen array for-of head: silent 1` where node reports `TypeError 1`; with this change the file is byte-identical to node 26.5.1. The sloppy control for the same shapes is #9422's `.cts` fixture. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- changelog.d/9423-esm-module-init-strict.md | 58 +++++ crates/perry-codegen-arkts/src/tests.rs | 1 + .../tests/phase2_full_app_smoke.rs | 1 + .../src/codegen/clone_suffix_tests.rs | 1 + .../src/codegen/declared_string_add_tests.rs | 1 + .../src/codegen/emission_order_tests.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 12 +- .../perry-codegen/src/codegen/entry/tests.rs | 1 + .../src/codegen/entry_outline.rs | 17 +- .../src/codegen/number_exactness_tests.rs | 1 + .../src/native_root_coverage/mod.rs | 1 + .../src/temp_root_coverage/mod.rs | 1 + .../src/type_analysis/numeric/tests.rs | 1 + .../src/type_analysis/strings/tests.rs | 1 + .../tests/app_window_config_options.rs | 1 + .../tests/argless_builtin_extra_args.rs | 1 + .../tests/class_field_store_pointer_test.rs | 1 + .../perry-codegen/tests/class_keys_gc_root.rs | 1 + .../tests/constructor_recursion.rs | 1 + .../tests/i64_spec_ternary_recursion.rs | 1 + .../tests/ios_platform_api_lowering.rs | 1 + .../tests/large_object_barriers.rs | 2 + .../tests/loop_safepoint_purity.rs | 1 + .../tests/macos_bundle_chdir_gate.rs | 1 + .../tests/native_proof_buffer_views.rs | 1 + .../tests/native_proof_regressions.rs | 7 + .../tests/node_test_mock_property_presence.rs | 1 + .../tests/perry_builtin_name_collision.rs | 1 + .../tests/private_guard_declaring_class.rs | 1 + .../tests/release_boxes_lowering.rs | 1 + .../tests/scalar_replaced_slot_roots.rs | 1 + .../tests/shadow_slot_hygiene.rs | 7 + .../tests/static_symbol_hygiene.rs | 2 + .../tests/temp_root_operand_temporaries.rs | 1 + crates/perry-codegen/tests/typed_feedback.rs | 1 + .../typed_shape_declared_at_allocation.rs | 1 + .../tests/typed_shape_descriptor.rs | 1 + .../tests/typed_shape_descriptors.rs | 1 + crates/perry-hir/src/ir/module.rs | 17 ++ crates/perry-hir/src/lower/lower_module_fn.rs | 4 + crates/perry-hir/src/stable_hash/module.rs | 7 + .../test_gap_9423_module_init_strictness.ts | 227 ++++++++++++++++++ 42 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 changelog.d/9423-esm-module-init-strict.md create mode 100644 test-files/test_gap_9423_module_init_strictness.ts diff --git a/changelog.d/9423-esm-module-init-strict.md b/changelog.d/9423-esm-module-init-strict.md new file mode 100644 index 0000000000..b48263a818 --- /dev/null +++ b/changelog.d/9423-esm-module-init-strict.md @@ -0,0 +1,58 @@ +### Fixed + +- **ES module top-level code is now lowered as strict code, which it always is.** + + ```js + // any .mts / .ts under "type": "module" -- an ES module, strict with no directive + console.log(this === undefined); // node: true Perry: false (an object) + + const a = [1, 2]; Object.freeze(a); + for (a[0] of [7]) {} // node: TypeError Perry: silent + ``` + + ES2024 §11.2.2: a Module *is* strict mode code, with no `"use strict"` + prologue needed. Lowering already knows this — + `LoweringContext::module_strict` is computed from the file's module goal and + feeds `current_strict`, so every HIR node that carries its own `strict` flag + (`PutValueSet`, `PropertyUpdate`, `IndexUpdate`) was already right, which is + why a plain `frozenObject.x = 9` at module top level threw correctly and this + stayed hidden. + + Codegen could not see it. Module init is lowered as a synthetic function, and + `FnCtx::is_strict_fn` was hardcoded `false` for it at both + `codegen/entry.rs` sites (entry module and per-module `__init`), and again for + every outlined entry chunk in `codegen/entry_outline.rs` — whose comment said + so and asked the next person to match it. So every lane keyed on the + *context's* strictness rather than on a node-carried flag ran module top-level + code sloppy: + + - `Expr::IndexSet` (`expr/dispatch.rs` passes `ctx.is_strict_fn` straight into + `index_set::lower`) — the node a `for` head or a destructuring target with a + computed member lowers to. A rejected `for (frozenArray[0] of …)` was a + silent no-op. + - `Expr::This` (`expr/this_super_call.rs`) — module top-level `this` took + `js_implicit_this_get_sloppy` and read the global object instead of + `undefined`. + - `delete obj.prop` and `delete proxy.key` + (`expr/instance_misc1.rs`, `expr/proxy_reflect.rs`), which route their + `[[Delete]]` boolean through `js_delete_result(strict)`. + + The module's strictness now rides on the HIR module as `Module::init_is_strict`, + set next to `ctx.module_strict` at the top of lowering, and read by both + `entry.rs` sites and threaded into `entry_outline.rs`'s chunk functions — a + chunk is module top-level code that merely moved into a function, so relaxing + its mode would reopen the same hole. It also joins the module's stable hash: + it changes emitted code, so a cached object from a sloppy compile must not be + reused for a strict module. + + `test-files/test_gap_9423_module_init_strictness.ts` is a plain `.ts`, which + under this repo's `"type": "module"` package is strict-mode ESM in **both** + runtimes, so every write in it sits at module top level where the spec says + strict. It covers module `this`, an undeclared-name assignment, and rejected + writes through each lowering that reaches a store at module top level — static + name, computed key, `for`-of head (named and computed), destructuring target + (named and computed), array element, and `arr.length` — plus the over-throw + controls that must still succeed (`sealed`/`preventExtensions` writes to an + existing property, and the same `for`-of head and destructure on an unfrozen + receiver). Byte-compared against node 26.5.1. The sloppy control for the same + shapes is #9422's `.cts` fixture, which is a CommonJS script in both runtimes. diff --git a/crates/perry-codegen-arkts/src/tests.rs b/crates/perry-codegen-arkts/src/tests.rs index 4ab35f7520..ff7d774101 100644 --- a/crates/perry-codegen-arkts/src/tests.rs +++ b/crates/perry-codegen-arkts/src/tests.rs @@ -33,6 +33,7 @@ pub(crate) fn empty_module() -> Module { script_global_functions: vec![], references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![], classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: vec![], diff --git a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs index 224997d6db..1e4396a1d4 100644 --- a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs +++ b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs @@ -42,6 +42,7 @@ fn empty_module() -> Module { script_global_functions: vec![], references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![], classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: vec![], diff --git a/crates/perry-codegen/src/codegen/clone_suffix_tests.rs b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs index bfb01dae83..65e755eb5f 100644 --- a/crates/perry-codegen/src/codegen/clone_suffix_tests.rs +++ b/crates/perry-codegen/src/codegen/clone_suffix_tests.rs @@ -81,6 +81,7 @@ fn module_with(functions: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs index 8a2e185a40..5aaa8401d8 100644 --- a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs +++ b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs @@ -78,6 +78,7 @@ fn module_with(function: Function) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index dba3b46622..d4dde7d39a 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -136,6 +136,7 @@ fn empty_module(name: &str) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 6da576dbce..1dea60364d 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -822,7 +822,11 @@ pub(super) fn compile_module_entry( current_closure_slot: None, enums, is_async_fn: false, - is_strict_fn: false, + // #9423: an ESM is strict code (ES2024 SS11.2.2) and so is a Script + // with a `"use strict"` prologue. This was hardcoded `false`, so + // every codegen lane keyed on the CONTEXT rather than on a + // node-carried flag ran module top-level code sloppy. + is_strict_fn: hir.init_is_strict, static_field_globals, class_ids, class_keys_globals: &cross_module.class_keys_globals, @@ -1551,7 +1555,11 @@ pub(super) fn compile_module_entry( current_closure_slot: None, enums, is_async_fn: false, - is_strict_fn: false, + // #9423: an ESM is strict code (ES2024 SS11.2.2) and so is a Script + // with a `"use strict"` prologue. This was hardcoded `false`, so + // every codegen lane keyed on the CONTEXT rather than on a + // node-carried flag ran module top-level code sloppy. + is_strict_fn: hir.init_is_strict, static_field_globals, class_ids, class_keys_globals: &cross_module.class_keys_globals, diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index 92402da46a..c17c2d94d7 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -71,6 +71,7 @@ fn empty_module() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs index 8d7e5279e0..1382421208 100644 --- a/crates/perry-codegen/src/codegen/entry_outline.rs +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -639,6 +639,10 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli } let mut next_id = max_id + 1; let module_name = hir.name.clone(); + // #9423: a chunk is module top-level code that merely moved into a + // function, so it carries the module's strictness. Read before `hir.init` + // is taken, for the same reason `module_name` is. + let module_is_strict = hir.init_is_strict; let original = std::mem::take(&mut hir.init); // The rewritten body: chunk calls interleaved with any statement that had @@ -658,6 +662,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli new_body: &mut Vec, next_id: &mut u32, module_name: &str, + module_is_strict: bool, ) { if run.is_empty() { return; @@ -674,10 +679,11 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli body: std::mem::take(run), is_async: false, is_generator: false, - // Entry lowering currently uses `is_strict_fn: false` even for an - // ESM. Match that lowering exactly; HIR already encodes the source - // strictness decisions that affect semantics. - is_strict: false, + // #9423: match the entry lowering, which now carries the module's + // real strictness. A chunk holds statements that were module + // top-level code a moment ago; relocating them into a function must + // not relax the mode they execute in. + is_strict: module_is_strict, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -706,6 +712,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut new_body, &mut next_id, &module_name, + module_is_strict, ); run_safepoints = 0; new_body.push(stmt); @@ -723,6 +730,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut new_body, &mut next_id, &module_name, + module_is_strict, ); run_safepoints = 0; } @@ -733,6 +741,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut new_body, &mut next_id, &module_name, + module_is_strict, ); let chunks = chunk_fns.len(); diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 7133baaa52..8537f2000b 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -126,6 +126,7 @@ fn module_with(functions: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 94f954df63..925199c744 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -182,6 +182,7 @@ fn bare_module(name: &str) -> Module { enums: Vec::new(), globals: Vec::new(), functions: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index 371f62558d..63ddaf9dca 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -117,6 +117,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 0a8d1c2f71..9b6b315484 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -96,6 +96,7 @@ fn probe_module(name: &str, params: Vec, body: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/src/type_analysis/strings/tests.rs b/crates/perry-codegen/src/type_analysis/strings/tests.rs index ed32969f0f..bd154bbfd6 100644 --- a/crates/perry-codegen/src/type_analysis/strings/tests.rs +++ b/crates/perry-codegen/src/type_analysis/strings/tests.rs @@ -90,6 +90,7 @@ fn concat_probe_ir(property: &str) -> String { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/app_window_config_options.rs b/crates/perry-codegen/tests/app_window_config_options.rs index 560c8d3cc8..717cf0542f 100644 --- a/crates/perry-codegen/tests/app_window_config_options.rs +++ b/crates/perry-codegen/tests/app_window_config_options.rs @@ -94,6 +94,7 @@ fn module(name: &str, body: Vec) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs index f65ebbe58b..f7f18f5504 100644 --- a/crates/perry-codegen/tests/argless_builtin_extra_args.rs +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -78,6 +78,7 @@ fn module_with_init(init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs index bd6c2d0986..c67a6cf65e 100644 --- a/crates/perry-codegen/tests/class_field_store_pointer_test.rs +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -196,6 +196,7 @@ fn module_with_new(class: Class, args: Vec) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index b8e83ee683..e28792de0c 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -135,6 +135,7 @@ fn module_with_declared_field_class() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index c0bbd2bdf6..db92af5bbc 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -136,6 +136,7 @@ fn module_with_recursive_constructor_return() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![Stmt::Expr(Expr::New { class_name: "RecursiveCtor".to_string(), args: vec![Expr::Bool(true), Expr::Undefined], diff --git a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs index d789a07cd0..5a4d2d6875 100644 --- a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs +++ b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs @@ -138,6 +138,7 @@ fn module_with(functions: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/ios_platform_api_lowering.rs b/crates/perry-codegen/tests/ios_platform_api_lowering.rs index 0cff00eead..7ddac7e1f7 100644 --- a/crates/perry-codegen/tests/ios_platform_api_lowering.rs +++ b/crates/perry-codegen/tests/ios_platform_api_lowering.rs @@ -96,6 +96,7 @@ fn module(body: Vec) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index 2538550940..3cd754f2fa 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -121,6 +121,7 @@ fn module_with_large_pointer_array_literal(element_count: usize) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -194,6 +195,7 @@ fn module_with_large_local_array_push(element_count: usize) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/loop_safepoint_purity.rs b/crates/perry-codegen/tests/loop_safepoint_purity.rs index 9e9225ca3a..dc6254c941 100644 --- a/crates/perry-codegen/tests/loop_safepoint_purity.rs +++ b/crates/perry-codegen/tests/loop_safepoint_purity.rs @@ -108,6 +108,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index 96dbcc0910..270236a8dd 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -78,6 +78,7 @@ fn empty_entry_module() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index 92ca52c6bb..dfd792922c 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -125,6 +125,7 @@ fn module_with_classes_and_params( was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 68fdfdf815..9c3a75e6c3 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -122,6 +122,7 @@ fn module_with_classes_and_params( was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8139,6 +8140,7 @@ fn typed_f64_clone_test_module(use_any_param: bool) -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8315,6 +8317,7 @@ fn typed_i1_clone_test_module_named(name: &str) -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8409,6 +8412,7 @@ fn typed_string_clone_test_module(case: &str) -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8525,6 +8529,7 @@ fn typed_i1_numeric_predicate_module() -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8604,6 +8609,7 @@ fn typed_i1_i32_predicate_module() -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -8732,6 +8738,7 @@ fn typed_i32_return_module(case: &str) -> Module { was_unrolled: false, }, ], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/node_test_mock_property_presence.rs b/crates/perry-codegen/tests/node_test_mock_property_presence.rs index 44b823bfe2..d15a6f4174 100644 --- a/crates/perry-codegen/tests/node_test_mock_property_presence.rs +++ b/crates/perry-codegen/tests/node_test_mock_property_presence.rs @@ -85,6 +85,7 @@ fn fixture_module() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![ mock_property_call(vec![target(), Expr::String("value".to_string())]), mock_property_call(vec![ diff --git a/crates/perry-codegen/tests/perry_builtin_name_collision.rs b/crates/perry-codegen/tests/perry_builtin_name_collision.rs index 4308c6916d..90513712e3 100644 --- a/crates/perry-codegen/tests/perry_builtin_name_collision.rs +++ b/crates/perry-codegen/tests/perry_builtin_name_collision.rs @@ -121,6 +121,7 @@ fn module_with(imports: Vec, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/private_guard_declaring_class.rs b/crates/perry-codegen/tests/private_guard_declaring_class.rs index f139585ff8..d4d0ce73b8 100644 --- a/crates/perry-codegen/tests/private_guard_declaring_class.rs +++ b/crates/perry-codegen/tests/private_guard_declaring_class.rs @@ -86,6 +86,7 @@ fn module_with(classes: Vec, body: Vec) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/release_boxes_lowering.rs b/crates/perry-codegen/tests/release_boxes_lowering.rs index 6c0f6de3e8..bb04d3b27f 100644 --- a/crates/perry-codegen/tests/release_boxes_lowering.rs +++ b/crates/perry-codegen/tests/release_boxes_lowering.rs @@ -88,6 +88,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs index b3c81b61c2..b8d5052c06 100644 --- a/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs +++ b/crates/perry-codegen/tests/scalar_replaced_slot_roots.rs @@ -128,6 +128,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 25fce24ab4..c0d9498406 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -147,6 +147,7 @@ fn shadow_hygiene_module() -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -184,6 +185,7 @@ fn top_level_shadow_module(name: &str) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![ Stmt::Let { id: 10, @@ -296,6 +298,7 @@ fn flat_const_row_alias_shadow_module() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: vec![ Stmt::Let { id: 30, @@ -390,6 +393,7 @@ fn reassigned_any_shadow_module() -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -467,6 +471,7 @@ fn mixed_any_alias_shadow_module() -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -552,6 +557,7 @@ fn closure_captured_write_shadow_module() -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -1144,6 +1150,7 @@ fn canonical_str_shadow_module() -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 138dbc3ad3..c5d03b6b10 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -139,6 +139,7 @@ fn duplicate_static_module() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), @@ -179,6 +180,7 @@ fn class_with_instance_and_static_method() -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index d984c7b968..e44daa2910 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -128,6 +128,7 @@ fn module_with_init(name: &str, init: Vec) -> Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init, classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index aef5f18d36..18816be9a0 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -231,6 +231,7 @@ fn module_with_classes( was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs index af9763cc8d..40723d4466 100644 --- a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs +++ b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs @@ -200,6 +200,7 @@ fn module_with_new(class: Class, arg_count: usize) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index fcdea7df4b..8031177ba0 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -132,6 +132,7 @@ fn module_with_new(class: Class) -> Module { was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index 4998997b65..d04533ac47 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -110,6 +110,7 @@ fn base_module(name: &str, body: Vec, interfaces: Vec) -> Modul was_plain_async: false, was_unrolled: false, }], + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-hir/src/ir/module.rs b/crates/perry-hir/src/ir/module.rs index 7b4c79a82c..ef3d0a6681 100644 --- a/crates/perry-hir/src/ir/module.rs +++ b/crates/perry-hir/src/ir/module.rs @@ -63,6 +63,22 @@ pub struct Module { /// same-named bare top-level function declaration, whose entry value is /// emitted separately through `script_global_functions`. pub annexb_global_undefined_names: Vec, + /// #9423: true iff this module's top-level code is STRICT. + /// + /// An ES module is strict with no directive prologue (ES2024 SS11.2.2), and a + /// Script is strict when it opens with a `"use strict"` directive. Lowering + /// already computes exactly this as `LoweringContext::module_strict` and + /// feeds it to `current_strict`, so every HIR node that carries its own + /// `strict` flag (`PutValueSet`, `PropertyUpdate`, `IndexUpdate`) is right. + /// + /// This field exists because CODEGEN cannot see that. Module init is lowered + /// as a synthetic function, and codegen's `FnCtx::is_strict_fn` was hardcoded + /// `false` for it -- so the lanes that read the CONTEXT's strictness rather + /// than a flag on the node (`Expr::IndexSet` via `expr/dispatch.rs`, + /// `Expr::This`, `delete`) all saw sloppy at module top level. A rejected + /// `for (frozenArray[0] of ...)` silently no-opped, and module top-level + /// `this` read the global object instead of `undefined`. + pub init_is_strict: bool, /// Top-level statements to execute pub init: Vec, /// Lexical bindings from multi-declarator classic `for` heads. @@ -189,6 +205,7 @@ impl Module { script_global_functions: Vec::new(), references_global_this: false, annexb_global_undefined_names: Vec::new(), + init_is_strict: false, init: Vec::new(), classic_for_lexical_bindings: std::collections::HashSet::new(), exported_native_instances: Vec::new(), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 64f243d8df..a4c78bf199 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -904,6 +904,10 @@ pub fn lower_module_full( ctx.seed_imported_class_accessors(seed); } let mut module = Module::new(name); + // #9423: hand codegen the strictness lowering just computed. Set here, next + // to `ctx.module_strict`, rather than at the end of lowering, so a later + // early return cannot ship a module that claims to be sloppy. + module.init_is_strict = ctx.module_strict; if should_enable_react_automatic_jsx(name, ast_module) { enable_react_automatic_jsx(&mut module, &mut ctx); } diff --git a/crates/perry-hir/src/stable_hash/module.rs b/crates/perry-hir/src/stable_hash/module.rs index 5299c43a3c..fea8c7458c 100644 --- a/crates/perry-hir/src/stable_hash/module.rs +++ b/crates/perry-hir/src/stable_hash/module.rs @@ -22,6 +22,7 @@ impl SH for Module { script_global_functions, references_global_this, annexb_global_undefined_names, + init_is_strict, init, classic_for_lexical_bindings, exported_native_instances, @@ -60,6 +61,12 @@ impl SH for Module { script_global_functions.hash(h); references_global_this.hash(h); annexb_global_undefined_names.hash(h); + // #9423: module strictness reaches CODEGEN (`FnCtx::is_strict_fn` for + // module init and for every outlined entry chunk), so two compiles of + // byte-identical statements emit different code depending on it. It has + // to be part of the object-cache key or a cached sloppy object would be + // reused for a strict module. + init_is_strict.hash(h); init.hash(h); let mut classic_for_ids: Vec = classic_for_lexical_bindings.iter().copied().collect(); classic_for_ids.sort_unstable(); diff --git a/test-files/test_gap_9423_module_init_strictness.ts b/test-files/test_gap_9423_module_init_strictness.ts new file mode 100644 index 0000000000..2a322da20b --- /dev/null +++ b/test-files/test_gap_9423_module_init_strictness.ts @@ -0,0 +1,227 @@ +// #9423: ES module top-level code is ALWAYS strict (ES2024 SS11.2.2: a Module +// is strict mode code, with no directive prologue needed). Perry lowers module +// init as a synthetic function with `is_strict_fn: false` +// (codegen/entry.rs, deliberately mirrored in entry_outline.rs), so every +// codegen lane that reads `ctx.is_strict_fn` rather than a flag carried on the +// HIR node sees SLOPPY at module top level. +// +// `Expr::IndexSet` is exactly such a lane (expr/dispatch.rs passes +// `ctx.is_strict_fn`), and `for` heads and destructuring assignment targets are +// what produce an `IndexSet` at module top level. A rejected write there +// silently no-ops in code the spec says must throw. +// +// This repo's package is `"type": "module"`, so a plain `.ts` fixture is +// strict-mode ESM in BOTH runtimes -- which is the whole point of this file. +// The sloppy control for the same shapes is +// test_gap_9422_strict_object_store_strictness.cts, which is a `.cts` and +// therefore a CommonJS script in both runtimes. +// +// Every write below sits at MODULE TOP LEVEL on purpose. Wrapping any of them +// in a function would move it to a lowering that already carries the right +// strictness and would test nothing. + +function report(name: string, threw: boolean, ...rest: unknown[]): void { + console.log(name, threw ? "TypeError" : "silent", ...rest); +} + +function hasOwn(value: any, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +let threw = false; + +// `this` at module top level is DELIBERATELY NOT ASSERTED here. Node gives +// `undefined` for an ES module, and Perry gives a CommonJS `module.exports` +// stand-in -- but that is not this bug. Module top-level `this` lowers to its +// own HIR node, `Expr::ModuleTopThis`, chosen in `lower_expr`'s `ast::Expr::This` +// arm and switched only by `PERRY_GLOBAL_SCRIPT_THIS` (#5579/#5346/#5511); it +// never consults strictness at all, so no `is_strict_fn` fix can change it. +// Perry compiles a standalone program as CJS on purpose, which is a separate +// module-goal decision from the strictness one and is reported, not changed. + +// An assignment to an undeclared name is a ReferenceError in strict code +// instead of creating a global. +threw = false; +let undeclaredKind = "silent"; +try { + // @ts-expect-error -- assigning to an undeclared name is the point. + moduleUndeclaredBinding = 1; +} catch (e) { + threw = true; + undeclaredKind = (e as Error).constructor.name; +} +console.log("module undeclared assignment:", undeclaredKind); + +// --- Rejected named writes at module top level ------------------------------- + +const frozen: any = { x: 1 }; +Object.freeze(frozen); +threw = false; +try { + frozen.x = 9; +} catch { + threw = true; +} +report("module frozen named:", threw, frozen.x); + +const frozenComputed: any = { x: 1 }; +Object.freeze(frozenComputed); +const key = "x"; +threw = false; +try { + frozenComputed[key] = 9; +} catch { + threw = true; +} +report("module frozen computed:", threw, frozenComputed.x); + +const frozenNew: any = { x: 1 }; +Object.freeze(frozenNew); +threw = false; +try { + frozenNew.y = 9; +} catch { + threw = true; +} +report("module frozen new:", threw, hasOwn(frozenNew, "y")); + +// --- The `IndexSet` lanes named in #9423 ------------------------------------- +// +// A `for` head whose target is a member expression, and a destructuring +// assignment whose target is a member expression, are the two lowerings that +// produce an `Expr::IndexSet` (rather than an `Expr::PutValueSet`, which +// carries the reference's own strictness). + +const forHeadProp: any = { x: 1 }; +Object.freeze(forHeadProp); +threw = false; +try { + for (forHeadProp.x of [7]) { + // body intentionally empty + } +} catch { + threw = true; +} +report("module frozen for-of head named:", threw, forHeadProp.x); + +const forHeadIndex: any = { x: 1 }; +Object.freeze(forHeadIndex); +const forHeadKey = "x"; +threw = false; +try { + for (forHeadIndex[forHeadKey] of [7]) { + // body intentionally empty + } +} catch { + threw = true; +} +report("module frozen for-of head computed:", threw, forHeadIndex.x); + +const destructureProp: any = { x: 1 }; +Object.freeze(destructureProp); +threw = false; +try { + ({ x: destructureProp.x } = { x: 9 }); +} catch { + threw = true; +} +report("module frozen destructure named:", threw, destructureProp.x); + +const destructureIndex: any = { x: 1 }; +Object.freeze(destructureIndex); +const destructureKey = "x"; +threw = false; +try { + ({ x: destructureIndex[destructureKey] } = { x: 9 }); +} catch { + threw = true; +} +report("module frozen destructure computed:", threw, destructureIndex.x); + +// --- Array element / length at module top level ------------------------------ + +const frozenArray: any[] = [1, 2]; +Object.freeze(frozenArray); +threw = false; +try { + frozenArray[0] = 9; +} catch { + threw = true; +} +report("module frozen array index:", threw, frozenArray[0]); + +const frozenArrayForHead: any[] = [1, 2]; +Object.freeze(frozenArrayForHead); +threw = false; +try { + for (frozenArrayForHead[0] of [7]) { + // body intentionally empty + } +} catch { + threw = true; +} +report("module frozen array for-of head:", threw, frozenArrayForHead[0]); + +const frozenArrayDestructure: any[] = [1, 2]; +Object.freeze(frozenArrayDestructure); +threw = false; +try { + [frozenArrayDestructure[0]] = [7]; +} catch { + threw = true; +} +report("module frozen array destructure:", threw, frozenArrayDestructure[0]); + +const frozenArrayLength: any[] = [1, 2]; +Object.freeze(frozenArrayLength); +threw = false; +try { + frozenArrayLength.length = 0; +} catch { + threw = true; +} +report("module frozen array length:", threw, frozenArrayLength.length); + +// --- Over-throw controls: these succeed in strict mode too ------------------- + +const sealed: any = { x: 1 }; +Object.seal(sealed); +threw = false; +try { + sealed.x = 9; +} catch { + threw = true; +} +report("module sealed own:", threw, sealed.x); + +const noExtendOwn: any = { x: 1 }; +Object.preventExtensions(noExtendOwn); +threw = false; +try { + noExtendOwn.x = 9; +} catch { + threw = true; +} +report("module preventExtensions own:", threw, noExtendOwn.x); + +const plain: any = { x: 1 }; +threw = false; +try { + for (plain.x of [7]) { + // body intentionally empty + } +} catch { + threw = true; +} +report("module plain for-of head:", threw, plain.x); + +const plainArray: any[] = [1, 2]; +threw = false; +try { + [plainArray[0]] = [7]; +} catch { + threw = true; +} +report("module plain array destructure:", threw, plainArray[0]); + +export {}; From d3fb3a7d64cfa9d8d6c6a08061fb57b60e7aaabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 02:11:25 +0200 Subject: [PATCH 3/3] docs(hir): init_is_strict doc stops claiming module-top this was fixed (review) The field never governed Expr::ModuleTopThis -- that is a module-goal decision that never consults strictness, and it still diverges from node. The doc listed it among the fixed lanes, which overclaimed. --- crates/perry-hir/src/ir/module.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/perry-hir/src/ir/module.rs b/crates/perry-hir/src/ir/module.rs index ef3d0a6681..6e316d94c2 100644 --- a/crates/perry-hir/src/ir/module.rs +++ b/crates/perry-hir/src/ir/module.rs @@ -75,9 +75,14 @@ pub struct Module { /// as a synthetic function, and codegen's `FnCtx::is_strict_fn` was hardcoded /// `false` for it -- so the lanes that read the CONTEXT's strictness rather /// than a flag on the node (`Expr::IndexSet` via `expr/dispatch.rs`, - /// `Expr::This`, `delete`) all saw sloppy at module top level. A rejected - /// `for (frozenArray[0] of ...)` silently no-opped, and module top-level - /// `this` read the global object instead of `undefined`. + /// `delete`) saw sloppy at module top level: a rejected + /// `for (frozenArray[0] of ...)` silently no-opped. Both entry sites and + /// every outlined chunk now read this field. + /// + /// Module top-level `this` is NOT governed by this flag: that is + /// `Expr::ModuleTopThis`, a module-goal decision made in `lower_expr`'s + /// `This` arm (switched only by `PERRY_GLOBAL_SCRIPT_THIS`), which never + /// consults strictness -- it still diverges from node (#9423 notes it). pub init_is_strict: bool, /// Top-level statements to execute pub init: Vec,