From dccc6c8d369a1970fc3a4fd93d8a9221fbcebc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 11:49:15 +0200 Subject: [PATCH 1/2] feat(native): stabilize native value profile --- crates/perry-api-manifest/src/native_abi.rs | 56 ++- .../perry-codegen/src/expr/literals_vars.rs | 5 +- crates/perry-codegen/src/expr/mod.rs | 19 +- crates/perry-codegen/src/expr/pod_record.rs | 249 +++++++++++++- .../src/lower_call/extern_func.rs | 224 ++++++++++-- .../src/lower_call/omitted_native_params.rs | 11 +- .../src/native_value/materialize.rs | 40 ++- crates/perry-codegen/src/native_value/pod.rs | 45 ++- .../src/native_value/verify/abi.rs | 10 + crates/perry-codegen/src/runtime_decls/mod.rs | 7 + crates/perry-codegen/src/stmt/let_stmt.rs | 42 ++- .../native_library.rs | 96 +++++- .../native_proof_regressions/pod_manifest.rs | 104 ++++++ crates/perry-hir/src/lib.rs | 2 + crates/perry-hir/src/native_profile.rs | 259 ++++++++++++++ crates/perry-runtime/src/native_abi.rs | 123 +++++++ crates/perry-transform/src/inline/analysis.rs | 21 +- crates/perry-transform/src/inline/mod.rs | 22 +- .../compile/resolve/native_library.rs | 320 +++++++++++++++++- docs/api/manifest.schema.json | 60 +++- docs/src/language/native-values.md | 35 +- docs/src/native-libraries/manifest-v1.md | 60 +++- .../test_parity_native_value_profile.ts | 17 + 23 files changed, 1716 insertions(+), 111 deletions(-) create mode 100644 crates/perry-hir/src/native_profile.rs diff --git a/crates/perry-api-manifest/src/native_abi.rs b/crates/perry-api-manifest/src/native_abi.rs index acc8898d41..aa926b3758 100644 --- a/crates/perry-api-manifest/src/native_abi.rs +++ b/crates/perry-api-manifest/src/native_abi.rs @@ -226,6 +226,10 @@ pub enum NativeAbiType { Json, /// JavaScript truthiness lowered to a C `i32` boolean slot. Bool, + /// Signed 8-bit integer slot. + I8, + /// Signed 16-bit integer slot. + I16, /// Signed 32-bit integer slot. I32, /// Signed 64-bit integer slot. @@ -233,6 +237,11 @@ pub enum NativeAbiType { /// Legacy string return where the native function returns the string /// pointer as an `i64` instead of a C pointer. I64String, + /// Unsigned 8-bit integer slot. The manifest spelling `byte` is accepted + /// as an alias and canonicalizes to `u8`. + U8, + /// Unsigned 16-bit integer slot. + U16, /// Unsigned 32-bit integer slot. U32, /// Unsigned 64-bit integer slot. @@ -240,6 +249,9 @@ pub enum NativeAbiType { /// Pointer-sized unsigned integer slot. Perry's native runtime targets are /// currently 64-bit, so this lowers as an LLVM `i64`. USize, + /// Pointer-sized signed integer slot. Perry's native runtime targets are + /// currently 64-bit, so this lowers as an LLVM `i64`. + ISize, /// 32-bit float slot. F32, /// 64-bit float slot. The legacy manifest spelling `"number"` is accepted @@ -280,12 +292,17 @@ impl NativeAbiType { "string" => Ok(Self::String), "json" => Ok(Self::Json), "bool" | "boolean" => Ok(Self::Bool), + "i8" => Ok(Self::I8), + "i16" => Ok(Self::I16), "i32" => Ok(Self::I32), "i64" => Ok(Self::I64), "i64_str" => Ok(Self::I64String), + "u8" | "byte" => Ok(Self::U8), + "u16" => Ok(Self::U16), "u32" => Ok(Self::U32), "u64" => Ok(Self::U64), "usize" => Ok(Self::USize), + "isize" => Ok(Self::ISize), "f32" => Ok(Self::F32), "f64" | "number" => Ok(Self::F64), "ptr" => Ok(Self::Ptr), @@ -338,12 +355,17 @@ impl NativeAbiType { Self::String => "string", Self::Json => "json", Self::Bool => "bool", + Self::I8 => "i8", + Self::I16 => "i16", Self::I32 => "i32", Self::I64 => "i64", Self::I64String => "i64_str", + Self::U8 => "u8", + Self::U16 => "u16", Self::U32 => "u32", Self::U64 => "u64", Self::USize => "usize", + Self::ISize => "isize", Self::F32 => "f32", Self::F64 => "f64", Self::Ptr => "ptr", @@ -419,11 +441,16 @@ impl NativeAbiType { pub fn is_valid_pod_field(&self) -> bool { matches!( self, - Self::I32 + Self::I8 + | Self::I16 + | Self::I32 | Self::I64 + | Self::U8 + | Self::U16 | Self::U32 | Self::U64 | Self::USize + | Self::ISize | Self::F32 | Self::F64 | Self::BufferLen @@ -464,11 +491,16 @@ impl NativeAbiType { Self::Pod(_) => "object", Self::PodAndCount(_) => "PerryPodView", Self::BufferAndLen => "Buffer", - Self::I32 + Self::I8 + | Self::I16 + | Self::I32 | Self::I64 + | Self::U8 + | Self::U16 | Self::U32 | Self::U64 | Self::USize + | Self::ISize | Self::F32 | Self::F64 | Self::BufferLen @@ -576,4 +608,24 @@ mod tests { // Not a scalar POD field. assert!(!json.is_valid_pod_field()); } + + #[test] + fn exact_width_scalar_spellings_are_canonical_and_pod_safe() { + for (spelling, expected, canonical) in [ + ("i8", NativeAbiType::I8, "i8"), + ("i16", NativeAbiType::I16, "i16"), + ("u8", NativeAbiType::U8, "u8"), + ("byte", NativeAbiType::U8, "u8"), + ("u16", NativeAbiType::U16, "u16"), + ("isize", NativeAbiType::ISize, "isize"), + ] { + let parsed = NativeAbiType::parse_str(spelling).expect("exact-width descriptor"); + assert_eq!(parsed, expected); + assert_eq!(parsed.canonical_kind(), canonical); + assert!(parsed.is_valid_param()); + assert!(parsed.is_valid_return()); + assert!(parsed.is_valid_pod_field()); + assert_eq!(parsed.js_type_name(), "number"); + } + } } diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index ef58ba5166..3c28430864 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -13,7 +13,6 @@ use crate::lower_string_concat::{ lower_string_self_append_chain, }; use crate::nanbox::double_literal; -use crate::native_value::MaterializationReason; use crate::type_analysis::{is_map_expr, is_set_expr, receiver_class_name}; use crate::types::{DOUBLE, I32, I64}; @@ -21,7 +20,7 @@ use super::{ can_lower_expr_as_i32_in_current_region, emit_root_nanbox_store_on_block, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr, emit_write_barrier, is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, - lower_pod_local_reassignment, materialize_pod_local, nanbox_string_inline, FnCtx, + lower_pod_local_reassignment, materialize_pod_value_copy, nanbox_string_inline, FnCtx, TrustedBoxCapturePtr, }; @@ -439,7 +438,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // module-scope `let`s (the ones in `hir.init` at top level). Expr::LocalGet(id) => { if ctx.pod_records.contains_key(id) { - return materialize_pod_local(ctx, *id, MaterializationReason::PodMaterialization); + return materialize_pod_value_copy(ctx, *id); } // Captured by closure (from outer scope): if let Some(&capture_idx) = ctx.closure_captures.get(id) { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index a58645e92e..b12c1edbbe 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -99,8 +99,9 @@ pub(crate) use nanbox_inline::{ pub(crate) use native_record::{array_kind_fact, effect_fact, raw_f64_layout_fact}; pub(crate) use object_literal::lower_object_literal; pub(crate) use pod_record::{ - lower_and_store_initial_pod_field, lower_pod_local_reassignment, materialize_pod_local, - try_lower_pod_field_get, try_lower_pod_field_set, + copy_pod_local, lower_and_store_initial_pod_field, lower_pod_local_reassignment, + materialize_pod_local, materialize_pod_value_copy, try_lower_pod_field_get, + try_lower_pod_field_set, }; pub(crate) use proven_view_access::{ index_is_exact_i32_shape, local_is_proven_int_store_view, @@ -2873,10 +2874,16 @@ fn native_number_to_f64(ctx: &mut FnCtx<'_>, lowered: &LoweredValue) -> Option { Some(ctx.block().uitofp(I32, &lowered.value, DOUBLE)) } - NativeRep::I64 | NativeRep::ISize => Some(ctx.block().sitofp(I64, &lowered.value, DOUBLE)), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - Some(ctx.block().uitofp(I64, &lowered.value, DOUBLE)) - } + NativeRep::I64 | NativeRep::ISize => Some(ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + )), + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => Some(ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + )), _ => None, } } diff --git a/crates/perry-codegen/src/expr/pod_record.rs b/crates/perry-codegen/src/expr/pod_record.rs index 67033cbf06..522a189863 100644 --- a/crates/perry-codegen/src/expr/pod_record.rs +++ b/crates/perry-codegen/src/expr/pod_record.rs @@ -6,7 +6,7 @@ use crate::nanbox::{double_literal, POINTER_MASK_I64, TAG_UNDEFINED_I64}; use crate::native_value::{ field_expected_rep, llvm_type_for_native_rep, materialize_js_value, BufferAccessMode, LoweredValue, MaterializationReason, NativeRep, NativeValueState, PodLayoutField, - PodLayoutManifest, SemanticKind, + PodLayoutManifest, PodLocal, SemanticKind, }; use crate::type_analysis::expr_may_return_boxed_value_from_raw_f64_fallback; use crate::types::{DOUBLE, F32, I16, I32, I64, I8}; @@ -15,6 +15,198 @@ use super::{ emit_root_nanbox_store_on_block, lower_expr, lower_expr_native, nanbox_pointer_inline, FnCtx, }; +pub(crate) fn copy_pod_local( + ctx: &mut FnCtx<'_>, + destination_id: u32, + source_id: u32, +) -> Result> { + let Some(source) = ctx.pod_records.get(&source_id).cloned() else { + return Ok(None); + }; + let data_slot = ctx + .func + .alloca_entry_bytes_aligned(source.layout.size, source.layout.alignment); + let materialized_slot = ctx.func.alloca_entry(DOUBLE); + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.func + .entry_allocas_push_store(DOUBLE, &undef, &materialized_slot); + + let current = ctx.block().load(DOUBLE, &source.materialized_slot); + let current_bits = ctx.block().bitcast_double_to_i64(¤t); + let source_is_native = ctx.block().icmp_eq(I64, ¤t_bits, TAG_UNDEFINED_I64); + let native_idx = ctx.new_block("pod.copy.native"); + let materialized_idx = ctx.new_block("pod.copy.materialized"); + let merge_idx = ctx.new_block("pod.copy.merge"); + let native_label = ctx.block_label(native_idx); + let materialized_label = ctx.block_label(materialized_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block() + .cond_br(&source_is_native, &native_label, &materialized_label); + + // A still-native source can be copied field-for-field without forcing + // i64/u64 through a JavaScript number or allocating an object. + ctx.current_block = native_idx; + for field in &source.layout.fields { + let native = load_pod_field_native( + ctx, + source_id, + &source.data_slot, + field, + "pod_record_copy_source_native", + ); + store_pod_field_native(ctx, destination_id, &data_slot, field, &native); + } + ctx.block().br(&merge_label); + + // Once the source has materialized, its ordinary object is authoritative. + // Revalidate every declared scalar while snapshotting it back into the + // destination's independent native storage. + ctx.current_block = materialized_idx; + let source_handle = unbox_object_handle(ctx, ¤t); + for field in &source.layout.fields { + let value = load_materialized_pod_field_path(ctx, &source_handle, &field.path); + let native = strict_pod_copy_field(ctx, &value, field); + store_pod_field_native(ctx, destination_id, &data_slot, field, &native); + } + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; + + let lowered = LoweredValue { + semantic: SemanticKind::PodRecord, + rep: NativeRep::PodRecord { + layout_id: source.layout.layout_id.clone(), + size: source.layout.size, + alignment: source.layout.alignment, + }, + llvm_ty: crate::types::PTR, + value: data_slot.clone(), + }; + ctx.record_lowered_value( + "PodRecordCopyInit", + Some(destination_id), + "pod_record_value_copy", + &lowered, + None, + None, + Some(MaterializationReason::PodMaterialization), + false, + false, + vec![ + format!("layout_id={}", source.layout.layout_id), + format!("source_local={source_id}"), + "assignment_semantics=copy".to_string(), + "source_representation=native_or_materialized".to_string(), + ], + ); + if let Some(record) = ctx.native_rep_records.last_mut() { + record.pod_layout = Some(source.layout.clone()); + } + Ok(Some(PodLocal { + layout: source.layout, + data_slot, + materialized_slot, + })) +} + +fn load_materialized_pod_field_path( + ctx: &mut FnCtx<'_>, + object_handle: &str, + path: &[String], +) -> String { + let mut current_object = object_handle.to_string(); + let mut result = double_literal(f64::NAN); + for (index, part) in path.iter().enumerate() { + let key = interned_key_handle(ctx, part); + result = ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, ¤t_object), (I64, &key)], + ); + if index + 1 != path.len() { + current_object = + ctx.block() + .call(I64, "js_native_abi_check_pod_object", &[(DOUBLE, &result)]); + } + } + result +} + +fn strict_pod_copy_field(ctx: &mut FnCtx<'_>, value: &str, field: &PodLayoutField) -> LoweredValue { + match field.native_rep { + NativeRep::I8 => LoweredValue::i8(ctx.block().call( + I8, + "js_native_abi_check_i8", + &[(DOUBLE, value)], + )), + NativeRep::I16 => LoweredValue::i16(ctx.block().call( + I16, + "js_native_abi_check_i16", + &[(DOUBLE, value)], + )), + NativeRep::I32 => LoweredValue::i32(ctx.block().call( + I32, + "js_native_abi_check_i32", + &[(DOUBLE, value)], + )), + NativeRep::I64 => LoweredValue::i64(ctx.block().call( + I64, + "js_native_abi_check_i64", + &[(DOUBLE, value)], + )), + NativeRep::U8 => LoweredValue::u8(ctx.block().call( + I8, + "js_native_abi_check_u8", + &[(DOUBLE, value)], + )), + NativeRep::U16 => LoweredValue::u16(ctx.block().call( + I16, + "js_native_abi_check_u16", + &[(DOUBLE, value)], + )), + NativeRep::U32 => LoweredValue::u32(ctx.block().call( + I32, + "js_native_abi_check_u32", + &[(DOUBLE, value)], + )), + NativeRep::U64 => LoweredValue::u64(ctx.block().call( + I64, + "js_native_abi_check_u64", + &[(DOUBLE, value)], + )), + NativeRep::ISize => LoweredValue::isize(ctx.block().call( + I64, + "js_native_abi_check_isize", + &[(DOUBLE, value)], + )), + NativeRep::USize => LoweredValue::usize(ctx.block().call( + I64, + "js_native_abi_check_usize", + &[(DOUBLE, value)], + )), + NativeRep::F32 => LoweredValue::f32(ctx.block().call( + F32, + "js_native_abi_check_f32", + &[(DOUBLE, value)], + )), + NativeRep::F64 => LoweredValue::f64(ctx.block().call( + DOUBLE, + "js_native_abi_check_f64", + &[(DOUBLE, value)], + )), + NativeRep::BufferLen => LoweredValue::buffer_len(ctx.block().call( + I32, + "js_native_abi_check_u32", + &[(DOUBLE, value)], + )), + NativeRep::HandleId => LoweredValue::handle_id(ctx.block().call( + I64, + "js_native_abi_check_u64", + &[(DOUBLE, value)], + )), + ref other => unreachable!("POD copy contained non-scalar field {other:?}"), + } +} + pub(crate) fn materialize_pod_local( ctx: &mut FnCtx<'_>, local_id: u32, @@ -33,6 +225,28 @@ pub(crate) fn materialize_pod_local( )) } +pub(crate) fn materialize_pod_value_copy(ctx: &mut FnCtx<'_>, local_id: u32) -> Result { + let value = materialize_pod_local(ctx, local_id, MaterializationReason::PodMaterialization)?; + let cloned = ctx + .block() + .call(DOUBLE, "js_structured_clone", &[(DOUBLE, &value)]); + let lowered = LoweredValue::js_value(cloned.clone()); + ctx.record_lowered_value_with_access_mode( + "PodRecordValueRead", + Some(local_id), + "pod_record_materialized_value_copy", + &lowered, + None, + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::PodMaterialization), + false, + false, + vec!["value_semantics=copy_at_managed_boundary".to_string()], + ); + Ok(cloned) +} + pub(crate) fn try_lower_pod_field_get( ctx: &mut FnCtx<'_>, local_id: u32, @@ -341,8 +555,37 @@ fn materialize_pod_parts( let obj_handle = ctx .block() .call(I64, "js_object_alloc", &[(I32, "0"), (I32, &field_count)]); + let mut nested_objects = std::collections::HashMap::, String>::new(); + nested_objects.insert(Vec::new(), obj_handle.clone()); for field in &layout.fields { - let key_handle = interned_key_handle(ctx, &field.name); + let mut parent_path = Vec::new(); + for part in field.path.iter().take(field.path.len().saturating_sub(1)) { + let mut child_path = parent_path.clone(); + child_path.push(part.clone()); + if !nested_objects.contains_key(&child_path) { + let child = + ctx.block() + .call(I64, "js_object_alloc", &[(I32, "0"), (I32, &field_count)]); + let child_value = nanbox_pointer_inline(ctx.block(), &child); + let parent = nested_objects + .get(&parent_path) + .expect("POD materialization parent exists") + .clone(); + let key = interned_key_handle(ctx, part); + ctx.block().call_void( + "js_object_set_field_by_name", + &[(I64, &parent), (I64, &key), (DOUBLE, &child_value)], + ); + nested_objects.insert(child_path.clone(), child); + } + parent_path = child_path; + } + let parent = nested_objects + .get(&parent_path) + .expect("POD materialization object exists") + .clone(); + let property = field.path.last().unwrap_or(&field.name); + let key_handle = interned_key_handle(ctx, property); let value_js = load_pod_field_as_js( ctx, local_id, @@ -353,7 +596,7 @@ fn materialize_pod_parts( ); ctx.block().call_void( "js_object_set_field_by_name", - &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &value_js)], + &[(I64, &parent), (I64, &key_handle), (DOUBLE, &value_js)], ); } let created_value = nanbox_pointer_inline(ctx.block(), &obj_handle); diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 456d76a051..c04d15dad8 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1,8 +1,5 @@ -//! Cross-module function call via `Expr::ExternFuncRef` — covers -//! built-in extern names (setTimeout, setInterval, gc, jsx, …), -//! perry/system + perry/updater + perry/background dispatch via the -//! `lower_perry_ui_table_call` machinery, V8-fallback bridge calls, -//! and the generic `perry_fn___` consumer-prefix path. +//! Cross-module `Expr::ExternFuncRef` calls: built-in externs, Perry dispatch +//! tables, V8 fallback bridges, and generic `perry_fn___` calls. use super::builtin_table_gate::callee_is_from_perry_module; use anyhow::{anyhow, Result}; @@ -22,7 +19,7 @@ use crate::native_value::{ NativeAbiTypeRecord, NativeRep, PodLayoutManifest, PodRecordViewManifest, SemanticKind, }; use crate::type_analysis::{is_array_expr, is_string_expr}; -use crate::types::{DOUBLE, F32, I1, I32, I64, I8, PTR, VOID}; +use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8, PTR, VOID}; use super::{ lower_perry_ui_table_call, perry_background_table_lookup, perry_system_table_lookup, @@ -71,6 +68,33 @@ fn record_native_abi_return( ); } +fn materialize_checked_integer_return( + ctx: &mut FnCtx<'_>, + raw: &str, + helper: &'static str, + descriptor: &NativeAbiType, +) -> String { + let value = ctx.block().call(DOUBLE, helper, &[(I64, raw)]); + let lowered = LoweredValue::js_value(value.clone()); + ctx.record_lowered_value( + "NativeLibraryReturnMaterialize", + None, + "native_library.checked_integer_return", + &lowered, + None, + None, + Some(MaterializationReason::ReturnAbi), + false, + false, + vec![ + format!("descriptor={}", descriptor.canonical_kind()), + format!("guard={helper}"), + "requirement=exact_js_safe_integer".to_string(), + ], + ); + value +} + pub(super) fn lower_buffer_and_len_param( ctx: &mut FnCtx<'_>, descriptor: &NativeAbiType, @@ -286,6 +310,18 @@ fn lower_pod_field_from_js_value( value: &str, ) -> LoweredValue { let (helper, lowered) = match &field.native_rep { + NativeRep::I8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_i8", &[(DOUBLE, value)]); + ("js_native_abi_check_i8", LoweredValue::i8(raw)) + } + NativeRep::I16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_i16", &[(DOUBLE, value)]); + ("js_native_abi_check_i16", LoweredValue::i16(raw)) + } NativeRep::I32 => { let raw = ctx .block() @@ -298,6 +334,18 @@ fn lower_pod_field_from_js_value( .call(I64, "js_native_abi_check_i64", &[(DOUBLE, value)]); ("js_native_abi_check_i64", LoweredValue::i64(raw)) } + NativeRep::U8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_u8", &[(DOUBLE, value)]); + ("js_native_abi_check_u8", LoweredValue::u8(raw)) + } + NativeRep::U16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_u16", &[(DOUBLE, value)]); + ("js_native_abi_check_u16", LoweredValue::u16(raw)) + } NativeRep::U32 => { let raw = ctx .block() @@ -316,6 +364,12 @@ fn lower_pod_field_from_js_value( .call(I64, "js_native_abi_check_usize", &[(DOUBLE, value)]); ("js_native_abi_check_usize", LoweredValue::usize(raw)) } + NativeRep::ISize => { + let raw = ctx + .block() + .call(I64, "js_native_abi_check_isize", &[(DOUBLE, value)]); + ("js_native_abi_check_isize", LoweredValue::isize(raw)) + } NativeRep::F32 => { let raw = ctx .block() @@ -883,6 +937,40 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I32); } + NativeAbiType::I8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_i8", &[(DOUBLE, val)]); + let native = LoweredValue::i8(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_i8", "int8_range")), + "i8.checked", + ); + lowered.push(raw); + arg_types.push(I8); + } + NativeAbiType::I16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_i16", &[(DOUBLE, val)]); + let native = LoweredValue::i16(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_i16", "int16_range")), + "i16.checked", + ); + lowered.push(raw); + arg_types.push(I16); + } NativeAbiType::I32 => { let raw = ctx .block() @@ -917,6 +1005,40 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I64); } + NativeAbiType::U8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_u8", &[(DOUBLE, val)]); + let native = LoweredValue::u8(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_u8", "uint8_range")), + "u8.checked", + ); + lowered.push(raw); + arg_types.push(I8); + } + NativeAbiType::U16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_u16", &[(DOUBLE, val)]); + let native = LoweredValue::u16(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_u16", "uint16_range")), + "u16.checked", + ); + lowered.push(raw); + arg_types.push(I16); + } NativeAbiType::U32 | NativeAbiType::BufferLen => { let raw = ctx .block() @@ -962,6 +1084,23 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I64); } + NativeAbiType::ISize => { + let raw = ctx + .block() + .call(I64, "js_native_abi_check_isize", &[(DOUBLE, val)]); + let native = LoweredValue::isize(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_isize", "safe_integer_isize")), + "isize.checked", + ); + lowered.push(raw); + arg_types.push(I64); + } NativeAbiType::F32 => { let raw = ctx .block() @@ -1480,11 +1619,14 @@ pub fn try_lower_extern_func_call( || name.contains("file_dialog"))); let returns_void = matches!(manifest_ret, Some(NativeAbiType::Void)) || (manifest_ret.is_none() && matches!(ext_return_type, HirType::Void)); + let returns_i8 = matches!(manifest_ret, Some(NativeAbiType::I8 | NativeAbiType::U8)); + let returns_i16 = matches!(manifest_ret, Some(NativeAbiType::I16 | NativeAbiType::U16)); let returns_i32 = matches!(manifest_ret, Some(NativeAbiType::I32 | NativeAbiType::Bool)); let returns_i64 = matches!(manifest_ret, Some(NativeAbiType::I64)); let returns_u32 = matches!(manifest_ret, Some(NativeAbiType::U32)); let returns_u64 = matches!(manifest_ret, Some(NativeAbiType::U64)); let returns_usize = matches!(manifest_ret, Some(NativeAbiType::USize)); + let returns_isize = matches!(manifest_ret, Some(NativeAbiType::ISize)); let returns_f32 = matches!(manifest_ret, Some(NativeAbiType::F32)); let returns_buffer_len = matches!(manifest_ret, Some(NativeAbiType::BufferLen)); let returns_handle = matches!(manifest_ret, Some(NativeAbiType::Handle(_))); @@ -1522,6 +1664,38 @@ pub fn try_lower_extern_func_call( } let boxed = nanbox_string_inline(ctx.block(), &ptr_i64); return Ok(Some(boxed)); + } else if returns_i8 { + ctx.pending_declares.push((name.clone(), I8, arg_types)); + let raw = ctx.block().call(I8, name, &arg_slices); + let lowered = if matches!(manifest_ret, Some(NativeAbiType::U8)) { + LoweredValue::u8(raw.clone()) + } else { + LoweredValue::i8(raw.clone()) + }; + if let Some(descriptor) = manifest_ret { + record_native_abi_return(ctx, descriptor, &lowered, name); + } + return Ok(Some(materialize_js_value( + ctx, + lowered, + MaterializationReason::ReturnAbi, + ))); + } else if returns_i16 { + ctx.pending_declares.push((name.clone(), I16, arg_types)); + let raw = ctx.block().call(I16, name, &arg_slices); + let lowered = if matches!(manifest_ret, Some(NativeAbiType::U16)) { + LoweredValue::u16(raw.clone()) + } else { + LoweredValue::i16(raw.clone()) + }; + if let Some(descriptor) = manifest_ret { + record_native_abi_return(ctx, descriptor, &lowered, name); + } + return Ok(Some(materialize_js_value( + ctx, + lowered, + MaterializationReason::ReturnAbi, + ))); } else if returns_i32 { ctx.pending_declares.push((name.clone(), I32, arg_types)); let raw = ctx.block().call(I32, name, &arg_slices); @@ -1545,23 +1719,25 @@ pub fn try_lower_extern_func_call( lowered, MaterializationReason::ReturnAbi, ))); - } else if returns_i64 { - // C function returns i64 in x0 (e.g. `*mut View` - // handles). Declare as I64; the value comes back as a - // raw integer. Convert via `sitofp` so callers see a - // normal JS number; subsequent FFI calls that pass it - // back as an i64 param will truncate via `fptosi`. + } else if returns_i64 || returns_isize { + // Reject native results outside JavaScript's exact integer range. ctx.pending_declares.push((name.clone(), I64, arg_types)); let raw = ctx.block().call(I64, name, &arg_slices); - let lowered = LoweredValue::i64(raw.clone()); + let lowered = if returns_isize { + LoweredValue::isize(raw.clone()) + } else { + LoweredValue::i64(raw.clone()) + }; if let Some(descriptor) = manifest_ret { record_native_abi_return(ctx, descriptor, &lowered, name); + return Ok(Some(materialize_checked_integer_return( + ctx, + &raw, + "js_native_abi_materialize_i64", + descriptor, + ))); } - return Ok(Some(materialize_js_value( - ctx, - lowered, - MaterializationReason::ReturnAbi, - ))); + unreachable!("i64 return routing requires a manifest descriptor"); } else if returns_u32 || returns_buffer_len { ctx.pending_declares.push((name.clone(), I32, arg_types)); let raw = ctx.block().call(I32, name, &arg_slices); @@ -1588,12 +1764,14 @@ pub fn try_lower_extern_func_call( }; if let Some(descriptor) = manifest_ret { record_native_abi_return(ctx, descriptor, &lowered, name); + return Ok(Some(materialize_checked_integer_return( + ctx, + &raw, + "js_native_abi_materialize_u64", + descriptor, + ))); } - return Ok(Some(materialize_js_value( - ctx, - lowered, - MaterializationReason::ReturnAbi, - ))); + unreachable!("u64 return routing requires a manifest descriptor"); } else if returns_f32 { ctx.pending_declares.push((name.clone(), F32, arg_types)); let raw = ctx.block().call(F32, name, &arg_slices); diff --git a/crates/perry-codegen/src/lower_call/omitted_native_params.rs b/crates/perry-codegen/src/lower_call/omitted_native_params.rs index 3566392233..2503f36f27 100644 --- a/crates/perry-codegen/src/lower_call/omitted_native_params.rs +++ b/crates/perry-codegen/src/lower_call/omitted_native_params.rs @@ -31,7 +31,7 @@ use super::extern_func::{ }; use crate::expr::FnCtx; use crate::nanbox::double_literal; -use crate::types::{LlvmType, DOUBLE, F32, I32, I64, PTR}; +use crate::types::{LlvmType, DOUBLE, F32, I16, I32, I64, I8, PTR}; /// Append a sentinel for every manifest param past `passed_args`. No-op when /// the caller passed at least as many args as the manifest declares. @@ -106,10 +106,19 @@ pub(super) fn pad_omitted_native_params( lowered.push("0".to_string()); arg_types.push(I32); } + NativeAbiType::I8 | NativeAbiType::U8 => { + lowered.push("0".to_string()); + arg_types.push(I8); + } + NativeAbiType::I16 | NativeAbiType::U16 => { + lowered.push("0".to_string()); + arg_types.push(I16); + } NativeAbiType::I64 | NativeAbiType::I64String | NativeAbiType::U64 | NativeAbiType::USize + | NativeAbiType::ISize | NativeAbiType::Ptr | NativeAbiType::HandleId | NativeAbiType::Handle(_) diff --git a/crates/perry-codegen/src/native_value/materialize.rs b/crates/perry-codegen/src/native_value/materialize.rs index 2006bfd327..3272a06341 100644 --- a/crates/perry-codegen/src/native_value/materialize.rs +++ b/crates/perry-codegen/src/native_value/materialize.rs @@ -376,7 +376,11 @@ pub(crate) fn materialize_js_value_bits( ctx.block().bitcast_double_to_i64(&value) } NativeRep::I64 | NativeRep::ISize => { - let value = ctx.block().sitofp(I64, &lowered.value, DOUBLE); + let value = ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ); ctx.block().bitcast_double_to_i64(&value) } NativeRep::U8 => { @@ -394,7 +398,11 @@ pub(crate) fn materialize_js_value_bits( ctx.block().bitcast_double_to_i64(&value) } NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - let value = ctx.block().uitofp(I64, &lowered.value, DOUBLE); + let value = ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ); ctx.block().bitcast_double_to_i64(&value) } NativeRep::BufferLen => { @@ -579,7 +587,11 @@ pub(crate) fn materialize_js_value( ctx.block().sitofp(I32, &widened, DOUBLE) } NativeRep::I32 => ctx.block().sitofp(I32, &lowered.value, DOUBLE), - NativeRep::I64 | NativeRep::ISize => ctx.block().sitofp(I64, &lowered.value, DOUBLE), + NativeRep::I64 | NativeRep::ISize => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ), NativeRep::U8 => { let widened = ctx.block().zext(I8, &lowered.value, I32); ctx.block().uitofp(I32, &widened, DOUBLE) @@ -589,9 +601,11 @@ pub(crate) fn materialize_js_value( ctx.block().uitofp(I32, &widened, DOUBLE) } NativeRep::U32 => ctx.block().uitofp(I32, &lowered.value, DOUBLE), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - ctx.block().uitofp(I64, &lowered.value, DOUBLE) - } + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ), NativeRep::BufferLen => ctx.block().uitofp(I32, &lowered.value, DOUBLE), NativeRep::F32 => ctx.block().fpext(F32, &lowered.value, DOUBLE), NativeRep::StringRef => nanbox_string_ref_boxed(ctx, &lowered.value), @@ -655,7 +669,11 @@ pub(crate) fn materialize_js_value_without_record( ctx.block().sitofp(I32, &widened, DOUBLE) } NativeRep::I32 => ctx.block().sitofp(I32, &lowered.value, DOUBLE), - NativeRep::I64 | NativeRep::ISize => ctx.block().sitofp(I64, &lowered.value, DOUBLE), + NativeRep::I64 | NativeRep::ISize => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ), NativeRep::U8 => { let widened = ctx.block().zext(I8, &lowered.value, I32); ctx.block().uitofp(I32, &widened, DOUBLE) @@ -665,9 +683,11 @@ pub(crate) fn materialize_js_value_without_record( ctx.block().uitofp(I32, &widened, DOUBLE) } NativeRep::U32 => ctx.block().uitofp(I32, &lowered.value, DOUBLE), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - ctx.block().uitofp(I64, &lowered.value, DOUBLE) - } + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ), NativeRep::BufferLen => ctx.block().uitofp(I32, &lowered.value, DOUBLE), NativeRep::F32 => ctx.block().fpext(F32, &lowered.value, DOUBLE), NativeRep::BufferView(_) diff --git a/crates/perry-codegen/src/native_value/pod.rs b/crates/perry-codegen/src/native_value/pod.rs index 7e34f47369..aababc15b1 100644 --- a/crates/perry-codegen/src/native_value/pod.rs +++ b/crates/perry-codegen/src/native_value/pod.rs @@ -89,7 +89,14 @@ pub(crate) fn layout_for_pod_view_type( pub(crate) fn collect_pod_init_fields( ctx: &FnCtx<'_>, init: &Expr, + layout: &PodLayoutManifest, ) -> Result { + let mut fields = Vec::with_capacity(layout.fields.len()); + flatten_pod_init_fields(ctx, init, layout, &[], &mut fields)?; + Ok(PodInitFields { fields }) +} + +fn direct_pod_init_fields(ctx: &FnCtx<'_>, init: &Expr) -> Result, String> { match init { Expr::Object(props) => { let mut seen = std::collections::HashSet::new(); @@ -100,7 +107,7 @@ pub(crate) fn collect_pod_init_fields( } fields.push((name.clone(), value.clone())); } - Ok(PodInitFields { fields }) + Ok(fields) } Expr::New { class_name, args, .. @@ -126,13 +133,42 @@ pub(crate) fn collect_pod_init_fields( } fields.push((field.name.clone(), arg.clone())); } - Ok(PodInitFields { fields }) + Ok(fields) } Expr::ObjectSpread { .. } => Err("spread_property".to_string()), _ => Err("unsupported_initializer".to_string()), } } +fn flatten_pod_init_fields( + ctx: &FnCtx<'_>, + init: &Expr, + layout: &PodLayoutManifest, + prefix: &[String], + output: &mut Vec<(String, Expr)>, +) -> Result<(), String> { + for (name, value) in direct_pod_init_fields(ctx, init)? { + let mut path = prefix.to_vec(); + path.push(name); + let flattened = path.join("."); + if layout.fields.iter().any(|field| field.path == path) { + output.push((flattened, value)); + continue; + } + if layout + .fields + .iter() + .any(|field| field.path.len() > path.len() && field.path.starts_with(path.as_slice())) + { + flatten_pod_init_fields(ctx, &value, layout, &path, output) + .map_err(|reason| format!("nested_field:{flattened}:{reason}"))?; + continue; + } + output.push((flattened, value)); + } + Ok(()) +} + pub(crate) fn validate_exact_init( layout: &PodLayoutManifest, init_fields: &PodInitFields, @@ -337,11 +373,16 @@ pub(crate) fn expected_rep_for_native_rep(rep: &NativeRep) -> Option Option { Some(match ty { + NativeAbiType::I8 => NativeRep::I8, + NativeAbiType::I16 => NativeRep::I16, NativeAbiType::I32 => NativeRep::I32, NativeAbiType::I64 => NativeRep::I64, + NativeAbiType::U8 => NativeRep::U8, + NativeAbiType::U16 => NativeRep::U16, NativeAbiType::U32 => NativeRep::U32, NativeAbiType::U64 => NativeRep::U64, NativeAbiType::USize => NativeRep::USize, + NativeAbiType::ISize => NativeRep::ISize, NativeAbiType::F32 => NativeRep::F32, NativeAbiType::F64 => NativeRep::F64, NativeAbiType::BufferLen => NativeRep::BufferLen, diff --git a/crates/perry-codegen/src/native_value/verify/abi.rs b/crates/perry-codegen/src/native_value/verify/abi.rs index 07c2612505..b466dda876 100644 --- a/crates/perry-codegen/src/native_value/verify/abi.rs +++ b/crates/perry-codegen/src/native_value/verify/abi.rs @@ -158,11 +158,16 @@ pub(crate) fn validate_native_abi_type_record( ) } "bool" => matches!(&record.native_rep, NativeRep::I1 | NativeRep::I32), + "i8" => matches!(&record.native_rep, NativeRep::I8), + "i16" => matches!(&record.native_rep, NativeRep::I16), "i32" => matches!(&record.native_rep, NativeRep::I32), "i64" => matches!(&record.native_rep, NativeRep::I64), + "u8" => matches!(&record.native_rep, NativeRep::U8), + "u16" => matches!(&record.native_rep, NativeRep::U16), "u32" => matches!(&record.native_rep, NativeRep::U32), "u64" => matches!(&record.native_rep, NativeRep::U64), "usize" => matches!(&record.native_rep, NativeRep::USize), + "isize" => matches!(&record.native_rep, NativeRep::ISize), "f32" => matches!(&record.native_rep, NativeRep::F32), "f64" => matches!(&record.native_rep, NativeRep::F64 | NativeRep::JsValue), "buffer_len" => matches!(&record.native_rep, NativeRep::BufferLen), @@ -256,11 +261,16 @@ pub(crate) fn valid_runtime_guard_helper(kind: &str, helper: &str) -> bool { "string" => helper == "js_native_abi_check_string_ptr", "json" => helper == "js_json_stringify", "bool" => helper == "js_is_truthy", + "i8" => helper == "js_native_abi_check_i8", + "i16" => helper == "js_native_abi_check_i16", "i32" => helper == "js_native_abi_check_i32", "i64" | "i64_str" => helper == "js_native_abi_check_i64", + "u8" => helper == "js_native_abi_check_u8", + "u16" => helper == "js_native_abi_check_u16", "u32" | "buffer_len" => helper == "js_native_abi_check_u32", "u64" => helper == "js_native_abi_check_u64", "usize" => helper == "js_native_abi_check_usize", + "isize" => helper == "js_native_abi_check_isize", "f32" => helper == "js_native_abi_check_f32", "f64" => helper == "js_native_abi_check_f64", "ptr" => helper == "js_native_abi_check_ptr", diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 76338f75f7..a0781361f5 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -144,11 +144,18 @@ pub fn declare_phase1(module: &mut LlModule) { module.declare_function("js_typed_string_arg_to_raw", I64, &[DOUBLE]); module.declare_function("js_param_type_guard", I32, &[DOUBLE, PTR, I32]); module.declare_function("js_native_abi_check_f32", F32, &[DOUBLE]); + module.declare_function("js_native_abi_check_i8", I8, &[DOUBLE]); + module.declare_function("js_native_abi_check_i16", I16, &[DOUBLE]); module.declare_function("js_native_abi_check_i32", I32, &[DOUBLE]); module.declare_function("js_native_abi_check_i64", I64, &[DOUBLE]); + module.declare_function("js_native_abi_check_u8", I8, &[DOUBLE]); + module.declare_function("js_native_abi_check_u16", I16, &[DOUBLE]); module.declare_function("js_native_abi_check_u32", I32, &[DOUBLE]); module.declare_function("js_native_abi_check_u64", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_usize", I64, &[DOUBLE]); + module.declare_function("js_native_abi_check_isize", I64, &[DOUBLE]); + module.declare_function("js_native_abi_materialize_i64", DOUBLE, &[I64]); + module.declare_function("js_native_abi_materialize_u64", DOUBLE, &[I64]); module.declare_function("js_native_abi_check_string_ptr", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_ptr", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_buffer_data_ptr", PTR, &[DOUBLE]); diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 706dde5c59..4a2c62ad9f 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1,5 +1,3 @@ -//! `Stmt::Let` lowering — large arm extracted from the dispatcher. - use super::*; use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffer_view}; @@ -20,13 +18,8 @@ use crate::native_value::{ use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; -/// #5271: does `init` provably evaluate to a plain object literal? Two -/// shapes reach codegen: a data-only literal stays `Expr::Object`, while a -/// literal carrying methods/getters lowers to an immediately-invoked -/// object-building closure whose sole param is named `__perry_obj_iife` -/// and whose single argument is the seed `Object(..)`. Recognizing both -/// lets `o.trim()` / `internals.trim(v, s)` resolve to the receiver's own -/// member rather than `String.prototype.trim`. +/// #5271: recognize both data-only object literals and method/getter IIFEs so +/// own members win over built-in prototype methods during lowering. fn is_object_literal_init(init: &perry_hir::Expr) -> bool { use perry_hir::Expr; match init { @@ -443,6 +436,25 @@ pub(crate) fn lower_let( } if let Some(init_expr) = init { + let copied = match init_expr { + perry_hir::Expr::LocalGet(source_id) if !ctx.boxed_vars.contains(&id) => { + crate::expr::copy_pod_local(ctx, id, *source_id)? + } + _ => None, + }; + if let Some(copied) = copied { + ctx.local_types.insert(id, refined_ty.clone()); + ctx.locals.insert(id, copied.materialized_slot.clone()); + ctx.pod_records.insert(id, copied); + if ctx.module_globals.contains_key(&id) { + let _ = crate::expr::materialize_pod_local( + ctx, + id, + MaterializationReason::PodMaterialization, + )?; + } + return Ok(()); + } match crate::native_value::layout_decision_for_type(ctx, &refined_ty) { PodLayoutDecision::Layout(_) if ctx.boxed_vars.contains(&id) && !ctx.module_globals.contains_key(&id) => @@ -454,12 +466,11 @@ pub(crate) fn lower_let( ); } PodLayoutDecision::Layout(layout) => { - match crate::native_value::collect_pod_init_fields(ctx, init_expr).and_then( - |fields| { + match crate::native_value::collect_pod_init_fields(ctx, init_expr, &layout) + .and_then(|fields| { crate::native_value::validate_exact_init(&layout, &fields)?; Ok(fields) - }, - ) { + }) { Ok(init_fields) => { let data_slot = ctx .func @@ -535,9 +546,8 @@ pub(crate) fn lower_let( } } - // Keep a non-escaping uppercase result virtual when every consumer is a - // fused string operation. Store the original boxed receiver now so later - // writes to its source local cannot change the captured value. + // Keep non-escaping uppercase results virtual for fused consumers while + // snapshotting the receiver against later source-local writes. if let Some(perry_hir::Expr::Call { callee, args, .. }) = init { if ctx.fusible_uppercase_locals.contains(&id) && args.is_empty() diff --git a/crates/perry-codegen/tests/native_proof_regressions/native_library.rs b/crates/perry-codegen/tests/native_proof_regressions/native_library.rs index c572e9fa19..4fcfe1fa0f 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/native_library.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/native_library.rs @@ -1,5 +1,74 @@ use super::*; +#[test] +fn native_library_exact_width_scalars_use_distinct_guards_and_c_abi_slots() { + let opts = native_library_opts(vec![ + ( + "native_exact_args", + vec!["i8", "i16", "u8", "u16", "isize"], + "void", + ), + ("native_ret_i8", vec![], "i8"), + ("native_ret_i16", vec![], "i16"), + ("native_ret_u8", vec![], "u8"), + ("native_ret_u16", vec![], "u16"), + ("native_ret_isize", vec![], "isize"), + ]); + let module = module( + "native_library_exact_widths.ts", + vec![ + Stmt::Expr(extern_call( + "native_exact_args", + vec![int(-8), int(-16), int(8), int(16), int(-64)], + Type::Void, + )), + Stmt::Expr(extern_call("native_ret_i8", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_i16", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_u8", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_u16", vec![], Type::Number)), + Stmt::Return(Some(extern_call("native_ret_isize", vec![], Type::Number))), + ], + ); + let ir = String::from_utf8(compile_module(&module, opts.clone()).unwrap()).unwrap(); + for guard in [ + "js_native_abi_check_i8", + "js_native_abi_check_i16", + "js_native_abi_check_u8", + "js_native_abi_check_u16", + "js_native_abi_check_isize", + ] { + assert!(ir.contains(guard), "missing guard {guard}:\n{ir}"); + } + assert!( + ir.contains("declare void @native_exact_args(i8, i16, i8, i16, i64)") + && ir.contains("declare i8 @native_ret_i8()") + && ir.contains("declare i16 @native_ret_i16()") + && ir.contains("declare i8 @native_ret_u8()") + && ir.contains("declare i16 @native_ret_u16()") + && ir.contains("declare i64 @native_ret_isize()"), + "exact-width descriptors must preserve their C ABI slots:\n{ir}" + ); + + let artifact = compile_artifact_json_for_module_with_opts(module, opts); + let records = artifact["records"].as_array().unwrap(); + for (kind, rep, helper) in [ + ("i8", "i8", "js_native_abi_check_i8"), + ("i16", "i16", "js_native_abi_check_i16"), + ("u8", "u8", "js_native_abi_check_u8"), + ("u16", "u16", "js_native_abi_check_u16"), + ("isize", "isize", "js_native_abi_check_isize"), + ] { + assert!( + records.iter().any(|record| { + record["native_abi_type"]["canonical_kind"] == kind + && record["native_rep_name"] == rep + && record["native_abi_type"]["runtime_guard"]["helper"] == helper + }), + "missing exact-width proof {kind}/{rep}/{helper}:\n{artifact:#}" + ); + } +} + #[test] fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() { let opts = native_library_opts(vec![ @@ -61,6 +130,8 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() && ir.contains("declare i32 @native_ret_buffer_len()") && ir.contains("declare i64 @native_ret_handle()") && ir.contains("declare i64 @native_ret_promise()") + && ir.contains("call double @js_native_abi_materialize_i64") + && ir.contains("call double @js_native_abi_materialize_u64") && ir.contains("call double @js_native_handle_new_borrowed"), "expected lowercase manifest return kinds to drive LLVM declarations:\n{ir}" ); @@ -121,13 +192,6 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() ); } for (consumer, from_rep, op, lossy) in [ - ("materialize_js_value", "u64", "unsigned_int_to_float", true), - ( - "materialize_js_value", - "usize", - "unsigned_int_to_float", - true, - ), ("materialize_js_value", "f32", "float_extend", false), ( "materialize_native_handle_runtime", @@ -154,6 +218,24 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() "expected native-library transition {from_rep}->{op}:\n{artifact:#}" ); } + for (descriptor, guard) in [ + ("i64", "js_native_abi_materialize_i64"), + ("u64", "js_native_abi_materialize_u64"), + ("usize", "js_native_abi_materialize_u64"), + ] { + assert!( + records.iter().any(|record| { + record["consumer"] == "native_library.checked_integer_return" + && record["notes"].as_array().is_some_and(|notes| { + notes + .iter() + .any(|note| note == &format!("descriptor={descriptor}")) + && notes.iter().any(|note| note == &format!("guard={guard}")) + }) + }), + "missing exact safe-integer return guard for {descriptor}:\n{artifact:#}" + ); + } } #[test] diff --git a/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs b/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs index 556cfb4d08..916168727e 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs @@ -1,5 +1,109 @@ use super::*; +#[test] +fn pod_i64_and_u64_materialization_uses_safe_integer_guards() { + let packet_ty = pod_type(&[ + ("signed", Type::Named("PerryI64".to_string())), + ("unsigned", Type::Named("PerryU64".to_string())), + ]); + let module = module( + "pod_safe_integer_materialization.ts", + vec![ + pod_let( + 1, + "packet", + packet_ty, + vec![("signed", int(-7)), ("unsigned", int(9))], + ), + Stmt::Return(Some(local(1))), + ], + ); + + let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + assert!( + ir.contains("call double @js_native_abi_materialize_i64"), + "signed POD fields must reject imprecise managed materialization:\n{ir}" + ); + assert!( + ir.contains("call double @js_native_abi_materialize_u64"), + "unsigned POD fields must reject imprecise managed materialization:\n{ir}" + ); +} + +#[test] +fn nested_pod_initializers_and_local_assignments_preserve_value_semantics() { + let nested_ty = pod_type(&[ + ("code", Type::Named("PerryU16".to_string())), + ("delta", Type::Named("PerryI8".to_string())), + ]); + let packet_ty = pod_type(&[ + ("tag", Type::Named("PerryU8".to_string())), + ("nested", nested_ty), + ]); + let module = module( + "nested_pod_copy.ts", + vec![ + pod_let( + 1, + "original", + packet_ty.clone(), + vec![ + ("tag", int(7)), + ( + "nested", + Expr::Object(vec![ + ("code".to_string(), int(513)), + ("delta".to_string(), int(-8)), + ]), + ), + ], + ), + Stmt::Let { + id: 2, + name: "copy".to_string(), + ty: packet_ty, + mutable: true, + init: Some(local(1)), + }, + Stmt::Return(Some(local(1))), + ], + ); + + let artifact = compile_artifact_json_for_module(module); + let records = artifact["records"].as_array().unwrap(); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "PodRecordLiteralInit" + && record["consumer"] == "pod_record_stack_alloc" + && record["pod_layout"]["fields"] + .as_array() + .is_some_and(|fields| fields.len() == 3) + }), + "nested POD literal should flatten into one C layout:\n{artifact:#}" + ); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "PodRecordCopyInit" + && record["consumer"] == "pod_record_value_copy" + && record["notes"].as_array().is_some_and(|notes| { + notes.iter().any(|note| note == "assignment_semantics=copy") + }) + }), + "POD local assignment must snapshot into independent storage:\n{artifact:#}" + ); + assert!( + records.iter().any(|record| { + record["consumer"] == "pod_record_materialized_value_copy" + && record["notes"].as_array().is_some_and(|notes| { + notes + .iter() + .any(|note| note == "value_semantics=copy_at_managed_boundary") + }) + }), + "a managed POD boundary must receive a copied object:\n{artifact:#}" + ); +} + #[test] fn checked_native_scalar_conversions_keep_dynamic_pod_initializers_native() { let packet_ty = pod_type(&[ diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index df90315ec6..2819ddb2dd 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -24,6 +24,7 @@ pub(crate) mod lower_decl; pub(crate) mod lower_patterns; pub(crate) mod lower_types; pub mod monomorph; +pub mod native_profile; pub mod stable_hash; pub mod types; pub mod walker; @@ -64,3 +65,4 @@ pub use lower::{ lower_module_with_class_id_types_seed_and_entry, }; pub use monomorph::monomorphize_module; +pub use native_profile::exported_native_pod_abi; diff --git a/crates/perry-hir/src/native_profile.rs b/crates/perry-hir/src/native_profile.rs new file mode 100644 index 0000000000..32d0a8b5c0 --- /dev/null +++ b/crates/perry-hir/src/native_profile.rs @@ -0,0 +1,259 @@ +//! Source-contract extraction for the public `perry/native` POD profile. + +use std::collections::HashSet; + +use perry_api_manifest::{NativeAbiType, NativePodAbi, NativePodFieldAbi}; + +use crate::{types::Type, Interface, Module}; + +/// Resolve an exported `pod` alias into the canonical native-library POD +/// descriptor used by manifest validation and code generation. +pub fn exported_native_pod_abi(module: &Module, export_name: &str) -> Result { + let alias = module + .type_aliases + .iter() + .find(|alias| alias.is_exported && alias.name == export_name) + .ok_or_else(|| format!("source export `{export_name}` is not an exported type alias"))?; + if !alias.type_params.is_empty() { + return Err(format!( + "source export `{export_name}` must not declare type parameters" + )); + } + let Type::Generic { base, type_args } = &alias.ty else { + return Err(format!( + "source export `{export_name}` must be declared as pod" + )); + }; + if base != "PerryPod" || type_args.len() != 1 { + return Err(format!( + "source export `{export_name}` must be declared as pod" + )); + } + + let mut resolving = HashSet::new(); + let fields = pod_fields(module, &type_args[0], &mut resolving)?; + if fields.is_empty() { + return Err(format!( + "source POD `{export_name}` must contain at least one field" + )); + } + Ok(NativePodAbi { + name: Some(export_name.to_string()), + fields, + }) +} + +fn pod_fields( + module: &Module, + ty: &Type, + resolving: &mut HashSet, +) -> Result, String> { + match ty { + Type::Object(object) => { + if object.index_signature.is_some() { + return Err("POD object types cannot contain an index signature".to_string()); + } + let order = object.property_order.as_ref().ok_or_else(|| { + "POD object type is missing stable source property order".to_string() + })?; + order + .iter() + .map(|name| { + let property = object.properties.get(name).ok_or_else(|| { + format!("POD property `{name}` is missing from its object type") + })?; + if property.optional { + return Err(format!("POD field `{name}` must not be optional")); + } + Ok(NativePodFieldAbi { + name: name.clone(), + ty: pod_field_type(module, &property.ty, resolving)?, + }) + }) + .collect() + } + Type::Named(name) => { + if !resolving.insert(name.clone()) { + return Err(format!( + "recursive POD source type `{name}` is not supported" + )); + } + let result = if let Some(alias) = module.type_aliases.iter().find(|a| a.name == *name) { + if !alias.type_params.is_empty() { + Err(format!( + "POD source type alias `{name}` must not be generic" + )) + } else { + pod_fields(module, &alias.ty, resolving) + } + } else if let Some(interface) = module.interfaces.iter().find(|i| i.name == *name) { + interface_fields(module, interface, resolving) + } else { + Err(format!("POD source type `{name}` could not be resolved")) + }; + resolving.remove(name); + result + } + _ => Err("pod requires an object literal, interface, or object type alias".to_string()), + } +} + +fn interface_fields( + module: &Module, + interface: &Interface, + resolving: &mut HashSet, +) -> Result, String> { + if !interface.type_params.is_empty() || !interface.extends.is_empty() { + return Err(format!( + "POD interface `{}` must not be generic or extend another interface", + interface.name + )); + } + if !interface.methods.is_empty() { + return Err(format!( + "POD interface `{}` must not contain methods", + interface.name + )); + } + interface + .properties + .iter() + .map(|property| { + if property.optional { + return Err(format!( + "POD field `{}` must not be optional", + property.name + )); + } + Ok(NativePodFieldAbi { + name: property.name.clone(), + ty: pod_field_type(module, &property.ty, resolving)?, + }) + }) + .collect() +} + +fn pod_field_type( + module: &Module, + ty: &Type, + resolving: &mut HashSet, +) -> Result { + let scalar = match ty { + Type::Number => Some(NativeAbiType::F64), + Type::Named(name) => match name.as_str() { + "PerryI8" => Some(NativeAbiType::I8), + "PerryI16" => Some(NativeAbiType::I16), + "PerryI32" => Some(NativeAbiType::I32), + "PerryI64" => Some(NativeAbiType::I64), + "PerryU8" | "PerryByte" => Some(NativeAbiType::U8), + "PerryU16" => Some(NativeAbiType::U16), + "PerryU32" => Some(NativeAbiType::U32), + "PerryU64" => Some(NativeAbiType::U64), + "PerryISize" => Some(NativeAbiType::ISize), + "PerryUSize" => Some(NativeAbiType::USize), + "PerryF32" => Some(NativeAbiType::F32), + "PerryF64" => Some(NativeAbiType::F64), + "PerryBufferLen" => Some(NativeAbiType::BufferLen), + "PerryHandleId" => Some(NativeAbiType::HandleId), + _ => None, + }, + _ => None, + }; + if let Some(scalar) = scalar { + return Ok(scalar); + } + + match ty { + Type::Generic { base, type_args } if base == "PerryPod" && type_args.len() == 1 => { + Ok(NativeAbiType::Pod(NativePodAbi { + name: None, + fields: pod_fields(module, &type_args[0], resolving)?, + })) + } + Type::Named(name) => { + if !resolving.insert(name.clone()) { + return Err(format!( + "recursive POD source type `{name}` is not supported" + )); + } + let result = if let Some(alias) = module.type_aliases.iter().find(|a| a.name == *name) { + if !alias.type_params.is_empty() { + Err(format!("POD field type alias `{name}` must not be generic")) + } else { + pod_field_type(module, &alias.ty, resolving) + } + } else { + Err(format!( + "POD field type `{name}` is not a fixed-width scalar or nested pod" + )) + }; + resolving.remove(name); + result + } + _ => Err(format!( + "POD field type `{ty:?}` is not a fixed-width scalar or nested pod" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lower(source: &str) -> Module { + let ast = perry_parser::parse_typescript(source, "native-contract.ts").expect("parse"); + crate::set_current_module_source(source.to_string()); + let module = crate::lower_module(&ast, "native-contract", "native-contract.ts"); + crate::clear_current_module_source(); + module.expect("lower") + } + + #[test] + fn extracts_all_public_scalar_widths_and_nested_pods_in_source_order() { + let module = lower( + r#" +import type { pod, i8, i16, i32, i64, u8, byte, u16, u32, u64, isize, usize, f32, f64 } from "perry/native"; +interface Nested { code: u16; weight: f32 } +export type Packet = pod<{ + a: i8; b: i16; c: i32; d: i64; + e: u8; f: byte; g: u16; h: u32; i: u64; + j: isize; k: usize; l: f32; m: f64; n: number; + nested: pod; +}>; +"#, + ); + + let pod = exported_native_pod_abi(&module, "Packet").expect("extract POD"); + let names: Vec<_> = pod.fields.iter().map(|field| field.name.as_str()).collect(); + assert_eq!( + names, + ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "nested"] + ); + assert_eq!(pod.fields[0].ty, NativeAbiType::I8); + assert_eq!(pod.fields[5].ty, NativeAbiType::U8); + assert_eq!(pod.fields[9].ty, NativeAbiType::ISize); + assert_eq!(pod.fields[13].ty, NativeAbiType::F64); + let NativeAbiType::Pod(nested) = &pod.fields[14].ty else { + panic!("nested field must be a POD") + }; + assert_eq!(nested.fields[0].ty, NativeAbiType::U16); + assert_eq!(nested.fields[1].ty, NativeAbiType::F32); + } + + #[test] + fn requires_an_exported_closed_pod_alias() { + let module = lower( + r#" +import type { pod, u32 } from "perry/native"; +type Hidden = pod<{ value: u32 }>; +export type Optional = pod<{ value?: u32 }>; +"#, + ); + assert!(exported_native_pod_abi(&module, "Hidden") + .unwrap_err() + .contains("not an exported type alias")); + assert!(exported_native_pod_abi(&module, "Optional") + .unwrap_err() + .contains("must not be optional")); + } +} diff --git a/crates/perry-runtime/src/native_abi.rs b/crates/perry-runtime/src/native_abi.rs index fdac3138e7..7141671220 100644 --- a/crates/perry-runtime/src/native_abi.rs +++ b/crates/perry-runtime/src/native_abi.rs @@ -19,6 +19,13 @@ fn throw_type_error(message: &str) -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } +#[cold] +fn throw_range_error(message: &str) -> ! { + let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = crate::error::js_rangeerror_new(msg); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) +} + fn strict_number(value: f64, message: &str) -> f64 { let js_value = JSValue::from_bits(value.to_bits()); if js_value.is_int32() { @@ -198,6 +205,33 @@ static KEEP_JS_TYPED_STRING_ARG_GUARD: extern "C" fn(f64) -> i32 = js_typed_stri #[used] static KEEP_JS_TYPED_STRING_ARG_TO_RAW: extern "C" fn(f64) -> i64 = js_typed_string_arg_to_raw; +// Manifest calls are emitted from generated LLVM IR and therefore have no +// Rust call graph edge in a release staticlib. Keep every newly public exact- +// width boundary helper alive under whole-program LTO/dead stripping. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_I8: extern "C" fn(f64) -> i8 = js_native_abi_check_i8; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_I16: extern "C" fn(f64) -> i16 = js_native_abi_check_i16; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_U8: extern "C" fn(f64) -> u8 = js_native_abi_check_u8; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_U16: extern "C" fn(f64) -> u16 = js_native_abi_check_u16; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_ISIZE: extern "C" fn(f64) -> isize = js_native_abi_check_isize; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_MATERIALIZE_I64: extern "C" fn(i64) -> f64 = + js_native_abi_materialize_i64; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_MATERIALIZE_U64: extern "C" fn(u64) -> f64 = + js_native_abi_materialize_u64; + // Static-name and static-method lowering emits these by-id wrappers directly // from generated LLVM IR. Keep roots here so LTO cannot strip the symbols just // because the Rust crate graph has no ordinary caller. @@ -245,6 +279,26 @@ pub extern "C" fn js_native_abi_check_i32(value: f64) -> i32 { number as i32 } +/// Validate and lower a manifest `i8` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_i8(value: f64) -> i8 { + let number = strict_integer(value, "Expected integer for native i8 parameter"); + if number < i8::MIN as f64 || number > i8::MAX as f64 { + throw_type_error("Native i8 parameter is out of range"); + } + number as i8 +} + +/// Validate and lower a manifest `i16` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_i16(value: f64) -> i16 { + let number = strict_integer(value, "Expected integer for native i16 parameter"); + if number < i16::MIN as f64 || number > i16::MAX as f64 { + throw_type_error("Native i16 parameter is out of range"); + } + number as i16 +} + /// Validate and lower a manifest `i64` parameter. #[no_mangle] pub extern "C" fn js_native_abi_check_i64(value: f64) -> i64 { @@ -265,6 +319,26 @@ pub extern "C" fn js_native_abi_check_u32(value: f64) -> u32 { number as u32 } +/// Validate and lower a manifest `u8`/`byte` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_u8(value: f64) -> u8 { + let number = strict_integer(value, "Expected integer for native u8 parameter"); + if number < 0.0 || number > u8::MAX as f64 { + throw_type_error("Native u8 parameter is out of range"); + } + number as u8 +} + +/// Validate and lower a manifest `u16` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_u16(value: f64) -> u16 { + let number = strict_integer(value, "Expected integer for native u16 parameter"); + if number < 0.0 || number > u16::MAX as f64 { + throw_type_error("Native u16 parameter is out of range"); + } + number as u16 +} + /// Validate and lower a manifest `u64` parameter. #[no_mangle] pub extern "C" fn js_native_abi_check_u64(value: f64) -> u64 { @@ -285,6 +359,30 @@ pub extern "C" fn js_native_abi_check_usize(value: f64) -> usize { number as usize } +/// Validate and lower a manifest `isize` parameter on 64-bit native targets. +#[no_mangle] +pub extern "C" fn js_native_abi_check_isize(value: f64) -> isize { + strict_safe_integer(value, "Expected safe integer for native isize parameter") as isize +} + +/// Materialize a signed 64-bit native value without silently rounding it. +#[no_mangle] +pub extern "C" fn js_native_abi_materialize_i64(value: i64) -> f64 { + if value < MIN_SAFE_INTEGER as i64 || value > MAX_SAFE_INTEGER as i64 { + throw_range_error("Native i64/isize value cannot be represented exactly as a number"); + } + value as f64 +} + +/// Materialize an unsigned 64-bit native value without silently rounding it. +#[no_mangle] +pub extern "C" fn js_native_abi_materialize_u64(value: u64) -> f64 { + if value > MAX_SAFE_INTEGER as u64 { + throw_range_error("Native u64/usize value cannot be represented exactly as a number"); + } + value as f64 +} + /// Validate a manifest `string` parameter and return a raw StringHeader pointer. #[no_mangle] pub extern "C" fn js_native_abi_check_string_ptr(value: f64) -> i64 { @@ -388,19 +486,44 @@ mod tests { #[test] fn scalar_guards_reject_incompatible_js_values() { + assert_eq!(js_native_abi_check_i8(-128.0), -128); + assert_eq!(js_native_abi_check_i16(32_767.0), 32_767); assert_eq!(js_native_abi_check_i32(12.0), 12); + assert_eq!(js_native_abi_check_u8(255.0), 255); + assert_eq!(js_native_abi_check_u16(65_535.0), 65_535); assert_eq!(js_native_abi_check_u32(4_000_000_000.0), 4_000_000_000); + assert_eq!(js_native_abi_check_isize(-42.0), -42); + assert_eq!( + js_native_abi_materialize_i64(MIN_SAFE_INTEGER as i64), + MIN_SAFE_INTEGER + ); + assert_eq!( + js_native_abi_materialize_u64(MAX_SAFE_INTEGER as u64), + MAX_SAFE_INTEGER + ); assert_eq!(js_native_abi_check_f32(6.25), 6.25f32); assert!(catch_runtime_throw(|| { js_native_abi_check_i32(1.5); })); + assert!(catch_runtime_throw(|| { + js_native_abi_check_i8(128.0); + })); + assert!(catch_runtime_throw(|| { + js_native_abi_check_u16(-1.0); + })); assert!(catch_runtime_throw(|| { js_native_abi_check_u32(-1.0); })); assert!(catch_runtime_throw(|| { js_native_abi_check_i64(MAX_SAFE_INTEGER + 2.0); })); + assert!(catch_runtime_throw(|| { + js_native_abi_materialize_i64(MAX_SAFE_INTEGER as i64 + 1); + })); + assert!(catch_runtime_throw(|| { + js_native_abi_materialize_u64(MAX_SAFE_INTEGER as u64 + 1); + })); assert!(catch_runtime_throw(|| { let s = crate::string::js_string_from_bytes(b"no".as_ptr(), 2); js_native_abi_check_f64(f64::from_bits(JSValue::string_ptr(s).bits())); diff --git a/crates/perry-transform/src/inline/analysis.rs b/crates/perry-transform/src/inline/analysis.rs index d33c06a0b9..14c3179c62 100644 --- a/crates/perry-transform/src/inline/analysis.rs +++ b/crates/perry-transform/src/inline/analysis.rs @@ -1,4 +1,4 @@ -use perry_hir::types::{FuncId, LocalId}; +use perry_hir::types::{FuncId, LocalId, Type}; use perry_hir::walker::walk_expr_children; use perry_hir::{Class, Expr, Function, Module, Stmt}; use std::collections::{HashMap, HashSet}; @@ -95,6 +95,13 @@ pub fn is_inlinable(func: &Function) -> bool { return false; } + // Standalone POD records are copy values. The ordinary call boundary + // materializes a fresh object for a POD argument, while substitution + // would make writes to the callee parameter target the caller's local. + if has_pod_value_param(func) { + return false; + } + // Don't inline functions that are too large if func.body.len() > MAX_INLINE_STMTS { return false; @@ -331,6 +338,9 @@ pub fn is_inlinable_method(func: &Function) -> bool { if func.params.iter().any(|p| p.is_rest) { return false; } + if has_pod_value_param(func) { + return false; + } if func.body.len() > MAX_INLINE_STMTS { return false; } @@ -353,6 +363,15 @@ pub fn is_inlinable_method(func: &Function) -> bool { true } +fn has_pod_value_param(func: &Function) -> bool { + func.params.iter().any(|param| { + matches!( + ¶m.ty, + Type::Generic { base, type_args } if base == "PerryPod" && type_args.len() == 1 + ) + }) +} + /// Check if `stmts` contains any `Expr::Call { callee: FuncRef(target_id) }`, /// recursively. Stops at closure boundaries — a self-reference inside a nested /// closure is a value-position read, not a same-frame recursive tail, and the diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 50cbf13977..c1f17bc1cc 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -796,7 +796,7 @@ fn inline_functions_inner( #[cfg(test)] mod tests { use super::*; - use perry_hir::{ImportSpecifier, ModuleKind}; + use perry_hir::{ImportSpecifier, ModuleKind, Param}; fn function(id: FuncId, body: Vec) -> Function { Function { @@ -869,6 +869,26 @@ mod tests { }) } + #[test] + fn pod_value_parameters_preserve_the_call_copy_boundary() { + let mut func = function(1, vec![Stmt::Return(None)]); + func.params.push(Param { + id: 1, + name: "value".to_string(), + ty: Type::Generic { + base: "PerryPod".to_string(), + type_args: vec![Type::Object(Default::default())], + }, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }); + + assert!(!is_inlinable(&func)); + assert!(!is_inlinable_method(&func)); + } + #[test] fn cross_module_synthetic_imports_are_sorted() { let mut module = Module::new("dest"); diff --git a/crates/perry/src/commands/compile/resolve/native_library.rs b/crates/perry/src/commands/compile/resolve/native_library.rs index 53e278a844..fe67d12272 100644 --- a/crates/perry/src/commands/compile/resolve/native_library.rs +++ b/crates/perry/src/commands/compile/resolve/native_library.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, Result}; use perry_api_manifest::{ @@ -625,7 +625,7 @@ fn parse_native_pod_descriptor( value: &serde_json::Value, ) -> Result { let object = value.as_object().expect("pod descriptor is an object"); - let allowed = ["kind", "name", "fields"]; + let allowed = ["kind", "name", "fields", "source"]; for key in object.keys() { if !allowed.contains(&key.as_str()) { return Err(invalid_native_abi_error( @@ -639,7 +639,7 @@ fn parse_native_pod_descriptor( } } - let name = match object.get("name") { + let mut name = match object.get("name") { Some(v) => Some( v.as_str() .filter(|s| !s.trim().is_empty()) @@ -658,23 +658,92 @@ fn parse_native_pod_descriptor( None => None, }; - let fields_value = object.get("fields").ok_or_else(|| { - invalid_native_abi_error( - package_json, - function_index, - function_name, - slot, - &value.to_string(), - "pod descriptor requires a `fields` array", - ) - })?; + let manifest_fields = object + .get("fields") + .map(|fields_value| { + parse_native_pod_fields( + package_json, + function_index, + function_name, + slot, + fields_value, + ) + }) + .transpose()?; + let source_pod = object + .get("source") + .map(|source| { + let source = source.as_str().filter(|s| !s.trim().is_empty()).ok_or_else(|| { + invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + "pod descriptor `source` must be a non-empty relative path and exported type, such as `./src/native.ts#Point`", + ) + })?; + parse_source_pod_contract(package_json, source, name.as_deref()).map_err(|reason| { + invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + &reason, + ) + }) + }) + .transpose()?; + if name.is_none() { + name = source_pod.as_ref().and_then(|pod| pod.name.clone()); + } + + let fields = match (manifest_fields, source_pod) { + (Some(fields), Some(source)) => { + if let Some(reason) = pod_contract_drift(&fields, &source.fields, "") { + return Err(invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + &format!("POD manifest/source contract drift: {reason}"), + )); + } + fields + } + (Some(fields), None) => fields, + (None, Some(source)) => source.fields, + (None, None) => { + return Err(invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + "pod descriptor requires either a `fields` array or a `source` reference", + )); + } + }; + + Ok(NativeAbiType::Pod(NativePodAbi { name, fields })) +} + +fn parse_native_pod_fields( + package_json: &Path, + function_index: usize, + function_name: &str, + slot: &str, + fields_value: &serde_json::Value, +) -> Result> { let fields_array = fields_value.as_array().ok_or_else(|| { invalid_native_abi_error( package_json, function_index, function_name, slot, - &value.to_string(), + &fields_value.to_string(), "pod descriptor `fields` must be an array", ) })?; @@ -684,7 +753,7 @@ fn parse_native_pod_descriptor( function_index, function_name, slot, - &value.to_string(), + &fields_value.to_string(), "pod descriptor `fields` must contain at least one field", )); } @@ -778,7 +847,7 @@ fn parse_native_pod_descriptor( function_name, &format!("{slot}.fields[{field_index}].type"), &ty.to_string(), - "pod field type must be one of i32, i64, u32, u64, usize, f32, f64, number, buffer_len, handle_id, or nested pod", + "pod field type must be a fixed-width scalar (i8/i16/i32/i64/u8/u16/u32/u64/isize/usize/f32/f64), number, buffer_len, handle_id, or nested pod", )); } fields.push(NativePodFieldAbi { @@ -787,7 +856,224 @@ fn parse_native_pod_descriptor( }); } - Ok(NativeAbiType::Pod(NativePodAbi { name, fields })) + Ok(fields) +} + +fn parse_source_pod_contract( + package_json: &Path, + source_reference: &str, + fallback_name: Option<&str>, +) -> Result { + let (source_path, export_name) = match source_reference.rsplit_once('#') { + Some((path, export)) if !path.is_empty() && !export.is_empty() => (path, export), + Some(_) => { + return Err("pod descriptor `source` must use `#`".into()) + } + None => ( + source_reference, + fallback_name.ok_or_else(|| { + "pod descriptor `source` without `#ExportName` requires a non-empty `name`" + .to_string() + })?, + ), + }; + let relative = Path::new(source_path); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err("pod descriptor `source` must stay inside the native package".into()); + } + let package_dir = package_json + .parent() + .ok_or_else(|| "native package.json has no parent directory".to_string())?; + let canonical_package = package_dir + .canonicalize() + .map_err(|error| format!("could not resolve native package directory: {error}"))?; + let source_file = package_dir.join(relative); + let canonical_source = source_file.canonicalize().map_err(|error| { + format!( + "could not read POD source `{}`: {error}", + source_file.display() + ) + })?; + if !canonical_source.starts_with(&canonical_package) { + return Err("pod descriptor `source` must stay inside the native package".into()); + } + let source = fs::read_to_string(&canonical_source).map_err(|error| { + format!( + "could not read POD source `{}`: {error}", + canonical_source.display() + ) + })?; + let filename = canonical_source.to_string_lossy(); + let ast = perry_parser::parse_typescript(&source, &filename) + .map_err(|error| format!("could not parse POD source `{filename}`: {error}"))?; + perry_hir::set_current_module_source(source); + let lowered = perry_hir::lower_module(&ast, source_path, &filename); + perry_hir::clear_current_module_source(); + let hir = + lowered.map_err(|error| format!("could not lower POD source `{filename}`: {error}"))?; + perry_hir::exported_native_pod_abi(&hir, export_name) + .map_err(|error| format!("invalid POD source `{source_reference}`: {error}")) +} + +fn pod_contract_drift( + manifest: &[NativePodFieldAbi], + source: &[NativePodFieldAbi], + prefix: &str, +) -> Option { + if manifest.len() != source.len() { + return Some(format!( + "{prefix}field count is {} in the manifest but {} in source", + manifest.len(), + source.len() + )); + } + for (index, (manifest_field, source_field)) in manifest.iter().zip(source).enumerate() { + if manifest_field.name != source_field.name { + return Some(format!( + "{prefix}field {index} is `{}` in the manifest but `{}` in source (field order is ABI-significant)", + manifest_field.name, source_field.name + )); + } + match (&manifest_field.ty, &source_field.ty) { + (NativeAbiType::Pod(manifest_pod), NativeAbiType::Pod(source_pod)) => { + let nested_prefix = format!("{prefix}{}.", manifest_field.name); + if let Some(reason) = + pod_contract_drift(&manifest_pod.fields, &source_pod.fields, &nested_prefix) + { + return Some(reason); + } + } + (manifest_ty, source_ty) if manifest_ty != source_ty => { + return Some(format!( + "{prefix}field `{}` is `{manifest_ty}` in the manifest but `{source_ty}` in source", + manifest_field.name + )); + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod pod_source_contract_tests { + use super::*; + use serde_json::json; + + fn source_package(source: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("temporary package"); + let package_json = dir.path().join("package.json"); + fs::write(&package_json, "{}").expect("package.json"); + fs::write(dir.path().join("native.ts"), source).expect("native source"); + (dir, package_json) + } + + #[test] + fn source_reference_derives_the_exact_ordered_pod_contract() { + let (_dir, package_json) = source_package( + r#" +import type { pod, i8, u16, isize, f32 } from "perry/native"; +export type Packet = pod<{ tag: i8; length: u16; cursor: isize; weight: f32 }>; +"#, + ); + let descriptor = json!({ + "kind": "pod", + "source": "./native.ts#Packet" + }); + let NativeAbiType::Pod(pod) = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &descriptor, + ) + .expect("source contract") else { + panic!("expected POD descriptor") + }; + assert_eq!(pod.fields[0].ty, NativeAbiType::I8); + assert_eq!(pod.fields[1].ty, NativeAbiType::U16); + assert_eq!(pod.fields[2].ty, NativeAbiType::ISize); + assert_eq!(pod.fields[3].ty, NativeAbiType::F32); + } + + #[test] + fn duplicate_manifest_contract_reports_order_and_width_drift() { + let (_dir, package_json) = source_package( + r#" +import type { pod, u8, u16 } from "perry/native"; +export type Packet = pod<{ tag: u8; length: u16 }>; +"#, + ); + let wrong_order = json!({ + "kind": "pod", + "source": "./native.ts#Packet", + "fields": [ + { "name": "length", "type": "u16" }, + { "name": "tag", "type": "u8" } + ] + }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &wrong_order, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("field order is ABI-significant"), "{error}"); + + let wrong_width = json!({ + "kind": "pod", + "source": "./native.ts#Packet", + "fields": [ + { "name": "tag", "type": "u16" }, + { "name": "length", "type": "u16" } + ] + }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &wrong_width, + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("is `u16` in the manifest but `u8` in source"), + "{error}" + ); + } + + #[test] + fn source_reference_cannot_escape_the_package() { + let (_dir, package_json) = source_package( + r#"import type { pod, u8 } from "perry/native"; +export type Packet = pod<{ tag: u8 }>;"#, + ); + let descriptor = json!({ "kind": "pod", "source": "../native.ts#Packet" }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &descriptor, + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("must stay inside the native package"), + "{error}" + ); + } } fn invalid_native_abi_error( diff --git a/docs/api/manifest.schema.json b/docs/api/manifest.schema.json index 2db9df7492..1d8a093a1f 100644 --- a/docs/api/manifest.schema.json +++ b/docs/api/manifest.schema.json @@ -95,12 +95,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -125,12 +131,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -155,12 +167,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -359,7 +377,11 @@ "abiPodDescriptor": { "type": "object", "additionalProperties": false, - "required": ["kind", "fields"], + "required": ["kind"], + "anyOf": [ + { "required": ["fields"] }, + { "required": ["source"] } + ], "properties": { "kind": { "const": "pod" }, "name": { @@ -373,13 +395,23 @@ "minItems": 1, "items": { "$ref": "#/$defs/abiPodFieldDescriptor" }, "description": "Ordered C-layout fields. Field order is part of the ABI." + }, + "source": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+?(?:#[^#]+)?$", + "description": "Package-relative TypeScript source contract, normally ./path.ts#ExportedPod. If fields are also present Perry rejects any ABI drift." } } }, "abiPodAndCountDescriptor": { "type": "object", "additionalProperties": false, - "required": ["kind", "fields"], + "required": ["kind"], + "anyOf": [ + { "required": ["fields"] }, + { "required": ["source"] } + ], "properties": { "kind": { "const": "pod+count" }, "name": { @@ -393,6 +425,12 @@ "minItems": 1, "items": { "$ref": "#/$defs/abiPodFieldDescriptor" }, "description": "Ordered C-layout record fields. The JS argument supplies a packed record view and Perry lowers it to data pointer plus record count." + }, + "source": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+?(?:#[^#]+)?$", + "description": "Package-relative TypeScript source contract, normally ./path.ts#ExportedPod. If fields are also present Perry rejects any ABI drift." } } }, @@ -431,10 +469,16 @@ "abiPodFieldString": { "type": "string", "enum": [ + "i8", + "i16", "i32", "i64", + "u8", + "byte", + "u16", "u32", "u64", + "isize", "usize", "f32", "f64", @@ -452,10 +496,16 @@ "properties": { "kind": { "enum": [ + "i8", + "i16", "i32", "i64", + "u8", + "byte", + "u16", "u32", "u64", + "isize", "usize", "f32", "f64", @@ -483,12 +533,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", diff --git a/docs/src/language/native-values.md b/docs/src/language/native-values.md index a625af381a..67b46b71e8 100644 --- a/docs/src/language/native-values.md +++ b/docs/src/language/native-values.md @@ -56,8 +56,8 @@ arena.dispose(); ## Supported scalar layouts -The first public slice exposes the native representations the POD and native -ABI verifier already supports: +The public profile exposes the native representations the POD and native ABI +verifier supports: | Type | Native representation | |---|---| @@ -105,9 +105,10 @@ The scalar aliases establish representation inside a `pod` layout and at supported native ABI boundaries. A matching checked conversion may initialize a POD field from a dynamic value without forcing the whole record back to an ordinary object; the conversion guard runs before the value enters the native -record. They do not change the semantics of -standalone TypeScript arithmetic. Guaranteed native lanes across -general-purpose collections are a later part of the native value profile. +record. They do not change standalone TypeScript arithmetic: operators still +follow ordinary JavaScript number rules unless an explicit checked conversion +is used. The brand records intent for POD layout and native boundaries; it is +not a second runtime number object. ## POD records @@ -130,6 +131,30 @@ POD layout uses the target's native byte order. It does not define a portable serialization format; use `DataView` or another explicit encoder when stored or transmitted bytes require a specified endianness. +POD assignment has value semantics. `const copy = header` snapshots the +declared scalar fields into independent storage, so later property writes do +not alias the original. Passing a standalone POD to an ordinary function also +passes an independent value, even when the compiler would otherwise inline +that function. Nested object initializers are flattened recursively according +to the declared layout. `PodView` is different: it is an explicit view over +arena storage and aliases that storage by design. + +## Materialization and optimization guarantees + +The checked value and layout behavior above is stable language contract. The +compiler may keep a proven POD local or scalar in native storage, but that is +an optimization rather than an observable promise. Passing values through an +ordinary TypeScript function, array, object, or other managed API may +materialize JavaScript-compatible numbers or objects. Materialization must +preserve the checked value; in particular, no `i64`, `u64`, `isize`, or +`usize` conversion can silently introduce an imprecise JavaScript number. + +At a `perry.nativeLibrary` boundary, manifest descriptors restore the exact C +ABI width and signedness. A manifest POD may reference the exported TypeScript +`pod` declaration; compilation and `perry native validate` reject drift in +field type or order before generating a call. See [Native Library Manifest +v1](../native-libraries/manifest-v1.md#functions). + ## Arena ownership `NativeArena.alloc` owns a fixed native allocation. `view` creates a typed diff --git a/docs/src/native-libraries/manifest-v1.md b/docs/src/native-libraries/manifest-v1.md index f5e7a2bdf5..a32d0361cb 100644 --- a/docs/src/native-libraries/manifest-v1.md +++ b/docs/src/native-libraries/manifest-v1.md @@ -121,8 +121,9 @@ Existing string spellings remain valid. The canonical descriptor vocabulary is: ```text -jsvalue, string, json, bool, i32, i64, i64_str, u32, u64, usize, -f32, f64, number, ptr, buffer_len, buffer+len, handle, +jsvalue, string, json, bool, i8, i16, i32, i64, i64_str, +u8, byte, u16, u32, u64, isize, usize, f32, f64, number, +ptr, buffer_len, buffer+len, handle, promise, pod, void ``` @@ -153,8 +154,9 @@ Descriptors with metadata may also use object form: { "kind": "pod", "name": "Packet", + "source": "./src/native.ts#Packet", "fields": [ - { "name": "tag", "type": "u32" }, + { "name": "tag", "type": "u8" }, { "name": "count", "type": "usize" }, { "name": "weight", "abi": { "kind": "f32" } } ] @@ -183,16 +185,35 @@ C-layout storage and pass to native code as a pointer. The `fields` array is ordered, and field order is part of the ABI. Each field must have a non-empty `name` and exactly one of `type` or `abi`. +Instead of repeating the record, `source` may reference an exported +`pod` alias in the same package: + +```json +{ "kind": "pod", "source": "./src/native.ts#Packet" } +``` + +The path must be package-relative and cannot escape the package. Perry derives +the ordered fields from source during compilation and `perry native validate`. +When both `source` and `fields` are present, they must match recursively; +width, signedness, field order, and nested-record drift are reported before +native code is called. A source without `#ExportName` uses the descriptor's +`name`. The referenced declaration must be an exported, non-generic `pod` +alias whose record is closed and contains no optional, managed, or pointerful +fields. + POD field types are restricted to numeric ABI scalars that have stable C layout: ```text -i32, i64, u32, u64, usize, f32, f64, number, buffer_len +i8, i16, i32, i64, u8, byte, u16, u32, u64, isize, usize, +f32, f64, number, buffer_len, handle_id, nested pod ``` -`number` aliases `f64`; `buffer_len` is a `u32` byte-length scalar. +`byte` aliases `u8`, `number` aliases `f64`, and `buffer_len` is a `u32` +byte-length scalar. `handle_id` is a pointer-free integer identifier; it is +not an owned or borrowed `handle`. Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, -`bool`, `ptr`, `buffer+len`, `handle`, `promise`, nested `pod`, and +`bool`, `ptr`, `buffer+len`, `handle`, `promise`, and `void` are rejected in POD fields. ### Param types @@ -203,11 +224,16 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"string"` | `*const StringHeader` | `string` | | `"json"` | `*const StringHeader` | any JSON-serializable value (`JSON.stringify`d at the callsite) | | `"bool"` | `i32` truthy flag | `boolean` | -| `"i32"` | `i32` | `number` truncated to signed 32-bit | -| `"i64"` | `i64` | `number` converted to signed 64-bit | -| `"u32"` | `u32` | `number` converted to unsigned 32-bit | -| `"u64"` | `u64` | `number` converted to unsigned 64-bit | -| `"usize"` | `usize` | `number` converted to pointer-sized unsigned integer | +| `"i8"` | `i8` | checked signed 8-bit `number` | +| `"i16"` | `i16` | checked signed 16-bit `number` | +| `"i32"` | `i32` | checked signed 32-bit `number` | +| `"i64"` | `i64` | checked safe-integer `number` | +| `"u8"` / `"byte"` | `u8` | checked unsigned 8-bit `number` | +| `"u16"` | `u16` | checked unsigned 16-bit `number` | +| `"u32"` | `u32` | checked unsigned 32-bit `number` | +| `"u64"` | `u64` | checked non-negative safe-integer `number` | +| `"isize"` | `isize` | checked pointer-sized signed safe-integer `number` | +| `"usize"` | `usize` | checked pointer-sized unsigned safe-integer `number` | | `"f32"` | `f32` | `number` narrowed to 32-bit float | | `"f64"` / `"number"` | `f64` | `number` | | `"ptr"` | `i64` raw boxed pointer payload | raw pointer escape hatch | @@ -226,10 +252,15 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"ptr"` | `-> *const u8` *(see note)* | `string` legacy pointer return | | `"i64_str"` | `-> i64` | `string` (the `i64` is a `*StringHeader`) | | `"bool"` | `-> i32` | `boolean` | +| `"i8"` | `-> i8` | `number` | +| `"i16"` | `-> i16` | `number` | | `"i32"` | `-> i32` | `number` | | `"i64"` | `-> i64` | `number` | +| `"u8"` / `"byte"` | `-> u8` | `number` | +| `"u16"` | `-> u16` | `number` | | `"u32"` | `-> u32` | `number` | | `"u64"` | `-> u64` | `number` | +| `"isize"` | `-> isize` | `number` | | `"usize"` | `-> usize` | `number` | | `"f32"` | `-> f32` | `number` via explicit `f32 -> f64` materialization | | `"f64"` / `"number"` | `-> f64` | `number` | @@ -238,6 +269,10 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"promise"` / `"promise"` | `-> i64` | JavaScript `Promise` | | `"void"` | `-> ()` | `undefined` | +Signed and unsigned 64-bit returns (including `isize`/`usize`) are checked +before becoming a TypeScript `number`. A value outside JavaScript's exact +safe-integer range throws a `RangeError`; Perry never silently rounds it. + > Note on `"string"` vs. `"i64_str"`: both produce a string on the > TypeScript side, but they differ in how Rust returns the pointer. > Use `"string"` / `"ptr"` when your `extern "C" fn` is declared @@ -260,7 +295,8 @@ verifier-backed C-layout storage. > object. It is opt-in and param-only, so real-string `"string"` params > keep their strict non-string-rejecting check. -Native-only numeric descriptors (`f32`, `u32`, `u64`, `usize`, +Native-only numeric descriptors (`i8` through `i64`, `u8` through `u64`, +`isize`, `usize`, `f32`, `buffer_len`) render as TypeScript `number`. Handles remain opaque GC-managed values, even though native functions still receive and return raw `i64` resource pointers at the ABI boundary. POD parameters diff --git a/test-files/test_parity_native_value_profile.ts b/test-files/test_parity_native_value_profile.ts index 476e135fb9..5863bda19a 100644 --- a/test-files/test_parity_native_value_profile.ts +++ b/test-files/test_parity_native_value_profile.ts @@ -16,6 +16,11 @@ type Narrow = NativeRecord<{ pointerDelta: SignedSize; }>; +type Nested = NativeRecord<{ + outer: Octet; + inner: NativeRecord<{ code: HalfWord; delta: SignedByte }>; +}>; + const size = sizeOf
(); const alignment = alignOf
(); const sequenceOffset = offsetOf
("sequence"); @@ -48,6 +53,16 @@ const convertedHeader: Header = { sequence: LongWord(42), gain: FloatWord(0.1), }; +const originalNested: Nested = { + outer: Octet(7), + inner: { code: HalfWord(513), delta: SignedByte(-8) }, +}; +let copiedNested = originalNested; +copiedNested.outer = Octet(9); +function mutateHeader(value: Header): void { + value.flags = Word(99); +} +mutateHeader(convertedHeader); let rejectedFraction = false; let rejectedType = false; let rejectedOctet = false; @@ -125,6 +140,8 @@ console.log( ":" + convertedSignedHalfWord + ":" + convertedSignedSize + ":" + narrow.delta + ":" + narrow.count + ":" + narrow.offset + ":" + narrow.pointerDelta + ",header=" + convertedHeader.flags + ":" + convertedHeader.sequence + ":" + (convertedHeader.gain > 0.1) + + ",podCopy=" + originalNested.outer + ":" + copiedNested.outer + + ":" + originalNested.inner.code + ":" + copiedNested.inner.delta + ",rejectedFraction=" + rejectedFraction + ",rejectedType=" + rejectedType + ",rejectedOctet=" + rejectedOctet + From c30815042a3cd0c18cd981e5b89c81e11190abaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 11:50:34 +0200 Subject: [PATCH 2/2] docs(changelog): note native value profile --- changelog.d/8720-native-value-profile.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/8720-native-value-profile.md diff --git a/changelog.d/8720-native-value-profile.md b/changelog.d/8720-native-value-profile.md new file mode 100644 index 0000000000..e2cd59e6f4 --- /dev/null +++ b/changelog.d/8720-native-value-profile.md @@ -0,0 +1,6 @@ +### Added + +- Stabilized the native value profile with checked exact-width scalars, + source-linked and nested POD layouts, and value-copy semantics across local + assignments and ordinary function boundaries. Invalid or imprecise native + crossings now fail explicitly instead of truncating or losing precision.