Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions changelog.d/9422-strict-array-length-store.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions changelog.d/9423-esm-module-init-strict.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions crates/perry-codegen-arkts/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/clone_suffix_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fn module_with(functions: Vec<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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/emission_order_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 10 additions & 2 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/entry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
17 changes: 13 additions & 4 deletions crates/perry-codegen/src/codegen/entry_outline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -658,6 +662,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli
new_body: &mut Vec<perry_hir::Stmt>,
next_id: &mut u32,
module_name: &str,
module_is_strict: bool,
) {
if run.is_empty() {
return;
Expand All @@ -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(),
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/number_exactness_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fn module_with(functions: Vec<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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/native_root_coverage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/temp_root_coverage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ fn module_with_init(name: &str, init: Vec<Stmt>) -> 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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/type_analysis/numeric/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ fn probe_module(name: &str, params: Vec<Param>, body: Vec<Stmt>) -> 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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/type_analysis/strings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/app_window_config_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ fn module(name: &str, body: Vec<Stmt>) -> 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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/argless_builtin_extra_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fn module_with_init(init: Vec<Stmt>) -> 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ fn module_with_new(class: Class, args: Vec<Expr>) -> 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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/class_keys_gc_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/constructor_recursion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/i64_spec_ternary_recursion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ fn module_with(functions: Vec<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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/ios_platform_api_lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ fn module(body: Vec<Stmt>) -> 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(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/tests/large_object_barriers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/loop_safepoint_purity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ fn module_with_init(name: &str, init: Vec<Stmt>) -> 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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/tests/macos_bundle_chdir_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading