From 3bf23f909c68353f2d9bcfa620e3b8060d75fe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 12:30:44 +0200 Subject: [PATCH 1/4] feat: expose initial perry native value profile --- crates/perry-api-manifest/src/entries.rs | 2 + crates/perry-codegen/src/expr/property_get.rs | 15 +++ .../src/runtime_decls/strings_part2.rs | 1 + .../src/destructuring/var_decl/type_infer.rs | 4 +- crates/perry-hir/src/lower/context.rs | 29 ++++++ .../expr_call/intrinsics/native_arena.rs | 45 +++++++-- .../perry-hir/src/lower/lowering_context.rs | 9 ++ crates/perry-hir/src/lower/module_decl.rs | 2 + .../module_decl/native_profile_import.rs | 31 ++++++ crates/perry-hir/src/lower_types.rs | 16 +++- crates/perry-hir/src/lower_types/extract.rs | 13 ++- crates/perry-hir/tests/native_arena.rs | 70 ++++++++++++++ crates/perry-runtime/src/native_arena.rs | 11 ++- crates/perry/src/commands/types.rs | 23 ++++- docs/src/SUMMARY.md | 1 + docs/src/language/native-values.md | 94 +++++++++++++++++++ tests/fixtures/native_value_profile.ts | 30 ++++++ tests/test_native_value_profile.sh | 42 +++++++++ types/perry/native/index.d.ts | 81 ++++++++++++++++ types/perry/native/package.json | 3 + 20 files changed, 502 insertions(+), 20 deletions(-) create mode 100644 crates/perry-hir/src/lower/module_decl/native_profile_import.rs create mode 100644 docs/src/language/native-values.md create mode 100644 tests/fixtures/native_value_profile.ts create mode 100755 tests/test_native_value_profile.sh create mode 100644 types/perry/native/index.d.ts create mode 100644 types/perry/native/package.json diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 57a969b44b..60529e18f8 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -138,6 +138,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "perry/i18n", // internationalization runtime "worker_threads", // (Node builtin) OS-thread workers "perry/thread", // perry-native threading (parallelMap/spawn) + "perry/native", // exact native layouts and arena-backed POD values // `perry/gc` — explicit GC control (collect / minor / idleHint). // Served entirely by perry-runtime; a no-op-style Perry-native // surface like `perry/thread` (doesn't resolve under Node/Bun). @@ -254,6 +255,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ "perry/widget", "perry/i18n", "perry/thread", + "perry/native", "perry/gc", "perry/media", "perry/audio", diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index a9fdaeeb88..b24b77f4b7 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -131,6 +131,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } match expr { + Expr::PropertyGet { + object, property, .. + } if property == "length" + && matches!(object.as_ref(), Expr::LocalGet(id) if ctx.pod_views.contains_key(id)) => + { + // A NativePodView is a verifier-owned GC object, not a normal JS + // object with a property table. Read its record count through the + // validating runtime helper so the public `PodView.length` + // declaration has the promised observable behavior and disposed + // owners are still rejected. + let view = lower_expr(ctx, object)?; + Ok(ctx + .block() + .call(DOUBLE, "js_native_pod_view_length", &[(DOUBLE, &view)])) + } Expr::PropertyGet { object, property, .. } if matches!(object.as_ref(), Expr::LocalGet(id) diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index ff2fd7c670..4b0a74fc12 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -125,6 +125,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_native_arena_alloc", I64, &[I64]); module.declare_function("js_native_arena_view", I64, &[I64, I32, I64, I64]); module.declare_function("js_native_pod_view", I64, &[I64, I64, I64, I64, I64, I64]); + module.declare_function("js_native_pod_view_length", DOUBLE, &[DOUBLE]); module.declare_function("js_native_abi_check_pod_view_data_ptr", PTR, &[DOUBLE, I64]); module.declare_function( "js_native_abi_check_pod_view_record_count", diff --git a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs index 793010cb25..952495e3bc 100644 --- a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs +++ b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs @@ -6,7 +6,7 @@ use crate::types::Type; use swc_ecma_ast as ast; use crate::lower::LoweringContext; -use crate::lower_types::{extract_ts_type, infer_type_from_expr}; +use crate::lower_types::{extract_ts_type, extract_ts_type_with_ctx, infer_type_from_expr}; /// #7547: the type of a declaration in a **`for` initializer** /// (`for (let j = 0; …)`). @@ -84,7 +84,7 @@ pub(crate) fn infer_decl_type( let mut ty = ident .type_ann .as_ref() - .map(|ann| extract_ts_type(&ann.type_ann)) + .map(|ann| extract_ts_type_with_ctx(&ann.type_ann, Some(ctx))) .unwrap_or_else(|| { // No type annotation: try local inference from initializer if let Some(init_expr) = &decl.init { diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 4f6eec2a90..478ced762f 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -93,6 +93,7 @@ impl LoweringContext { pending_body_enums: Vec::new(), interfaces: Vec::new(), type_aliases: Vec::new(), + native_profile_type_aliases: HashMap::new(), immutable_locals: HashSet::new(), interface_source_keys: std::collections::HashMap::new(), interface_object_types: std::collections::HashMap::new(), @@ -1288,6 +1289,34 @@ impl LoweringContext { }) } + pub(crate) fn register_native_profile_type_alias( + &mut self, + local_name: String, + imported_name: &str, + ) { + let canonical = match imported_name { + "u32" => "PerryU32", + "u64" => "PerryU64", + "usize" => "PerryUSize", + "i32" => "PerryI32", + "i64" => "PerryI64", + "f32" => "PerryF32", + "f64" => "PerryF64", + "pod" => "PerryPod", + "PodView" => "PerryPodView", + "NativeArena" => "NativeArena", + _ => return, + }; + self.native_profile_type_aliases + .insert(local_name, canonical.to_string()); + } + + pub(crate) fn resolve_native_profile_type_alias(&self, name: &str) -> Option<&str> { + self.native_profile_type_aliases + .get(name) + .map(String::as_str) + } + /// #wall5: shadow a native-module name for the current scope IF it is a /// registered module (so a local/param of that name resolves as a value, not /// the module). No-op for non-module names. Restore with diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs index 5be149de1c..f92fd43d9b 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs @@ -14,6 +14,25 @@ fn pod_layout_intrinsic_is_shadowed(ctx: &LoweringContext, name: &str) -> bool { || ctx.lookup_imported_func(name).is_some() } +fn pod_layout_intrinsic_name(ctx: &LoweringContext, local_name: &str) -> Option<&'static str> { + if matches!(local_name, "sizeof" | "alignof" | "offsetof") + && !pod_layout_intrinsic_is_shadowed(ctx, local_name) + { + return match local_name { + "sizeof" => Some("sizeof"), + "alignof" => Some("alignof"), + "offsetof" => Some("offsetof"), + _ => None, + }; + } + match ctx.lookup_native_module(local_name) { + Some(("perry/native", Some("sizeof"))) => Some("sizeof"), + Some(("perry/native", Some("alignof"))) => Some("alignof"), + Some(("perry/native", Some("offsetof"))) => Some("offsetof"), + _ => None, + } +} + fn explicit_single_type_arg( ctx: &LoweringContext, call: &ast::CallExpr, @@ -75,13 +94,10 @@ pub(crate) fn try_pod_layout_constants( let ast::Expr::Ident(ident) = callee_expr.as_ref() else { return Ok(None); }; - let name = ident.sym.as_ref(); - if !matches!(name, "sizeof" | "alignof" | "offsetof") { + let local_name = ident.sym.as_ref(); + let Some(name) = pod_layout_intrinsic_name(ctx, local_name) else { return Ok(None); - } - if pod_layout_intrinsic_is_shadowed(ctx, name) { - return Ok(None); - } + }; if has_spread { crate::lower_bail!(call.span, "{}(...) does not accept spread arguments", name); } @@ -167,6 +183,15 @@ fn native_arena_global_is_shadowed(ctx: &LoweringContext) -> bool { || ctx.lookup_class("NativeArena").is_some() } +fn is_native_arena_constructor_ident(ctx: &LoweringContext, ident: &ast::Ident) -> bool { + let name = ident.sym.as_ref(); + (name == "NativeArena" && !native_arena_global_is_shadowed(ctx)) + || matches!( + ctx.lookup_native_module(name), + Some(("perry/native", Some("NativeArena"))) + ) +} + fn native_memory_global_is_shadowed(ctx: &LoweringContext) -> bool { ctx.lookup_local("NativeMemory").is_some() || ctx.lookup_func("NativeMemory").is_some() @@ -242,9 +267,8 @@ fn is_native_arena_alloc_call(ctx: &LoweringContext, call: &ast::CallExpr) -> bo let ast::Expr::Member(member) = callee_expr.as_ref() else { return false; }; - matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if obj.sym.as_ref() == "NativeArena") + matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if is_native_arena_constructor_ident(ctx, obj)) && matches!(&member.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "alloc") - && !native_arena_global_is_shadowed(ctx) } fn native_arena_owner_type(ty: &crate::types::Type) -> bool { @@ -286,8 +310,9 @@ pub(crate) fn try_native_arena_public_api( }; let method = prop.sym.as_ref(); - if matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if obj.sym.as_ref() == "NativeArena") { - if method != "alloc" || native_arena_global_is_shadowed(ctx) { + if matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if is_native_arena_constructor_ident(ctx, obj)) + { + if method != "alloc" { return Ok(None); } if has_spread { diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 3a7f50a224..26a5fa7998 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -171,6 +171,15 @@ pub struct LoweringContext { pub(crate) interfaces: Vec<(String, InterfaceId)>, /// Type aliases: name -> (id, type_params, aliased_type) pub(crate) type_aliases: Vec<(String, TypeAliasId, Vec, Type)>, + /// Type names imported from `perry/native`: local name -> the canonical + /// compiler marker (`u32` -> `PerryU32`, `pod` -> `PerryPod`, ...). + /// + /// Native modules do not have source HIR declarations, so type-only + /// imports are otherwise erased before type extraction. Keeping this + /// small alias table lets the public module spellings reuse the existing + /// native-representation and POD pipeline without teaching every + /// downstream pass a second vocabulary. + pub(crate) native_profile_type_aliases: HashMap, /// Issue #179 typed-parse: interface name → field names in AST /// source order. Populated alongside `interfaces` during /// `lower_interface_decl`. `ObjectType::properties` is a HashMap diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 407a3e72f8..9a071b8106 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -15,6 +15,7 @@ use crate::ir::*; // Topical sub-modules extracted from this file (issue #1435 — pure code move). mod namespace; mod native_default_import; +mod native_profile_import; // Re-export moved items so existing `crate::...` / `super::*` call paths keep // resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`. @@ -125,6 +126,7 @@ pub(crate) fn lower_module_decl( // method registry — `obj.method()` worked only via the // CLASS_VTABLE_REGISTRY runtime fallback (#392 followup) and // `typeof obj.method` returned `"undefined"`. Issue #446. + native_profile_import::register_native_profile_type_imports(ctx, &source, import_decl); if import_decl.type_only && is_native { return Ok(()); } diff --git a/crates/perry-hir/src/lower/module_decl/native_profile_import.rs b/crates/perry-hir/src/lower/module_decl/native_profile_import.rs new file mode 100644 index 0000000000..faf204b1f7 --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/native_profile_import.rs @@ -0,0 +1,31 @@ +use swc_ecma_ast as ast; + +use crate::lower::LoweringContext; + +/// Preserve type-only imports from Perry's compiler-owned native profile. +/// Native modules have no source HIR declarations, so these aliases would +/// otherwise be erased before TypeScript annotation extraction sees them. +pub(super) fn register_native_profile_type_imports( + ctx: &mut LoweringContext, + source: &str, + import_decl: &ast::ImportDecl, +) { + if source != "perry/native" { + return; + } + for spec in &import_decl.specifiers { + let ast::ImportSpecifier::Named(named) = spec else { + continue; + }; + let local = named.local.sym.to_string(); + let imported = named + .imported + .as_ref() + .map(|name| match name { + ast::ModuleExportName::Ident(id) => id.sym.to_string(), + ast::ModuleExportName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + }) + .unwrap_or_else(|| local.clone()); + ctx.register_native_profile_type_alias(local, &imported); + } +} diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index a74454f9d0..053534de19 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -54,6 +54,15 @@ fn native_arena_global_is_shadowed(ctx: &LoweringContext) -> bool { || ctx.lookup_class("NativeArena").is_some() } +fn is_native_arena_constructor_ident(ctx: &LoweringContext, ident: &ast::Ident) -> bool { + let name = ident.sym.as_ref(); + (name == "NativeArena" && !native_arena_global_is_shadowed(ctx)) + || matches!( + ctx.lookup_native_module(name), + Some(("perry/native", Some("NativeArena"))) + ) +} + fn native_arena_owner_type(ty: &Type) -> bool { matches!(ty, Type::Named(name) if name == "NativeArena" || name == "NativeArenaOwner") } @@ -62,7 +71,7 @@ fn expr_may_infer_to_native_arena_owner(expr: &ast::Expr, ctx: &LoweringContext) match expr { ast::Expr::Ident(ident) => { let name = ident.sym.as_ref(); - if name == "NativeArena" && !native_arena_global_is_shadowed(ctx) { + if is_native_arena_constructor_ident(ctx, ident) { return true; } ctx.lookup_local_type(name) @@ -81,7 +90,7 @@ fn expr_may_infer_to_native_arena_owner(expr: &ast::Expr, ctx: &LoweringContext) matches!( (member.obj.as_ref(), method.sym.as_ref()), (ast::Expr::Ident(obj), "alloc") - if obj.sym.as_ref() == "NativeArena" && !native_arena_global_is_shadowed(ctx) + if is_native_arena_constructor_ident(ctx, obj) ) } ast::Expr::Member(member) if matches!(member.obj.as_ref(), ast::Expr::This(_)) => { @@ -151,9 +160,8 @@ fn infer_native_arena_call_return_type( }; let method_name = method.sym.as_ref(); - if matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if obj.sym.as_ref() == "NativeArena") + if matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if is_native_arena_constructor_ident(ctx, obj)) && method_name == "alloc" - && !native_arena_global_is_shadowed(ctx) { return Some(Type::Named("NativeArena".to_string())); } diff --git a/crates/perry-hir/src/lower_types/extract.rs b/crates/perry-hir/src/lower_types/extract.rs index 27186b63a6..0fc28b1cf9 100644 --- a/crates/perry-hir/src/lower_types/extract.rs +++ b/crates/perry-hir/src/lower_types/extract.rs @@ -96,7 +96,7 @@ pub(crate) fn extract_ts_type_with_ctx( // Type reference: Array, MyClass, T (type param), etc. TsTypeRef(type_ref) => { - let name = match &type_ref.type_name { + let mut name = match &type_ref.type_name { ast::TsEntityName::Ident(ident) => ident.sym.to_string(), ast::TsEntityName::TsQualifiedName(qname) => { // Qualified names like Foo.Bar @@ -136,6 +136,17 @@ pub(crate) fn extract_ts_type_with_ctx( } } + // `perry/native` is a compiler-owned module, so its declaration + // file is not lowered as source HIR. Canonicalize its imported + // author-facing names here, before generic handling, so both + // `pod` and scalar fields such as `u32` flow through the same + // proven representation path as their legacy `Perry*` spellings. + if let Some(canonical) = + ctx.and_then(|context| context.resolve_native_profile_type_alias(&name)) + { + name = canonical.to_string(); + } + // Check for built-in generic types or generic instantiations if let Some(type_params) = &type_ref.type_params { match name.as_str() { diff --git a/crates/perry-hir/tests/native_arena.rs b/crates/perry-hir/tests/native_arena.rs index d714b3a438..ac7163ab3d 100644 --- a/crates/perry-hir/tests/native_arena.rs +++ b/crates/perry-hir/tests/native_arena.rs @@ -311,6 +311,76 @@ fn pod_layout_constants_lower_to_compile_time_hir_nodes() { )); } +#[test] +fn perry_native_imports_reuse_canonical_pod_lowering() { + let module = lower_src( + r#" + import { + type u32 as Word, + type f32, + type pod as NativeRecord, + type PodView, + NativeArena as Arena, + sizeof as sizeOf, + alignof as alignOf, + offsetof as offsetOf, + } from "perry/native"; + + type Packet = NativeRecord<{ tag: Word; gain: f32; }>; + const packetSize = sizeOf(); + const packetAlign = alignOf(); + const gainOffset = offsetOf("gain"); + const arena = Arena.alloc(packetSize); + const view: PodView = arena.podView(0, 1); + "#, + ) + .expect("perry/native imports should lower through the existing POD pipeline"); + + assert!(matches!( + find_let(&module, "packetSize"), + Stmt::Let { + init: Some(Expr::PodLayoutSizeOf { + ty: Type::Generic { base, .. }, + }), + .. + } if base == "PerryPod" + )); + assert!(matches!( + find_let(&module, "packetAlign"), + Stmt::Let { + init: Some(Expr::PodLayoutAlignOf { + ty: Type::Generic { base, .. }, + }), + .. + } if base == "PerryPod" + )); + assert!(matches!( + find_let(&module, "gainOffset"), + Stmt::Let { + init: Some(Expr::PodLayoutOffsetOf { ty, field_path }), + .. + } if matches!(ty, Type::Generic { base, .. } if base == "PerryPod") + && field_path == &vec!["gain".to_string()] + )); + assert!(matches!( + find_let(&module, "arena"), + Stmt::Let { + init: Some(Expr::NativeArenaAlloc(_)), + ty: Type::Named(name), + .. + } if name == "NativeArena" + )); + assert!(matches!( + find_let(&module, "view"), + Stmt::Let { + init: Some(Expr::NativePodView { .. }), + ty: Type::Generic { base, type_args }, + .. + } if base == "PerryPodView" + && matches!(type_args.as_slice(), [Type::Generic { base, .. }] if base == "PerryPod") + )); +} + #[test] fn native_arena_pod_layout_constants_preserve_generic_pod_type_param() { let module = lower_src( diff --git a/crates/perry-runtime/src/native_arena.rs b/crates/perry-runtime/src/native_arena.rs index 1b00248cbd..5e8b74fa38 100644 --- a/crates/perry-runtime/src/native_arena.rs +++ b/crates/perry-runtime/src/native_arena.rs @@ -409,18 +409,24 @@ fn strict_pod_view_from_value(value: f64, expected_layout_id: u64) -> *const Nat 0 }; if raw_ptr == 0 { - throw_type_error(b"Expected NativePodView for native pod+count parameter"); + throw_type_error(b"Expected NativePodView"); } let view = raw_ptr as *const NativePodViewHeader; unsafe { validate_pod_view_alive(view); - if (*view).layout_id != expected_layout_id { + if expected_layout_id != 0 && (*view).layout_id != expected_layout_id { throw_type_error(b"NativePodView layout does not match manifest pod+count parameter"); } } view } +#[no_mangle] +pub extern "C" fn js_native_pod_view_length(value: f64) -> f64 { + let view = strict_pod_view_from_value(value, 0); + unsafe { (*view).record_count as f64 } +} + #[no_mangle] pub extern "C" fn js_native_abi_check_pod_view_data_ptr( value: f64, @@ -554,6 +560,7 @@ mod tests { unsafe { (*owner).data.add(8) as *const u8 } ); assert_eq!(js_native_abi_check_pod_view_record_count(boxed, 0x1234), 3); + assert_eq!(js_native_pod_view_length(boxed), 3.0); assert!(catch_runtime_throw(|| { let _ = js_native_abi_check_pod_view_data_ptr(boxed, 0x5678); diff --git a/crates/perry/src/commands/types.rs b/crates/perry/src/commands/types.rs index ccd822c474..85b2353aad 100644 --- a/crates/perry/src/commands/types.rs +++ b/crates/perry/src/commands/types.rs @@ -29,6 +29,7 @@ const PERRY_AUDIO_DTS: &str = include_str!("../../../../types/perry/audio/index. const PERRY_TUI_DTS: &str = include_str!("../../../../types/perry/tui/index.d.ts"); const PERRY_WEBASSEMBLY_DTS: &str = include_str!("../../../../types/perry/webassembly/index.d.ts"); const PERRY_BUILD_DTS: &str = include_str!("../../../../types/perry/build/index.d.ts"); +const PERRY_NATIVE_DTS: &str = include_str!("../../../../types/perry/native/index.d.ts"); // Auto-generated stdlib `.d.ts` from the API manifest (#465's // "stretch" deliverable: editor `.d.ts` shipped alongside the @@ -60,6 +61,9 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { // Issue #76 — `perry/build` compile-time intrinsics // (`embedWasm`). Imported via `import { embedWasm } from "perry/build"`. ("build", PERRY_BUILD_DTS), + // Issue #6827 — stable author-facing aliases over Perry's existing + // native scalar, POD-layout, and arena intrinsics. + ("native", PERRY_NATIVE_DTS), ]; // Each sub-module gets index.d.ts @@ -80,7 +84,7 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { if !quiet { println!( - " Created .perry/types/ type stubs (ui, thread, i18n, system, media, audio, tui, webassembly, build, stdlib)" + " Created .perry/types/ type stubs (ui, thread, i18n, system, media, audio, tui, webassembly, build, native, stdlib)" ); } @@ -114,3 +118,20 @@ pub fn run(args: TypesArgs, format: OutputFormat, _use_color: bool) -> Result<() Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_perry_native_type_stub() { + let project = tempfile::tempdir().expect("temporary project"); + write_perry_type_stubs(project.path(), true).expect("write type stubs"); + + let native_stub = project.path().join(".perry/types/perry/native/index.d.ts"); + let source = fs::read_to_string(native_stub).expect("read native type stub"); + assert!(source.contains("export type u32")); + assert!(source.contains("export type pod")); + assert!(source.contains("export declare const NativeArena")); + } +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 4fefff30b5..9d2e6a76b4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ - [Supported Features](language/supported-features.md) - [Type System](language/type-system.md) +- [Native Layout Values](language/native-values.md) - [Decorators](language/decorators.md) - [Limitations](language/limitations.md) diff --git a/docs/src/language/native-values.md b/docs/src/language/native-values.md new file mode 100644 index 0000000000..4760ef11e0 --- /dev/null +++ b/docs/src/language/native-values.md @@ -0,0 +1,94 @@ +# Native Layout Values + +Perry keeps ordinary TypeScript values ordinary. A `number` still has +JavaScript number semantics, and normal objects and arrays remain managed +values. At boundaries where byte width and C-compatible layout are part of +correctness, `perry/native` provides an explicit, opt-in contract. + +```typescript +import { + type u32, + type u64, + type f32, + type pod, + type PodView, + NativeArena, + sizeof, + alignof, + offsetof, +} from "perry/native"; + +type PacketHeader = pod<{ + flags: u32; + sequence: u64; + gain: f32; +}>; + +const byteLength = sizeof(); +const alignment = alignof(); +const sequenceOffset = offsetof("sequence"); + +const arena = NativeArena.alloc(byteLength * 16); +const headers: PodView = arena.podView(0, 16); + +console.log(byteLength, alignment, sequenceOffset, headers.length); +arena.dispose(); +``` + +## Supported scalar layouts + +The first public slice exposes the native representations the POD and native +ABI verifier already supports: + +| Type | Native representation | +|---|---| +| `i32` | signed 32-bit integer | +| `i64` | signed 64-bit integer | +| `u32` | unsigned 32-bit integer | +| `u64` | unsigned 64-bit integer | +| `usize` | target pointer-sized unsigned integer | +| `f32` | IEEE-754 binary32 | +| `f64` | IEEE-754 binary64 | + +These names replace the internal-looking `PerryI32`, `PerryU64`, +`PerryF32`, and related spellings in new application code. The old names +remain available as compatibility aliases. + +The scalar aliases currently establish representation inside a `pod` layout +and at supported native ABI boundaries. They do not change the semantics of +standalone TypeScript arithmetic. Checked scalar conversion functions, +additional widths (`i8`, `i16`, `u8`, `u16`, and `isize`), and guaranteed +native lanes across general-purpose collections are later parts of the native +value profile. + +## POD records + +`pod` asks the compiler to verify a C-layout record. The supported field +set is deliberately narrow: + +- the scalar aliases listed above; +- ordinary `number`, represented as `f64`; +- nested `pod` records; and +- compatible legacy `Perry*` native scalar markers. + +Managed or pointer-bearing values such as `string`, normal arrays, class +instances, closures, promises, maps, and sets are rejected as POD fields. +Field order is source order. Perry computes target C alignment and padding; +`sizeof`, `alignof`, and `offsetof` become compile-time constants and require +an explicit POD type argument. `offsetof` also requires a string-literal field +path, with dotted paths accepted for nested records. + +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. + +## Arena ownership + +`NativeArena.alloc` owns a fixed native allocation. `view` creates a typed +array view and `podView` creates a `PodView` over that allocation. Byte +offsets, lengths, alignment, and disposal are checked by the existing native +memory verifier and runtime guards. + +Call `dispose()` when the allocation is no longer needed. Access through a +view after disposal is an error. `PodView` is currently exposed as read-only; +mutable borrowed POD views are not yet part of the public contract. diff --git a/tests/fixtures/native_value_profile.ts b/tests/fixtures/native_value_profile.ts new file mode 100644 index 0000000000..6ca208bffb --- /dev/null +++ b/tests/fixtures/native_value_profile.ts @@ -0,0 +1,30 @@ +import { + type u32, + type u64, + type pod, + type PodView, + NativeArena, + sizeof, + alignof, + offsetof, +} from "perry/native"; + +type Header = pod<{ + flags: u32; + sequence: u64; +}>; + +const size = sizeof
(); +const alignment = alignof
(); +const sequenceOffset = offsetof
("sequence"); +const arena = NativeArena.alloc(size); +const headers: PodView
= arena.podView(0, 1); + +console.log( + "size=" + size + + ",align=" + alignment + + ",sequence=" + sequenceOffset + + ",length=" + headers.length, +); + +arena.dispose(); diff --git a/tests/test_native_value_profile.sh b/tests/test_native_value_profile.sh new file mode 100755 index 0000000000..a28f3c797e --- /dev/null +++ b/tests/test_native_value_profile.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# End-to-end regression for issue #6827's first public native-value slice: +# `perry/native` imports must resolve without a runtime module and lower to the +# existing verifier-backed POD layout and NativeArena intrinsics. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$SCRIPT_DIR/.." +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +if [ -z "${PERRY:-}" ]; then + cargo build -q -p perry + PERRY="$REPO_ROOT/target/debug/perry" +fi + +case "$PERRY" in + /*) ;; + *) PERRY="$(pwd)/$PERRY" ;; +esac + +if [ ! -x "$PERRY" ]; then + echo "FAIL: perry binary not found at $PERRY" + exit 1 +fi + +cp "$SCRIPT_DIR/fixtures/native_value_profile.ts" "$TEST_TMPDIR/main.ts" + +cd "$TEST_TMPDIR" +"$PERRY" compile main.ts --output native_value_profile --no-cache >/dev/null + +EXPECTED="size=16,align=8,sequence=8,length=1" +ACTUAL=$(./native_value_profile) +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "FAIL: perry/native output mismatch" + echo "Expected: $EXPECTED" + echo "Actual: $ACTUAL" + exit 1 +fi + +echo "PASS" diff --git a/types/perry/native/index.d.ts b/types/perry/native/index.d.ts new file mode 100644 index 0000000000..1eff775745 --- /dev/null +++ b/types/perry/native/index.d.ts @@ -0,0 +1,81 @@ +// Type declarations for `perry/native` — explicit native layouts and owned +// arena storage. These public names map to Perry's existing verifier-backed +// native representation pipeline; the legacy ambient `Perry*` names remain +// available for compatibility. + +/** Exact-width signed 32-bit integer when used in a native/POD contract. */ +export type i32 = number & { readonly __perryI32?: never }; + +/** Exact-width signed 64-bit integer when used in a native/POD contract. */ +export type i64 = number & { readonly __perryI64?: never }; + +/** Exact-width unsigned 32-bit integer when used in a native/POD contract. */ +export type u32 = number & { readonly __perryU32?: never }; + +/** Exact-width unsigned 64-bit integer when used in a native/POD contract. */ +export type u64 = number & { readonly __perryU64?: never }; + +/** Target pointer-sized unsigned integer in a native/POD contract. */ +export type usize = number & { readonly __perryUSize?: never }; + +/** IEEE-754 binary32 value when used in a native/POD contract. */ +export type f32 = number & { readonly __perryF32?: never }; + +/** IEEE-754 binary64 value when used in a native/POD contract. */ +export type f64 = number & { readonly __perryF64?: never }; + +/** + * A record with compiler-verified C field order, alignment, and padding. + * Accepted fields are native scalar aliases, nested `pod` records, and the + * compatible legacy `Perry*` scalar markers. + */ +export type pod = T & { readonly __perryPod?: never }; + +/** A read-only indexed view of POD records stored in a `NativeArena`. */ +export interface PodView> { + readonly length: number; + readonly [index: number]: T; + readonly __perryPodView?: never; +} + +/** Compile-time size, in bytes, of a verified POD record. */ +export declare function sizeof>(): number; + +/** Compile-time alignment, in bytes, of a verified POD record. */ +export declare function alignof>(): number; + +/** + * Compile-time byte offset of a field. Nested fields use a dotted path such + * as `"header.flags"`; the path must be a string literal. + */ +export declare function offsetof>(field: string): number; + +/** Owned native allocation used to create typed-array and POD views. */ +export interface NativeArena { + view(kind: typeof Int8Array, byteOffset: number, length: number): Int8Array; + view(kind: typeof Uint8Array, byteOffset: number, length: number): Uint8Array; + view(kind: typeof Uint8ClampedArray, byteOffset: number, length: number): Uint8ClampedArray; + view(kind: typeof Int16Array, byteOffset: number, length: number): Int16Array; + view(kind: typeof Uint16Array, byteOffset: number, length: number): Uint16Array; + view(kind: typeof Int32Array, byteOffset: number, length: number): Int32Array; + view(kind: typeof Uint32Array, byteOffset: number, length: number): Uint32Array; + view(kind: typeof Float32Array, byteOffset: number, length: number): Float32Array; + view(kind: typeof Float64Array, byteOffset: number, length: number): Float64Array; + view(kind: "Int8Array", byteOffset: number, length: number): Int8Array; + view(kind: "Uint8Array", byteOffset: number, length: number): Uint8Array; + view(kind: "Uint8ClampedArray", byteOffset: number, length: number): Uint8ClampedArray; + view(kind: "Int16Array", byteOffset: number, length: number): Int16Array; + view(kind: "Uint16Array", byteOffset: number, length: number): Uint16Array; + view(kind: "Int32Array", byteOffset: number, length: number): Int32Array; + view(kind: "Uint32Array", byteOffset: number, length: number): Uint32Array; + view(kind: "Float32Array", byteOffset: number, length: number): Float32Array; + view(kind: "Float64Array", byteOffset: number, length: number): Float64Array; + podView>(byteOffset: number, count: number): PodView; + dispose(): void; +} + +export interface NativeArenaConstructor { + alloc(byteLength: number): NativeArena; +} + +export declare const NativeArena: NativeArenaConstructor; diff --git a/types/perry/native/package.json b/types/perry/native/package.json new file mode 100644 index 0000000000..1704e6b78b --- /dev/null +++ b/types/perry/native/package.json @@ -0,0 +1,3 @@ +{ + "types": "./index.d.ts" +} From 1cd06361beac18b272bec67e275599add85866a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 12:32:06 +0200 Subject: [PATCH 2/4] docs: add #8032 changelog fragment --- changelog.d/8032-native-value-profile.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/8032-native-value-profile.md diff --git a/changelog.d/8032-native-value-profile.md b/changelog.d/8032-native-value-profile.md new file mode 100644 index 0000000000..daa3492205 --- /dev/null +++ b/changelog.d/8032-native-value-profile.md @@ -0,0 +1,7 @@ +### Native values: expose the first stable `perry/native` profile (#6827) + +Applications can now import fixed-width scalar markers, verified `pod` +records, compile-time layout intrinsics, `PodView`, and `NativeArena` from +`perry/native`. Named import aliases reuse Perry's existing verifier-backed +native layout pipeline, generated project type stubs include the module, and +`PodView.length` reports the checked record count at runtime. From 021d79b791b8c847a8e8027822b59eb209e3af39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 13:54:25 +0200 Subject: [PATCH 3/4] fix: preserve native profile hoisting and aliases --- crates/perry-codegen/src/stmt/let_stmt.rs | 14 +++++ .../native_proof_regressions/pod_manifest.rs | 38 +++++++++++++ crates/perry-hir/src/lower/lower_module_fn.rs | 4 ++ crates/perry-hir/src/lower/module_decl.rs | 2 +- .../module_decl/native_profile_import.rs | 54 ++++++++++++++++--- crates/perry-hir/tests/native_arena.rs | 27 ++++++++++ tests/fixtures/native_value_profile.ts | 40 +++++++------- 7 files changed, 151 insertions(+), 28 deletions(-) diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 3146f8ea43..517936096d 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1826,6 +1826,20 @@ pub(crate) fn lower_let( count_source: pod_view_count_source(ctx, count), }, ); + } else if let perry_hir::Expr::LocalGet(source_id) = init_expr { + // An immutable local-to-local assignment preserves the + // exact NativePodView value. Carry its provenance to the + // alias so `.length` and native pod+count boundaries keep + // using validating helpers instead of the object PIC. + if let Some(source_view) = ctx.pod_views.get(source_id).cloned() { + ctx.pod_views.insert( + id, + crate::native_value::PodViewLocal { + view_slot: slot.clone(), + ..source_view + }, + ); + } } } v 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 e64924be24..6d4e4dc1ca 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs @@ -294,6 +294,44 @@ fn native_pod_view_explicit_public_type_lowers_without_left_hand_annotation() { ); } +#[test] +fn native_pod_view_length_survives_immutable_local_alias() { + let packet_ty = pod_type(&[ + ("tag", Type::Named("PerryU32".to_string())), + ("gain", Type::Named("PerryF32".to_string())), + ]); + let view_ty = pod_view_type(packet_ty); + let module = module( + "native_public_pod_view_length_alias.ts", + vec![ + native_arena_owner_let(1, "owner", int(4096), false), + native_pod_view_let(2, "direct", view_ty.clone(), 1, int(0), int(8)), + Stmt::Let { + id: 3, + name: "alias".to_string(), + ty: view_ty, + mutable: false, + init: Some(local(2)), + }, + Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(local(3)), + property: "length".to_string(), + byte_offset: 0, + })), + ], + ); + + let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + assert!( + ir.contains("call double @js_native_pod_view_length"), + "an immutable PodView alias must use the validating length helper:\n{ir}" + ); + assert!( + !ir.contains("call double @js_object_get_field_ic_miss"), + "a PodView alias must not enter the ordinary object-property PIC:\n{ir}" + ); +} + #[test] fn native_pod_view_embedded_type_survives_any_expected_type() { let packet_ty = pod_type(&[ diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index ca992b511b..c20ba418ab 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -484,6 +484,10 @@ pub fn lower_module_full( // same `__perry_cap_*` symbols. let mut ctx = LoweringContext::with_class_id_start_salted(source_file_path, name, start_class_id); + // Static imports are hoisted. Register `perry/native` type and value + // aliases before any pre-pass extracts annotations or lowers expressions, + // including when the declaration appears after its first source use. + module_decl::native_profile_import::pre_register_native_profile_imports(&mut ctx, ast_module); // #6812 (w16): scan the module lowering actually consumes (post-fold) for // constant-bounded dynamic-key builder widths; `lower_object` attaches // them to the per-site empty-literal classes as alloc_width_hint. diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 9a071b8106..a76a8f15eb 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -15,7 +15,7 @@ use crate::ir::*; // Topical sub-modules extracted from this file (issue #1435 — pure code move). mod namespace; mod native_default_import; -mod native_profile_import; +pub(super) mod native_profile_import; // Re-export moved items so existing `crate::...` / `super::*` call paths keep // resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`. diff --git a/crates/perry-hir/src/lower/module_decl/native_profile_import.rs b/crates/perry-hir/src/lower/module_decl/native_profile_import.rs index faf204b1f7..87352a0362 100644 --- a/crates/perry-hir/src/lower/module_decl/native_profile_import.rs +++ b/crates/perry-hir/src/lower/module_decl/native_profile_import.rs @@ -2,6 +2,40 @@ use swc_ecma_ast as ast; use crate::lower::LoweringContext; +/// Register `perry/native` imports before any source-order type or expression +/// lowering. ES module imports are hoisted, so code before the declaration may +/// use both its type names and value aliases. +pub(in crate::lower) fn pre_register_native_profile_imports( + ctx: &mut LoweringContext, + module: &ast::Module, +) { + for item in &module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) = item else { + continue; + }; + let source = import_decl.src.value.as_str().unwrap_or(""); + if source != "perry/native" { + continue; + } + + register_native_profile_type_imports(ctx, source, import_decl); + if import_decl.type_only { + continue; + } + for spec in &import_decl.specifiers { + let ast::ImportSpecifier::Named(named) = spec else { + continue; + }; + if named.is_type_only { + continue; + } + let local = named.local.sym.to_string(); + let imported = imported_name(named, &local); + ctx.register_native_module(local, "perry/native".to_string(), Some(imported)); + } + } +} + /// Preserve type-only imports from Perry's compiler-owned native profile. /// Native modules have no source HIR declarations, so these aliases would /// otherwise be erased before TypeScript annotation extraction sees them. @@ -18,14 +52,18 @@ pub(super) fn register_native_profile_type_imports( continue; }; let local = named.local.sym.to_string(); - let imported = named - .imported - .as_ref() - .map(|name| match name { - ast::ModuleExportName::Ident(id) => id.sym.to_string(), - ast::ModuleExportName::Str(s) => s.value.as_str().unwrap_or("").to_string(), - }) - .unwrap_or_else(|| local.clone()); + let imported = imported_name(named, &local); ctx.register_native_profile_type_alias(local, &imported); } } + +fn imported_name(named: &ast::ImportNamedSpecifier, local: &str) -> String { + named + .imported + .as_ref() + .map(|name| match name { + ast::ModuleExportName::Ident(id) => id.sym.to_string(), + ast::ModuleExportName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + }) + .unwrap_or_else(|| local.to_string()) +} diff --git a/crates/perry-hir/tests/native_arena.rs b/crates/perry-hir/tests/native_arena.rs index ac7163ab3d..26798c5aea 100644 --- a/crates/perry-hir/tests/native_arena.rs +++ b/crates/perry-hir/tests/native_arena.rs @@ -381,6 +381,33 @@ fn perry_native_imports_reuse_canonical_pod_lowering() { )); } +#[test] +fn perry_native_imports_are_hoisted_before_type_and_value_lowering() { + let module = lower_src( + r#" + type Packet = NativeRecord<{ tag: Word; }>; + const packetSize = sizeOf(); + + import { + type u32 as Word, + type pod as NativeRecord, + sizeof as sizeOf, + } from "perry/native"; + "#, + ) + .expect("perry/native imports should be registered before their first source use"); + + assert!(matches!( + find_let(&module, "packetSize"), + Stmt::Let { + init: Some(Expr::PodLayoutSizeOf { + ty: Type::Generic { base, .. }, + }), + .. + } if base == "PerryPod" + )); +} + #[test] fn native_arena_pod_layout_constants_preserve_generic_pod_type_param() { let module = lower_src( diff --git a/tests/fixtures/native_value_profile.ts b/tests/fixtures/native_value_profile.ts index 6ca208bffb..55a1ebd629 100644 --- a/tests/fixtures/native_value_profile.ts +++ b/tests/fixtures/native_value_profile.ts @@ -1,30 +1,32 @@ -import { - type u32, - type u64, - type pod, - type PodView, - NativeArena, - sizeof, - alignof, - offsetof, -} from "perry/native"; - -type Header = pod<{ - flags: u32; - sequence: u64; +type Header = NativeRecord<{ + flags: Word; + sequence: LongWord; }>; -const size = sizeof
(); -const alignment = alignof
(); -const sequenceOffset = offsetof
("sequence"); -const arena = NativeArena.alloc(size); +const size = sizeOf
(); +const alignment = alignOf
(); +const sequenceOffset = offsetOf
("sequence"); +const arena = Arena.alloc(size); const headers: PodView
= arena.podView(0, 1); +const aliasedHeaders = headers; + +// Static imports are hoisted, including aliases used above their declaration. +import { + type u32 as Word, + type u64 as LongWord, + type pod as NativeRecord, + type PodView, + NativeArena as Arena, + sizeof as sizeOf, + alignof as alignOf, + offsetof as offsetOf, +} from "perry/native"; console.log( "size=" + size + ",align=" + alignment + ",sequence=" + sequenceOffset + - ",length=" + headers.length, + ",length=" + aliasedHeaders.length, ); arena.dispose(); From d85e1c76bf7e30c0587fef4fb42aba1f2c4d07f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 14:59:53 +0200 Subject: [PATCH 4/4] fix: enforce perry native manifest surface --- crates/perry-api-manifest/src/entries.rs | 5 +++ .../perry-api-manifest/src/entries/part_1.rs | 28 ++++++++++++++++ crates/perry-api-manifest/src/lib.rs | 22 +++++++++++++ .../expr_call/intrinsics/native_arena.rs | 4 +-- crates/perry-hir/tests/native_arena.rs | 32 +++++++++++++++++++ .../tests/unimplemented_api_check.rs | 17 ++++++++++ docs/api/perry.d.ts | 13 +++++++- docs/src/api/reference.md | 15 ++++++++- 8 files changed, 131 insertions(+), 5 deletions(-) diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 60529e18f8..e07b2f22d9 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -383,6 +383,11 @@ const fn method_sig_entry( } } +const fn intrinsic(mut entry: ApiEntry) -> ApiEntry { + entry.source = ApiSource::Intrinsic; + entry +} + const fn property(module: &'static str, name: &'static str) -> ApiEntry { ApiEntry { module, diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index a9508418c0..c88e798eae 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1488,6 +1488,34 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ &[p_any("p0")], TypeSpec::Promise, ), + // #6827 — public native layout profile. The three layout helpers are + // compiler intrinsics (their generic POD type is erased before runtime); + // NativeArena is a runtime-backed constructor value. + intrinsic(method_sig( + "perry/native", + "sizeof", + false, + None, + &[], + TypeSpec::Number, + )), + intrinsic(method_sig( + "perry/native", + "alignof", + false, + None, + &[], + TypeSpec::Number, + )), + intrinsic(method_sig( + "perry/native", + "offsetof", + false, + None, + &[p_str("field")], + TypeSpec::Number, + )), + property("perry/native", "NativeArena"), // `perry/gc` — explicit GC control. `collect()` runs a full collection // (same as the global `gc()`), `minor()` runs a nursery-only collection // and returns the freed byte count, `idleHint()` runs a threshold-due diff --git a/crates/perry-api-manifest/src/lib.rs b/crates/perry-api-manifest/src/lib.rs index 1b20168270..16e6bc3c32 100644 --- a/crates/perry-api-manifest/src/lib.rs +++ b/crates/perry-api-manifest/src/lib.rs @@ -548,6 +548,28 @@ mod tests { assert_eq!(bare.is_some(), prefixed.is_some()); } + #[test] + fn perry_native_public_value_surface_is_manifested() { + for name in ["sizeof", "alignof", "offsetof", "NativeArena"] { + assert!( + module_has_public_named_export("perry/native", name), + "perry/native missing public value export {name}" + ); + } + + for name in ["sizeof", "alignof", "offsetof"] { + let entry = module_has_symbol("perry/native", name) + .unwrap_or_else(|| panic!("perry/native missing intrinsic {name}")); + assert!(matches!(entry.kind, ApiKind::Method { .. })); + assert_eq!(entry.source, ApiSource::Intrinsic, "{name}"); + assert_eq!(entry.returns, TypeSpec::Number, "{name}"); + } + + let arena = module_has_symbol("perry/native", "NativeArena") + .expect("perry/native missing NativeArena"); + assert!(matches!(arena.kind, ApiKind::Property)); + } + /// `dotenv.parse` regression guard. /// /// `js_dotenv_parse` has always been implemented and declared to codegen, diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs index f92fd43d9b..84375b46e4 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/native_arena.rs @@ -9,9 +9,7 @@ use crate::lower_types::extract_ts_type_with_ctx; use super::super::super::{lower_expr, LoweringContext}; fn pod_layout_intrinsic_is_shadowed(ctx: &LoweringContext, name: &str) -> bool { - ctx.lookup_local(name).is_some() - || ctx.lookup_func(name).is_some() - || ctx.lookup_imported_func(name).is_some() + ctx.shadows_unqualified_global(name) } fn pod_layout_intrinsic_name(ctx: &LoweringContext, local_name: &str) -> Option<&'static str> { diff --git a/crates/perry-hir/tests/native_arena.rs b/crates/perry-hir/tests/native_arena.rs index 26798c5aea..96943abad7 100644 --- a/crates/perry-hir/tests/native_arena.rs +++ b/crates/perry-hir/tests/native_arena.rs @@ -467,6 +467,38 @@ fn pod_layout_constants_respect_shadowing() { ))); } +#[test] +fn pod_layout_constants_respect_class_shadowing() { + let module = lower_src( + r#" + type Packet = PerryPod<{ tag: PerryU32; }>; + class sizeof {} + class alignof {} + class offsetof {} + const size = sizeof(); + const alignment = alignof(); + const offset = offsetof("tag"); + "#, + ) + .expect("class-shadowed layout helper calls should use ordinary call lowering"); + + for name in ["size", "alignment", "offset"] { + assert!(matches!( + find_let(&module, name), + Stmt::Let { + init: Some(Expr::Call { .. }), + .. + } + )); + } + assert!(!module_any(&module, |expr| matches!( + expr, + Expr::PodLayoutSizeOf { .. } + | Expr::PodLayoutAlignOf { .. } + | Expr::PodLayoutOffsetOf { .. } + ))); +} + #[test] fn pod_layout_constants_reject_dynamic_offset_path() { let err = lower_src( diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index 44c5ee119c..217fc516d7 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -321,6 +321,23 @@ fn supported_module_with_unknown_member_is_rejected() { ); } +#[test] +fn perry_native_namespace_rejects_unknown_call_in_strict_mode() { + let result = lower_result_strict( + r#" + import * as native from "perry/native"; + native.__perry_known_bogus_native_call__(); + "#, + ); + let err = result.expect_err("unknown perry/native namespace calls must be rejected"); + assert!( + err.contains("perry/native.__perry_known_bogus_native_call__") + && err.contains("not implemented") + && err.contains("#463"), + "expected the strict native-module surface refusal, got: {err}" + ); +} + /// Coverage sweep for #513: every module in `NATIVE_MODULES` must error /// on a known-bogus property. Catches manifest entries that flag a /// module as "covered" without actually flipping strictness on. diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 368530b4a7..abc59be75f 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2006 entries across 122 modules +// Coverage: 2010 entries across 123 modules type PerryU32 = number & { readonly __perryU32?: never }; type PerryU64 = number & { readonly __perryU64?: never }; @@ -2664,6 +2664,17 @@ declare module "perry/media" { export function stop(...args: any[]): any; } +declare module "perry/native" { + /** stdlib */ + export const NativeArena: any; + /** intrinsic */ + export function alignof(): number; + /** intrinsic */ + export function offsetof(field: string): number; + /** intrinsic */ + export function sizeof(): number; +} + declare module "perry/plugin" { /** stdlib */ export class PluginApi { [key: string]: any; } diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index a3dc0bf8e3..86505c36aa 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2929 entries across 124 modules. +Total: 2933 entries across 125 modules. ## Modules @@ -82,6 +82,7 @@ Total: 2929 entries across 124 modules. - [`perry/gc`](#perrygc) - [`perry/i18n`](#perryi18n) - [`perry/media`](#perrymedia) +- [`perry/native`](#perrynative) - [`perry/plugin`](#perryplugin) - [`perry/system`](#perrysystem) - [`perry/thread`](#perrythread) @@ -2522,6 +2523,18 @@ Total: 2929 entries across 124 modules. - `setVolume` — module - `stop` — module +## `perry/native` + +### Methods + +- `alignof` — module *(intrinsic)* +- `offsetof` — module *(intrinsic)* +- `sizeof` — module *(intrinsic)* + +### Properties + +- `NativeArena` + ## `perry/plugin` ### Classes