From d59a1a8c812e28232343d87cb5cf1b2ac5920377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 13:14:46 +0000 Subject: [PATCH 1/2] fix: align Error reflection and locale grouping --- Cargo.lock | 2 + changelog.d/9440-error-subclass-name.md | 7 + changelog.d/9450-intl-locale-grouping.md | 6 + crates/perry-codegen/src/lower_call/new.rs | 11 +- crates/perry-runtime/Cargo.toml | 7 +- crates/perry-runtime/src/error.rs | 26 ++- .../perry-runtime/src/error_subclass_stack.rs | 45 +++++ crates/perry-runtime/src/intl.rs | 17 +- .../perry-runtime/src/intl/number_format.rs | 156 ++++++++++++++---- .../src/object/descriptor_own_names.rs | 28 ++++ crates/perry-runtime/src/object/mod.rs | 1 + ...test_gap_9440_error_name_own_enumerable.ts | 66 ++++++++ .../test_gap_9450_intl_indian_grouping.ts | 40 +++++ 13 files changed, 354 insertions(+), 58 deletions(-) create mode 100644 changelog.d/9440-error-subclass-name.md create mode 100644 changelog.d/9450-intl-locale-grouping.md create mode 100644 crates/perry-runtime/src/object/descriptor_own_names.rs create mode 100644 test-files/test_gap_9440_error_name_own_enumerable.ts create mode 100644 test-files/test_gap_9450_intl_indian_grouping.ts diff --git a/Cargo.lock b/Cargo.lock index f02b6d6e1e..df85e88428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6333,8 +6333,10 @@ dependencies = [ "hostname", "icu_calendar", "icu_datetime", + "icu_decimal", "icu_locale", "icu_locale_core", + "icu_provider", "icu_time", "idna", "itoa", diff --git a/changelog.d/9440-error-subclass-name.md b/changelog.d/9440-error-subclass-name.md new file mode 100644 index 0000000000..a0ce62c601 --- /dev/null +++ b/changelog.d/9440-error-subclass-name.md @@ -0,0 +1,7 @@ +### Fixed + +- Error subclasses no longer receive an enumerable own `name` property during + construction. They inherit the non-enumerable built-in name from the Error + prototype chain, matching Node across JSON serialization, own-key reflection, + spread, `for...in`, and inspection; explicit `error.name = value` assignments + remain ordinary enumerable own properties. (#9440) diff --git a/changelog.d/9450-intl-locale-grouping.md b/changelog.d/9450-intl-locale-grouping.md new file mode 100644 index 0000000000..6076e5fccc --- /dev/null +++ b/changelog.d/9450-intl-locale-grouping.md @@ -0,0 +1,6 @@ +### Fixed + +- `Intl.NumberFormat` and Number/BigInt locale formatting now use CLDR primary + and secondary grouping widths instead of fixed three-digit groups. Indian + locales therefore render `123456789` as `12,34,56,789`, while western + grouping and `useGrouping: false` remain unchanged. (#9450) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index fdc93a03c5..5824153b20 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1463,17 +1463,16 @@ fn lower_new_impl_inner<'a>( // without finding any user-class constructor, synthesize the JS // spec default ctor `constructor(...args) { super(...args); }` — // i.e. forward the first arg to Error's initialization, which - // sets `this.message` + `this.name`. Without this, `new MyError( - // "hello")` returns an object with `.message` / `.name` + // sets `this.message`. Without this, `new MyError( + // "hello")` returns an object with `.message` // unset — the SIGABRT-on-property-read happens because the slot // index lookup misses and downstream NaN-box decode reads // garbage. // // Walk the chain to find the terminating Error-like name (so // `class A extends Error {}; class B extends A {}` also flows - // through correctly). If found, set `this.message = args[0]` - // and `this.name = ` directly, mirroring the - // SuperCall Error-like arm in expr.rs. + // through correctly). If found, initialize `message`, mirroring the + // SuperCall Error-like arm in expr.rs, then capture `stack`. // // BUT: if `class_name` is an imported stub with a cross-module // ctor with a real body/effect, defer to that path — the source @@ -1483,7 +1482,7 @@ fn lower_new_impl_inner<'a>( // assign the wrong arg to `message` and corrupt the instance. // When the imported ctor is a synthesized empty 0-param ctor for the // bare-extends-Error case, calling it is a no-op and we still need - // Error-init to populate `this.message` / `this.name`. + // Error-init to populate `this.message`. let imported_ctor_has_body_or_fields = ctx .imported_class_ctors .get(class_name) diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index f2546c6af5..d7822cf070 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -162,7 +162,7 @@ intl-segmenter = ["dep:unicode-segmentation"] # reachable), so only the `Intl.` namespace members depend on it. The # compiler enables it on any `Intl`/locale-formatting token, erring toward # enabling (same over-approximation contract as `temporal`). -intl-namespace = [] +intl-namespace = ["dep:icu_decimal", "dep:icu_provider", "dep:icu_locale_core"] # Per-namespace `globalThis` member tables. Each installs the reflectable # members of one built-in namespace (`Math.max`, `JSON.stringify`, # `Reflect.get`, `Atomics.add`, …) as real properties. Call sites in user code @@ -343,6 +343,11 @@ unicode-segmentation = { version = "1", optional = true } # their compiled data are already in the default lock graph via icu_datetime. icu_locale = { version = "2", optional = true } icu_locale_core = { version = "2", optional = true } +# CLDR decimal grouping metadata (primary/secondary group widths and the +# locale's minimum grouping threshold), shared by Intl.NumberFormat and +# Number/BigInt.prototype.toLocaleString. +icu_decimal = { version = "2", default-features = false, features = ["compiled_data"], optional = true } +icu_provider = { version = "2", default-features = false, optional = true } # CLDR date/time formatting for Intl.DateTimeFormat / Date.prototype.toLocale* # (icu4x 2.x, matching the icu_calendar/icu_locale_core already in the graph). # `compiled_data` vendors the CLDR dataset, so byte-for-byte-with-Node locale diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 6c84e889e5..a955cc02aa 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1591,19 +1591,26 @@ unsafe fn read_string_header_owned(ptr: *const StringHeader) -> String { #[no_mangle] pub extern "C" fn js_error_to_string(error: *mut ErrorHeader) -> *mut StringHeader { unsafe { - let name = error_to_string_part(error, "name", js_error_get_name(error), "Error"); - let message = error_to_string_part(error, "message", js_error_get_message(error), ""); - let result = if name.is_empty() { - message - } else if message.is_empty() { - name - } else { - format!("{name}: {message}") - }; + let result = error_display_headline(error); js_string_from_bytes(result.as_ptr(), result.len() as u32) } } +pub(crate) unsafe fn error_display_headline(error: *mut ErrorHeader) -> String { + if error.is_null() { + return "Error".to_string(); + } + let name = error_to_string_part(error, "name", js_error_get_name(error), "Error"); + let message = error_to_string_part(error, "message", js_error_get_message(error), ""); + if name.is_empty() { + message + } else if message.is_empty() { + name + } else { + format!("{name}: {message}") + } +} + unsafe fn error_to_string_part( error: *mut ErrorHeader, key: &str, @@ -1913,6 +1920,7 @@ pub(crate) use stack_frames::{ #[path = "error_subclass_stack.rs"] mod subclass_stack; +pub(crate) use subclass_stack::error_subclass_inspect_headline; pub use subclass_stack::js_error_subclass_capture_stack; #[cfg(test)] diff --git a/crates/perry-runtime/src/error_subclass_stack.rs b/crates/perry-runtime/src/error_subclass_stack.rs index 3fd3f9d8d7..6852dae1bc 100644 --- a/crates/perry-runtime/src/error_subclass_stack.rs +++ b/crates/perry-runtime/src/error_subclass_stack.rs @@ -68,6 +68,51 @@ unsafe fn error_subclass_stack_head(receiver: f64) -> String { format!("{name}: {message}") } } +/// Node-style first line for `util.inspect` on an Error subclass. +pub(crate) unsafe fn error_subclass_inspect_headline(receiver: f64) -> String { + let receiver_ptr = crate::value::js_nanbox_get_pointer(receiver); + if receiver_ptr == 0 + || !crate::value::addr_class::is_above_handle_band(receiver_ptr as usize) + || !crate::object::is_valid_obj_ptr(receiver_ptr as *const u8) + { + return "Error".to_string(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let handle = scope.root_nanbox_f64(receiver); + let obj = || { + crate::value::js_nanbox_get_pointer(handle.get_nanbox_f64()) + as *const crate::object::ObjectHeader + }; + let class_id = (*obj()).class_id; + let name = error_object_field_string(obj(), b"name").unwrap_or_else(|| "Error".to_string()); + let message = error_object_field_string(obj(), b"message").unwrap_or_default(); + let builtin_name = matches!( + name.as_str(), + "Error" + | "TypeError" + | "RangeError" + | "ReferenceError" + | "SyntaxError" + | "URIError" + | "EvalError" + | "AggregateError" + ); + let display_name = if builtin_name { + crate::object::class_name_for_id(class_id) + .filter(|class_name| !class_name.is_empty() && class_name != &name) + .map(|class_name| format!("{class_name} [{name}]")) + .unwrap_or(name) + } else { + name + }; + if display_name.is_empty() { + message + } else if message.is_empty() { + display_name + } else { + format!("{display_name}: {message}") + } +} /// Lazy `stack` getter for an Error SUBCLASS instance (#9410). /// diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 06656f6ed2..ae6c46176d 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -737,19 +737,6 @@ fn rest_arg(rest: f64, index: u32) -> f64 { } } -fn group_integer_digits(digits: &str, separator: char) -> String { - let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); - let len = digits.len(); - for (i, ch) in digits.chars().enumerate() { - let from_end = len - i; - grouped.push(ch); - if from_end > 1 && from_end % 3 == 1 { - grouped.push(separator); - } - } - grouped -} - fn format_number_parts( value: f64, locale: &str, @@ -793,7 +780,9 @@ fn format_number_parts( if negative { out.push('-'); } - out.push_str(&group_integer_digits(int_part, group_sep)); + out.push_str(&number_format::group_integer_digits_for_locale( + int_part, group_sep, locale, + )); if !frac_part.is_empty() { out.push(decimal_sep); out.push_str(frac_part); diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 1779acca07..b20e3bfa5e 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -495,40 +495,108 @@ pub(crate) fn decimal_msd_exponent(int_part: &str, frac_part: &str) -> i32 { } } -/// Group an integer digit string into locale parts. Pushes `integer`/`group` -/// segments. Grouping is applied when `grouping` is true and the integer has >3 -/// digits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DecimalGrouping { + primary: usize, + secondary: usize, + min_grouping: usize, +} + +const WESTERN_GROUPING: DecimalGrouping = DecimalGrouping { + primary: 3, + secondary: 3, + min_grouping: 1, +}; + +#[cfg(feature = "intl-namespace")] +fn icu_locale_grouping(locale: &str) -> Option { + use icu_decimal::provider::{Baked, DecimalSymbolsV1}; + use icu_provider::{DataIdentifierBorrowed, DataProvider, DataRequest, DataResponse}; + + let locale: icu_locale_core::Locale = locale.parse().ok()?; + let data_locale = icu_provider::DataLocale::from(&locale); + let response: DataResponse = Baked + .load(DataRequest { + id: DataIdentifierBorrowed::for_locale(&data_locale), + ..Default::default() + }) + .ok()?; + let sizes = response.payload.get().grouping_sizes; + Some(DecimalGrouping { + primary: sizes.primary as usize, + secondary: if sizes.secondary == 0 { + sizes.primary as usize + } else { + sizes.secondary as usize + }, + min_grouping: sizes.min_grouping as usize, + }) +} + +/// CLDR grouping widths for the resolved locale. +pub(crate) fn locale_grouping(locale: &str) -> DecimalGrouping { + #[cfg(feature = "intl-namespace")] + if let Some(grouping) = icu_locale_grouping(locale) { + return grouping; + } + let _ = locale; + WESTERN_GROUPING +} + +/// Whether grouping separators should be emitted for an integer of `int_len` +/// digits under the resolved `useGrouping` value. +pub(crate) fn grouping_enabled(use_grouping: &str, int_len: usize, sizes: DecimalGrouping) -> bool { + if sizes.primary == 0 { + return false; + } + match use_grouping { + "false" => false, + "always" => int_len > sizes.primary, + "min2" => int_len >= sizes.primary + sizes.min_grouping.max(2), + _ => int_len >= sizes.primary + sizes.min_grouping.max(1), + } +} + +/// Group an ASCII integer digit string into locale-aware `integer`/`group` +/// segments. The rightmost group uses `primary`; all preceding groups use +/// `secondary` (for example, en-IN uses 3 then repeated 2). pub(crate) fn push_grouped_integer( parts: &mut Vec<(&'static str, String)>, int_digits: &str, group_sep: char, grouping: bool, + sizes: DecimalGrouping, ) { - if !grouping || int_digits.len() <= 3 { + let primary = sizes.primary; + if !grouping || primary == 0 || int_digits.len() <= primary { parts.push(("integer", int_digits.to_string())); return; } - let chars: Vec = int_digits.chars().collect(); - let n = chars.len(); - let head = if n % 3 == 0 { 3 } else { n % 3 }; - parts.push(("integer", chars[..head].iter().collect())); - let mut i = head; - while i < n { + let secondary = sizes.secondary.max(1); + let primary_start = int_digits.len() - primary; + let remainder = primary_start % secondary; + let head = if remainder == 0 { secondary } else { remainder }; + parts.push(("integer", int_digits[..head].to_string())); + let mut index = head; + while index < primary_start { parts.push(("group", group_sep.to_string())); - parts.push(("integer", chars[i..i + 3].iter().collect())); - i += 3; + parts.push(("integer", int_digits[index..index + secondary].to_string())); + index += secondary; } + parts.push(("group", group_sep.to_string())); + parts.push(("integer", int_digits[primary_start..].to_string())); } -/// Whether grouping separators should be emitted for an integer of `int_len` -/// digits under the resolved `useGrouping` value. -pub(crate) fn grouping_enabled(use_grouping: &str, int_len: usize) -> bool { - match use_grouping { - "false" => false, - "min2" => int_len >= 5, - // "auto" / "always" both group for the locales we render (Latin/de). - _ => int_len > 3, - } +pub(crate) fn group_integer_digits_for_locale( + digits: &str, + separator: char, + locale: &str, +) -> String { + let sizes = locale_grouping(locale); + let enabled = grouping_enabled("auto", digits.len(), sizes); + let mut parts = Vec::new(); + push_grouped_integer(&mut parts, digits, separator, enabled, sizes); + parts.into_iter().map(|(_, value)| value).collect() } /// Locale-specific display symbol for USD. Most locales use "$"; Korean and @@ -666,6 +734,7 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> // #7429: CLDR separators for the resolved locale, not a de-vs-rest guess. let (group_sep, decimal_sep) = locale_separators(&r.locale); + let grouping_sizes = locale_grouping(&r.locale); let mut parts: Vec<(&'static str, String)> = Vec::new(); let is_zero = value == 0.0; @@ -758,7 +827,7 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); } - push_grouped_integer(&mut parts, &i_out, group_sep, false); + push_grouped_integer(&mut parts, &i_out, group_sep, false, grouping_sizes); if !f_out.is_empty() { parts.push(("decimal", decimal_sep.to_string())); parts.push(("fraction", f_out)); @@ -804,8 +873,8 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); } - let grouping = grouping_enabled(&r.use_grouping, i_out.len()); - push_grouped_integer(&mut parts, &i_out, group_sep, grouping); + let grouping = grouping_enabled(&r.use_grouping, i_out.len(), grouping_sizes); + push_grouped_integer(&mut parts, &i_out, group_sep, grouping, grouping_sizes); if !f_out.is_empty() { parts.push(("decimal", decimal_sep.to_string())); parts.push(("fraction", f_out)); @@ -827,8 +896,8 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)> while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); } - let grouping = grouping_enabled(&r.use_grouping, i_out.len()); - push_grouped_integer(&mut parts, &i_out, group_sep, grouping); + let grouping = grouping_enabled(&r.use_grouping, i_out.len(), grouping_sizes); + push_grouped_integer(&mut parts, &i_out, group_sep, grouping, grouping_sizes); if !f_out.is_empty() { parts.push(("decimal", decimal_sep.to_string())); parts.push(("fraction", f_out)); @@ -1145,6 +1214,7 @@ fn bigint_number_parts_exact( ) -> Vec<(&'static str, String)> { // #7429: CLDR separators for the resolved locale, not a de-vs-rest guess. let (group_sep, decimal_sep) = locale_separators(&r.locale); + let grouping_sizes = locale_grouping(&r.locale); set_round_ctx(&r.rounding_mode, negative); let mut parts: Vec<(&'static str, String)> = Vec::new(); @@ -1155,8 +1225,8 @@ fn bigint_number_parts_exact( while (i_out.len() as u32) < r.min_int { i_out.insert(0, '0'); } - let grouping = grouping_enabled(&r.use_grouping, i_out.len()); - push_grouped_integer(&mut parts, &i_out, group_sep, grouping); + let grouping = grouping_enabled(&r.use_grouping, i_out.len(), grouping_sizes); + push_grouped_integer(&mut parts, &i_out, group_sep, grouping, grouping_sizes); if !f_out.is_empty() { parts.push(("decimal", decimal_sep.to_string())); parts.push(("fraction", f_out)); @@ -1532,3 +1602,33 @@ pub(crate) fn number_format_resolved_options_object(obj: *const ObjectHeader) -> set_field(out, "trailingZeroDisplay", string_value(&r.trailing_zero)); js_nanbox_pointer(out as i64) } + +#[cfg(test)] +mod grouping_tests { + use super::*; + + fn render(digits: &str, sizes: DecimalGrouping) -> String { + let mut parts = Vec::new(); + push_grouped_integer(&mut parts, digits, ',', true, sizes); + parts.into_iter().map(|(_, value)| value).collect() + } + + #[cfg(feature = "intl-namespace")] + #[test] + fn uses_cldr_secondary_group_widths() { + let sizes = locale_grouping("en-IN"); + assert_eq!( + (sizes.primary, sizes.secondary, sizes.min_grouping), + (3, 2, 1) + ); + assert_eq!(render("123456789", sizes), "12,34,56,789"); + } + + #[test] + fn preserves_western_grouping() { + let sizes = locale_grouping("en-US"); + assert_eq!(render("123456789", sizes), "123,456,789"); + assert!(grouping_enabled("auto", 4, sizes)); + assert!(!grouping_enabled("false", 9, sizes)); + } +} diff --git a/crates/perry-runtime/src/object/descriptor_own_names.rs b/crates/perry-runtime/src/object/descriptor_own_names.rs new file mode 100644 index 0000000000..8a8a91237e --- /dev/null +++ b/crates/perry-runtime/src/object/descriptor_own_names.rs @@ -0,0 +1,28 @@ +//! Own-property-name normalization that would push `descriptors.rs` over the +//! repository's 2,000-line cap. + +use super::*; + +/// Put an Error subclass's lazy `stack` slot ahead of `message`, matching V8. +pub(crate) unsafe fn normalize_error_subclass_own_names( + obj: *const ObjectHeader, + names: &mut Vec, +) { + if (*obj).class_id == 0 || !extends_builtin_error((*obj).class_id) { + return; + } + let mut insert_at = names + .iter() + .position(|name| canonical_array_index(name).is_none()) + .unwrap_or(names.len()); + for special in ["stack", "message"] { + if let Some(index) = names.iter().position(|name| name == special) { + let name = names.remove(index); + if index < insert_at { + insert_at -= 1; + } + names.insert(insert_at, name); + insert_at += 1; + } + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 4fd9536efc..11a3789d45 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -75,6 +75,7 @@ mod data_view_registry; mod dataview_proto_thunks; mod date_proto_thunks; mod delete_rest; +mod descriptor_own_names; pub(crate) mod descriptors; mod disposable_proto_thunks; pub(crate) mod exotic_expando; diff --git a/test-files/test_gap_9440_error_name_own_enumerable.ts b/test-files/test_gap_9440_error_name_own_enumerable.ts new file mode 100644 index 0000000000..c03fc848a6 --- /dev/null +++ b/test-files/test_gap_9440_error_name_own_enumerable.ts @@ -0,0 +1,66 @@ +// #9440: Error-subclass construction stamped `name` as an own enumerable +// property. Node inherits the non-enumerable value from Error.prototype, so +// the wrong shape leaked through JSON, every own-key API, for-in and spread. +// Compared byte-for-byte against `node --experimental-strip-types`. + +import { inspect } from "node:util"; + +class MyErr extends Error {} +class MyTypeErr extends TypeError {} + +function ownSnapshot(label: string, error: Error): void { + const forIn: string[] = []; + for (const key in error) { + forIn.push(key); + } + + console.log(label + " value: " + error.name); + console.log(label + " json: " + JSON.stringify(error)); + console.log( + label + " own names: " + JSON.stringify(Object.getOwnPropertyNames(error)), + ); + console.log(label + " keys: " + JSON.stringify(Object.keys(error))); + console.log(label + " for-in: " + JSON.stringify(forIn)); + console.log(label + " spread: " + JSON.stringify({ ...error })); + // Stack paths and frames are host-specific. The first line is the stable + // Error headline that util.inspect derives from the effective name. + console.log(label + " inspect: " + inspect(error).split("\n")[0]); +} + +ownSnapshot("Error", new Error("boom")); +ownSnapshot("subclass", new MyErr("boom")); +ownSnapshot("type-subclass", new MyTypeErr("boom")); + +// Assignment must still use ordinary [[Set]] semantics: it creates an own, +// writable/enumerable/configurable data property and all enumeration paths see +// exactly that one additional key. +const assigned = new MyErr("boom"); +assigned.name = "Custom"; +ownSnapshot("assigned", assigned); +const descriptor = Object.getOwnPropertyDescriptor(assigned, "name"); +console.log( + "assigned descriptor: " + + JSON.stringify({ + value: descriptor?.value, + writable: descriptor?.writable, + enumerable: descriptor?.enumerable, + configurable: descriptor?.configurable, + }), +); + +// The native ErrorHeader path must agree with the ordinary ObjectHeader used +// by a subclass. This also pins assignment after construction, rather than a +// class-body field initializer. +const assignedBase = new Error("base"); +assignedBase.name = "CustomBase"; +ownSnapshot("assigned-base", assignedBase); + +// Controls for the actual prototype placement. +console.log( + "Error.prototype.name: " + + JSON.stringify(Object.getOwnPropertyDescriptor(Error.prototype, "name")), +); +console.log( + "TypeError.prototype.name: " + + JSON.stringify(Object.getOwnPropertyDescriptor(TypeError.prototype, "name")), +); diff --git a/test-files/test_gap_9450_intl_indian_grouping.ts b/test-files/test_gap_9450_intl_indian_grouping.ts new file mode 100644 index 0000000000..51bbe298a4 --- /dev/null +++ b/test-files/test_gap_9450_intl_indian_grouping.ts @@ -0,0 +1,40 @@ +// #9450: Intl.NumberFormat grouped every locale in fixed 3-digit runs. +// CLDR's en-IN pattern has a 3-digit primary group and 2-digit secondary +// groups. Compared byte-for-byte against `node --experimental-strip-types`. + +const indianValues = [1234, 12345, 123456, 1234567, 123456789]; +const indian = new Intl.NumberFormat("en-IN"); + +for (const value of indianValues) { + console.log("Intl en-IN " + value + ": " + indian.format(value)); +} + +// Pin typed group/integer parts, not only their concatenated spelling. +console.log( + "parts en-IN: " + + JSON.stringify(indian.formatToParts(123456789).map(({ type, value }) => [type, value])), +); + +// Three-digit controls must remain unchanged, including distinct separators. +for (const locale of ["en-US", "de-DE", "fr-FR"]) { + console.log(locale + ": " + new Intl.NumberFormat(locale).format(123456789)); +} + +console.log( + "en-IN ungrouped: " + + new Intl.NumberFormat("en-IN", { useGrouping: false }).format(123456789), +); + +// Number.prototype.toLocaleString delegates to the same formatter and must +// preserve the locale's primary/secondary pattern at every divergent width. +for (const value of indianValues) { + console.log("toLocaleString en-IN " + value + ": " + value.toLocaleString("en-IN")); +} + +// BigInt has a separate exact-precision rendering path; it consumes the same +// grouping metadata and must not quietly retain fixed 3-digit runs. +console.log( + "BigInt en-IN: " + + (12345678901234567890n as any).toLocaleString("en-IN"), +); + From 68e6125d0e97a10751648c8505fb57410a4c984e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 17:48:37 +0200 Subject: [PATCH 2/2] =?UTF-8?q?train:=20trim=20#9538=20to=20the=20live=20#?= =?UTF-8?q?9450=20half=20=E2=80=94=20the=20#9440=20half=20landed=20via=20#?= =?UTF-8?q?9511/#9541/#9543=20(fixture=20verified=20byte-identical=20on=20?= =?UTF-8?q?main=20before=20trimming)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/9440-error-subclass-name.md | 7 --- crates/perry-codegen/src/lower_call/new.rs | 11 ++--- crates/perry-runtime/src/error.rs | 26 ++++------- .../perry-runtime/src/error_subclass_stack.rs | 45 ------------------- .../src/object/descriptor_own_names.rs | 28 ------------ crates/perry-runtime/src/object/mod.rs | 1 - 6 files changed, 15 insertions(+), 103 deletions(-) delete mode 100644 changelog.d/9440-error-subclass-name.md delete mode 100644 crates/perry-runtime/src/object/descriptor_own_names.rs diff --git a/changelog.d/9440-error-subclass-name.md b/changelog.d/9440-error-subclass-name.md deleted file mode 100644 index a0ce62c601..0000000000 --- a/changelog.d/9440-error-subclass-name.md +++ /dev/null @@ -1,7 +0,0 @@ -### Fixed - -- Error subclasses no longer receive an enumerable own `name` property during - construction. They inherit the non-enumerable built-in name from the Error - prototype chain, matching Node across JSON serialization, own-key reflection, - spread, `for...in`, and inspection; explicit `error.name = value` assignments - remain ordinary enumerable own properties. (#9440) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 5824153b20..fdc93a03c5 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1463,16 +1463,17 @@ fn lower_new_impl_inner<'a>( // without finding any user-class constructor, synthesize the JS // spec default ctor `constructor(...args) { super(...args); }` — // i.e. forward the first arg to Error's initialization, which - // sets `this.message`. Without this, `new MyError( - // "hello")` returns an object with `.message` + // sets `this.message` + `this.name`. Without this, `new MyError( + // "hello")` returns an object with `.message` / `.name` // unset — the SIGABRT-on-property-read happens because the slot // index lookup misses and downstream NaN-box decode reads // garbage. // // Walk the chain to find the terminating Error-like name (so // `class A extends Error {}; class B extends A {}` also flows - // through correctly). If found, initialize `message`, mirroring the - // SuperCall Error-like arm in expr.rs, then capture `stack`. + // through correctly). If found, set `this.message = args[0]` + // and `this.name = ` directly, mirroring the + // SuperCall Error-like arm in expr.rs. // // BUT: if `class_name` is an imported stub with a cross-module // ctor with a real body/effect, defer to that path — the source @@ -1482,7 +1483,7 @@ fn lower_new_impl_inner<'a>( // assign the wrong arg to `message` and corrupt the instance. // When the imported ctor is a synthesized empty 0-param ctor for the // bare-extends-Error case, calling it is a no-op and we still need - // Error-init to populate `this.message`. + // Error-init to populate `this.message` / `this.name`. let imported_ctor_has_body_or_fields = ctx .imported_class_ctors .get(class_name) diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index a955cc02aa..6c84e889e5 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1591,26 +1591,19 @@ unsafe fn read_string_header_owned(ptr: *const StringHeader) -> String { #[no_mangle] pub extern "C" fn js_error_to_string(error: *mut ErrorHeader) -> *mut StringHeader { unsafe { - let result = error_display_headline(error); + let name = error_to_string_part(error, "name", js_error_get_name(error), "Error"); + let message = error_to_string_part(error, "message", js_error_get_message(error), ""); + let result = if name.is_empty() { + message + } else if message.is_empty() { + name + } else { + format!("{name}: {message}") + }; js_string_from_bytes(result.as_ptr(), result.len() as u32) } } -pub(crate) unsafe fn error_display_headline(error: *mut ErrorHeader) -> String { - if error.is_null() { - return "Error".to_string(); - } - let name = error_to_string_part(error, "name", js_error_get_name(error), "Error"); - let message = error_to_string_part(error, "message", js_error_get_message(error), ""); - if name.is_empty() { - message - } else if message.is_empty() { - name - } else { - format!("{name}: {message}") - } -} - unsafe fn error_to_string_part( error: *mut ErrorHeader, key: &str, @@ -1920,7 +1913,6 @@ pub(crate) use stack_frames::{ #[path = "error_subclass_stack.rs"] mod subclass_stack; -pub(crate) use subclass_stack::error_subclass_inspect_headline; pub use subclass_stack::js_error_subclass_capture_stack; #[cfg(test)] diff --git a/crates/perry-runtime/src/error_subclass_stack.rs b/crates/perry-runtime/src/error_subclass_stack.rs index 6852dae1bc..3fd3f9d8d7 100644 --- a/crates/perry-runtime/src/error_subclass_stack.rs +++ b/crates/perry-runtime/src/error_subclass_stack.rs @@ -68,51 +68,6 @@ unsafe fn error_subclass_stack_head(receiver: f64) -> String { format!("{name}: {message}") } } -/// Node-style first line for `util.inspect` on an Error subclass. -pub(crate) unsafe fn error_subclass_inspect_headline(receiver: f64) -> String { - let receiver_ptr = crate::value::js_nanbox_get_pointer(receiver); - if receiver_ptr == 0 - || !crate::value::addr_class::is_above_handle_band(receiver_ptr as usize) - || !crate::object::is_valid_obj_ptr(receiver_ptr as *const u8) - { - return "Error".to_string(); - } - let scope = crate::gc::RuntimeHandleScope::new(); - let handle = scope.root_nanbox_f64(receiver); - let obj = || { - crate::value::js_nanbox_get_pointer(handle.get_nanbox_f64()) - as *const crate::object::ObjectHeader - }; - let class_id = (*obj()).class_id; - let name = error_object_field_string(obj(), b"name").unwrap_or_else(|| "Error".to_string()); - let message = error_object_field_string(obj(), b"message").unwrap_or_default(); - let builtin_name = matches!( - name.as_str(), - "Error" - | "TypeError" - | "RangeError" - | "ReferenceError" - | "SyntaxError" - | "URIError" - | "EvalError" - | "AggregateError" - ); - let display_name = if builtin_name { - crate::object::class_name_for_id(class_id) - .filter(|class_name| !class_name.is_empty() && class_name != &name) - .map(|class_name| format!("{class_name} [{name}]")) - .unwrap_or(name) - } else { - name - }; - if display_name.is_empty() { - message - } else if message.is_empty() { - display_name - } else { - format!("{display_name}: {message}") - } -} /// Lazy `stack` getter for an Error SUBCLASS instance (#9410). /// diff --git a/crates/perry-runtime/src/object/descriptor_own_names.rs b/crates/perry-runtime/src/object/descriptor_own_names.rs deleted file mode 100644 index 8a8a91237e..0000000000 --- a/crates/perry-runtime/src/object/descriptor_own_names.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Own-property-name normalization that would push `descriptors.rs` over the -//! repository's 2,000-line cap. - -use super::*; - -/// Put an Error subclass's lazy `stack` slot ahead of `message`, matching V8. -pub(crate) unsafe fn normalize_error_subclass_own_names( - obj: *const ObjectHeader, - names: &mut Vec, -) { - if (*obj).class_id == 0 || !extends_builtin_error((*obj).class_id) { - return; - } - let mut insert_at = names - .iter() - .position(|name| canonical_array_index(name).is_none()) - .unwrap_or(names.len()); - for special in ["stack", "message"] { - if let Some(index) = names.iter().position(|name| name == special) { - let name = names.remove(index); - if index < insert_at { - insert_at -= 1; - } - names.insert(insert_at, name); - insert_at += 1; - } - } -} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 11a3789d45..4fd9536efc 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -75,7 +75,6 @@ mod data_view_registry; mod dataview_proto_thunks; mod date_proto_thunks; mod delete_rest; -mod descriptor_own_names; pub(crate) mod descriptors; mod disposable_proto_thunks; pub(crate) mod exotic_expando;