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/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..6e316d94c2 100644 --- a/crates/perry-hir/src/ir/module.rs +++ b/crates/perry-hir/src/ir/module.rs @@ -63,6 +63,27 @@ 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`, + /// `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, /// Lexical bindings from multi-declarator classic `for` heads. @@ -189,6 +210,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/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(); 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 {};