From 1b1bcaa9c5e8db31a9e5100b253a1fbedc3296e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 22:29:54 +0200 Subject: [PATCH 1/2] fix(codegen): a literal birth mints its shape with the plain prototype The driver gives every anonymous object-literal content (BirthProto::Literal) one static ShapeId, and the startup literal seed mints it with class id 0. Each module's own birth mint passed the __AnonShape_* class id instead, which the runtime turns into a prototype fact. That was equivalent only while the id had no vtable class, but per-module class ids collide: an anonymous shape's id can also be another module's DECLARED class. The mint then derived that class's prototype for a plain literal, so two modules' equal literal contents -- one static id -- reached the mint with different facts and the second aborted: "the static ShapeId ... was refused by the shape mint". OpenCode's TUI died this way at startup (ajv's and json5's one-key literals). Pass class id 0 to the static and lazy shape mints of a literal birth, as the seed does. The keys array still carries the real class id. --- .../src/codegen/static_shape_ids.rs | 4 + .../src/codegen/static_shape_ids_tests.rs | 217 ++++++++++++++++++ .../perry-codegen/src/codegen/string_pool.rs | 20 +- 3 files changed, 238 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/codegen/static_shape_ids.rs b/crates/perry-codegen/src/codegen/static_shape_ids.rs index 4734aac728..1c2a4da4e7 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids.rs @@ -329,6 +329,9 @@ pub(crate) struct ClassBirth { pub wide_live: u32, /// Its content, when it is nameable. pub shape: Option, + /// An anonymous object-literal class (`__AnonShape_*`): its birth names + /// the plain prototype ([`BirthProto::Literal`]), never a class one. + pub literal: bool, } /// `(keys global, packed names, field count, raw-f64 mask words, pointer mask @@ -387,6 +390,7 @@ pub(crate) fn class_birth( class_id, wide_live, shape, + literal, } } diff --git a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs index b92a055675..f4c1d80773 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs @@ -560,3 +560,220 @@ fn literal_key_cache_mints_require_a_seed_even_without_a_guard() { vec![(assigned[&literal], literal)] ); } + +/// A class with plain `this.f = f` fields, as `mint_anon_shape_class` +/// synthesizes for an object literal (or as a declared class would look). +fn field_class( + id: u32, + name: &str, + fields: &[&str], + ctor_id: u32, + param_base: u32, +) -> perry_hir::Class { + use perry_hir::types::Type; + use perry_hir::{ClassField, Expr, Function, Param, Stmt}; + let params: Vec = fields + .iter() + .enumerate() + .map(|(i, f)| Param { + id: param_base + i as u32, + name: (*f).to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }) + .collect(); + let body = params + .iter() + .map(|p| { + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: p.name.clone(), + value: Box::new(Expr::LocalGet(p.id)), + }) + }) + .collect(); + perry_hir::Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: fields + .iter() + .map(|f| ClassField { + name: (*f).to_string(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }) + .collect(), + constructor: Some(Function { + id: ctor_id, + name: "constructor".to_string(), + type_params: Vec::new(), + params, + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }), + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +/// `const o = { a: 1 }` (an `__AnonShape_*` birth) beside `new Point(2)`. +fn literal_and_declared_module() -> perry_hir::Module { + use perry_hir::types::Type; + use perry_hir::{Expr, Stmt}; + let mut hir = perry_hir::Module::new("literal_birth_mint_test"); + hir.classes.push(field_class( + 1, + "__AnonShape_000000000000a001", + &["a"], + 90, + 60, + )); + hir.classes.push(field_class(2, "Point", &["x"], 91, 70)); + for (id, class, v) in [ + (50, "__AnonShape_000000000000a001", 1.0), + (51, "Point", 2.0), + ] { + hir.init.push(Stmt::Let { + id, + name: format!("v{id}"), + ty: Type::Any, + mutable: false, + init: Some(Expr::New { + class_name: class.to_string(), + args: vec![Expr::Number(v)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }); + } + hir +} + +/// The arguments of every `call ... @callee(...)` in `ir`, as their value text +/// (call instructions only: the module's `declare` line names the callee too). +fn mint_calls(ir: &str, callee: &str) -> Vec> { + let needle = format!("@{callee}("); + ir.lines() + .filter(|line| line.contains(" call ") && !line.trim_start().starts_with("declare")) + .filter_map(|line| { + let start = line.find(&needle)? + needle.len(); + let end = start + line[start..].find(')')?; + Some( + line[start..end] + .split(", ") + .map(|arg| arg.rsplit(' ').next().unwrap_or("").to_string()) + .collect(), + ) + }) + .collect() +} + +#[test] +fn a_literal_birth_mints_its_shape_with_the_plain_prototype_on_both_routes() { + // Per-module class ids collide: an `__AnonShape_*` id can also be another + // module's DECLARED class. Passing it to the shape mint let the runtime + // derive that class's prototype for a plain literal, so two modules' equal + // literal contents -- ONE static id -- reached the mint with different + // facts and the second aborted ("the static ShapeId ... was refused by the + // shape mint"; OpenCode's TUI: ajv's and json5's `{ x }` literals). A + // literal birth names the plain prototype, as the startup literal seed + // (`js_shape_seed_plain`) does: class id 0. + let hir = literal_and_declared_module(); + + // Lazy route (no static id): `(keys, field_count, class_id, rep)`. + let lazy = String::from_utf8( + crate::compile_module( + &hir, + crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("module compiles"), + ) + .expect("UTF-8 IR"); + let calls = mint_calls(&lazy, "js_object_shape_id_for_class_keys"); + assert_eq!(calls.len(), 2, "one lazy mint per birth class:\n{lazy}"); + let class_ids: Vec<&str> = calls.iter().map(|args| args[2].as_str()).collect(); + assert!( + class_ids.contains(&"0"), + "the literal's mint must name class id 0: {calls:?}" + ); + assert!( + class_ids.iter().any(|cid| *cid != "0"), + "a declared class keeps its own class id (its prototype is a shape fact): {calls:?}" + ); + + // Static route: the driver assigned the literal's content an id. + let content = BirthShape { + keys: b"a\0".to_vec(), + key_count: 1, + live: 1, + proto: BirthProto::Literal, + typed: None, + rep: 0, + constfn: Vec::new(), + }; + let requested = SHAPE_ID_BASE + 7; + let stat = String::from_utf8( + crate::compile_module( + &hir, + crate::CompileOptions { + emit_ir_only: true, + static_shape_ids: vec![(content, requested)], + ..Default::default() + }, + ) + .expect("module compiles"), + ) + .expect("UTF-8 IR"); + // `(keys, field_count, live, class_id, requested, rep)` + let calls = mint_calls(&stat, "js_object_shape_id_for_class_keys_static"); + let literal: Vec<&Vec> = calls + .iter() + .filter(|args| args[4] == requested.to_string()) + .collect(); + assert_eq!( + literal.len(), + 1, + "the driver's id must reach the literal's static mint for this test to mean anything: {calls:?}\n{stat}" + ); + assert_eq!( + literal[0][3], "0", + "the literal's static mint must name class id 0, as its seed does: {calls:?}" + ); +} diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 30d1255137..1523551bb1 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -721,6 +721,20 @@ pub(super) fn emit_string_pool( .unwrap_or(0) .to_string(); let cid_str = class_id.to_string(); + // A literal birth names the plain prototype (`BirthProto::Literal`), so + // its shape mints pass class id 0, exactly as the startup literal seed + // (`js_shape_seed_plain`) does. Passing the anonymous class's own id was + // equivalent only while that id had no vtable class: per-module class + // ids collide, so an `__AnonShape_*` id can also be another module's + // DECLARED class, and the mint then derived that class's prototype -- + // different facts under the one static id the driver assigned to this + // content, which the mint refuses with an abort. The keys array still + // carries the real id (`js_build_class_keys_array` above). + let mint_cid_str = if birth.literal { + "0".to_string() + } else { + cid_str.clone() + }; let fc_str = field_count.to_string(); let packed_ref = if packed.is_empty() { "null".to_string() @@ -790,7 +804,7 @@ pub(super) fn emit_string_pool( (I64, &arr), (I32, &fc_str), (I32, &live.to_string()), - (I32, &cid_str), + (I32, &mint_cid_str), (I32, &requested), (I64, &rep_str), ], @@ -809,7 +823,7 @@ pub(super) fn emit_string_pool( (I64, &arr), (I32, &fc_str), (I32, &birth_live.to_string()), - (I32, &cid_str), + (I32, &mint_cid_str), (I64, &rep_str), ], ), @@ -819,7 +833,7 @@ pub(super) fn emit_string_pool( &[ (I64, &arr), (I32, &fc_str), - (I32, &cid_str), + (I32, &mint_cid_str), (I64, &rep_str), ], ), From d57797dc07a3ecd798c9e97b6f4ee7c42ec1eca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 22:35:51 +0200 Subject: [PATCH 2/2] changelog: fragment for #11839 --- changelog.d/11839-literal-birth-plain-prototype.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11839-literal-birth-plain-prototype.md diff --git a/changelog.d/11839-literal-birth-plain-prototype.md b/changelog.d/11839-literal-birth-plain-prototype.md new file mode 100644 index 0000000000..fcdb61f6e0 --- /dev/null +++ b/changelog.d/11839-literal-birth-plain-prototype.md @@ -0,0 +1 @@ +- **perry-codegen**: an object literal's shape no longer takes another module's class prototype, and the TUI of the natively compiled OpenCode v1.18.30 no longer aborts at startup with "the static ShapeId ... was refused by the shape mint". Anonymous literal births now pass class id 0 to their shape mints, as the startup literal seed does; passing the anonymous class's own id broke whenever that per-module id collided with a declared class elsewhere.