From fc4fe3d3a826bdb4da785be04001efcc28fcdd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 11:32:05 +0200 Subject: [PATCH 1/2] fix(codegen): audit local binding type proofs --- .github/workflows/test.yml | 11 + crates/perry-codegen/src/codegen/closure.rs | 49 +- crates/perry-codegen/src/codegen/entry.rs | 8 +- crates/perry-codegen/src/codegen/function.rs | 4 +- crates/perry-codegen/src/codegen/method.rs | 8 +- crates/perry-codegen/src/codegen/mod.rs | 35 +- .../src/codegen/module_globals_emit.rs | 44 +- crates/perry-codegen/src/codegen/opts.rs | 13 +- crates/perry-codegen/src/codegen/typed_abi.rs | 69 ++- .../src/codegen/typed_abi/tests.rs | 2 +- .../src/collectors/pointer_locals.rs | 181 ++++--- .../perry-codegen/src/expr/array_methods.rs | 16 +- crates/perry-codegen/src/expr/array_push.rs | 40 +- crates/perry-codegen/src/expr/bigint_set.rs | 29 +- crates/perry-codegen/src/expr/binary.rs | 17 + .../perry-codegen/src/expr/buffer_access.rs | 26 +- .../src/expr/computed_store_rooting_tests.rs | 95 +++- .../perry-codegen/src/expr/i32_fast_path.rs | 5 +- crates/perry-codegen/src/expr/index_get.rs | 10 +- .../src/expr/index_get_claim_tests.rs | 17 +- crates/perry-codegen/src/expr/index_set.rs | 24 +- .../perry-codegen/src/expr/literals_vars.rs | 7 +- .../src/expr/logical_collections.rs | 10 +- crates/perry-codegen/src/expr/math_simple.rs | 46 +- crates/perry-codegen/src/expr/misc_methods.rs | 7 +- crates/perry-codegen/src/expr/mod.rs | 56 +- .../perry-codegen/src/expr/native_memory.rs | 4 +- crates/perry-codegen/src/expr/new_dynamic.rs | 2 +- crates/perry-codegen/src/expr/property_get.rs | 20 +- crates/perry-codegen/src/expr/property_set.rs | 56 +- .../src/expr/proven_view_access.rs | 25 +- .../perry-codegen/src/expr/proxy_reflect.rs | 19 +- crates/perry-codegen/src/expr/shadow_slot.rs | 25 +- crates/perry-codegen/src/expr/slot_rep.rs | 6 +- .../perry-codegen/src/expr/this_super_call.rs | 1 + .../src/lower_call/closure_analysis.rs | 44 +- .../src/lower_call/console_promise.rs | 2 +- .../src/lower_call/early_branches.rs | 92 ++-- .../perry-codegen/src/lower_call/func_ref.rs | 24 +- .../src/lower_call/native/mod.rs | 4 +- crates/perry-codegen/src/lower_call/new.rs | 3 +- .../src/lower_call/new_ctor_args.rs | 37 +- .../src/lower_call/options/abort.rs | 4 +- .../property_get/dynamic_dispatch.rs | 39 +- .../src/lower_call/scalar_method.rs | 17 +- crates/perry-codegen/src/lower_conditional.rs | 11 +- .../perry-codegen/src/lower_string_concat.rs | 4 +- .../perry-codegen/src/lower_string_method.rs | 2 +- .../harness_self_tests.rs | 4 +- .../src/native_root_coverage/mechanics.rs | 29 +- .../src/native_root_coverage/mod.rs | 4 +- crates/perry-codegen/src/rooting/temp_root.rs | 36 +- .../src/stmt/element_shape_loop.rs | 81 ++- crates/perry-codegen/src/stmt/let_stmt.rs | 81 +-- crates/perry-codegen/src/stmt/loops.rs | 34 +- .../src/stmt/masked_window_region.rs | 9 +- crates/perry-codegen/src/stmt/unused_expr.rs | 6 +- crates/perry-codegen/src/type_analysis.rs | 3 +- .../src/type_analysis/numeric.rs | 35 +- .../src/type_analysis/numeric/tests.rs | 101 ++-- .../src/type_analysis/predicates.rs | 15 +- .../perry-codegen/src/type_analysis/refine.rs | 98 +++- .../src/type_analysis/strings.rs | 67 ++- .../src/type_analysis/strings/tests.rs | 4 +- .../perry-codegen/src/type_analysis_facts.rs | 12 +- .../perry-codegen/src/type_analysis_tests.rs | 38 +- .../tests/native_proof_regressions.rs | 373 +++++++------ .../artifact_records.rs | 8 +- docs/src/SUMMARY.md | 1 + .../internals/local-binding-type-evidence.md | 104 ++++ scripts/local_binding_type_allowlist.json | 501 ++++++++++++++++++ scripts/local_binding_type_audit.py | 476 +++++++++++++++++ scripts/pre-tag-check.sh | 5 + ...test_gap_7846_local_binding_type_proofs.ts | 86 +++ 74 files changed, 2778 insertions(+), 703 deletions(-) create mode 100644 docs/src/internals/local-binding-type-evidence.md create mode 100644 scripts/local_binding_type_allowlist.json create mode 100644 scripts/local_binding_type_audit.py create mode 100644 test-files/test_gap_7846_local_binding_type_proofs.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31470e48f7..dd47702250 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,6 +160,17 @@ jobs: if: ${{ !cancelled() }} run: ./scripts/check_file_size.sh + # #7846: erased TypeScript annotations and initializer-refined local + # types are hints, not runtime proofs. Every codegen read must choose the + # whole-region write-invalidating accessor or carry an inventoried + # runtime/representation justification. Count-exact entries make both a + # new consumer and a stale exemption fail. + - name: Local binding type-proof audit + if: ${{ !cancelled() }} + run: | + python3 scripts/local_binding_type_audit.py --self-test + python3 scripts/local_binding_type_audit.py + # Well-known binding provenance pins: every third-party binding in # well_known_bindings.toml must carry an [bindings..upstream] # pin, and the lock-step rule (ported-at == version) must hold, so a diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 5c6fc15e84..1ed57b5c08 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -109,7 +109,7 @@ fn emit_public_typed_closure_trampoline( module_prefix: &str, generic_body_name: &str, kind: TypedFunctionTrampolineKind, - string_capture_count: usize, + capture_reps: &[TypedParamRep], ) -> Result<()> { let params = match closure_expr { perry_hir::Expr::Closure { params, .. } => params, @@ -155,15 +155,11 @@ fn emit_public_typed_closure_trampoline( None => ok, }); } - if string_capture_count > 0 { - if let Some(capture_guard) = - emit_typed_string_capture_guard(blk, "%this_closure", string_capture_count) - { - guard = Some(match guard { - Some(prev) => blk.and(I1, &prev, &capture_guard), - None => capture_guard, - }); - } + if let Some(capture_guard) = emit_typed_capture_guard(blk, "%this_closure", capture_reps) { + guard = Some(match guard { + Some(prev) => blk.and(I1, &prev, &capture_guard), + None => capture_guard, + }); } } @@ -253,13 +249,13 @@ fn load_typed_capture( } } -pub(crate) fn emit_typed_string_capture_guard( +pub(crate) fn emit_typed_capture_guard( blk: &mut crate::block::LlBlock, closure_handle: &str, - capture_count: usize, + capture_reps: &[TypedParamRep], ) -> Option { let mut guard: Option = None; - for idx in 0..capture_count { + for (idx, rep) in capture_reps.iter().enumerate() { let idx = idx.to_string(); let captured_bits = blk.call( I64, @@ -267,12 +263,7 @@ pub(crate) fn emit_typed_string_capture_guard( &[(I64, closure_handle), (I32, &idx)], ); let captured = blk.bitcast_i64_to_double(&captured_bits); - let raw = blk.call( - I32, - "js_typed_string_arg_guard", - &[(DOUBLE, captured.as_str())], - ); - let ok = blk.icmp_ne(I32, &raw, "0"); + let ok = emit_typed_arg_guard(blk, *rep, &captured); guard = Some(match guard { Some(prev) => blk.and(I1, &prev, &ok), None => ok, @@ -913,6 +904,8 @@ pub(super) fn compile_closure( native_facts: &native_facts, locals, local_types, + proven_local_types: std::collections::HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals, const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), @@ -1092,7 +1085,7 @@ pub(super) fn compile_closure( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: false, ic_site_counter: ic_base, ic_globals: Vec::new(), @@ -1168,15 +1161,11 @@ pub(super) fn compile_closure( llmod.add_raw_global(raw.clone()); } if let Some(kind) = typed_public_trampoline { - let string_capture_count = if matches!(kind, TypedFunctionTrampolineKind::StringRef) { - cross_module - .typed_string_closure_capture_counts - .get(&func_id) - .copied() - .unwrap_or(0) - } else { - 0 - }; + let capture_reps = cross_module + .typed_closure_capture_reps + .get(&func_id) + .map(Vec::as_slice) + .unwrap_or(&[]); emit_public_typed_closure_trampoline( llmod, func_id, @@ -1184,7 +1173,7 @@ pub(super) fn compile_closure( module_prefix, &llvm_name, kind, - string_capture_count, + capture_reps, )?; } Ok(()) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 3d639fb373..983d6028eb 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -733,6 +733,8 @@ pub(super) fn compile_module_entry( native_facts: &main_native_facts, locals: HashMap::new(), local_types: init_local_types, + proven_local_types: HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals: crate::collectors::reassigned_locals(&hir.init), const_string_locals: HashMap::new(), const_number_locals: HashMap::new(), @@ -912,7 +914,7 @@ pub(super) fn compile_module_entry( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: hir.init_was_unrolled, ic_site_counter: ic_base, ic_globals: Vec::new(), @@ -1404,6 +1406,8 @@ pub(super) fn compile_module_entry( native_facts: &init_native_facts, locals: HashMap::new(), local_types: HashMap::new(), + proven_local_types: HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals: crate::collectors::reassigned_locals(&hir.init), const_string_locals: HashMap::new(), const_number_locals: HashMap::new(), @@ -1583,7 +1587,7 @@ pub(super) fn compile_module_entry( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: hir.init_was_unrolled, ic_site_counter: ic_base, ic_globals: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index ea9a50477a..7456c1b11d 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -713,6 +713,8 @@ pub(super) fn compile_function( native_facts: &native_facts, locals, local_types, + proven_local_types: std::collections::HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals: crate::collectors::reassigned_locals(&f.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), @@ -888,7 +890,7 @@ pub(super) fn compile_function( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: f.was_unrolled, ic_site_counter: ic_base, ic_globals: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 517ceb7462..2cfebe238f 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -427,6 +427,8 @@ pub(super) fn compile_method( native_facts: &native_facts, locals, local_types, + proven_local_types: std::collections::HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals: crate::collectors::reassigned_locals(&method.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), @@ -599,7 +601,7 @@ pub(super) fn compile_method( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: method.was_unrolled, ic_site_counter: ic_base, ic_globals: Vec::new(), @@ -1488,6 +1490,8 @@ pub(super) fn compile_static_method( native_facts: &native_facts, locals, local_types, + proven_local_types: std::collections::HashMap::new(), + module_global_proven_types: &cross_module.module_global_proven_types, reassigned_locals: crate::collectors::reassigned_locals(&f.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), @@ -1664,7 +1668,7 @@ pub(super) fn compile_static_method( typed_i1_closures: &cross_module.typed_i1_closures, typed_i1_closure_param_reps: &cross_module.typed_i1_closure_param_reps, typed_string_closures: &cross_module.typed_string_closures, - typed_string_closure_capture_counts: &cross_module.typed_string_closure_capture_counts, + typed_closure_capture_reps: &cross_module.typed_closure_capture_reps, was_unrolled: f.was_unrolled, ic_site_counter: ic_base, ic_globals: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 9590b3ffda..c01f4d2019 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -204,7 +204,7 @@ mod testing_feature_gate_tests; mod typed_abi; mod typed_abi_opt_report; -pub(crate) use closure::emit_typed_string_capture_guard; +pub(crate) use closure::emit_typed_capture_guard; pub use helpers::resolve_target_triple; pub(crate) use helpers::{ decide_codegen_units, decide_full_outline_ic, default_target_triple, full_outline_ic_enabled, @@ -217,12 +217,12 @@ pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, - generic_function_body_name, generic_method_body_name, typed_f64_closure_name, - typed_f64_function_name, typed_f64_method_name, typed_f64_receiver_method_info, - typed_f64_receiver_method_name, typed_i1_closure_name, typed_i1_function_name, - typed_i1_method_name, typed_i32_closure_name, typed_i32_function_name, typed_i32_method_name, - typed_param_reps_match_args, typed_string_closure_name, typed_string_function_name, - typed_string_method_name, TypedParamRep, TypedReceiverMethodInfo, + generic_function_body_name, generic_method_body_name, typed_arg_is_guard_candidate, + typed_f64_closure_name, typed_f64_function_name, typed_f64_method_name, + typed_f64_receiver_method_info, typed_f64_receiver_method_name, typed_i1_closure_name, + typed_i1_function_name, typed_i1_method_name, typed_i32_closure_name, typed_i32_function_name, + typed_i32_method_name, typed_param_reps_match_args, typed_string_closure_name, + typed_string_function_name, typed_string_method_name, TypedParamRep, TypedReceiverMethodInfo, }; use artifacts::{emit_module_artifacts, ModuleArtifactsCtx}; @@ -1794,6 +1794,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> local_async_funcs, local_generator_funcs, async_step_closures: hir.async_step_closures.iter().copied().collect(), + module_global_proven_types: std::collections::HashMap::new(), funcs_reading_dynamic_this, type_aliases: opts.type_aliases, imported_func_param_counts: opts.imported_func_param_counts, @@ -1921,7 +1922,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> typed_i32_closures: std::collections::HashSet::new(), typed_i1_closures: std::collections::HashSet::new(), typed_string_closures: std::collections::HashSet::new(), - typed_string_closure_capture_counts: std::collections::HashMap::new(), + typed_closure_capture_reps: std::collections::HashMap::new(), typed_i1_closure_param_reps: std::collections::HashMap::new(), compiler_private_async_i32_control_locals, compiler_private_async_i1_control_locals, @@ -2076,6 +2077,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let module_globals_emit::ModuleGlobals { module_globals, module_global_types, + module_global_proven_types, static_field_globals, } = module_globals_emit::emit_module_globals( &mut llmod, @@ -2084,6 +2086,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &cross_module.compile_time_constants, &module_prefix, ); + cross_module.module_global_proven_types = module_global_proven_types; // Method registry + cross-module method/getter/setter/ctor/static // extern declares. See `method_registry::build_method_names`. @@ -2214,9 +2217,16 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> cross_module.typed_i32_closures.clear(); cross_module.typed_i1_closures.clear(); cross_module.typed_string_closures.clear(); - cross_module.typed_string_closure_capture_counts.clear(); + cross_module.typed_closure_capture_reps.clear(); cross_module.typed_i1_closure_param_reps.clear(); for (func_id, expr) in &closures { + if let Some(captures) = + typed_abi::typed_f64_closure_capture_reps(expr, &typed_abi_local_types) + { + cross_module + .typed_closure_capture_reps + .insert(*func_id, captures.into_iter().map(|(_, rep)| rep).collect()); + } match typed_abi::typed_f64_closure_rejection_reason_with_types(expr, &typed_abi_local_types) { None => { @@ -2320,13 +2330,6 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .insert(*func_id, reps); } } - let capture_count = - typed_abi::typed_string_closure_capture_reps(expr, &typed_abi_local_types) - .map(|captures| captures.len()) - .unwrap_or(0); - cross_module - .typed_string_closure_capture_counts - .insert(*func_id, capture_count); } Some(reason) => record_typed_clone_rejection( &mut typed_clone_rejection_records, diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index c102953bc9..e79c725c18 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -24,9 +24,32 @@ use super::ImportedClass; pub(crate) struct ModuleGlobals { pub module_globals: HashMap, pub module_global_types: HashMap, + pub module_global_proven_types: HashMap, pub static_field_globals: HashMap<(String, String), String>, } +/// Runtime kinds established without consulting a TypeScript annotation. +/// This deliberately covers only the module-global values the thread transfer +/// check can safely permit; every unrecognized expression stays hazardous. +fn module_global_runtime_type(init: &perry_hir::Expr) -> Option { + use perry_hir::types::Type; + use perry_hir::Expr; + match init { + Expr::Undefined | Expr::Void(_) => Some(Type::Void), + Expr::Null => Some(Type::Null), + Expr::Bool(_) | Expr::Compare { .. } => Some(Type::Boolean), + Expr::Number(_) | Expr::Integer(_) => Some(Type::Number), + Expr::BigInt(_) => Some(Type::BigInt), + Expr::String(_) | Expr::WtfString(_) | Expr::I18nString { .. } | Expr::TypeOf(_) => { + Some(Type::String) + } + Expr::New { class_name, .. } if class_name == "SharedArrayBuffer" => { + Some(Type::Named(class_name.clone())) + } + _ => None, + } +} + /// Emit module-level globals (with exported-var getters) and static-class-field /// globals. `compile_time_constants` supplies init values for known synthetic /// consts (`__platform__`, `__plugins__`). @@ -197,6 +220,7 @@ pub(crate) fn emit_module_globals( // so method calls in other functions fall through to the generic // dispatch instead of the class method registry. let mut module_global_types: HashMap = HashMap::new(); + let mut module_global_proven_types: HashMap = HashMap::new(); // Collect exported variable names so we can create external // globals + getter functions for cross-module access. let exported_var_names: std::collections::HashSet = @@ -239,13 +263,30 @@ pub(crate) fn emit_module_globals( } let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); collect_init_lets(&hir.init, &mut init_lets); + let let_counts = init_lets.iter().fold(HashMap::new(), |mut counts, stmt| { + if let perry_hir::Stmt::Let { id, .. } = stmt { + *counts.entry(*id).or_insert(0usize) += 1; + } + counts + }); + let reassigned = crate::collectors::reassigned_locals_in_module(hir); for s in init_lets { - if let perry_hir::Stmt::Let { id, name, ty, .. } = s { + if let perry_hir::Stmt::Let { + id, name, ty, init, .. + } = s + { // Always record the declared type for module-level lets // so all functions see it (not just the entry function). if !matches!(ty, perry_hir::types::Type::Any) { module_global_types.insert(*id, ty.clone()); } + if let Some(proven) = init + .as_ref() + .filter(|_| let_counts.get(id) == Some(&1) && !reassigned.contains(id)) + .and_then(module_global_runtime_type) + { + module_global_proven_types.insert(*id, proven); + } if referenced_from_fn.contains(id) || exported_var_names.contains(name) { // A `var` redeclared at module scope (`var x = …; … var x = …;`) // lowers to multiple `Stmt::Let` sharing the SAME id. The backing @@ -449,6 +490,7 @@ pub(crate) fn emit_module_globals( ModuleGlobals { module_globals, module_global_types, + module_global_proven_types, static_field_globals, } } diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 4fc94cbbb0..0f16eb9af7 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -624,6 +624,10 @@ pub(crate) struct CrossModuleCtx { /// completion/timer queues on the worker thread and alias the main /// thread's heap. pub async_step_closures: std::collections::HashSet, + /// Module-global runtime kinds proven directly from their initializer and + /// invalidated by any module-wide write. Used by the worker-thread safety + /// check; declared module types are intentionally excluded (#7846). + pub module_global_proven_types: std::collections::HashMap, /// FuncIds of locally-defined plain functions whose body reads the /// dynamic `this` binding (directly or via a this-capturing arrow). /// Bare `f()` call sites to these must reset the runtime IMPLICIT_THIS @@ -870,10 +874,11 @@ pub(crate) struct CrossModuleCtx { /// closure identity/arity, string argument guards, and any required string /// capture guards pass. pub typed_string_closures: std::collections::HashSet, - /// Number of immutable string captures consumed by each typed-string - /// closure clone. Direct local call sites use this to guard capture slots - /// before entering the raw string ABI. - pub typed_string_closure_capture_counts: std::collections::HashMap, + /// Candidate raw representations for immutable captures consumed by each + /// typed closure clone. Every typed entry path checks the current capture + /// bits against these reps before entering the raw ABI. + pub typed_closure_capture_reps: + std::collections::HashMap>, /// Per-closure typed-i1 clone parameter reps. This lets direct local /// closure calls target mixed native predicate clones such as /// `i1(i64 closure, double, double)` without routing through the public diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 06488ac802..c29f9a380f 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -121,6 +121,9 @@ fn typed_closure_capture_reps( }; let mut reps = Vec::with_capacity(captures.len()); for id in captures { + // This is a candidate representation derived from source metadata, + // not a proof. Both typed-clone entry paths guard the current capture + // bits against `rep` before entering the raw ABI. let ty = module_local_types.get(id)?; let rep = typed_param_rep_for_type(ty)?; reps.push((*id, rep)); @@ -193,21 +196,71 @@ pub(crate) fn typed_param_reps_match_args( args: &[Expr], ) -> bool { reps.len() == args.len() - && args.iter().zip(reps.iter()).all(|(arg, rep)| match rep { - TypedParamRep::F64 => crate::type_analysis::is_numeric_expr(ctx, arg), - TypedParamRep::I32 => { - matches!( + && args + .iter() + .zip(reps.iter()) + .all(|(arg, rep)| typed_arg_is_guard_candidate(ctx, *rep, arg)) +} + +/// Whether an argument is worth routing through a typed-call guard. +/// +/// Runtime-derived facts admit the route directly. A local's erased source +/// type may also nominate a route because every caller of this predicate emits +/// `rep.guard_fn()` over the live JSValue and takes the generic body on guard +/// failure. The hint never authorizes raw lowering on its own. +pub(crate) fn typed_arg_is_guard_candidate( + ctx: &crate::expr::FnCtx<'_>, + rep: TypedParamRep, + arg: &Expr, +) -> bool { + let runtime_proven = match rep { + TypedParamRep::F64 => crate::type_analysis::is_numeric_expr(ctx, arg), + TypedParamRep::I32 => { + matches!(arg, Expr::LocalGet(id) if ctx.integer_locals.contains(id)) + || matches!( crate::type_analysis::static_type_of(ctx, arg), Some(Type::Int32) - ) || matches!( + ) + || matches!( arg, Expr::Integer(n) if (i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(n) ) + } + TypedParamRep::I1 => crate::type_analysis::is_bool_expr(ctx, arg), + TypedParamRep::StringRef => { + crate::type_analysis::string_value_is_runtime_guaranteed(ctx, arg) + } + }; + if runtime_proven { + return true; + } + + let hinted_rep = match arg { + Expr::LocalGet(id) => ctx.local_type_hint(id).and_then(typed_param_rep_for_type), + // An element annotation may nominate a guarded route even when the + // separately proven outer Array kind intentionally erases its element + // details to `Any`. The consumer still validates/coerces the live + // result before using a raw representation. + Expr::IndexGet { object, index } if crate::type_analysis::is_numeric_expr(ctx, index) => { + let Expr::LocalGet(array_id) = object.as_ref() else { + return false; + }; + match ctx.local_type_hint(array_id) { + Some(Type::Array(elem)) => typed_param_rep_for_type(elem), + Some(Type::Generic { base, type_args }) + if base == "Array" && type_args.len() == 1 => + { + typed_param_rep_for_type(&type_args[0]) + } + _ => None, } - TypedParamRep::I1 => crate::type_analysis::is_bool_expr(ctx, arg), - TypedParamRep::StringRef => crate::type_analysis::is_definitely_string_expr(ctx, arg), - }) + } + _ => crate::type_analysis::static_type_of(ctx, arg) + .as_ref() + .and_then(typed_param_rep_for_type), + }; + hinted_rep == Some(rep) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/perry-codegen/src/codegen/typed_abi/tests.rs b/crates/perry-codegen/src/codegen/typed_abi/tests.rs index 7112178910..8a3ef8f3fb 100644 --- a/crates/perry-codegen/src/codegen/typed_abi/tests.rs +++ b/crates/perry-codegen/src/codegen/typed_abi/tests.rs @@ -169,7 +169,7 @@ fn string_clone_accepts_mixed_params_when_only_string_rep_flows_to_return() { } #[test] -fn closure_clone_accepts_mixed_immutable_captures_for_numeric_return() { +fn closure_capture_representations_are_runtime_guard_candidates() { let expr = Expr::Closure { func_id: 7, params: vec![param(20, "scale", Type::Number)], diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index ed797babdc..f75a5f1224 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -161,15 +161,12 @@ fn pointer_analysis_array_type(elem: Type) -> Type { } struct PointerAnalysisFacts<'a> { - local_types: &'a HashMap, local_value_types: &'a HashMap, } impl HirTypeFacts for PointerAnalysisFacts<'_> { fn local_type(&self, id: u32) -> Option<&Type> { - self.local_value_types - .get(&id) - .or_else(|| self.local_types.get(&id)) + self.local_value_types.get(&id) } fn global_type(&self, _id: u32) -> Option<&Type> { @@ -213,20 +210,6 @@ pub fn collect_pointer_typed_locals( flat_const_ids: &HashSet, ) -> std::collections::HashMap { use perry_hir::Stmt; - /// Does this local need a shadow-stack slot? - /// - /// The third copy of the pointer question, now routed to the one - /// definition (#7236). It was the exact complement of - /// [`is_definitely_non_pointer_type`] over every `Type` variant *except* - /// `Symbol`, which neither of them claimed — so a `Symbol` local was - /// simultaneously "not a pointer" (no slot here) and "pointer-bearing" - /// (`typed_shape`, which lays out the GC's own field masks). The - /// per-variant rationale that used to live here moved to - /// `type_is_pointer_bearing`'s doc comment with it. - fn is_ptr_typed(ty: &Type) -> bool { - crate::typed_shape::type_is_pointer_bearing(ty) - } - fn expr_value_type( expr: &Expr, local_types: &HashMap, @@ -315,7 +298,6 @@ pub fn collect_pointer_typed_locals( | Expr::JsonStringifyFull(..) => Some(Type::String), Expr::LocalGet(id) => local_value_types .get(id) - .or_else(|| local_types.get(id)) .map(pointer_analysis_type) .or_else(|| { if non_pointer_locals.contains(id) { @@ -434,10 +416,7 @@ pub fn collect_pointer_typed_locals( } Expr::Void(_) => Some(Type::Void), _ => { - let facts = PointerAnalysisFacts { - local_types, - local_value_types, - }; + let facts = PointerAnalysisFacts { local_value_types }; match infer_expr_type(expr, &facts) { Type::Any | Type::Unknown => None, ty => Some(pointer_analysis_type(&ty)), @@ -456,6 +435,44 @@ pub fn collect_pointer_typed_locals( .is_some_and(|ty| is_definitely_non_pointer_type(&ty)) } + /// Inductive check for a local-preserving numeric recurrence such as + /// `let i = 0; i = i + 1`. The caller separately requires an independent + /// non-pointer seed definition and checks every write under this + /// assumption, so a declaration or a circular `x = x` is never evidence. + fn expr_is_known_non_pointer_assuming_local( + expr: &Expr, + assumed_id: u32, + local_types: &HashMap, + local_value_types: &HashMap, + non_pointer_locals: &HashSet, + ) -> bool { + match expr { + Expr::LocalGet(id) if *id == assumed_id => true, + Expr::Binary { + op: BinaryOp::Add, + left, + right, + } => { + expr_is_known_non_pointer_assuming_local( + left, + assumed_id, + local_types, + local_value_types, + non_pointer_locals, + ) && expr_is_known_non_pointer_assuming_local( + right, + assumed_id, + local_types, + local_value_types, + non_pointer_locals, + ) + } + _ => { + expr_is_known_non_pointer(expr, local_types, local_value_types, non_pointer_locals) + } + } + } + fn collect_expr_writes_in_closure_stmts( stmts: &[Stmt], writes: &mut HashMap>, @@ -801,36 +818,21 @@ pub fn collect_pointer_typed_locals( // under-rooting / use-after-free risk, only (rarely) one extra safe root. writes.retain(|id, _| local_types.contains_key(id)); - let mut local_value_types: HashMap = local_types - .iter() - .filter_map(|(id, ty)| { - if !matches!(ty, Type::Any | Type::Unknown) { - Some((*id, pointer_analysis_type(ty))) - } else { - None - } - }) - .collect(); - let mut non_pointer_locals: HashSet = local_types - .iter() - .filter_map(|(id, ty)| { - if is_definitely_non_pointer_type(ty) { - Some(*id) - } else { - None - } - }) - .collect(); + // Declared TypeScript types are not runtime evidence. Infer pointer-ness + // only from the complete write set below; until that succeeds a binding + // keeps a conservative shadow slot (#7846). + let mut local_value_types: HashMap = HashMap::new(); + let mut non_pointer_locals: HashSet = HashSet::new(); // #6219 perf: BOUND the refinement fixpoint. // // This loop exists only to PROVE additional locals non-pointer (it ONLY // ever GROWS `non_pointer_locals`, below), which lets `walk`/param slots - // DROP them from the shadow frame. Every slot decision gates on - // `is_ptr_typed(declared_ty) && !non_pointer_locals.contains(id)`, so - // curtailing this loop is strictly conservative: a local that would have - // been proven non-pointer instead keeps a safe extra root. It never removes - // a needed slot — no under-rooting / use-after-free is possible. + // DROP them from the shadow frame. Declared types never participate in that + // decision, so curtailing this loop is strictly conservative: a local that + // would have been proven non-pointer instead keeps a safe extra root. It + // never removes a needed slot — no under-rooting / use-after-free is + // possible. // // Unbounded (`while changed`) the loop is O(locals × iterations): a long // def-use chain (`a = b; b = c; …`) needs one pass per link. Next.js/webpack @@ -840,7 +842,8 @@ pub fn collect_pointer_typed_locals( // standalone server, still short of LLVM emission. A fixed iteration cap // bounds it while keeping full precision for normal frames (which converge // in a handful of passes); a hard size gate skips refinement entirely on the - // pathologically huge frames where even a few passes are wasted work. + // pathologically huge frames where even a few passes are wasted work. In + // that case every unproven binding keeps a conservative shadow slot. const MAX_FIXPOINT_ITERS: usize = 16; const MAX_FIXPOINT_LOCALS: usize = 8192; let mut iters = 0usize; @@ -883,6 +886,7 @@ pub fn collect_pointer_typed_locals( let is_param = param_ids.contains(id); let mut precise_inference = !is_param; let mut all_non_pointer = !local_writes.is_empty() && !is_param; + let mut has_independent_non_pointer_seed = false; for write in local_writes { let write_ty = match write { LocalWrite::NonPointer => Some(Type::Number), @@ -891,6 +895,17 @@ pub fn collect_pointer_typed_locals( .map(|ty| pointer_analysis_type(&ty)) } }; + let write_is_non_pointer_assuming_self = match write { + LocalWrite::NonPointer => true, + LocalWrite::Expr(expr) => expr_is_known_non_pointer_assuming_local( + expr, + *id, + &local_types, + &local_value_types, + &non_pointer_locals, + ), + }; + all_non_pointer &= write_is_non_pointer_assuming_self; match write_ty { Some(Type::Any | Type::Unknown) => { all_non_pointer = false; @@ -898,8 +913,7 @@ pub fn collect_pointer_typed_locals( precise_inference = false; } Some(ty) => { - all_non_pointer &= - is_definitely_non_pointer_type(&ty) || non_pointer_locals.contains(id); + has_independent_non_pointer_seed |= is_definitely_non_pointer_type(&ty); if precise_inference { match &inferred_ty { None => inferred_ty = Some(ty), @@ -912,25 +926,23 @@ pub fn collect_pointer_typed_locals( } } None => { - all_non_pointer = false; inferred_ty = None; precise_inference = false; } } } - if matches!(local_types.get(id), Some(Type::Any | Type::Unknown)) { - if precise_inference { - if let Some(ty) = inferred_ty { - if local_value_types.get(id) != Some(&ty) { - local_value_types.insert(*id, ty); - changed = true; - } - } else if local_value_types.remove(id).is_some() { + all_non_pointer &= has_independent_non_pointer_seed; + if precise_inference { + if let Some(ty) = inferred_ty { + if local_value_types.get(id) != Some(&ty) { + local_value_types.insert(*id, ty); changed = true; } } else if local_value_types.remove(id).is_some() { changed = true; } + } else if local_value_types.remove(id).is_some() { + changed = true; } if all_non_pointer && non_pointer_locals.insert(*id) { changed = true; @@ -969,7 +981,9 @@ pub fn collect_pointer_typed_locals( }); } for p in params { - if is_ptr_typed(&p.ty) && !non_pointer_locals.contains(&p.id) { + // The incoming argument is a definition the body write walk cannot + // inspect, and its annotation can lie. Root every generic-ABI param. + if !non_pointer_locals.contains(&p.id) { assign_slot(&mut out, &mut next_slot, p.id); } } @@ -982,10 +996,8 @@ pub fn collect_pointer_typed_locals( ) { for s in stmts { match s { - Stmt::Let { id, ty, .. } - if is_ptr_typed(ty) - && !non_pointer_locals.contains(id) - && !flat_row_alias_ids.contains(id) => + Stmt::Let { id, .. } + if !non_pointer_locals.contains(id) && !flat_row_alias_ids.contains(id) => { assign_slot(out, next_slot, *id); } @@ -1093,6 +1105,45 @@ mod tests { use super::*; use perry_hir::{Function, Param, Stmt}; + #[test] + fn declared_scalar_holding_pointer_keeps_shadow_slot() { + let stmts = vec![Stmt::Let { + id: 1, + name: "declared_number_holds_object".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Object(vec![( + "answer".to_string(), + Expr::Integer(42), + )])), + }]; + + let slots = collect_pointer_typed_locals(&[], &stmts, &HashSet::new()); + assert!( + slots.contains_key(&1), + "a declared scalar type cannot suppress rooting for an actual object" + ); + } + + #[test] + fn declared_scalar_parameter_keeps_shadow_slot() { + let params = vec![Param { + id: 1, + name: "value".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }]; + + let slots = collect_pointer_typed_locals(¶ms, &[], &HashSet::new()); + assert!( + slots.contains_key(&1), + "a generic-ABI argument can violate its declared scalar type" + ); + } + /// #6998: `const it = u8[Symbol.iterator]` binds a **heap** value — /// `js_object_get_symbol_property` hands back the accessor — into a local /// whose HIR type is `Any`. Typed `Number` here it would get no shadow diff --git a/crates/perry-codegen/src/expr/array_methods.rs b/crates/perry-codegen/src/expr/array_methods.rs index a177e0c9e9..9f13ebcdf9 100644 --- a/crates/perry-codegen/src/expr/array_methods.rs +++ b/crates/perry-codegen/src/expr/array_methods.rs @@ -90,14 +90,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // array-initialized local can now hold a number. Mirror // `is_array_expr`/`receiver_class_name`: only fold a local whose // binding has not been written after initialization. - let static_type_is_still_valid = !matches!( - o.as_ref(), - Expr::LocalGet(id) if ctx.reassigned_locals.contains(id) - ); - if let Some(ty) = static_type_is_still_valid - .then_some(crate::type_analysis::static_type_of(ctx, o)) - .flatten() - { + let ty = match o.as_ref() { + // Folding is an answer, not guarded dispatch. Use only the + // initializer-derived, whole-region-stable proof; a declared + // `number[]` can hold any runtime value (#7846). + Expr::LocalGet(id) => ctx.stable_local_type_proof(id).cloned(), + _ => crate::type_analysis::static_type_of(ctx, o), + }; + if let Some(ty) = ty { if matches!( ty, perry_hir::types::Type::Array(_) | perry_hir::types::Type::Tuple(_) diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index d428ca0b13..dbce1afe9c 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -74,6 +74,31 @@ use super::{ TypedFeedbackContract, TypedFeedbackKind, }; +/// Metadata may admit the #7839 append tier because that tier tests the live +/// result bits before doing GC bookkeeping. A lying numeric operand makes JS +/// `+` produce a heap value; the pointer test then takes the note/addref/ +/// barrier arm, so the annotation selects a checked lowering, not an answer. +fn guarded_numeric_add_push_candidate(ctx: &FnCtx<'_>, value: &Expr) -> bool { + if is_numeric_expr(ctx, value) { + return true; + } + match value { + Expr::LocalGet(id) => matches!( + ctx.local_type_hint(id), + Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32) + ), + Expr::Binary { + op: perry_hir::BinaryOp::Add, + left, + right, + } => { + guarded_numeric_add_push_candidate(ctx, left) + && guarded_numeric_add_push_candidate(ctx, right) + } + _ => false, + } +} + /// The expression's result: the new length per ES2024 `Array.prototype.push`. /// /// `js_array_length` is NOT a field read — it resolves Proxy arrays through @@ -557,7 +582,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> layout_note_needed }; let write_barrier_needed = array_store_needs_write_barrier(ctx, value); - let value_is_numeric = is_numeric_expr(ctx, value); + let value_is_numeric = guarded_numeric_add_push_candidate(ctx, value); let require_numeric_layout = value_is_numeric && expr_has_numeric_pointer_free_array_layout(ctx, &array_expr); // #7839 — the inline append's three GC-bookkeeping calls behind ONE @@ -603,8 +628,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // numeric values (e.g. a read fallback's INT32-boxed bits) keep // the runtime-guarded tier: stored verbatim they would corrupt // the dense raw-f64 invariant. - let keep_guarded_numeric_push = super::typed_feedback_emission_enabled() - || !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value); + let inline_value_shape = + crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value) + || matches!( + value.as_ref(), + Expr::Binary { + op: perry_hir::BinaryOp::Add, + .. + } + ) && value_is_numeric; + let keep_guarded_numeric_push = + super::typed_feedback_emission_enabled() || !inline_value_shape; if require_numeric_layout && keep_guarded_numeric_push && !ctx.boxed_vars.contains(array_id) diff --git a/crates/perry-codegen/src/expr/bigint_set.rs b/crates/perry-codegen/src/expr/bigint_set.rs index c0316c9b7a..7683f57fc9 100644 --- a/crates/perry-codegen/src/expr/bigint_set.rs +++ b/crates/perry-codegen/src/expr/bigint_set.rs @@ -10,7 +10,7 @@ use perry_hir::{BinaryOp, Expr}; use crate::nanbox::{double_literal, POINTER_MASK_I64}; use crate::type_analysis::{ - is_bigint_expr, is_definitely_string_expr, is_numeric_expr, set_static_type_args, + is_bigint_expr, set_static_type_args, string_value_is_runtime_guaranteed, }; use crate::types::{DOUBLE, F32, I1, I32, I64, PTR}; @@ -585,9 +585,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let use_boolean_set = receiver_boolean_set && can_lower_i1_for_collection_value(ctx, value); let receiver_number_set = is_static_number_set(ctx, &set_expr); - let use_number_set = receiver_number_set && is_numeric_expr(ctx, value); + let use_number_set = receiver_number_set + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value, + ); let receiver_string_set = is_static_string_set(ctx, &set_expr); - let value_is_string = is_definitely_string_expr(ctx, value); + let value_is_string = string_value_is_runtime_guaranteed(ctx, value); let use_string_set = receiver_string_set && value_is_string; let new_handle = if use_i32_set { let value_i32 = @@ -857,9 +862,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let use_boolean_set = receiver_boolean_set && can_lower_i1_for_collection_value(ctx, value); let receiver_number_set = is_static_number_set(ctx, set); - let use_number_set = receiver_number_set && is_numeric_expr(ctx, value); + let use_number_set = receiver_number_set + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value, + ); let use_string_set = - is_static_string_set(ctx, set) && is_definitely_string_expr(ctx, value); + is_static_string_set(ctx, set) && string_value_is_runtime_guaranteed(ctx, value); let s_box = lower_expr(ctx, set)?; let s_handle = { let blk = ctx.block(); @@ -1092,9 +1102,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let use_boolean_set = receiver_boolean_set && can_lower_i1_for_collection_value(ctx, value); let receiver_number_set = is_static_number_set(ctx, set); - let use_number_set = receiver_number_set && is_numeric_expr(ctx, value); + let use_number_set = receiver_number_set + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value, + ); let use_string_set = - is_static_string_set(ctx, set) && is_definitely_string_expr(ctx, value); + is_static_string_set(ctx, set) && string_value_is_runtime_guaranteed(ctx, value); let s_box = lower_expr(ctx, set)?; let s_handle = { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index c848fe78d9..d027347b0b 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -5,6 +5,7 @@ //! `lower_expr`'s outer dispatch. use anyhow::Result; +use perry_hir::types::Type as HirType; use perry_hir::{BinaryOp, Expr, LogicalOp}; use crate::lower_string_concat::{ @@ -684,10 +685,26 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { crate::type_analysis::is_numeric_expr(ctx, right) || is_bigint_expr(ctx, right) || is_bool_expr(ctx, right) + || matches!( + right.as_ref(), + Expr::LocalGet(id) + if matches!( + ctx.local_type_hint(id), + Some(HirType::Number | HirType::Int32) + ) + ) } else { crate::type_analysis::is_numeric_expr(ctx, left) || is_bigint_expr(ctx, left) || is_bool_expr(ctx, left) + || matches!( + left.as_ref(), + Expr::LocalGet(id) + if matches!( + ctx.local_type_hint(id), + Some(HirType::Number | HirType::Int32) + ) + ) }; if other_known_primitive { return lower_string_coerce_concat(ctx, left, right, l_is_str, r_is_str); diff --git a/crates/perry-codegen/src/expr/buffer_access.rs b/crates/perry-codegen/src/expr/buffer_access.rs index bbef4054be..1af1d0fc22 100644 --- a/crates/perry-codegen/src/expr/buffer_access.rs +++ b/crates/perry-codegen/src/expr/buffer_access.rs @@ -11,7 +11,7 @@ use crate::types::{DOUBLE, F32, I16, I32, I8, PTR}; use super::{ attach_buffer_view_facts, bounds_for_buffer_access_width, buffer_alias_metadata_suffix, buffer_view_lowered_value, can_lower_expr_as_i32, effective_alias_state_for_access, - int_range_expr, is_numeric_expr, lower_expr_native, FnCtx, + int_range_expr, is_numeric_expr, lower_expr, lower_expr_native, FnCtx, }; #[derive(Debug, Clone, Copy)] @@ -721,7 +721,13 @@ pub(crate) fn lower_typed_array_store( { return Ok(None); } - if matches!(view.elem, BufferElem::F32 | BufferElem::F64) && !is_numeric_expr(ctx, value_expr) { + if matches!(view.elem, BufferElem::F32 | BufferElem::F64) + && !crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value_expr, + ) + { return Ok(None); } // `data_slot` is a raw cached pointer. It is stable across a collecting RHS @@ -748,7 +754,21 @@ pub(crate) fn lower_typed_array_store( // The gates above guarantee this is inline-owned, non-movable storage when // the RHS collects, so loading the slot here does not reuse a GC-stale view // backing pointer. - let result = lower_expr_native(ctx, value_expr, expected)?; + // A declaration may nominate the floating store route, but the bytes we + // write must come from the live value. `lower_expr_native(F64)` still has + // legacy source-type fast paths, so annotation-only candidates are boxed + // and explicitly ToNumber-coerced here before a raw backing-store write. + let result = if matches!(proof.view.elem, BufferElem::F32 | BufferElem::F64) + && !is_numeric_expr(ctx, value_expr) + { + let boxed = lower_expr(ctx, value_expr)?; + let coerced = ctx + .block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &boxed)]); + LoweredValue::f64(coerced) + } else { + lower_expr_native(ctx, value_expr, expected)? + }; let emission = emit_buffer_access_pointer(ctx, &proof, spec); let stored = match proof.view.elem { BufferElem::I8 | BufferElem::U8 => { diff --git a/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs b/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs index e0ea8fa62f..9aeadbeb13 100644 --- a/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/computed_store_rooting_tests.rs @@ -531,10 +531,39 @@ fn collecting_native_view_operands_decline_the_cached_pointer_fast_path() { ); } +fn compile_masked_window_with_key( + name: &str, + key_ty: Type, + key_mutable: bool, + mut body: Vec, +) -> String { + let mut params = vec![param(1, "view", Type::Any)]; + if matches!(key_ty, Type::Int32) { + // The inert control needs a runtime-derived fact, not an annotation: + // a generic-ABI Int32 parameter can still receive an object. Keep the + // standalone-update controls can separately exercise the collector's + // literal-seeded integer recurrence proof. + body.insert( + 0, + Stmt::Let { + id: 2, + name: "key".to_string(), + ty: Type::Any, + mutable: key_mutable, + init: Some(Expr::Integer(0)), + }, + ); + } else { + params.push(param(2, "key", key_ty)); + } + compile_body_with_params(name, params, body) +} + fn compile_masked_window_loop(key_ty: Type, value: Expr) -> String { - compile_body_with_params( + compile_masked_window_with_key( "masked_window_coercion", - vec![param(1, "view", Type::Any), param(2, "key", key_ty)], + key_ty, + false, vec![ Stmt::Let { id: 3, @@ -577,16 +606,38 @@ fn masked_window_index_coercion_loop(key_ty: Type) -> String { }), right: Box::new(Expr::Integer(7)), }; - compile_masked_window_loop( + compile_masked_window_with_key( + "masked_window_index_coercion", key_ty, - Expr::Binary { - op: BinaryOp::Add, - left: Box::new(Expr::LocalGet(3)), - right: Box::new(Expr::IndexGet { - object: Box::new(Expr::LocalGet(1)), - index: Box::new(masked_index), - }), - }, + false, + vec![ + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: 4, + name: "i".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(4)), + right: Box::new(Expr::Integer(2)), + }), + update: Some(Expr::Update { + id: 4, + op: UpdateOp::Increment, + prefix: false, + }), + // A bare read isolates the property under test: whether the + // unary index coercion is non-collecting. + body: vec![Stmt::Expr(Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(masked_index), + })], + }, + Stmt::Return(Some(Expr::Integer(0))), + ], ) } @@ -642,11 +693,7 @@ fn masked_window_rhs_coercion_region(key_ty: Type) -> String { body.push(Stmt::Expr(Expr::LocalSet(3, Box::new(value())))); } body.push(Stmt::Return(Some(Expr::LocalGet(3)))); - compile_body_with_params( - "masked_window_rhs_coercion_region", - vec![param(1, "view", Type::Any), param(2, "key", key_ty)], - body, - ) + compile_masked_window_with_key("masked_window_rhs_coercion_region", key_ty, false, body) } fn masked_window_pair_sum() -> Expr { @@ -662,9 +709,10 @@ fn masked_window_pair_sum() -> Expr { } fn masked_window_standalone_update_loop(key_ty: Type) -> String { - compile_body_with_params( + compile_masked_window_with_key( "masked_window_standalone_update_loop", - vec![param(1, "view", Type::Any), param(2, "key", key_ty)], + key_ty, + true, vec![ Stmt::Let { id: 3, @@ -730,17 +778,14 @@ fn masked_window_standalone_update_region(key_ty: Type) -> String { ))); } body.push(Stmt::Return(Some(Expr::LocalGet(3)))); - compile_body_with_params( - "masked_window_standalone_update_region", - vec![param(1, "view", Type::Any), param(2, "key", key_ty)], - body, - ) + compile_masked_window_with_key("masked_window_standalone_update_region", key_ty, true, body) } /// #7640 E review follow-up — unary `+` over an `any` key can invoke user /// coercion even though the surrounding mask has a static index window. Such /// an index must decline before a typed-array tier hoists its raw data pointer; -/// the same shape with an inert i32 key proves the fast tier remains live. +/// the same shape with a literal-seeded integer local proves the fast tier +/// remains live without trusting a parameter annotation. #[test] fn collecting_masked_window_index_declines_the_hoisted_pointer_tier() { let inert = masked_window_index_coercion_loop(Type::Int32); @@ -808,7 +853,7 @@ fn collecting_rhs_declines_the_straight_line_masked_region() { /// A standalone update is not part of an index/RHS tree, but it executes in /// the same hoisted-pointer copy between masked reads. `any++` can run user -/// ToNumeric hooks; an inert i32 update remains call-free. +/// ToNumeric hooks; a literal-seeded integer recurrence remains call-free. #[test] fn collecting_standalone_update_declines_the_masked_loop() { let inert = masked_window_standalone_update_loop(Type::Int32); diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 9f87bffff4..f3d8ae00a3 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -13,7 +13,8 @@ use crate::native_value::{ MaterializationReason, NativeRep, }; use crate::type_analysis::{ - expr_may_return_boxed_value_from_raw_f64_fallback, is_definitely_string_expr, is_numeric_expr, + expr_may_return_boxed_value_from_raw_f64_fallback, is_numeric_expr, + string_value_is_runtime_guaranteed, }; use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8}; @@ -1006,7 +1007,7 @@ fn native_expr_kind(e: &Expr) -> &'static str { } fn lower_expr_native_string_ref(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { - if !is_definitely_string_expr(ctx, e) { + if !string_value_is_runtime_guaranteed(ctx, e) { bail!("cannot lower expression as native StringRef without a string proof"); } let boxed = lower_expr(ctx, e)?; diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 80714a9a70..fb1b8deb84 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -71,8 +71,16 @@ use inline_dyn_typed_array::lower_inline_dyn_typed_array_get; /// object: a type-confused, `unbox`ed-pointer-plus-wrong-offset write, /// not merely a missed optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + // This predicate selects only runtime-validated typed-array helpers (or a + // `buffer_view_slots` proof that invalidates on assignment), as documented + // above. Preserve the declared kind as a hint for that dynamic fallback; + // the general `static_type_of` deliberately drops reassigned bindings. + let ty = match object { + Expr::LocalGet(id) => ctx.local_type_hint(id).cloned(), + _ => crate::type_analysis::static_type_of(ctx, object), + }; matches!( - crate::type_analysis::static_type_of(ctx, object), + ty, Some(HirType::Named(name)) if matches!( name.as_str(), "Int8Array" diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index b7c9955a34..fe25b8dad5 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -20,10 +20,19 @@ fn declared_array_read_ir(name: &str, index: Expr) -> String { name: "items".to_string(), ty: Type::Array(Box::new(Type::String)), mutable: false, - // Deliberately violate the annotation in HIR. The source-level - // repro does the same through an `any` value stored in a typed - // field; this smaller shape reaches the identical IndexGet arm. - init: Some(Expr::String("ss".to_string())), + // Deliberately violate the annotation through a dynamic + // property read. The initializer really evaluates to a + // String, but (unlike a literal initializer) supplies no + // compile-time representation proof, matching the source + // repro's `any` value stored in a typed field. + init: Some(Expr::PropertyGet { + object: Box::new(Expr::Object(vec![( + "value".to_string(), + Expr::String("ss".to_string()), + )])), + property: "value".to_string(), + byte_offset: 0, + }), }, Stmt::Let { id: RESULT, diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 54e1fea4cd..83ddf7c589 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -128,8 +128,12 @@ fn lower_value_for_dynamic_index_set( /// typed-array object (data at byte 16): a type-confused write, not a missed /// optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + let ty = match object { + Expr::LocalGet(id) => ctx.local_type_hint(id).cloned(), + _ => crate::type_analysis::static_type_of(ctx, object), + }; matches!( - crate::type_analysis::static_type_of(ctx, object), + ty, Some(perry_hir::types::Type::Named(name)) if matches!( name.as_str(), "Int8Array" @@ -253,6 +257,15 @@ fn numeric_index_needs_runtime_key(ctx: &FnCtx<'_>, object: &Expr, index: &Expr) && !numeric_index_has_loop_array_index_proof(ctx, object, index) } +/// Whether a value is worth routing through the numeric-array store guard. +/// +/// A source type is only a candidate here: both guarded store tiers validate +/// the live JSValue before a raw-f64 store and retain the boxed fallback. It +/// must never be reused to elide layout notes or write barriers. +fn guarded_numeric_array_store_candidate(ctx: &FnCtx<'_>, value: &Expr) -> bool { + crate::codegen::typed_arg_is_guard_candidate(ctx, crate::codegen::TypedParamRep::F64, value) +} + fn typed_array_index_needs_runtime_key(ctx: &FnCtx<'_>, object: &Expr, index: &Expr) -> bool { !numeric_index_has_integer_array_index_proof(ctx, index) && !numeric_index_has_loop_array_index_proof(ctx, object, index) @@ -1214,8 +1227,9 @@ pub(crate) fn lower( let layout_note_needed = array_store_needs_layout_note(ctx, object, value); let write_barrier_needed = array_store_needs_write_barrier(ctx, value); let value_is_numeric = is_numeric_expr(ctx, value); - let require_numeric_layout = value_is_numeric - && expr_has_numeric_pointer_free_array_layout(ctx, object); + let require_numeric_layout = + guarded_numeric_array_store_candidate(ctx, value) + && expr_has_numeric_pointer_free_array_layout(ctx, object); // #7640 section A: the receiver was lowered, then the // index, then — the hazard — the VALUE, with no // rooting decision at all. `classify_for_length_hoist`'s @@ -1435,8 +1449,8 @@ pub(crate) fn lower( let layout_note_needed = array_store_needs_layout_note(ctx, object, value); let write_barrier_needed = array_store_needs_write_barrier(ctx, value); let value_is_numeric = is_numeric_expr(ctx, value); - let require_numeric_layout = - value_is_numeric && expr_has_numeric_pointer_free_array_layout(ctx, object); + let require_numeric_layout = guarded_numeric_array_store_candidate(ctx, value) + && expr_has_numeric_pointer_free_array_layout(ctx, object); let local_id = if let Expr::LocalGet(id) = object.as_ref() { Some(*id) } else { diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index b48dc82348..31fb98289f 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -502,7 +502,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // string-typed local that does `s = s + t` aborted codegen // with `string self-append: local N not in scope` because the // helper's `ctx.locals.get(id)` lookup whiffed. - if matches!(ctx.local_types.get(id), Some(HirType::String)) + // #7841: the tag-dispatched helper validates the destination's + // current value before choosing append versus ordinary JS `+`. + // This is therefore a dispatch hint, not a binding proof; using + // the stable-only query would disable the optimization for every + // self-append because this `LocalSet` is itself a reassignment. + if matches!(ctx.local_type_hint(id), Some(HirType::String)) && !ctx.module_globals.contains_key(id) && !ctx.closure_captures.contains_key(id) && !ctx.boxed_vars.contains(id) diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 62197e0a55..c740cdac01 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -48,7 +48,7 @@ use perry_hir::Expr; use crate::lower_conditional::lower_logical; use crate::nanbox::{double_literal, POINTER_MASK_I64, TAG_UNDEFINED}; use crate::rooting::{self, Arg, Repr}; -use crate::type_analysis::{is_definitely_string_expr, is_numeric_expr, map_static_type_args}; +use crate::type_analysis::{map_static_type_args, string_value_is_runtime_guaranteed}; use crate::types::{DOUBLE, I32, I64, PTR}; use super::{ @@ -571,10 +571,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- map.delete(key) -> boolean -------- Expr::MapDelete { map, key } => { let use_string_key_map = - is_static_string_key_map(ctx, map) && is_definitely_string_expr(ctx, key); + is_static_string_key_map(ctx, map) && string_value_is_runtime_guaranteed(ctx, key); let use_number_key_map = !use_string_key_map && is_static_number_key_map(ctx, map) - && is_numeric_expr(ctx, key); + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + key, + ); // #7615 slice 2: the map is live across the key's lowering. rooting::with_operands_rooted(ctx, &[map, key], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); diff --git a/crates/perry-codegen/src/expr/math_simple.rs b/crates/perry-codegen/src/expr/math_simple.rs index f222a27bb8..6320a14954 100644 --- a/crates/perry-codegen/src/expr/math_simple.rs +++ b/crates/perry-codegen/src/expr/math_simple.rs @@ -9,7 +9,7 @@ use perry_hir::types::Type as HirType; use perry_hir::{BinaryOp, Expr}; use crate::rooting::{operand_may_collect, with_operands_rooted, with_rooted_group, RootedGroup}; -use crate::type_analysis::{is_definitely_string_expr, is_numeric_expr, map_static_type_args}; +use crate::type_analysis::{map_static_type_args, string_value_is_runtime_guaranteed}; use crate::types::{DOUBLE, F32, I1, I32, I64}; use super::{ @@ -537,32 +537,36 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- map.set(key, value) / .get / .has -------- Expr::MapSet { map, key, value } => { let has_string_key_map = - is_static_string_key_map(ctx, map) && is_definitely_string_expr(ctx, key); + is_static_string_key_map(ctx, map) && string_value_is_runtime_guaranteed(ctx, key); let use_number_key_map = !has_string_key_map && is_static_number_key_map(ctx, map) - && is_numeric_expr(ctx, key); + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + key, + ); let static_number_string_map = use_number_key_map && is_static_number_string_map(ctx, map); let use_number_string_map = - static_number_string_map && is_definitely_string_expr(ctx, value); + static_number_string_map && string_value_is_runtime_guaranteed(ctx, value); let use_string_i32_map = is_static_string_i32_map(ctx, map) - && is_definitely_string_expr(ctx, key) + && string_value_is_runtime_guaranteed(ctx, key) && can_use_string_i32_map_value(ctx, value); let use_string_u32_map = is_static_string_u32_map(ctx, map) - && is_definitely_string_expr(ctx, key) + && string_value_is_runtime_guaranteed(ctx, key) && can_use_string_u32_map_value(ctx, value); let use_string_f32_map = is_static_string_f32_map(ctx, map) - && is_definitely_string_expr(ctx, key) + && string_value_is_runtime_guaranteed(ctx, key) && can_use_string_f32_map_value(value); - let use_string_number_map = - is_static_string_number_map(ctx, map) && is_definitely_string_expr(ctx, key); - let static_string_boolean_map = - is_static_string_boolean_map(ctx, map) && is_definitely_string_expr(ctx, key); + let use_string_number_map = is_static_string_number_map(ctx, map) + && string_value_is_runtime_guaranteed(ctx, key); + let static_string_boolean_map = is_static_string_boolean_map(ctx, map) + && string_value_is_runtime_guaranteed(ctx, key); let use_string_boolean_map = static_string_boolean_map && can_use_string_boolean_map_value(ctx, value); let use_string_string_map = is_static_string_string_map(ctx, map) - && is_definitely_string_expr(ctx, key) - && is_definitely_string_expr(ctx, value); + && string_value_is_runtime_guaranteed(ctx, key) + && string_value_is_runtime_guaranteed(ctx, value); // #6970: each operand is finished before the next is lowered, and // both are live in nothing but SSA registers until the runtime // call. `m.set(fresh(k), churn(N))` aborted inside `js_map_set` on @@ -895,10 +899,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } Expr::MapGet { map, key } => { let use_string_key_map = - is_static_string_key_map(ctx, map) && is_definitely_string_expr(ctx, key); + is_static_string_key_map(ctx, map) && string_value_is_runtime_guaranteed(ctx, key); let use_number_key_map = !use_string_key_map && is_static_number_key_map(ctx, map) - && is_numeric_expr(ctx, key); + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + key, + ); // #6970: `key` is lowered after the receiver and can collect, so the // receiver would otherwise sit unrooted in an SSA register across it. let value = with_operands_rooted(ctx, &[map, key], |ctx, values| { @@ -951,10 +959,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } Expr::MapHas { map, key } => { let use_string_key_map = - is_static_string_key_map(ctx, map) && is_definitely_string_expr(ctx, key); + is_static_string_key_map(ctx, map) && string_value_is_runtime_guaranteed(ctx, key); let use_number_key_map = !use_string_key_map && is_static_number_key_map(ctx, map) - && is_numeric_expr(ctx, key); + && crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + key, + ); // #6970: same hazard as `MapGet` — the key's lowering can collect // while the receiver is live only in an SSA register. let i32_v = with_operands_rooted(ctx, &[map, key], |ctx, values| { diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index 2b8ca86cf8..66aff54cd4 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -9,7 +9,6 @@ use perry_hir::{Expr, UnaryOp}; use crate::nanbox::double_literal; use crate::native_value::{ExpectedNativeRep, LoweredValue, MaterializationReason, NativeRep}; -use crate::type_analysis::is_numeric_expr; use crate::types::{DOUBLE, F32, I1, I32, I64, I8, PTR}; use super::{ @@ -840,7 +839,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { vec!["slot_kind=raw_i32_proven".to_string()], ); Ok(result) - } else if is_numeric_expr(ctx, value) { + } else if crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value, + ) { let (raw, slot_note) = lower_iter_result_f64_payload(ctx, value)?; let result = ctx.block().call( DOUBLE, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index eed3036ac9..84e4241711 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -227,11 +227,21 @@ pub(crate) struct FnCtx<'a> { pub native_facts: &'a NativeRegionFactGraph, /// Map from HIR LocalId → LLVM alloca pointer (e.g. `%r3`). pub locals: std::collections::HashMap, - /// Map from HIR LocalId → static HIR Type. Used by `is_string_expr` and - /// future type-aware dispatch sites (Phase B's "native instance flag - /// tracking" extension). Populated from function params and `Stmt::Let` - /// declarations as they're lowered. + /// Map from HIR LocalId → static HIR Type. This is an erased TypeScript + /// hint, not evidence about the value currently in the slot. Read it only + /// through [`FnCtx::local_type_hint`], whose exceptional consumers are + /// audited by `scripts/local_binding_type_audit.py`. + /// Populated from function params and `Stmt::Let` declarations as they're + /// lowered. pub local_types: std::collections::HashMap, + /// Runtime-derived type/kind evidence for the value installed by a local's + /// initializer. Unlike `local_types`, this map never receives a declared + /// annotation or a type inferred from one. + pub proven_local_types: std::collections::HashMap, + /// Module-global proofs used only by cross-thread admission. These are + /// collected from structural initializers with module-wide write + /// invalidation; ordinary local type predicates do not consult them. + pub module_global_proven_types: &'a std::collections::HashMap, /// Bindings assigned after declaration anywhere in this region. /// /// A TypeScript annotation describes the source-level contract, but an @@ -1262,7 +1272,8 @@ pub(crate) struct FnCtx<'a> { pub typed_i1_closure_param_reps: &'a std::collections::HashMap>, pub typed_string_closures: &'a std::collections::HashSet, - pub typed_string_closure_capture_counts: &'a std::collections::HashMap, + pub typed_closure_capture_reps: + &'a std::collections::HashMap>, /// True if `perry_transform::unroll_static_loops` expanded any /// static-trip-count for-loop in the function this FnCtx is lowering @@ -1657,6 +1668,10 @@ pub(crate) struct ElementShapeLoopFact { /// binds `r` generically. `None` for the single-statement accumulator /// form. pub element_binding: Option, + /// Mutable accumulator whose current value the preheader proved is a + /// Number. The matcher admits only assignments that preserve this fact, + /// and the fact exists only while lowering the guarded fast clone. + pub numeric_accumulator: u32, } /// Find the innermost active element-shape loop fact covering a @@ -1749,6 +1764,33 @@ pub(crate) fn class_field_loop_fact_lookup<'f>( } impl<'a> FnCtx<'a> { + /// Return runtime-derived initializer evidence only when no write anywhere + /// in this region can have invalidated it. + /// + /// This deliberately uses the conservative whole-region answer rather + /// than statement order: a missed optimization is safe, while using a + /// type after a non-dominating write is a wrong-code bug (#7846). Declared + /// annotations are never inserted into this map, so a successful lookup is + /// both provenance-checked and write-stable. + pub(crate) fn stable_local_type_proof(&self, id: &u32) -> Option<&HirType> { + if self.reassigned_locals.contains(id) { + None + } else { + self.proven_local_types.get(id) + } + } + + /// Return the binding's erased TypeScript type even if the binding is + /// reassigned. + /// + /// This escape hatch is for sites whose independent representation proof + /// or runtime guard validates the current value. Every production call is + /// inventoried by `scripts/local_binding_type_audit.py`; adding one without + /// an allowlist rationale fails CI. + pub(crate) fn local_type_hint(&self, id: &u32) -> Option<&HirType> { + self.local_types.get(id) + } + pub(crate) fn has_imported_extern_binding(&self, name: &str) -> bool { self.imported_vars.contains(name) || self.import_function_prefixes.contains_key(name) @@ -2262,7 +2304,7 @@ fn is_plain_f64_local(ctx: &FnCtx<'_>, id: u32) -> bool { && !ctx.i32_counter_slots.contains_key(&id) && ctx.locals.contains_key(&id) && matches!( - ctx.local_types.get(&id), + ctx.stable_local_type_proof(&id), Some(HirType::Number | HirType::Int32) ) } @@ -2272,7 +2314,7 @@ fn is_plain_i1_local(ctx: &FnCtx<'_>, id: u32) -> bool { && !ctx.boxed_vars.contains(&id) && !ctx.module_globals.contains_key(&id) && ctx.i1_local_slots.contains_key(&id) - && matches!(ctx.local_types.get(&id), Some(HirType::Boolean)) + && matches!(ctx.stable_local_type_proof(&id), Some(HirType::Boolean)) } /// Whether `expr` has an existing raw-`i1` proof strong enough to apply diff --git a/crates/perry-codegen/src/expr/native_memory.rs b/crates/perry-codegen/src/expr/native_memory.rs index 53cff8b2f6..0e9462ac14 100644 --- a/crates/perry-codegen/src/expr/native_memory.rs +++ b/crates/perry-codegen/src/expr/native_memory.rs @@ -176,7 +176,9 @@ fn proven_view( let Expr::LocalGet(local_id) = expr else { return None; }; - if !is_native_memory_typed_view(ctx.local_types.get(local_id)) { + // `buffer_view_slots` below is the representation proof and every write + // invalidates its pointer state. The type is only an early dispatch hint. + if !is_native_memory_typed_view(ctx.local_type_hint(local_id)) { return None; } let slot = ctx.buffer_view_slots.get(local_id)?.clone(); diff --git a/crates/perry-codegen/src/expr/new_dynamic.rs b/crates/perry-codegen/src/expr/new_dynamic.rs index 3f976316cd..72382212e5 100644 --- a/crates/perry-codegen/src/expr/new_dynamic.rs +++ b/crates/perry-codegen/src/expr/new_dynamic.rs @@ -240,7 +240,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if property == "constructor" { if let Expr::LocalGet(id) = object.as_ref() { let is_date = matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(name)) if name == "Date" ); if is_date { diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index b24b77f4b7..6e3d160470 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -65,6 +65,20 @@ use super::{ TypedFeedbackContract, TypedFeedbackKind, }; +/// A declared class may nominate the guarded field/method route, but never a +/// raw load by itself. Every field consumer below checks the live receiver's +/// class id and keys token before dereferencing; method-value/runtime-member +/// helpers retain their dynamic fallback semantics. +fn guarded_declared_class_get_candidate(ctx: &FnCtx<'_>, object: &Expr) -> Option { + let Expr::LocalGet(id) = object else { + return None; + }; + let HirType::Named(name) = ctx.local_type_hint(id)? else { + return None; + }; + ctx.classes.contains_key(name).then(|| name.clone()) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7219: reading `.buffer` on a tracked typed-array view HANDS OUT ITS // STORAGE, so the local's inline-storage proof stops holding from here on. @@ -680,7 +694,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if property == "constructor" { if let Expr::LocalGet(id) = object.as_ref() { let is_date = matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(name)) if name == "Date" ); if is_date { @@ -1265,7 +1279,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // the property is registered as a getter, call the // synthesized __get_ method instead of doing a // raw field load. - if let Some(class_name) = receiver_class_name(ctx, object) { + if let Some(class_name) = receiver_class_name(ctx, object) + .or_else(|| guarded_declared_class_get_candidate(ctx, object)) + { if class_name == "URLPattern" && is_url_pattern_data_property(property) { let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 911013ff52..de87b5590d 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -25,7 +25,6 @@ //! a fourth hand-written guard. use anyhow::Result; -use perry_hir::types::Type as HirType; use perry_hir::Expr; use crate::nanbox::POINTER_MASK_I64; @@ -47,6 +46,19 @@ use super::{ TypedFeedbackContract, TypedFeedbackKind, }; +/// Metadata-only class candidate for the runtime-guarded plain-field store. +/// Accessor calls still require a real receiver proof; a lying annotation is +/// routed to the by-name setter path instead. +fn guarded_declared_class_store_candidate(ctx: &FnCtx<'_>, object: &Expr) -> Option { + let Expr::LocalGet(id) = object else { + return None; + }; + let perry_hir::types::Type::Named(name) = ctx.local_type_hint(id)? else { + return None; + }; + ctx.classes.contains_key(name).then(|| name.clone()) +} + fn canonicalize_raw_f64_numeric_store_value( blk: &mut crate::block::LlBlock, value_double: &str, @@ -146,7 +158,9 @@ pub(crate) fn try_lower_sloppy_class_field_store( if crate::codegen::full_outline_ic_enabled() { return Ok(None); } - let Some(class_name) = receiver_class_name(ctx, object) else { + let Some(class_name) = receiver_class_name(ctx, object) + .or_else(|| guarded_declared_class_store_candidate(ctx, object)) + else { return Ok(None); }; if class_has_computed_runtime_members(ctx, &class_name) { @@ -742,15 +756,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // heap object, so mark the buffer shared here. Otherwise a // later `s = s + suffix` mutates it in-place via // js_string_append's refcount==1 fast path and corrupts this - // field. Only a `LocalGet` of a string-typed local can carry a - // uniquely-owned buffer (concat/literal results are shared). - if let Expr::LocalGet(src_id) = &**value { - if matches!(ctx.local_types.get(src_id), Some(HirType::String)) { - ctx.block().call_void( - "js_string_addref_if_heap_string", - &[(DOUBLE, &val_double)], - ); - } + // field. The helper checks the runtime tag, so apply it to + // every local source: an erased non-string annotation is + // not proof that the current value cannot be a string. + if matches!(&**value, Expr::LocalGet(_)) { + ctx.block() + .call_void("js_string_addref_if_heap_string", &[(DOUBLE, &val_double)]); } let lowered_js = LoweredValue { semantic: SemanticKind::JsValue, @@ -827,13 +838,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // aliases the string buffer; mark it shared so a later // self-append doesn't mutate it in-place and corrupt the // field. - if let Expr::LocalGet(src_id) = &**value { - if matches!(ctx.local_types.get(src_id), Some(HirType::String)) { - ctx.block().call_void( - "js_string_addref_if_heap_string", - &[(DOUBLE, &val_double)], - ); - } + if matches!(&**value, Expr::LocalGet(_)) { + ctx.block().call_void( + "js_string_addref_if_heap_string", + &[(DOUBLE, &val_double)], + ); } let lowered_js = LoweredValue { semantic: SemanticKind::JsValue, @@ -887,7 +896,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // exactly like the class-field arms. Keep the zero-cost // `LocalGet`/`This` path, and conditionally root a compound // receiver across an allocating value expression. - if let Some(class_name) = receiver_class_name(ctx, object) { + let proven_class_name = receiver_class_name(ctx, object); + if let Some(class_name) = proven_class_name + .clone() + .or_else(|| guarded_declared_class_store_candidate(ctx, object)) + { if class_has_computed_runtime_members(ctx, &class_name) { return lower_runtime_property_set_by_name(ctx, object, property, value); } @@ -901,6 +914,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .unwrap_or(false); if !is_static_accessor { if let Some(fn_name) = ctx.methods.get(&setter_key).cloned() { + if proven_class_name.is_none() { + return lower_runtime_property_set_by_name( + ctx, object, property, value, + ); + } return with_class_store_operands( ctx, object, diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index 4c71eab0cf..5322fc22f5 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -37,7 +37,8 @@ use anyhow::Result; use perry_hir::{BinaryOp, Expr}; use super::{ - attach_buffer_view_facts, can_lower_expr_as_i32, lower_expr_as_i32, lower_expr_native, FnCtx, + attach_buffer_view_facts, can_lower_expr_as_i32, is_numeric_expr, lower_expr, + lower_expr_as_i32, lower_expr_native, FnCtx, }; use crate::nanbox::{double_literal, TAG_UNDEFINED}; use crate::native_value::{ @@ -353,10 +354,15 @@ pub(crate) fn try_lower_proven_view_checked_store( { return Ok(None); } - if matches!(view.elem, BufferElem::F32 | BufferElem::F64) - && !crate::type_analysis::is_numeric_expr(ctx, value) - { - return Ok(None); + if matches!(view.elem, BufferElem::F32 | BufferElem::F64) { + let numeric_candidate = crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + value, + ); + if !numeric_candidate { + return Ok(None); + } } let idx_i32 = lower_expr_as_i32(ctx, index)?; @@ -367,6 +373,15 @@ pub(crate) fn try_lower_proven_view_checked_store( ExpectedNativeRep::I32 }; lower_expr_native(ctx, value, expected)? + } else if !is_numeric_expr(ctx, value) { + // Source metadata may select this checked store, but only the live + // value may supply raw bytes. Avoid `lower_expr_native(F64)`'s legacy + // annotation shortcut and perform explicit ToNumber coercion. + let boxed = lower_expr(ctx, value)?; + let coerced = ctx + .block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &boxed)]); + LoweredValue::f64(coerced) } else { lower_expr_native(ctx, value, ExpectedNativeRep::F64)? }; diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index ed8371bd77..db80555f0c 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -363,6 +363,7 @@ fn put_value_static_property_fast_path( return None; } receiver_class_name(ctx, target) + .or_else(|| guarded_declared_class_property_candidate(ctx, target)) .and_then(|class_name| { crate::type_analysis::class_field_global_index(ctx, &class_name, property) }) @@ -381,6 +382,7 @@ fn put_value_static_property_fast_path( return None; } receiver_class_name(ctx, target) + .or_else(|| guarded_declared_class_property_candidate(ctx, target)) .and_then(|class_name| { crate::type_analysis::class_field_global_index(ctx, &class_name, property) }) @@ -390,7 +392,8 @@ fn put_value_static_property_fast_path( if !strict { return None; } - let class_name = receiver_class_name(ctx, target)?; + let class_name = receiver_class_name(ctx, target) + .or_else(|| guarded_declared_class_property_candidate(ctx, target))?; crate::type_analysis::class_field_global_index(ctx, &class_name, property) .map(|_| property.clone()) } @@ -398,6 +401,20 @@ fn put_value_static_property_fast_path( } } +/// A source declaration may select the guarded class-field route, but it may +/// never authorize a raw slot access itself. `PropertySet` re-checks the live +/// receiver against the selected class/shape before touching the slot and +/// retains the ordinary runtime fallback on guard failure. +fn guarded_declared_class_property_candidate(ctx: &FnCtx<'_>, target: &Expr) -> Option { + let Expr::LocalGet(id) = target else { + return None; + }; + let perry_hir::types::Type::Named(name) = ctx.local_type_hint(id)? else { + return None; + }; + ctx.classes.contains_key(name).then(|| name.clone()) +} + /// Bounded polymorphic inline cache for a static-name `PutValue` whose target /// and receiver are the same expression. /// diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 7d90def217..14e181a6b0 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -42,6 +42,14 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp match expr { Expr::Undefined | Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Integer(_) => true, Expr::LocalGet(id) => { + // Whole-function write analysis proves these locals numeric by + // construction. That proof does not depend on a TypeScript + // annotation and remains valid at every read, including loop + // counters whose back-edge update makes an initializer-only + // proof ineligible. + if ctx.integer_locals.contains(id) { + return true; + } // A reserved shadow slot means the local is pointer-possible even // if its initializer refined `local_types` to a scalar. // @@ -62,14 +70,16 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp // change. The guard makes the arm exactly the old list minus // `Symbol`. !ctx.shadow_slot_map.contains_key(id) - && ctx.local_types.get(id).is_some_and(|ty| { + && ctx.stable_local_type_proof(id).is_some_and(|ty| { !matches!(ty, HirType::Union(_)) && !crate::typed_shape::type_is_pointer_bearing(ty) }) } Expr::Compare { .. } | Expr::Void(_) => true, Expr::Unary { .. } => true, - Expr::Binary { op, .. } => !matches!(op, BinaryOp::Add), + Expr::Binary { op, .. } => { + !matches!(op, BinaryOp::Add) || crate::type_analysis::is_numeric_expr(ctx, expr) + } // #6750 follow-up: a masked-index read covered by an ACTIVE // masked-window fact is a guard-proven numeric element load — never // a pointer — even when the receiver's static type is erased. @@ -308,6 +318,17 @@ pub(crate) fn emit_shadow_slot_update_for_expr( if ctx.masked_region_scalar_locals.contains(&local_id) { return; } + // The element-shape clone's preheader checked this accumulator's current + // Number tag, and the matcher admits only numeric-preserving writes in a + // call-free clone. Its old shadow value may remain conservatively rooted; + // the slow clone resumes ordinary mirroring after the scoped fact is gone. + if ctx + .element_shape_loop_facts + .iter() + .any(|fact| fact.numeric_accumulator == local_id) + { + return; + } let Some(slot_idx) = ctx.shadow_slot_map.get(&local_id).copied() else { return; }; diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 0f5262a977..67bc1e8bfc 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -635,7 +635,7 @@ pub(crate) fn store_canonical_local_from_double( /// /// - **Non-string-proven write**: some `LocalSet(id, v)` where `v` is not /// syntactically a definite string (mirrors -/// `type_analysis::strings::is_definitely_string_expr`, minus the +/// `type_analysis::strings::string_value_is_runtime_guaranteed`, minus the /// ctx-dependent arms), or any `Update` (++/--) on it. /// - **Compare hazard** (mirrors `compare.rs`'s `other_side_is_any` /// demote): the local appears on one side of an equality compare whose @@ -719,14 +719,14 @@ pub(crate) fn collect_canonical_str_ineligible_locals(stmts: &[perry_hir::Stmt]) } scan_declared(stmts, &mut declared_str); - // Ctx-free mirror of `is_definitely_string_expr` for the write / compare + // Ctx-free mirror of `string_value_is_runtime_guaranteed` for the write / compare // scans. Method calls whose NAME also exists on Array/Object (`slice`, // `concat`, `replace`, …) additionally require a syntactically-string // RECEIVER — name-only matching would classify `arr.slice()` as a // string write and skip the exclusion. Only the number-formatting / // universal-ToString family (`toString`/`toFixed`/`toPrecision`/ // `toExponential`) stays name-only, mirroring - // `is_definitely_string_expr`. A misclassification here is a missed + // `string_value_is_runtime_guaranteed`. A misclassification here is a missed // exclusion, not a correctness break (every specialized lowering // re-checks the runtime tag and falls back) — but keeping the scan // honest keeps ineligible locals off the canonical rep. diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index e60e61bf43..b0951db0e6 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -1080,6 +1080,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx, &parent_ctor.params, &lowered_args, + super_args, parent_capture_fill, ); diff --git a/crates/perry-codegen/src/lower_call/closure_analysis.rs b/crates/perry-codegen/src/lower_call/closure_analysis.rs index 185c46ea16..980a5564a2 100644 --- a/crates/perry-codegen/src/lower_call/closure_analysis.rs +++ b/crates/perry-codegen/src/lower_call/closure_analysis.rs @@ -285,12 +285,11 @@ pub enum ThreadClosureHazard { ModuleGlobalAccess(perry_hir::types::LocalId), } -/// Types whose module-global slot value can be read from a worker thread +/// Proven runtime kinds whose module-global slot value can be read from a worker thread /// without aliasing mutable main-heap structure: numbers and booleans are /// plain 64-bit copies; strings/bigints are immutable and permanently -/// rooted when they back a module global. `Any` / `Unknown` / type vars are -/// allowed because this is a best-effort AST-level check — rejecting -/// unprovable bindings would flag every untyped numeric global. +/// rooted when they back a module global. The input comes from structural +/// initializer evidence, never a declared TypeScript type (#7846). fn is_thread_transferable_global_type(ty: &perry_hir::types::Type) -> bool { use perry_hir::types::Type; match ty { @@ -302,10 +301,7 @@ fn is_thread_transferable_global_type(ty: &perry_hir::types::Type) -> bool { | Type::BigInt | Type::String | Type::StringLiteral(_) - | Type::Any - | Type::Unknown - | Type::Never - | Type::TypeVar(_) => true, + | Type::Never => true, Type::Union(members) => members.iter().all(is_thread_transferable_global_type), // SharedArrayBuffer is the one EXPLICIT shared-state escape hatch in // the threading model: its backing store is a process-global, @@ -322,20 +318,20 @@ fn is_thread_transferable_global_type(ty: &perry_hir::types::Type) -> bool { } /// Compute the set of module-global LocalIds a worker closure must not -/// touch: ids with a backing `@perry_global_*` slot whose declared/inferred -/// type is not a thread-transferable primitive. Ids with no recorded type -/// are allowed (best-effort — e.g. non-entry module inits don't seed -/// `local_types`, and `module_global_types` skips `Any`). +/// touch: ids with a backing `@perry_global_*` slot whose runtime-derived +/// initializer proof is absent or is not a thread-transferable primitive. +/// Missing evidence is hazardous; an erased annotation cannot admit a value +/// into a worker heap. pub fn hazardous_module_global_ids( module_globals: &std::collections::HashMap, - local_types: &std::collections::HashMap, + proven_types: &std::collections::HashMap, ) -> std::collections::HashSet { module_globals .keys() .filter(|id| { - local_types + !proven_types .get(id) - .is_some_and(|ty| !is_thread_transferable_global_type(ty)) + .is_some_and(is_thread_transferable_global_type) }) .copied() .collect() @@ -492,3 +488,21 @@ fn find_thread_hazard_expr( }); found } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thread_globals_require_runtime_derived_transfer_proof() { + let globals = std::collections::HashMap::from([ + (1, "proven_number".to_string()), + (2, "unproven_declared_number".to_string()), + ]); + let proofs = std::collections::HashMap::from([(1, perry_hir::types::Type::Number)]); + + let hazardous = hazardous_module_global_ids(&globals, &proofs); + assert!(!hazardous.contains(&1)); + assert!(hazardous.contains(&2)); + } +} diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index 662422867c..b8914addf9 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -32,7 +32,7 @@ fn util_types_arg_is_async_function_static(ctx: &FnCtx<'_>, expr: &Expr) -> Opti match expr { Expr::FuncRef(fid) => Some(ctx.local_async_funcs.contains(fid)), Expr::Closure { is_async, .. } => Some(*is_async), - Expr::LocalGet(id) => match ctx.local_types.get(id) { + Expr::LocalGet(id) => match ctx.stable_local_type_proof(id) { Some(HirType::Function(ft)) => Some(ft.is_async), _ => None, }, diff --git a/crates/perry-codegen/src/lower_call/early_branches.rs b/crates/perry-codegen/src/lower_call/early_branches.rs index 8f5204d58f..7d13aa535d 100644 --- a/crates/perry-codegen/src/lower_call/early_branches.rs +++ b/crates/perry-codegen/src/lower_call/early_branches.rs @@ -267,7 +267,7 @@ pub fn try_lower_index_get_call( } let is_static_string = matches!(index.as_ref(), Expr::String(_)) || crate::type_analysis::is_string_expr(ctx, index) - || crate::type_analysis::is_definitely_string_expr(ctx, index); + || crate::type_analysis::string_value_is_runtime_guaranteed(ctx, index); // #7210 (3): receiver, key and every argument are lowered in strict // sequence — each held as a bare SSA register while the rest lower, @@ -380,7 +380,9 @@ pub fn try_lower_closure_typed_local_call( // pointer from the closure header and invokes it with the closure // as the first arg followed by the user args. if let Expr::LocalGet(id) = callee { - if matches!(ctx.local_types.get(id), Some(HirType::Function(_))) { + // The checked closure-unbox path below validates the current callee; + // the erased type only decides whether to try that guarded dispatch. + if matches!(ctx.local_type_hint(id), Some(HirType::Function(_))) { let recv_box = lower_expr(ctx, callee)?; let mut lowered_args: Vec = Vec::with_capacity(args.len()); for a in args { @@ -508,28 +510,8 @@ pub fn try_lower_closure_typed_local_call( let typed_i1_param_reps = if ctx.typed_i1_closures.contains(&func_id) { if let Some(reps) = ctx.typed_i1_closure_param_reps.get(&func_id) { let matches_args = reps.len() == args.len() - && args.iter().zip(reps.iter()).all(|(arg, rep)| match rep { - crate::codegen::TypedParamRep::F64 => { - crate::type_analysis::is_numeric_expr(ctx, arg) - } - crate::codegen::TypedParamRep::I32 => { - matches!( - crate::type_analysis::static_type_of(ctx, arg), - Some(HirType::Int32) - ) || matches!( - arg, - Expr::Integer(n) - if (i64::from(i32::MIN) - ..=i64::from(i32::MAX)) - .contains(n) - ) - } - crate::codegen::TypedParamRep::I1 => { - crate::type_analysis::is_bool_expr(ctx, arg) - } - crate::codegen::TypedParamRep::StringRef => { - crate::type_analysis::is_definitely_string_expr(ctx, arg) - } + && args.iter().zip(reps.iter()).all(|(arg, rep)| { + crate::codegen::typed_arg_is_guard_candidate(ctx, *rep, arg) }); matches_args.then(|| reps.clone()) } else { @@ -538,6 +520,11 @@ pub fn try_lower_closure_typed_local_call( } else { None }; + let typed_capture_reps = ctx + .typed_closure_capture_reps + .get(&func_id) + .cloned() + .unwrap_or_default(); let fast_value = if let Some(typed_param_reps) = typed_f64_param_reps { let typed_fn = crate::codegen::typed_f64_closure_name(&closure_fn); let generic_closure_fn = @@ -550,6 +537,16 @@ pub fn try_lower_closure_typed_local_call( None => ok, }); } + if let Some(capture_guard) = crate::codegen::emit_typed_capture_guard( + ctx.block(), + &closure_handle, + &typed_capture_reps, + ) { + numeric_guard = Some(match numeric_guard { + Some(prev) => ctx.block().and(I1, &prev, &capture_guard), + None => capture_guard, + }); + } let typed_idx = ctx.new_block("closure_direct.typed_f64"); let generic_idx = ctx.new_block("closure_direct.generic"); @@ -640,6 +637,16 @@ pub fn try_lower_closure_typed_local_call( None => ok, }); } + if let Some(capture_guard) = crate::codegen::emit_typed_capture_guard( + ctx.block(), + &closure_handle, + &typed_capture_reps, + ) { + typed_guard = Some(match typed_guard { + Some(prev) => ctx.block().and(I1, &prev, &capture_guard), + None => capture_guard, + }); + } let typed_idx = ctx.new_block("closure_direct.typed_i32"); let generic_idx = ctx.new_block("closure_direct.generic"); @@ -732,24 +739,15 @@ pub fn try_lower_closure_typed_local_call( None => ok, }); } - let capture_count = ctx - .typed_string_closure_capture_counts - .get(&func_id) - .copied() - .unwrap_or(0); - if capture_count > 0 { - if let Some(capture_guard) = - crate::codegen::emit_typed_string_capture_guard( - ctx.block(), - &closure_handle, - capture_count, - ) - { - typed_guard = Some(match typed_guard { - Some(prev) => ctx.block().and(I1, &prev, &capture_guard), - None => capture_guard, - }); - } + if let Some(capture_guard) = crate::codegen::emit_typed_capture_guard( + ctx.block(), + &closure_handle, + &typed_capture_reps, + ) { + typed_guard = Some(match typed_guard { + Some(prev) => ctx.block().and(I1, &prev, &capture_guard), + None => capture_guard, + }); } let typed_idx = ctx.new_block("closure_direct.typed_string"); @@ -848,6 +846,16 @@ pub fn try_lower_closure_typed_local_call( None => ok, }); } + if let Some(capture_guard) = crate::codegen::emit_typed_capture_guard( + ctx.block(), + &closure_handle, + &typed_capture_reps, + ) { + typed_guard = Some(match typed_guard { + Some(prev) => ctx.block().and(I1, &prev, &capture_guard), + None => capture_guard, + }); + } let typed_idx = ctx.new_block("closure_direct.typed_i1"); let generic_idx = ctx.new_block("closure_direct.generic"); diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index 07f7b99981..4eabe3e967 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -20,22 +20,6 @@ fn is_i32_expr(ctx: &FnCtx<'_>, arg: &Expr) -> bool { } } -fn typed_i1_param_reps_match_args( - ctx: &FnCtx<'_>, - reps: &[crate::codegen::TypedParamRep], - args: &[Expr], -) -> bool { - reps.len() == args.len() - && args.iter().zip(reps.iter()).all(|(arg, rep)| match rep { - crate::codegen::TypedParamRep::F64 => crate::type_analysis::is_numeric_expr(ctx, arg), - crate::codegen::TypedParamRep::I32 => is_i32_expr(ctx, arg), - crate::codegen::TypedParamRep::I1 => crate::type_analysis::is_bool_expr(ctx, arg), - crate::codegen::TypedParamRep::StringRef => { - crate::type_analysis::is_definitely_string_expr(ctx, arg) - } - }) -} - fn typed_i1_signature_note(reps: &[crate::codegen::TypedParamRep]) -> String { let first = reps.first().map(|rep| rep.label()).unwrap_or("void"); if reps.len() <= 1 { @@ -519,7 +503,7 @@ pub fn try_lower_func_ref_call( { ctx.typed_i1_function_param_reps .get(fid) - .filter(|reps| typed_i1_param_reps_match_args(ctx, reps, args)) + .filter(|reps| crate::codegen::typed_param_reps_match_args(ctx, reps, args)) .cloned() } else { None @@ -532,7 +516,7 @@ pub fn try_lower_func_ref_call( { ctx.typed_i1_function_param_reps .get(fid) - .filter(|reps| typed_i1_param_reps_match_args(ctx, reps, args)) + .filter(|reps| crate::codegen::typed_param_reps_match_args(ctx, reps, args)) .cloned() } else { None @@ -545,7 +529,7 @@ pub fn try_lower_func_ref_call( { ctx.typed_i1_function_param_reps .get(fid) - .filter(|reps| typed_i1_param_reps_match_args(ctx, reps, args)) + .filter(|reps| crate::codegen::typed_param_reps_match_args(ctx, reps, args)) .cloned() } else { None @@ -558,7 +542,7 @@ pub fn try_lower_func_ref_call( { ctx.typed_i1_function_param_reps .get(fid) - .filter(|reps| typed_i1_param_reps_match_args(ctx, reps, args)) + .filter(|reps| crate::codegen::typed_param_reps_match_args(ctx, reps, args)) .cloned() } else { None diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index dac7fc670c..f6ca86807f 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -58,7 +58,7 @@ fn util_types_arg_is_async_function_static(ctx: &FnCtx<'_>, expr: &Expr) -> Opti match expr { Expr::FuncRef(fid) => Some(ctx.local_async_funcs.contains(fid)), Expr::Closure { is_async, .. } => Some(*is_async), - Expr::LocalGet(id) => match ctx.local_types.get(id) { + Expr::LocalGet(id) => match ctx.stable_local_type_proof(id) { Some(HirType::Function(ft)) => Some(ft.is_async), _ => None, }, @@ -230,7 +230,7 @@ pub(crate) fn lower_native_method_call( } else { let hazardous_ids = hazardous_module_global_ids( ctx.module_globals, - &ctx.local_types, + &ctx.module_global_proven_types, ); find_thread_hazard_in_body( body, diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 15214f1bce..ea12ccde4e 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1026,7 +1026,7 @@ fn lower_new_impl_inner<'a>( caps_absent_from_args, }); let mut saved_scope_for_ctor = class.constructor.as_ref().map(|ctor| { - bind_inline_constructor_params(ctx, &ctor.params, &lowered_args, ctor_capture_fill) + bind_inline_constructor_params(ctx, &ctor.params, &lowered_args, args, ctor_capture_fill) }); if let Some(stop_at) = inherited_ctor_class.clone() { @@ -1105,6 +1105,7 @@ fn lower_new_impl_inner<'a>( ctx, &parent_ctor.params, &lowered_args, + args, parent_capture_fill, ); diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index 420bc4523b..b448b644f0 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -23,12 +23,14 @@ use crate::types::{DOUBLE, I32, I64}; pub(crate) struct InlineConstructorScope { locals: std::collections::HashMap, local_types: std::collections::HashMap, + proven_local_types: std::collections::HashMap, boxed_vars: std::collections::HashSet, } pub(crate) fn restore_inline_constructor_scope(ctx: &mut FnCtx<'_>, saved: InlineConstructorScope) { ctx.locals = saved.locals; ctx.local_types = saved.local_types; + ctx.proven_local_types = saved.proven_local_types; ctx.boxed_vars = saved.boxed_vars; } @@ -36,18 +38,47 @@ pub(crate) fn bind_inline_constructor_params( ctx: &mut FnCtx<'_>, params: &[Param], lowered_args: &[String], + source_args: &[Expr], capture_fill: Option, ) -> InlineConstructorScope { let saved = InlineConstructorScope { locals: ctx.locals.clone(), local_types: ctx.local_types.clone(), + proven_local_types: ctx.proven_local_types.clone(), boxed_vars: ctx.boxed_vars.clone(), }; + // Snapshot initializer-derived evidence before installing constructor + // bindings. Source annotations are intentionally ignored: an inline + // parameter may inherit a representation only from the actual argument + // expression evaluated at this call site. Synthesized capture/rest/ + // arguments-object parameters have different marshaling and stay boxed. + let mut visible_index = 0usize; + let param_proofs: Vec> = params + .iter() + .map(|param| { + if param.name.starts_with("__perry_cap_") + || param.is_rest + || param.arguments_object.is_some() + { + return None; + } + let proof = source_args + .get(visible_index) + .and_then(|arg| crate::type_analysis::proven_type_from_init(ctx, arg)); + visible_index += 1; + proof + }) + .collect(); + crate::codegen::arguments::add_arguments_mapped_boxes(params, &mut ctx.boxed_vars); let values = inline_constructor_param_values_with_class(ctx, params, lowered_args, capture_fill); - for (param, arg_val) in params.iter().zip(values.iter()) { + for ((param, arg_val), proof) in params + .iter() + .zip(values.iter()) + .zip(param_proofs.into_iter()) + { let boxed_param = ctx.boxed_vars.contains(¶m.id) && param.arguments_object.is_none(); let slot = ctx .func @@ -63,6 +94,10 @@ pub(crate) fn bind_inline_constructor_params( } ctx.locals.insert(param.id, slot); ctx.local_types.insert(param.id, param.ty.clone()); + ctx.proven_local_types.remove(¶m.id); + if let Some(proof) = proof { + ctx.proven_local_types.insert(param.id, proof); + } } crate::codegen::arguments::materialize_arguments_object( diff --git a/crates/perry-codegen/src/lower_call/options/abort.rs b/crates/perry-codegen/src/lower_call/options/abort.rs index 0307fe68cf..831ea3f65f 100644 --- a/crates/perry-codegen/src/lower_call/options/abort.rs +++ b/crates/perry-codegen/src/lower_call/options/abort.rs @@ -21,7 +21,7 @@ pub(in crate::lower_call) fn is_abort_controller_expr(ctx: &FnCtx<'_>, e: &Expr) match e { Expr::New { class_name, .. } => class_name == "AbortController", Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(n)) if n == "AbortController" ), _ => false, @@ -34,7 +34,7 @@ pub(in crate::lower_call) fn is_abort_signal_typed_expr(ctx: &FnCtx<'_>, e: &Exp matches!( e, Expr::LocalGet(id) - if matches!(ctx.local_types.get(id), Some(HirType::Named(n)) if n == "AbortSignal") + if matches!(ctx.stable_local_type_proof(id), Some(HirType::Named(n)) if n == "AbortSignal") ) } diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 4ce487763a..0844040beb 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -24,6 +24,19 @@ use crate::lower_call::method_override::{ /// call it replaces — so past this width the site keeps the single-arm guard. const MAX_SUBCLASS_DISPATCH_ARMS: usize = 8; +/// A declared class may select the direct-method guard, but never prove the +/// direct call. The guard validates the live class id, keys token, own +/// override, and resolved method pointer; every miss uses dynamic dispatch. +fn guarded_declared_receiver_class_candidate(ctx: &FnCtx<'_>, object: &Expr) -> Option { + let Expr::LocalGet(id) = object else { + return None; + }; + let perry_hir::types::Type::Named(name) = ctx.local_type_hint(id)? else { + return None; + }; + ctx.classes.contains_key(name).then(|| name.clone()) +} + /// #7142: the proven-`this` clone a class-id dispatch-tower case may route to, /// plus the keys token the routed path must re-check inline. struct TowerPshapeRoute { @@ -161,6 +174,11 @@ pub(crate) fn try_lower_instance_method_call( args: &[Expr], call_byte_offset: u32, ) -> Result> { + // Runtime-derived evidence may identify the receiver directly. A declared + // local class is weaker: it may only select the later direct-method guard, + // whose miss path preserves dynamic JavaScript dispatch. + let receiver_class = receiver_class_name(ctx, object) + .or_else(|| guarded_declared_receiver_class_candidate(ctx, object)); // Skip dynamic dispatch when the receiver is GlobalGet (e.g. // `console.log`). GlobalGet is a module-level global object // (console, Math, JSON, etc.), not a class instance. Without @@ -176,9 +194,9 @@ pub(crate) fn try_lower_instance_method_call( // implementor and `buf.readUInt8(i)` would fall through to the // default 0.0 case when the Buffer's class id doesn't match any // tower entry. - let is_builtin_receiver = match receiver_class_name(ctx, object) { + let is_builtin_receiver = match receiver_class.as_deref() { Some(name) => matches!( - name.as_str(), + name, "Buffer" | "Uint8Array" | "Uint8ClampedArray" @@ -208,9 +226,9 @@ pub(crate) fn try_lower_instance_method_call( }; let needs_dynamic_dispatch = !is_global && !is_builtin_receiver - && match receiver_class_name(ctx, object) { + && match receiver_class.as_deref() { None => true, - Some(name) => !ctx.classes.contains_key(&name), + Some(name) => !ctx.classes.contains_key(name), }; if needs_dynamic_dispatch { // Find all (class_id → fn_name) for `property` — including @@ -689,7 +707,7 @@ pub(crate) fn try_lower_instance_method_call( } } - if let Some(class_name) = receiver_class_name(ctx, object) { + if let Some(class_name) = receiver_class { // Step 1: walk parent chain for the static method name. let mut static_fn: Option = None; let mut current_class = Some(class_name.clone()); @@ -1014,10 +1032,13 @@ pub(crate) fn try_lower_instance_method_call( .get(&typed_method_key) .is_some_and(|name| name == &fallback_fn) && args.len() == typed_formal_count - && args - .iter() - .all(|arg| crate::type_analysis::is_numeric_expr(ctx, arg)) - { + && args.iter().all(|arg| { + crate::codegen::typed_arg_is_guard_candidate( + ctx, + crate::codegen::TypedParamRep::F64, + arg, + ) + }) { Some(crate::codegen::typed_f64_receiver_method_name(&fallback_fn)) } else { None diff --git a/crates/perry-codegen/src/lower_call/scalar_method.rs b/crates/perry-codegen/src/lower_call/scalar_method.rs index eff2a894df..ab03768922 100644 --- a/crates/perry-codegen/src/lower_call/scalar_method.rs +++ b/crates/perry-codegen/src/lower_call/scalar_method.rs @@ -46,8 +46,7 @@ fn collect_guarded_numeric_arg_locals(ctx: &FnCtx<'_>, arg: &Expr) -> Option, id: u32, expected: Type) -> b && !ctx.boxed_vars.contains(&id) && !ctx.module_globals.contains_key(&id) && ctx.locals.contains_key(&id) - && ctx.local_types.get(&id).is_some_and(|ty| *ty == expected) + // `GuardedI32Local` / `GuardedI1Local` emit public tag guards before + // entering the scalar clone, so reassignment cannot make this answer. + && ctx.local_type_hint(&id).is_some_and(|ty| *ty == expected) } fn scalar_method_arg_plan(ctx: &FnCtx<'_>, arg: &Expr, param_ty: &Type) -> ScalarMethodArgPlan { @@ -314,6 +315,7 @@ fn lower_scalar_method_inline_body( ) -> Result { let saved_locals = ctx.locals.clone(); let saved_local_types = ctx.local_types.clone(); + let saved_proven_local_types = ctx.proven_local_types.clone(); let saved_this_len = ctx.this_stack.len(); let saved_class_len = ctx.class_stack.len(); let saved_scalar_ctor_len = ctx.scalar_ctor_target.len(); @@ -323,6 +325,7 @@ fn lower_scalar_method_inline_body( ctx.block().store(DOUBLE, value, &slot); ctx.locals.insert(param.id, slot); ctx.local_types.insert(param.id, param.ty.clone()); + ctx.proven_local_types.insert(param.id, param.ty.clone()); } ctx.scalar_ctor_target.push(receiver_id); @@ -344,6 +347,9 @@ fn lower_scalar_method_inline_body( ctx.block().store(DOUBLE, &value, &slot); ctx.locals.insert(*id, slot); ctx.local_types.insert(*id, ty.clone()); + if let Some(proven) = crate::type_analysis::proven_type_from_init(ctx, init) { + ctx.proven_local_types.insert(*id, proven); + } } perry_hir::Stmt::Expr(expr @ Expr::PropertySet { object, .. }) if matches!(object.as_ref(), Expr::This) => @@ -364,6 +370,7 @@ fn lower_scalar_method_inline_body( ctx.scalar_ctor_target.truncate(saved_scalar_ctor_len); ctx.locals = saved_locals; ctx.local_types = saved_local_types; + ctx.proven_local_types = saved_proven_local_types; let lowered = LoweredValue { semantic: SemanticKind::JsValue, @@ -411,6 +418,7 @@ fn lower_scalar_method_int32_inline_body( ) -> Result { let saved_locals = ctx.locals.clone(); let saved_local_types = ctx.local_types.clone(); + let saved_proven_local_types = ctx.proven_local_types.clone(); let saved_i32_slots = ctx.i32_counter_slots.clone(); let saved_this_len = ctx.this_stack.len(); let saved_class_len = ctx.class_stack.len(); @@ -421,6 +429,7 @@ fn lower_scalar_method_int32_inline_body( ctx.block().store(I32, value, &slot); ctx.i32_counter_slots.insert(param.id, slot); ctx.local_types.insert(param.id, param.ty.clone()); + ctx.proven_local_types.insert(param.id, param.ty.clone()); } ctx.scalar_ctor_target.push(receiver_id); @@ -442,6 +451,7 @@ fn lower_scalar_method_int32_inline_body( ctx.block().store(I32, &value, &slot); ctx.i32_counter_slots.insert(*id, slot); ctx.local_types.insert(*id, ty.clone()); + ctx.proven_local_types.insert(*id, Type::Int32); } perry_hir::Stmt::Return(Some(expr)) => { raw_i32 = Some(lower_expr_as_i32(ctx, expr)?); @@ -458,6 +468,7 @@ fn lower_scalar_method_int32_inline_body( ctx.scalar_ctor_target.truncate(saved_scalar_ctor_len); ctx.locals = saved_locals; ctx.local_types = saved_local_types; + ctx.proven_local_types = saved_proven_local_types; ctx.i32_counter_slots = saved_i32_slots; let lowered = LoweredValue { diff --git a/crates/perry-codegen/src/lower_conditional.rs b/crates/perry-codegen/src/lower_conditional.rs index 3fe902fe6e..243cfa4b3b 100644 --- a/crates/perry-codegen/src/lower_conditional.rs +++ b/crates/perry-codegen/src/lower_conditional.rs @@ -34,10 +34,15 @@ use crate::types::{DOUBLE, I32, I64}; pub(crate) fn lower_truthy(ctx: &mut FnCtx<'_>, cond_val: &str, cond_expr: &Expr) -> String { if is_numeric_expr(ctx, cond_expr) && !expr_may_return_boxed_value_from_raw_f64_fallback(ctx, cond_expr) + // Keep bare binding truthiness independent of binding-level type + // evidence, including any future provenance extensions. Constructed + // numeric expressions stay inline; slot values use the total runtime + // predicate (#7846). + && !matches!(cond_expr, Expr::LocalGet(_)) { return ctx.block().fcmp("one", cond_val, "0.0"); } - if is_bool_expr(ctx, cond_expr) { + if is_bool_expr(ctx, cond_expr) && !matches!(cond_expr, Expr::LocalGet(_)) { // The lowered cond_val is *normally* NaN-boxed TAG_TRUE or TAG_FALSE, // but for optional `boolean` parameters that the caller didn't pass, // codegen pads the missing arg with TAG_UNDEFINED at the call site @@ -52,6 +57,10 @@ pub(crate) fn lower_truthy(ctx: &mut FnCtx<'_>, cond_val: &str, cond_expr: &Expr // an `Array<{entity, components}>` of length 0 and downstream // assertions on the entity count fired. // + // A bare local is also excluded above: a declared `boolean` can hold + // any runtime value, so its annotation may select this predicate but + // cannot license a tag equality as the answer. + // // Use `bits == TAG_TRUE` instead, which is also two ALU ops and // correctly reports `false` for both TAG_FALSE and TAG_UNDEFINED. let blk = ctx.block(); diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index 1aecd40230..198616ee8f 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -554,8 +554,8 @@ pub(crate) fn flatten_string_add_chain<'a>( left: l, right: r, } => { - let l_str = crate::type_analysis::is_definitely_string_expr(ctx, l); - let r_str = crate::type_analysis::is_definitely_string_expr(ctx, r); + let l_str = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, l); + let r_str = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, r); if !l_str && !r_str { // Stop the descent — this Add isn't unambiguously // string-shaped. Treat the entire `cur` subtree as diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index 27704d9974..19a480d2c0 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -559,7 +559,7 @@ fn lower_string_method_dispatch( let needle_is_regex = args.first().is_some_and(|needle| { matches!(needle, Expr::RegExp { .. }) || matches!(needle, Expr::LocalGet(id) if matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(n)) if n == "RegExp" )) }); diff --git a/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs index 6e3fd5362a..23d36b575d 100644 --- a/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs +++ b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs @@ -137,8 +137,8 @@ fn the_pipeline_produces_safepoints_and_a_map_on_every_shipped_target() { ); assert_eq!( root_allocas(fn_ir), - 2, - "[{target}] two heap locals, two root slots:\n{fn_ir}" + 3, + "[{target}] two heap locals plus the unchecked generic-ABI parameter, three root slots:\n{fn_ir}" ); let points = statepoints_of(&ir, target, &symbol); diff --git a/crates/perry-codegen/src/native_root_coverage/mechanics.rs b/crates/perry-codegen/src/native_root_coverage/mechanics.rs index 6aadee35ce..4b2e6cfe3e 100644 --- a/crates/perry-codegen/src/native_root_coverage/mechanics.rs +++ b/crates/perry-codegen/src/native_root_coverage/mechanics.rs @@ -83,9 +83,9 @@ fn a_live_pointer_local_is_a_root_in_the_emitted_map() { ); assert_eq!( root_allocas(fn_ir), - 2, - "[{target}] both heap locals must be `ptr addrspace(1)` root \ - slots:\n{fn_ir}" + 3, + "[{target}] both heap locals and the unchecked generic-ABI \ + parameter must be `ptr addrspace(1)` root slots:\n{fn_ir}" ); // (2) the result @@ -226,7 +226,7 @@ fn a_value_that_is_dead_at_a_safepoint_is_not_in_its_live_set() { /// /// **Sabotage** — `function/precise_roots.rs`, retyping every `alloca double` /// rather than only the bound roots: RED, the numeric program's root slots went -/// 1 → 2 while the heap control stayed at 2, collapsing the difference the +/// 2 → 3 while the heap control stayed at 3, collapsing the difference the /// mechanic is about. #[test] fn a_numeric_local_reserves_no_root_and_a_heap_one_does() { @@ -263,9 +263,10 @@ fn a_numeric_local_reserves_no_root_and_a_heap_one_does() { assert_eq!( (root_allocas(numeric_fn), root_allocas(heap_fn)), - (1, 2), - "[{target}] two locals, of which only the heap one may reserve a \ - root slot. Stated as a pair so a lowering that stopped rooting \ + (2, 3), + "[{target}] the unchecked parameter is the common baseline; of \ + the two locals, only the heap one may reserve another root slot. \ + Stated as a pair so a lowering that stopped rooting \ ANYTHING fails the second half instead of passing the first.\ \nnumeric:\n{numeric_fn}\nheap:\n{heap_fn}" ); @@ -446,9 +447,10 @@ fn a_loop_iterations_dead_root_is_not_live_at_the_next_iteration() { ); assert_eq!( subject_allocs[1].live.len(), - 1, - "[{target}] only `acc` may be live at the in-loop allocation. A \ - second root here is the previous iteration's `tmp` surviving the \ + 2, + "[{target}] only `acc` and the unchecked loop-bound parameter may \ + be live at the in-loop allocation. A third root here is the \ + previous iteration's `tmp` surviving the \ back edge: {:?}", subject_allocs[1] ); @@ -470,9 +472,10 @@ fn a_loop_iterations_dead_root_is_not_live_at_the_next_iteration() { let control_allocs = control_points.at("js_map_alloc"); assert_eq!( control_allocs.last().unwrap().live.len(), - 2, - "[{target}] CONTROL: two outer locals live across the loop must \ - both be roots at the in-loop allocation — so the count above is \ + 3, + "[{target}] CONTROL: two outer locals plus the unchecked bound \ + parameter live across the loop must all be roots at the in-loop \ + allocation — so the count above is \ not a cap: {:?}", control_allocs.last().unwrap() ); diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 43dd46a832..c960c46e23 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -68,10 +68,10 @@ //! |---|---|---|---| //! | 1 | a pointer local is a root | `ptr addrspace(1)` slot + live at the next allocation's statepoint + in the map | `mechanics::a_live_pointer_local_is_a_root_in_the_emitted_map` | //! | 2 | a dead value stops being a root | absent from the live set, against a live control | `mechanics::a_value_that_is_dead_at_a_safepoint_is_not_in_its_live_set` | -//! | 3 | a numeric local reserves nothing | slot counts `(1, 2)` against a heap twin | `mechanics::a_numeric_local_reserves_no_root_and_a_heap_one_does` | +//! | 3 | a numeric local reserves nothing | slot counts `(2, 3)` against a heap twin, including the unchecked parameter baseline | `mechanics::a_numeric_local_reserves_no_root_and_a_heap_one_does` | //! | 4 | slot indices unshifted by a numeric local | *subsumed by 3* — native roots have no indices; the substance is that a numeric local does not perturb the root set | — | //! | 5 | entry roots begin after the init prelude | first rooted safepoint follows `js_gc_init` and `__perry_init_strings_*` | `mechanics::no_entry_module_root_is_live_before_the_gc_is_initialized` | -//! | 6 | a loop's roots do not cross the back edge | in-loop live set is 1, against a 2-root control | `mechanics::a_loop_iterations_dead_root_is_not_live_at_the_next_iteration` | +//! | 6 | a loop's roots do not cross the back edge | in-loop live set is 2, against a 3-root control, including the unchecked parameter baseline | `mechanics::a_loop_iterations_dead_root_is_not_live_at_the_next_iteration` | //! | 7 | scalar-replaced heap field is a root (#6968) | extra slot + non-empty map, against the numeric twin | `mechanics::a_scalar_replaced_field_holding_a_heap_value_is_a_native_root` | //! | 8 | scalar-replaced numeric literal pays nothing (#6997) | empty map, against a one-heap-field twin | `mechanics::a_numeric_only_scalar_replaced_literal_pays_no_native_rooting` | //! | 9 | every reserved slot reaches the root set (#7184) | two live locals, two map roots | `mechanics::a_deduplicated_slot_index_still_reaches_the_native_root_set` | diff --git a/crates/perry-codegen/src/rooting/temp_root.rs b/crates/perry-codegen/src/rooting/temp_root.rs index 73f186cb38..bc881faba5 100644 --- a/crates/perry-codegen/src/rooting/temp_root.rs +++ b/crates/perry-codegen/src/rooting/temp_root.rs @@ -410,29 +410,25 @@ pub(crate) fn expr_is_inert_primitive(ctx: &FnCtx<'_>, expr: &Expr) -> bool { /// to still looks like a number here. Those per-function facts are sound for /// a genuine local and not for a global, so a global is never inert. /// -/// What this does NOT defend against is a *lying annotation*: `let n: number` -/// that is handed an object anyway. Nothing here catches that — but nothing -/// else in the compiler does either, and it is not this predicate's assumption -/// to make good on. `collect_pointer_typed_locals` reserves root slots from the -/// same declared type, so such a local has no shadow slot and the precise scan -/// cannot see the object at all; the value is already unrooted long before any -/// coercion of it reaches a poll decision. Honesty of scalar annotations is a -/// standing invariant of the precise-root design, inherited here rather than -/// introduced. +/// A declaration is deliberately absent from this judgment. The type arm uses +/// only runtime-derived initializer evidence; `integer_locals` is the separate +/// whole-write structural proof for literal-seeded recurrences. A lying scalar +/// annotation therefore retains its root and cannot make coercion look inert. pub(in crate::rooting) fn local_is_inert_primitive(ctx: &FnCtx<'_>, id: u32) -> bool { !ctx.shadow_slot_map.contains_key(&id) && !ctx.module_globals.contains_key(&id) - && matches!( - ctx.local_types.get(&id), - Some( - HirType::Number - | HirType::Int32 - | HirType::Boolean - | HirType::Null - | HirType::Void - | HirType::Never - ) - ) + && (ctx.integer_locals.contains(&id) + || matches!( + ctx.stable_local_type_proof(&id), + Some( + HirType::Number + | HirType::Int32 + | HirType::Boolean + | HirType::Null + | HirType::Void + | HirType::Never + ) + )) } /// Already-lowered operand values kept alive across work whose shape the diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index ca7e64833f..8e5521ca7e 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -141,6 +141,7 @@ struct ElementShapeVersionedLoop { /// #7771: the body's `const r = arr[counter]` binding in the two-statement /// form; `None` for the original single-statement accumulator body. element_binding: Option, + accumulator_id: u32, } /// Effect-free expression walk for the element-shape loop. @@ -155,6 +156,7 @@ fn element_shape_loop_pure_expr_collect( ctx: &FnCtx<'_>, expr: &perry_hir::Expr, counter_id: u32, + accumulator_id: u32, element_binding: Option, array: &mut Option, props: &mut std::collections::BTreeSet, @@ -204,7 +206,7 @@ fn element_shape_loop_pure_expr_collect( Expr::LocalGet(id) => { element_binding != Some(*id) && array.is_none_or(|a| a != *id) - && crate::type_analysis::is_numeric_expr(ctx, expr) + && (*id == accumulator_id || crate::type_analysis::is_numeric_expr(ctx, expr)) } Expr::Number(_) | Expr::Integer(_) => true, // NOTE (#7480 step 3): deliberately NOT gated on @@ -228,6 +230,7 @@ fn element_shape_loop_pure_expr_collect( ctx, left, counter_id, + accumulator_id, element_binding, array, props, @@ -235,6 +238,7 @@ fn element_shape_loop_pure_expr_collect( ctx, right, counter_id, + accumulator_id, element_binding, array, props, @@ -244,6 +248,7 @@ fn element_shape_loop_pure_expr_collect( ctx, operand, counter_id, + accumulator_id, element_binding, array, props, @@ -253,6 +258,7 @@ fn element_shape_loop_pure_expr_collect( ctx, left, counter_id, + accumulator_id, element_binding, array, props, @@ -260,13 +266,22 @@ fn element_shape_loop_pure_expr_collect( ctx, right, counter_id, + accumulator_id, element_binding, array, props, ) } Expr::MathMin(values) | Expr::MathMax(values) => values.iter().all(|e| { - element_shape_loop_pure_expr_collect(ctx, e, counter_id, element_binding, array, props) + element_shape_loop_pure_expr_collect( + ctx, + e, + counter_id, + accumulator_id, + element_binding, + array, + props, + ) }), Expr::MathAbs(value) | Expr::MathSqrt(value) @@ -279,6 +294,7 @@ fn element_shape_loop_pure_expr_collect( ctx, value, counter_id, + accumulator_id, element_binding, array, props, @@ -305,14 +321,10 @@ fn element_shape_loop_pure_expr_collect( /// Neither has to be *right*: the preheader compares the class id the runtime /// invariant reports against this one, so a wrong answer costs the clone, /// never correctness. The annotation stays a hint, never layout. -fn element_class_name(ctx: &FnCtx<'_>, array_id: u32, counter_id: u32) -> Option { - if let Some(named) = crate::type_analysis::receiver_class_name( - ctx, - &perry_hir::Expr::IndexGet { - object: Box::new(perry_hir::Expr::LocalGet(array_id)), - index: Box::new(perry_hir::Expr::LocalGet(counter_id)), - }, - ) { +fn element_class_name(ctx: &FnCtx<'_>, array_id: u32, _counter_id: u32) -> Option { + if let perry_hir::types::Type::Named(named) = + resolve_type_alias(ctx, declared_array_element_type_hint(ctx, array_id)?) + { // Only if it names a REAL class. `type Node = {v: number}` makes the // element type `Named("Node")`, and the receiver resolver reports // "Node" for it — a name no `ctx.classes` entry answers to, because the @@ -320,8 +332,8 @@ fn element_class_name(ctx: &FnCtx<'_>, array_id: u32, counter_id: u32) -> Option // shadowed arm 2 for every alias-typed array, which is how the second // half of `churn_read`'s miss survived #7669: the anon-shape resolver // landed and was then never consulted for the shape it was written for. - if ctx.classes.contains_key(&named) { - return Some(named); + if ctx.classes.contains_key(named) { + return Some(named.clone()); } } anon_shape_class_for_element_type(ctx, array_id) @@ -353,6 +365,24 @@ fn resolve_type_alias<'t>( /// to (`perry-hir/src/lower/context.rs::mint_anon_shape_class`). const ANON_SHAPE_PREFIX: &str = "__AnonShape_"; +/// Erased element metadata used only to choose the class-id candidate for the +/// versioned clone. The preheader validates that candidate against the live +/// array invariant before the clone is reachable. +fn declared_array_element_type_hint<'a>( + ctx: &'a FnCtx<'_>, + array_id: u32, +) -> Option<&'a perry_hir::types::Type> { + use perry_hir::types::Type as HirType; + + match resolve_type_alias(ctx, ctx.local_type_hint(&array_id)?) { + HirType::Array(elem) => Some(elem.as_ref()), + HirType::Generic { base, type_args } if base == "Array" && type_args.len() == 1 => { + Some(&type_args[0]) + } + _ => None, + } +} + /// #7480 step 3: resolve `keep: {v: number, w: number}[]` to the /// `__AnonShape_` class its literals allocate. /// @@ -384,15 +414,10 @@ const ANON_SHAPE_PREFIX: &str = "__AnonShape_"; fn anon_shape_class_for_element_type(ctx: &FnCtx<'_>, array_id: u32) -> Option { use perry_hir::types::Type as HirType; - let array_ty = resolve_type_alias(ctx, ctx.local_types.get(&array_id)?); - let elem = match array_ty { - HirType::Array(elem) => elem.as_ref(), - // `new Array<{v: number}>(n)` locals carry the generic spelling. - HirType::Generic { base, type_args } if base == "Array" && type_args.len() == 1 => { - &type_args[0] - } - _ => return None, - }; + // The annotation selects a candidate versioned clone. The clone's + // preheader validates the receiver kind, array head, shape, and key token + // before any representation-specific access, and falls back on failure. + let elem = declared_array_element_type_hint(ctx, array_id)?; // `type Node = {v: number; w: number}` — the annotation names the shape one // indirection away. Both levels are resolved (`type Row = Node[]` too). let HirType::Object(obj) = resolve_type_alias(ctx, elem) else { @@ -631,8 +656,10 @@ fn match_element_shape_versioned_loop( || !ctx.locals.contains_key(acc_id) || ctx.boxed_vars.contains(acc_id) || ctx.module_globals.contains_key(acc_id) - || ctx.shadow_slot_map.contains_key(acc_id) - || !crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*acc_id)) + // The declared type is only a candidate. The lowering validates the + // accumulator's current NaN-box tag in the preheader before installing + // the numeric fact for the fast clone. + || !matches!(ctx.local_type_hint(acc_id), Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32)) { return None; } @@ -648,6 +675,7 @@ fn match_element_shape_versioned_loop( ctx, value, counter_id, + *acc_id, element_binding, &mut array, &mut props, @@ -754,6 +782,7 @@ fn match_element_shape_versioned_loop( keys_global_name, fields, element_binding, + accumulator_id: *acc_id, }) } @@ -846,6 +875,9 @@ pub(super) fn lower_element_shape_versioned_for( trip_count, &slow_pre_label, )?; + let accumulator = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.accumulator_id))?; + let accumulator_is_number = emit_js_value_is_number(ctx, &accumulator); + let fast_path_ok = ctx.block().and(I1, &shape_ok, &accumulator_is_number); // Deliberately unterminated: it branches into the fast clone only after // the clone is PROVEN call-free below. let deref_idx = ctx.current_block; @@ -871,6 +903,7 @@ pub(super) fn lower_element_shape_versioned_for( fields: matched.fields.clone(), max_field_index, element_binding: matched.element_binding, + numeric_accumulator: matched.accumulator_id, }); let lowered = lower_for_after_init_with_i32_bound( ctx, @@ -898,7 +931,7 @@ pub(super) fn lower_element_shape_versioned_for( ctx.current_block = deref_idx; if fast_clone_call_free { ctx.block() - .cond_br(&shape_ok, &fast_pre_label, &slow_pre_label); + .cond_br(&fast_path_ok, &fast_pre_label, &slow_pre_label); } else { ctx.block().br(&slow_pre_label); } diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 7fc5ea4171..55cf73984e 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -289,6 +289,18 @@ pub(crate) fn lower_let( ty.clone() }; + // Keep runtime-derived evidence separate from the erased declaration. + // A binding such as `const n: number = ({} as any)` therefore records an + // Object proof (or no proof), never Number. The accessor additionally + // rejects every id written anywhere in this region, so this initializer + // fact cannot survive a non-dominating assignment (#7846). + ctx.proven_local_types.remove(&id); + if let Some(proven) = + init.and_then(|expr| crate::type_analysis::proven_type_from_init(ctx, expr)) + { + ctx.proven_local_types.insert(id, proven); + } + // #7773/#7506: a numeric local inherits a DECLARED-ONLY proof from its // initializer. `const v = o.x` reaches this as `Any` refined to `Number`, // while TypeScript's inferred `const sum = o.x + o.y` already reaches the @@ -937,6 +949,15 @@ pub(crate) fn lower_let( return Ok(()); } + // Preserve only initializer-derived argument evidence for + // the inlined constructor parameters. Their declarations are + // metadata, but the already-evaluated argument value can + // legitimately establish a call-site-scoped runtime kind. + let arg_proofs: Vec> = args + .iter() + .map(|arg| crate::type_analysis::proven_type_from_init(ctx, arg)) + .collect(); + // Lower args first let mut lowered_args: Vec = Vec::new(); for a in args { @@ -1024,15 +1045,23 @@ pub(crate) fn lower_let( if let Some(ctor) = &ctor { let saved_locals = ctx.locals.clone(); let saved_local_types = ctx.local_types.clone(); - for (param, arg_val) in ctor.params.iter().zip(lowered_args.iter()) { + let saved_proven_local_types = ctx.proven_local_types.clone(); + for (index, (param, arg_val)) in + ctor.params.iter().zip(lowered_args.iter()).enumerate() + { let slot = ctx.func.alloca_entry(DOUBLE); ctx.block().store(DOUBLE, arg_val, &slot); ctx.locals.insert(param.id, slot); ctx.local_types.insert(param.id, param.ty.clone()); + ctx.proven_local_types.remove(¶m.id); + if let Some(Some(proof)) = arg_proofs.get(index) { + ctx.proven_local_types.insert(param.id, proof.clone()); + } } crate::stmt::lower_stmts(ctx, &ctor.body)?; ctx.locals = saved_locals; ctx.local_types = saved_local_types; + ctx.proven_local_types = saved_proven_local_types; } else if class_has_extends { // No own ctor — JS spec defaults to // `constructor(...args) { super(...args); }`. Walk @@ -1047,6 +1076,7 @@ pub(crate) fn lower_let( if let Some(parent_ctor) = &parent_class.constructor { let saved_locals = ctx.locals.clone(); let saved_local_types = ctx.local_types.clone(); + let saved_proven_local_types = ctx.proven_local_types.clone(); for (i, param) in parent_ctor.params.iter().enumerate() { let slot = ctx.func.alloca_entry(DOUBLE); if i < lowered_args.len() { @@ -1059,6 +1089,10 @@ pub(crate) fn lower_let( } ctx.locals.insert(param.id, slot); ctx.local_types.insert(param.id, param.ty.clone()); + ctx.proven_local_types.remove(¶m.id); + if let Some(Some(proof)) = arg_proofs.get(i) { + ctx.proven_local_types.insert(param.id, proof.clone()); + } } ctx.class_stack.pop(); ctx.class_stack.push(pname.clone()); @@ -1067,6 +1101,7 @@ pub(crate) fn lower_let( ctx.class_stack.push(class_name.clone()); ctx.locals = saved_locals; ctx.local_types = saved_local_types; + ctx.proven_local_types = saved_proven_local_types; break; } parent_name = parent_class.extends_name.clone(); @@ -1728,8 +1763,8 @@ pub(crate) fn lower_let( } else { ctx.i1_local_slots.remove(&id); let v = lower_expr_with_expected_type(ctx, init_expr, Some(&refined_ty))?; - // String aliasing fix: `let y = x` (init is `LocalGet` - // of a string-typed local) shares the same heap + // String aliasing fix: `let y = x` (init is `LocalGet`) + // may share the same heap // pointer between `y` and `x`. A later // `x = x + suffix` would otherwise see refcount==1 // and mutate the string in-place via @@ -1742,19 +1777,13 @@ pub(crate) fn lower_let( // and `test_edge_error_handling`'s `finallyReturn` // started returning `start-try-finally` instead of // `start-try`. - if let perry_hir::Expr::LocalGet(src_id) = init_expr { - if matches!( - ctx.local_types.get(src_id), - Some(perry_hir::types::Type::String) - ) { - let blk = ctx.block(); - let s_ptr = blk.call( - crate::types::I64, - "js_get_string_pointer_unified", - &[(DOUBLE, &v)], - ); - blk.call_void("js_string_addref", &[(crate::types::I64, &s_ptr)]); - } + // The tag-checking helper is intentionally unconditional + // for a local source. A declared numeric/object type can + // still hold a string at runtime, and the old type gate + // then left this alias invisible to self-append (#7846). + if matches!(init_expr, perry_hir::Expr::LocalGet(_)) { + ctx.block() + .call_void("js_string_addref_if_heap_string", &[(DOUBLE, &v)]); } ctx.block().store(DOUBLE, &v, &slot); v @@ -1762,8 +1791,8 @@ pub(crate) fn lower_let( } else { ctx.i1_local_slots.remove(&id); let v = lower_expr_with_expected_type(ctx, init_expr, Some(&refined_ty))?; - // String aliasing fix: `let y = x` (init is `LocalGet` - // of a string-typed local) shares the same heap + // String aliasing fix: `let y = x` (init is `LocalGet`) may + // share the same heap // pointer between `y` and `x`. A later // `x = x + suffix` would otherwise see refcount==1 // and mutate the string in-place via @@ -1776,19 +1805,9 @@ pub(crate) fn lower_let( // and `test_edge_error_handling`'s `finallyReturn` // started returning `start-try-finally` instead of // `start-try`. - if let perry_hir::Expr::LocalGet(src_id) = init_expr { - if matches!( - ctx.local_types.get(src_id), - Some(perry_hir::types::Type::String) - ) { - let blk = ctx.block(); - let s_ptr = blk.call( - crate::types::I64, - "js_get_string_pointer_unified", - &[(DOUBLE, &v)], - ); - blk.call_void("js_string_addref", &[(crate::types::I64, &s_ptr)]); - } + if matches!(init_expr, perry_hir::Expr::LocalGet(_)) { + ctx.block() + .call_void("js_string_addref_if_heap_string", &[(DOUBLE, &v)]); } ctx.block().store(DOUBLE, &v, &slot); v diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 1167438cda..bd5a91e5be 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -227,7 +227,7 @@ fn match_numeric_bulk_fill_loop( _ => return None, }; let is_numeric_array = matches!( - ctx.local_types.get(&array_id), + ctx.stable_local_type_proof(&array_id), Some(perry_hir::types::Type::Array(elem)) if matches!(elem.as_ref(), perry_hir::types::Type::Number | perry_hir::types::Type::Int32) ); @@ -4153,7 +4153,10 @@ fn local_array_element_type<'t>( ctx: &'t FnCtx<'_>, local_id: u32, ) -> Option<&'t perry_hir::types::Type> { - match ctx.local_types.get(&local_id) { + // This element type only selects versioned loop candidates. Every caller + // validates the live receiver and element layout in a preheader guard + // before entering the raw clone. + match ctx.local_type_hint(&local_id) { Some(perry_hir::types::Type::Array(elem)) => Some(elem.as_ref()), Some(perry_hir::types::Type::Generic { base, type_args }) if base == "Array" && type_args.len() == 1 => @@ -4238,7 +4241,7 @@ pub(super) fn local_is_number_array(ctx: &FnCtx<'_>, local_id: u32) -> bool { /// ineligible — their guard chains would be dead weight. pub(super) fn local_is_untyped_candidate(ctx: &FnCtx<'_>, local_id: u32) -> bool { matches!( - ctx.local_types.get(&local_id), + ctx.stable_local_type_proof(&local_id), None | Some(perry_hir::types::Type::Any | perry_hir::types::Type::Unknown) ) } @@ -4349,9 +4352,6 @@ fn expr_is_packed_f64_loop_store_rhs_safe( Expr::LocalGet(id) => *id != arr_id && crate::type_analysis::is_numeric_expr(ctx, expr), Expr::Number(_) | Expr::Integer(_) => true, Expr::Binary { left, right, .. } => { - if !crate::type_analysis::is_numeric_expr(ctx, expr) { - return false; - } expr_is_packed_f64_loop_store_rhs_safe(ctx, left, arr_id, counter_id) && expr_is_packed_f64_loop_store_rhs_safe(ctx, right, arr_id, counter_id) } @@ -4363,17 +4363,16 @@ fn expr_is_packed_f64_loop_store_rhs_safe( } fn expr_is_packed_f64_loop_store_abs_rhs_safe( - ctx: &FnCtx<'_>, + _ctx: &FnCtx<'_>, expr: &perry_hir::Expr, arr_id: u32, counter_id: u32, ) -> bool { - crate::type_analysis::is_numeric_expr(ctx, expr) - && matches!( - expr, - perry_hir::Expr::IndexGet { object, index } - if is_packed_f64_loop_index(object, index, arr_id, counter_id) - ) + matches!( + expr, + perry_hir::Expr::IndexGet { object, index } + if is_packed_f64_loop_index(object, index, arr_id, counter_id) + ) } fn expr_is_packed_i32_loop_store_rhs_safe( @@ -4431,10 +4430,11 @@ fn expr_is_packed_i32_loop_store_rhs_safe( } fn local_is_int32_value(ctx: &FnCtx<'_>, local_id: u32) -> bool { - matches!( - ctx.local_types.get(&local_id), - Some(perry_hir::types::Type::Int32) - ) || ctx.integer_locals.contains(&local_id) + ctx.integer_locals.contains(&local_id) + || matches!( + ctx.stable_local_type_proof(&local_id), + Some(perry_hir::types::Type::Int32) + ) } fn expr_is_packed_f64_loop_safe( diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index f20dedaaa9..51bd160568 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -178,7 +178,7 @@ fn expr_is_number_under( Expr::LocalGet(id) => { refined.contains(id) || matches!( - ctx.local_types.get(id), + ctx.local_type_hint(id), Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32) ) } @@ -598,7 +598,7 @@ pub(super) fn try_match_masked_window_region( && !ctx.boxed_vars.contains(&id) && !ctx.closure_captures.contains_key(&id) && !matches!( - ctx.local_types.get(&id), + ctx.local_type_hint(&id), Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32) ) }; @@ -719,7 +719,10 @@ fn lower_region_copy( let id = refinements[r].local_id; let set_number = refinements[r].set_number; if saved_ids.insert(id) { - saved.push((id, ctx.local_types.get(&id).cloned())); + // Save the map entry itself. The active masked-window guard is + // the value proof; the whole-region reassignment set would + // intentionally hide this scoped refinement. + saved.push((id, ctx.local_type_hint(&id).cloned())); } if set_number { ctx.local_types.insert(id, perry_hir::types::Type::Number); diff --git a/crates/perry-codegen/src/stmt/unused_expr.rs b/crates/perry-codegen/src/stmt/unused_expr.rs index f44fbbab1c..73fc3351fc 100644 --- a/crates/perry-codegen/src/stmt/unused_expr.rs +++ b/crates/perry-codegen/src/stmt/unused_expr.rs @@ -105,8 +105,8 @@ fn unused_binary_is_pure_nonthrowing( if l_num && r_num { return true; } - let l_str = crate::type_analysis::is_definitely_string_expr(ctx, left); - let r_str = crate::type_analysis::is_definitely_string_expr(ctx, right); + let l_str = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, left); + let r_str = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, right); (l_str || r_str) && unused_primitive_expr_is_nonthrowing(ctx, left) && unused_primitive_expr_is_nonthrowing(ctx, right) @@ -130,7 +130,7 @@ fn unused_binary_is_pure_nonthrowing( fn unused_primitive_expr_is_nonthrowing(ctx: &FnCtx<'_>, expr: &perry_hir::Expr) -> bool { crate::type_analysis::is_numeric_expr(ctx, expr) - || crate::type_analysis::is_definitely_string_expr(ctx, expr) + || crate::type_analysis::string_value_is_runtime_guaranteed(ctx, expr) || crate::type_analysis::is_bool_expr(ctx, expr) || matches!( expr, diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index f427eeae5a..16f6d4ace7 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -53,7 +53,8 @@ pub(crate) use predicates::{ pub(crate) use predicates::tuple_index_literal; pub(crate) use refine::{ compute_auto_captures, declared_array_property_claim, is_crypto_digest_chain, - is_global_constructor_expr, is_process_namespace_version_property, refine_type_from_init, + is_global_constructor_expr, is_process_namespace_version_property, proven_type_from_init, + refine_type_from_init, }; pub(crate) use strings::{ class_name_extends_url_search_params, is_declared_string_expr, is_definitely_string_expr, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 86ba857571..2c90cc08d4 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -31,7 +31,7 @@ pub(crate) fn is_bigint_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { Expr::BigInt(_) => true, // `BigInt(x)` always returns a bigint. Expr::BigIntCoerce(_) => true, - Expr::LocalGet(id) => matches!(ctx.local_types.get(id), Some(HirType::BigInt)), + Expr::LocalGet(id) => matches!(ctx.stable_local_type_proof(id), Some(HirType::BigInt)), Expr::StaticMethodCall { class_name, method_name, @@ -140,10 +140,19 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // operands, the non-BigInt bitwise fast path. Expr::Uint8ArrayGet { index, .. } => is_numeric_expr(ctx, index), Expr::BufferIndexGet { .. } | Expr::Uint8ArrayLength(_) | Expr::BufferLength(_) => true, - Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), - Some(HirType::Number) | Some(HirType::Int32) - ), + Expr::LocalGet(id) => { + ctx.element_shape_loop_facts + .iter() + .rev() + .any(|fact| fact.numeric_accumulator == *id) + || ctx.integer_locals.contains(id) + || ctx.unsigned_i32_locals.contains(id) + || ctx.int_valued_i64_locals.contains_key(id) + || matches!( + ctx.stable_local_type_proof(id), + Some(HirType::Number) | Some(HirType::Int32) + ) + } // NOTE: Expr::Compare is NOT numeric — it produces a NaN-boxed // TAG_TRUE/TAG_FALSE which `fcmp one cond, 0.0` would handle // incorrectly (NaN compared with 0.0 is unordered → false). @@ -176,7 +185,7 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // would silently produce NaN. Expr::Update { id, .. } => { matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Number) | Some(HirType::Int32) ) || ctx.integer_locals.contains(id) || ctx.unsigned_i32_locals.contains(id) @@ -401,7 +410,7 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { let Expr::LocalGet(arr_id) = object.as_ref() else { return false; }; - match ctx.local_types.get(arr_id) { + match ctx.stable_local_type_proof(arr_id) { Some(HirType::Array(elem)) => { matches!(**elem, HirType::Number | HirType::Int32) } @@ -608,7 +617,8 @@ pub(crate) fn is_provably_not_bigint(ctx: &FnCtx<'_>, e: &Expr) -> bool { } // Already-proven primitives: a real Number/Int32, a Boolean, or a String // are all definitionally non-BigInt. - if is_numeric_expr(ctx, e) || is_bool_expr(ctx, e) || is_definitely_string_expr(ctx, e) { + if is_numeric_expr(ctx, e) || is_bool_expr(ctx, e) || string_value_is_runtime_guaranteed(ctx, e) + { return true; } match e { @@ -726,6 +736,13 @@ pub(crate) fn is_integer_valued_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// so letting it widen the divisor would introduce a UB window that /// `integer_locals` alone does not open. The dividend has no such constraint. pub(crate) fn is_integer_valued_divisor(ctx: &FnCtx<'_>, e: &Expr) -> bool { + if matches!( + e, + Expr::LocalGet(id) | Expr::Update { id, .. } + if ctx.int_valued_i64_locals.contains_key(id) + ) { + return false; + } integer_magnitude_bits_inner(ctx, e, false).is_some_and(|bits| bits <= MAX_FPTOSI_I64_BITS) } @@ -836,7 +853,7 @@ pub(crate) fn is_bool_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { | Expr::MapHas { .. } | Expr::MapDelete { .. } => true, Expr::ArrayIncludes { .. } => true, - Expr::LocalGet(id) => matches!(ctx.local_types.get(id), Some(HirType::Boolean)), + Expr::LocalGet(id) => matches!(ctx.stable_local_type_proof(id), Some(HirType::Boolean)), _ => false, } } diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 99ee0ac8f3..ad50d6acc1 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -392,43 +392,56 @@ fn char_code_at(recv: Expr, index: Expr) -> Expr { /// `for (let i = 0; i < 64; i++) h = (h ^ recv.charCodeAt(i)) | 0;` fn hash_loop_ir(param_ty: Type) -> String { let recv = Expr::LocalGet(1); - emitted_ir(probe_module( - "char_code_at_unit.ts", - vec![typed_param(1, "s", param_ty)], - vec![ - number_let(10, "h", true, Expr::Integer(0)), - Stmt::For { - init: Some(Box::new(number_let(11, "i", true, Expr::Integer(0)))), - condition: Some(Expr::Compare { - op: CompareOp::Lt, - left: Box::new(Expr::LocalGet(11)), - right: Box::new(Expr::Integer(64)), - }), - update: Some(Expr::Update { - id: 11, - op: UpdateOp::Increment, - prefix: false, - }), - body: vec![Stmt::Expr(Expr::LocalSet( - 10, - Box::new(Expr::Binary { - op: BinaryOp::BitOr, - left: Box::new(Expr::Binary { - op: BinaryOp::BitXor, - left: Box::new(Expr::LocalGet(10)), - right: Box::new(char_code_at(recv, Expr::LocalGet(11))), - }), - right: Box::new(Expr::Integer(0)), + let (params, mut body) = if matches!(param_ty, Type::String) { + ( + Vec::new(), + vec![Stmt::Let { + id: 1, + name: "s".to_string(), + ty: Type::String, + mutable: false, + init: Some(Expr::String( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-".to_string(), + )), + }], + ) + } else { + (vec![typed_param(1, "s", param_ty)], Vec::new()) + }; + body.extend([ + number_let(10, "h", true, Expr::Integer(0)), + Stmt::For { + init: Some(Box::new(number_let(11, "i", true, Expr::Integer(0)))), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(11)), + right: Box::new(Expr::Integer(64)), + }), + update: Some(Expr::Update { + id: 11, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![Stmt::Expr(Expr::LocalSet( + 10, + Box::new(Expr::Binary { + op: BinaryOp::BitOr, + left: Box::new(Expr::Binary { + op: BinaryOp::BitXor, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(char_code_at(recv, Expr::LocalGet(11))), }), - ))], - }, - Stmt::Return(Some(Expr::LocalGet(10))), - ], - )) + right: Box::new(Expr::Integer(0)), + }), + ))], + }, + Stmt::Return(Some(Expr::LocalGet(10))), + ]); + emitted_ir(probe_module("char_code_at_unit.ts", params, body)) } #[test] -fn char_code_at_on_a_string_receiver_is_statically_numeric() { +fn char_code_at_on_a_proven_string_receiver_is_statically_numeric() { // Defect 1: `is_numeric_expr` had no arm for a String-method call, so // `h ^ s.charCodeAt(i)` failed `expr/binary.rs`'s "both operands are // statically primitive" test and every iteration paid a @@ -447,7 +460,7 @@ fn char_code_at_on_a_string_receiver_is_statically_numeric() { } #[test] -fn char_code_at_on_a_string_receiver_emits_the_inline_ascii_read() { +fn char_code_at_on_a_proven_string_receiver_emits_the_inline_ascii_read() { // Defect 2: even with the receiver handle resolved, each character cost // two more opaque calls (`js_string_index_to_i32` + // `js_string_char_code_at`), which also pinned the loop-invariant header @@ -455,7 +468,7 @@ fn char_code_at_on_a_string_receiver_emits_the_inline_ascii_read() { let ir = hash_loop_ir(Type::String); assert!( ir.contains("cca.fast"), - "a string-typed receiver must get the inline ASCII charCodeAt fast \ + "a runtime-proven string receiver must get the inline ASCII charCodeAt fast \ path:\n{ir}" ); assert!( @@ -633,9 +646,9 @@ mod symbol_keyed_element_reads { } #[test] - fn a_numeric_index_keeps_the_inline_fast_path() { - // The guard against over-correcting: requiring a numeric index must - // not cost the ordinary `a[i]` element read its inline comparison. + fn a_numeric_index_keeps_the_array_fast_path_but_not_a_truthiness_claim() { + // A numeric index preserves the guarded array read, but its boxed + // fallback means the result binding still needs runtime truthiness. let ir = emitted_ir(probe_module( "numeric_element_read.ts", Vec::new(), @@ -663,8 +676,16 @@ mod symbol_keyed_element_reads { )); let body = probe_body(&ir); assert!( - body.contains("fcmp one"), - "a numeric index must keep the inline truthiness comparison:\n{body}" + body.contains("arr.guard.deref") && body.contains("arr.fast"), + "a numeric index must keep the guarded array read:\n{body}" + ); + assert!( + body.contains("js_is_truthy"), + "the result binding must use runtime truthiness:\n{body}" + ); + assert!( + !body.contains("fcmp one"), + "the read's boxed fallback must not become a numeric proof:\n{body}" ); } } diff --git a/crates/perry-codegen/src/type_analysis/predicates.rs b/crates/perry-codegen/src/type_analysis/predicates.rs index b6d63fa643..5674987095 100644 --- a/crates/perry-codegen/src/type_analysis/predicates.rs +++ b/crates/perry-codegen/src/type_analysis/predicates.rs @@ -62,7 +62,7 @@ pub(crate) fn is_global_builtin_named(expr: &Expr, name: &str) -> bool { /// - Async function calls (return type is Promise) pub(crate) fn is_promise_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { - Expr::LocalGet(id) => match ctx.local_types.get(id) { + Expr::LocalGet(id) => match ctx.stable_local_type_proof(id) { Some(HirType::Promise(_)) => true, // `const p: Promise = ...` is lowered as Generic { base: "Promise", ... } // by the HIR when the source annotation is `Promise` rather than the @@ -180,7 +180,7 @@ pub(crate) fn is_promise_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // recognize `fn({}).then(cb)` as a Promise receiver and the // .then call falls through to a generic dispatch that // silently drops the callback. - Expr::LocalGet(id) => match ctx.local_types.get(id) { + Expr::LocalGet(id) => match ctx.stable_local_type_proof(id) { Some(HirType::Function(ft)) if ft.is_async => true, Some(HirType::Function(ft)) => match ft.return_type.as_ref() { HirType::Promise(_) => true, @@ -262,7 +262,7 @@ pub(crate) fn receiver_is_error_type(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// blanket exclusion — the monomorphized-specialization resolution below is /// what those must go through (#6040). fn declared_type_overrides_shape_proof(ctx: &FnCtx<'_>, id: &u32) -> bool { - match ctx.local_types.get(id) { + match ctx.stable_local_type_proof(id) { Some(HirType::Named(name)) => ctx.classes.contains_key(name), Some(HirType::Generic { .. }) => true, _ => false, @@ -294,7 +294,7 @@ pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { .shape_proven_ptr_local(*id) .map(|fact| fact.class_name.clone()) } - Expr::LocalGet(id) => match ctx.local_types.get(id)? { + Expr::LocalGet(id) => match ctx.stable_local_type_proof(id)? { HirType::Named(name) => Some(name.clone()), // Generic instantiation `SimpleContainer`: prefer the // MONOMORPHIZED specialization `base$mangled` whenever it is @@ -342,7 +342,7 @@ pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { // method dispatch. Expr::IndexGet { object, .. } => { if let Expr::LocalGet(arr_id) = object.as_ref() { - if let Some(HirType::Array(elem)) = ctx.local_types.get(arr_id) { + if let Some(HirType::Array(elem)) = ctx.stable_local_type_proof(arr_id) { if let HirType::Named(name) = elem.as_ref() { return Some(name.clone()); } @@ -508,7 +508,10 @@ pub(crate) fn static_type_of(ctx: &FnCtx<'_>, e: &Expr) -> Option { Expr::String(_) | Expr::WtfString(_) => Some(HirType::String), Expr::Number(_) | Expr::Integer(_) => Some(HirType::Number), Expr::Bool(_) => Some(HirType::Boolean), - Expr::LocalGet(id) => ctx.local_types.get(id).cloned(), + // Source-level metadata only. Consumers that need a binding proof use + // `stable_local_type_proof` directly; guarded dispatchers may use this + // claim as a candidate. + Expr::LocalGet(id) => ctx.local_type_hint(id).cloned(), Expr::StaticMethodCall { class_name, method_name, diff --git a/crates/perry-codegen/src/type_analysis/refine.rs b/crates/perry-codegen/src/type_analysis/refine.rs index 7a82214211..c655dc6166 100644 --- a/crates/perry-codegen/src/type_analysis/refine.rs +++ b/crates/perry-codegen/src/type_analysis/refine.rs @@ -145,7 +145,9 @@ pub(crate) fn declared_property_type_from_annotation( property: &str, ) -> Option { let declared = match object { - Expr::LocalGet(id) => ctx.local_types.get(id)?, + // This function produces metadata only. Representation consumers must + // either re-check the current value or use `proven_local_types`. + Expr::LocalGet(id) => ctx.local_type_hint(id)?, _ => return None, }; match strip_nullish_union(declared)? { @@ -173,6 +175,96 @@ pub(crate) fn declared_property_type_from_annotation( } } +/// Derive only the runtime kind established by the initializer expression +/// itself. This is deliberately narrower than [`refine_type_from_init`], +/// which is also allowed to propagate declared property/return metadata for +/// consumers that carry their own runtime guard. +/// +/// Array/object/function details are erased to their outer runtime kind. A +/// specialized method HIR node is intentionally not enough: it may have been +/// selected from source metadata and may retain an override-aware fallback +/// whose result has another kind. New expression variants are unproven by +/// default until their full lowering contract is explicitly reviewed here. +pub(crate) fn proven_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option { + match init { + Expr::LocalGet(id) => ctx.stable_local_type_proof(id).cloned(), + Expr::Undefined | Expr::Void(_) => Some(HirType::Void), + Expr::Null => Some(HirType::Null), + Expr::Bool(_) | Expr::Compare { .. } => Some(HirType::Boolean), + Expr::Number(_) + | Expr::Integer(_) + | Expr::PodLayoutSizeOf { .. } + | Expr::PodLayoutAlignOf { .. } + | Expr::PodLayoutOffsetOf { .. } => Some(HirType::Number), + Expr::Unary { op, operand } => match op { + UnaryOp::Not => Some(HirType::Boolean), + UnaryOp::Neg | UnaryOp::BitNot if is_bigint_expr(ctx, operand) => Some(HirType::BigInt), + UnaryOp::Neg | UnaryOp::Pos | UnaryOp::BitNot if is_numeric_expr(ctx, operand) => { + Some(HirType::Number) + } + _ => None, + }, + Expr::Binary { op, left, right } + if is_bigint_expr(ctx, left) && is_bigint_expr(ctx, right) => + { + matches!( + op, + BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::Mod + | BinaryOp::Pow + | BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + ) + .then_some(HirType::BigInt) + } + Expr::Binary { left, right, .. } + if is_numeric_expr(ctx, left) && is_numeric_expr(ctx, right) => + { + Some(HirType::Number) + } + Expr::String(_) | Expr::WtfString(_) | Expr::I18nString { .. } | Expr::TypeOf(_) => { + Some(HirType::String) + } + Expr::Array(_) | Expr::ArraySpread(_) => Some(HirType::Array(Box::new(HirType::Any))), + Expr::MapNew | Expr::MapNewFromArray(_) => Some(HirType::Generic { + base: "Map".to_string(), + type_args: vec![HirType::Any, HirType::Any], + }), + Expr::SetNew | Expr::SetNewFromArray(_) => Some(HirType::Generic { + base: "Set".to_string(), + type_args: vec![HirType::Any], + }), + // These HIR constructors always allocate a Uint8Array representation; + // unlike metadata-selected access nodes, they have no override-aware + // result fallback. + Expr::Uint8ArrayNew(_) | Expr::Uint8ArrayFrom(_) => { + Some(HirType::Named("Uint8Array".to_string())) + } + Expr::Object(_) | Expr::ObjectSpread { .. } => Some(HirType::Object(Default::default())), + Expr::Closure { + is_async, + is_generator, + .. + } => Some(HirType::Function(perry_hir::types::FunctionType { + params: Vec::new(), + return_type: Box::new(HirType::Any), + is_async: *is_async, + is_generator: *is_generator, + })), + // A constructor can explicitly return a different object, so `new C` + // proves only Object, never C's class-specific layout. + Expr::New { .. } => Some(HirType::Object(Default::default())), + Expr::BigInt(_) => Some(HirType::BigInt), + _ => None, + } +} + /// Refine an `Any`-typed local's static type based on its initializer /// expression. Returns Some(Type) when we can statically prove the /// initializer produces a more specific type, so the `Stmt::Let` @@ -497,11 +589,11 @@ pub(crate) fn refine_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option, e: &Expr) -> bool { match e { Expr::SetNew | Expr::SetNewFromArray(_) => true, Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Generic { base, .. }) if base == "Set" ), // `this.field` where the field is declared as `Set` on the @@ -40,12 +40,19 @@ pub(crate) fn is_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { pub(crate) fn set_static_type_args<'a>(ctx: &'a FnCtx<'_>, e: &Expr) -> Option<&'a [HirType]> { match e { - Expr::LocalGet(id) => match ctx.local_types.get(id) { - Some(HirType::Generic { base, type_args }) if base == "Set" => { - Some(type_args.as_slice()) + Expr::LocalGet(id) + if matches!( + ctx.stable_local_type_proof(id), + Some(HirType::Generic { base, .. }) if base == "Set" + ) => + { + match ctx.local_type_hint(id) { + Some(HirType::Generic { base, type_args }) if base == "Set" => { + Some(type_args.as_slice()) + } + _ => None, } - _ => None, - }, + } Expr::PropertyGet { object, property, .. } => { @@ -70,7 +77,7 @@ pub(crate) fn is_url_search_params_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::UrlSearchParamsNew(_) => true, Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(name)) if name == "URLSearchParams" ), Expr::UrlGetSearchParams(_) => true, @@ -83,7 +90,7 @@ pub(crate) fn is_url_search_params_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } if property == "searchParams" => { if let Expr::LocalGet(id) = object.as_ref() { return matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Named(name)) if name == "URL" ); } @@ -136,7 +143,7 @@ pub(crate) fn is_map_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::MapNew | Expr::MapNewFromArray(_) => true, Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), + ctx.stable_local_type_proof(id), Some(HirType::Generic { base, .. }) if base == "Map" ), // `this.field` where the field is declared as `Map` on @@ -165,12 +172,19 @@ pub(crate) fn is_map_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { pub(crate) fn map_static_type_args<'a>(ctx: &'a FnCtx<'_>, e: &Expr) -> Option<&'a [HirType]> { match e { - Expr::LocalGet(id) => match ctx.local_types.get(id) { - Some(HirType::Generic { base, type_args }) if base == "Map" => { - Some(type_args.as_slice()) + Expr::LocalGet(id) + if matches!( + ctx.stable_local_type_proof(id), + Some(HirType::Generic { base, .. }) if base == "Map" + ) => + { + match ctx.local_type_hint(id) { + Some(HirType::Generic { base, type_args }) if base == "Map" => { + Some(type_args.as_slice()) + } + _ => None, } - _ => None, - }, + } Expr::PropertyGet { object, property, .. } => { @@ -201,7 +215,7 @@ pub(crate) fn is_definitely_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::String(_) | Expr::WtfString(_) => true, Expr::LocalGet(id) => matches!( - ctx.local_types.get(id), + ctx.local_type_hint(id), Some(HirType::String | HirType::StringLiteral(_)) ), Expr::PathToNamespacedPath(path) => is_definitely_string_expr(ctx, path), @@ -339,6 +353,23 @@ pub(crate) fn is_declared_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { if is_definitely_string_expr(ctx, e) { return true; } + if let Expr::LocalGet(id) = e { + return matches!( + ctx.local_type_hint(id), + Some(HirType::String | HirType::StringLiteral(_)) + ); + } + if let Expr::PropertyGet { + object, property, .. + } = e + { + if matches!( + super::refine::declared_property_type_from_annotation(ctx, object, property), + Some(HirType::String | HirType::StringLiteral(_)) + ) { + return true; + } + } matches!( e, Expr::PropertyGet { .. } | Expr::Call { .. } | Expr::IndexGet { .. } @@ -370,6 +401,10 @@ pub(crate) fn is_declared_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// compare, whereas defaulting the other way costs a silent wrong answer. pub(crate) fn string_value_is_runtime_guaranteed(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { + Expr::LocalGet(id) => matches!( + ctx.stable_local_type_proof(id), + Some(HirType::String | HirType::StringLiteral(_)) + ), Expr::String(_) | Expr::WtfString(_) | Expr::StringCoerce(_) @@ -504,7 +539,7 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::String(_) | Expr::WtfString(_) => true, Expr::LocalGet(id) => { - match ctx.local_types.get(id) { + match ctx.stable_local_type_proof(id) { Some(HirType::String | HirType::StringLiteral(_)) => true, // Union(String, Null/Void) — nullable strings are still // strings at runtime when non-null. The ?. and != null diff --git a/crates/perry-codegen/src/type_analysis/strings/tests.rs b/crates/perry-codegen/src/type_analysis/strings/tests.rs index d234c6bf42..7db2eb4b88 100644 --- a/crates/perry-codegen/src/type_analysis/strings/tests.rs +++ b/crates/perry-codegen/src/type_analysis/strings/tests.rs @@ -1,5 +1,5 @@ //! cargo-test-visible coverage for the declaration-based string proof in -//! `is_definitely_string_expr` and the `type X = { … }` arm of +//! `string_value_is_runtime_guaranteed` and the `type X = { … }` arm of //! `static_type_of`. //! //! `"lit" + r.field` where `field` is DECLARED `string` must lower to the @@ -137,7 +137,7 @@ fn alias_declared_string_field_takes_the_static_concat() { fn alias_declared_number_field_is_deliberately_not_routed() { // The alias resolution added here is consumed by the STRING side only. // `"t:" + r.amount` lands in the one-sided arm, which keeps the strict - // `is_definitely_string_expr` on the left and asks `is_numeric_expr` about + // `string_value_is_runtime_guaranteed` on the left and asks `is_numeric_expr` about // the right — and `is_numeric_expr`'s `PropertyGet` arm answers from // `ctx.classes` alone, on purpose: a `true` there means "this lowers to a // REAL double", and the guarded class-field diamond's cold arm can hand diff --git a/crates/perry-codegen/src/type_analysis_facts.rs b/crates/perry-codegen/src/type_analysis_facts.rs index 749a27044e..6eee542f72 100644 --- a/crates/perry-codegen/src/type_analysis_facts.rs +++ b/crates/perry-codegen/src/type_analysis_facts.rs @@ -6,7 +6,8 @@ use perry_hir::{infer_expr_type, infer_refinable_expr_type, Expr, HirTypeFacts}; use crate::expr::FnCtx; pub(crate) struct CodegenTypeFacts<'a> { - pub(crate) local_types: &'a std::collections::HashMap, + pub(crate) proven_local_types: &'a std::collections::HashMap, + pub(crate) reassigned_locals: &'a std::collections::HashSet, pub(crate) imported_func_return_types: &'a std::collections::HashMap, pub(crate) classes: &'a std::collections::HashMap, pub(crate) interfaces: &'a std::collections::HashMap, @@ -17,7 +18,8 @@ pub(crate) struct CodegenTypeFacts<'a> { impl<'a> CodegenTypeFacts<'a> { pub(crate) fn from_ctx(ctx: &'a FnCtx<'a>) -> Self { Self { - local_types: &ctx.local_types, + proven_local_types: &ctx.proven_local_types, + reassigned_locals: &ctx.reassigned_locals, imported_func_return_types: ctx.imported_func_return_types, classes: ctx.classes, interfaces: ctx.interfaces, @@ -29,7 +31,11 @@ impl<'a> CodegenTypeFacts<'a> { impl HirTypeFacts for CodegenTypeFacts<'_> { fn local_type(&self, id: u32) -> Option<&HirType> { - self.local_types.get(&id) + if self.reassigned_locals.contains(&id) { + None + } else { + self.proven_local_types.get(&id) + } } fn global_type(&self, _id: u32) -> Option<&HirType> { diff --git a/crates/perry-codegen/src/type_analysis_tests.rs b/crates/perry-codegen/src/type_analysis_tests.rs index 4468306a76..1eb2639659 100644 --- a/crates/perry-codegen/src/type_analysis_tests.rs +++ b/crates/perry-codegen/src/type_analysis_tests.rs @@ -1,6 +1,6 @@ use super::*; use perry_hir::infer_expr_type; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[test] fn hir_inferred_refinable_type_reuses_codegen_local_types() { @@ -117,13 +117,15 @@ fn hir_inferred_static_type_provides_codegen_fallback_facts() { #[test] fn hir_inferred_types_reuse_imported_function_return_facts() { let local_types = HashMap::new(); + let reassigned_locals = HashSet::new(); let imported_func_return_types = HashMap::from([("readName".to_string(), HirType::String)]); let classes = HashMap::new(); let interfaces = HashMap::new(); let class_stack = Vec::new(); let enums = HashMap::new(); let facts = CodegenTypeFacts { - local_types: &local_types, + proven_local_types: &local_types, + reassigned_locals: &reassigned_locals, imported_func_return_types: &imported_func_return_types, classes: &classes, interfaces: &interfaces, @@ -148,6 +150,30 @@ fn hir_inferred_types_reuse_imported_function_return_facts() { assert_eq!(infer_expr_type(&call, &facts), HirType::String); } +#[test] +fn codegen_type_facts_invalidate_reassigned_local_hints() { + use perry_hir::HirTypeFacts as _; + + let local_types = HashMap::from([(7, HirType::String)]); + let reassigned_locals = HashSet::from([7]); + let imported_func_return_types = HashMap::new(); + let classes = HashMap::new(); + let interfaces = HashMap::new(); + let class_stack = Vec::new(); + let enums = HashMap::new(); + let facts = CodegenTypeFacts { + proven_local_types: &local_types, + reassigned_locals: &reassigned_locals, + imported_func_return_types: &imported_func_return_types, + classes: &classes, + interfaces: &interfaces, + class_stack: &class_stack, + enums: &enums, + }; + + assert_eq!(facts.local_type(7), None); +} + #[test] fn hir_inferred_types_reuse_codegen_contextual_class_facts() { let mut local_types = HashMap::new(); @@ -291,8 +317,10 @@ fn hir_inferred_types_reuse_codegen_contextual_class_facts() { aliases: Vec::new(), }; let classes = HashMap::from([("Base".to_string(), &base), ("Widget".to_string(), &widget)]); + let reassigned_locals = HashSet::new(); let facts = CodegenTypeFacts { - local_types: &local_types, + proven_local_types: &local_types, + reassigned_locals: &reassigned_locals, imported_func_return_types: &imported_func_return_types, classes: &classes, interfaces: &interfaces, @@ -405,13 +433,15 @@ fn function_return_type_is_conservative() { // updated alongside it. use perry_hir::HirTypeFacts as _; let local_types = HashMap::new(); + let reassigned_locals = HashSet::new(); let imported_func_return_types = HashMap::new(); let classes = HashMap::new(); let interfaces = HashMap::new(); let class_stack = Vec::new(); let enums = HashMap::new(); let facts = CodegenTypeFacts { - local_types: &local_types, + proven_local_types: &local_types, + reassigned_locals: &reassigned_locals, imported_func_return_types: &imported_func_return_types, classes: &classes, interfaces: &interfaces, diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index f076816312..d8a72d1d2c 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -369,6 +369,16 @@ fn number_let(id: u32, name: &str, mutable: bool, init: Expr) -> Stmt { } } +fn string_let(id: u32, name: &str, value: &str) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::String, + mutable: false, + init: Some(Expr::String(value.to_string())), + } +} + fn map_type(key: Type, value: Type) -> Type { Type::Generic { base: "Map".to_string(), @@ -691,6 +701,59 @@ fn for_loop(counter_id: u32, bound: Expr, body: Vec) -> Stmt { // i8` in the module satisfied. use native_proof_support::assert_buffer_store_uses_dynamic_fallback; +fn truthiness_probe(condition: Expr) -> Stmt { + Stmt::Expr(Expr::Conditional { + condition: Box::new(condition), + then_expr: Box::new(int(1)), + else_expr: Box::new(int(0)), + }) +} + +#[test] +fn local_type_hints_never_answer_truthiness() { + let body = vec![ + // Erased declarations can lie at the initializer without any later + // LocalSet for the reassignment collector to find. + Stmt::Let { + id: 1, + name: "declared_number_holds_string".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::String("truthy".to_string())), + }, + truthiness_probe(local(1)), + Stmt::Let { + id: 2, + name: "declared_boolean_holds_number".to_string(), + ty: Type::Boolean, + mutable: false, + init: Some(int(7)), + }, + truthiness_probe(local(2)), + // Initializer refinement is equally stale after a write. + Stmt::Let { + id: 3, + name: "refined_number_reassigned".to_string(), + ty: Type::Any, + mutable: true, + init: Some(int(0)), + }, + Stmt::Expr(Expr::LocalSet( + 3, + Box::new(Expr::String("truthy after write".to_string())), + )), + truthiness_probe(local(3)), + Stmt::Return(Some(int(0))), + ]; + let ir = compile_ir("local_binding_truthiness_7846.ts", body); + + assert_eq!( + ir.matches("call i32 @js_is_truthy(").count(), + 3, + "declared and initializer-refined local types may select only the total runtime truthiness predicate:\n{ir}" + ); +} + #[test] fn array_isarray_reassigned_local_uses_runtime_predicate() { let body = vec![ @@ -1446,11 +1509,11 @@ fn number_coerce_of_numeric_array_fallback_keeps_runtime_call() { } #[test] -fn typed_array_f64_store_coerces_raw_numeric_array_fallback_value() { +fn typed_array_f64_store_coerces_annotation_only_numeric_value() { let module = module_with_classes_and_params( - "typed_array_f64_store_coerces_numeric_array_fallback.ts", + "typed_array_f64_store_coerces_annotation_only_value.ts", Vec::new(), - vec![param(3, "values", Type::Array(Box::new(Type::Number)))], + vec![param(3, "value", Type::Number)], Type::Number, vec![ native_arena_owner_let(1, "arena", int(64), false), @@ -1466,10 +1529,7 @@ fn typed_array_f64_store_coerces_raw_numeric_array_fallback_value() { Stmt::Expr(Expr::IndexSet { object: Box::new(local(2)), index: Box::new(int(0)), - value: Box::new(Expr::IndexGet { - object: Box::new(local(3)), - index: Box::new(int(0)), - }), + value: Box::new(local(3)), }), Stmt::Return(Some(int(0))), ], @@ -1477,7 +1537,7 @@ fn typed_array_f64_store_coerces_raw_numeric_array_fallback_value() { let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); assert!( ir.contains("call double @js_number_coerce"), - "Float64Array native stores must coerce guarded numeric-array fallback values before raw storage:\n{ir}" + "Float64Array native stores must coerce annotation-only numeric values before raw storage:\n{ir}" ); assert!( ir.contains("store double"), @@ -1667,7 +1727,7 @@ fn artifact_records_buffer_length_as_buffer_len_and_unsigned_materialization() { } #[test] -fn representation_first_numeric_locals_stay_f64_until_abi() { +fn representation_first_numeric_reassignment_drops_local_f64_proof() { let add_total = Expr::Binary { op: BinaryOp::Add, left: Box::new(local(1)), @@ -1715,14 +1775,14 @@ fn representation_first_numeric_locals_stay_f64_until_abi() { "expected numeric let init to stay region-local f64:\n{artifact:#}" ); assert!( - records.iter().any(|record| { + !records.iter().any(|record| { record["expr_kind"] == "LocalSet" && record["consumer"] == "ordinary_expr_value.local_set_f64" && record["local_id"] == 1 && record["native_rep_name"] == "f64" && record["native_value_state"] == "region_local" }), - "expected numeric local assignment to stay region-local f64:\n{artifact:#}" + "a reassignment must drop the declaration-derived local f64 proof:\n{artifact:#}" ); let binary_f64_count = records .iter() @@ -1734,36 +1794,26 @@ fn representation_first_numeric_locals_stay_f64_until_abi() { }) .count(); assert!( - binary_f64_count >= 3, - "expected binary ops to stay region-local f64:\n{artifact:#}" + binary_f64_count == 0, + "operations that read the reassigned local must stay off raw f64 lowering:\n{artifact:#}" ); let materialized: Vec<_> = records .iter() .filter(|record| record["native_value_state"] == "materialized") .collect(); - assert_eq!( - materialized.len(), - 1, - "numeric locals should materialize only at the return ABI boundary:\n{artifact:#}" - ); - let return_materialization = materialized[0]; - assert_eq!(return_materialization["consumer"], "materialize_js_value"); - assert_eq!( - return_materialization["materialization_reason"], - "return_abi" - ); - assert_eq!( - return_materialization["native_abi_transition"]["from_native_rep"], - "f64" - ); - assert_eq!( - return_materialization["native_abi_transition"]["to_native_rep"], - "js_value" + assert!( + materialized.iter().any(|record| { + record["consumer"] == "materialize_js_value" + && record["materialization_reason"] == "runtime_api" + && record["native_abi_transition"]["from_native_rep"] == "f64" + && record["native_abi_transition"]["to_native_rep"] == "js_value" + }), + "the conservative dynamic path should materialize literal operands at its runtime boundary:\n{artifact:#}" ); } #[test] -fn representation_first_boolean_locals_stay_i1_until_abi() { +fn representation_first_boolean_reassignment_drops_local_i1_proof() { let not_flag = Expr::Unary { op: UnaryOp::Not, operand: Box::new(local(1)), @@ -1828,7 +1878,7 @@ fn representation_first_boolean_locals_stay_i1_until_abi() { "expected boolean let init to stay region-local i1:\n{artifact:#}" ); assert!( - records.iter().any(|record| { + !records.iter().any(|record| { record["expr_kind"] == "LocalSet" && record["consumer"] == "ordinary_expr_value.local_set_i1" && record["local_id"] == 1 @@ -1836,7 +1886,7 @@ fn representation_first_boolean_locals_stay_i1_until_abi() { && record["llvm_ty"] == "i1" && record["native_value_state"] == "region_local" }), - "expected boolean local assignment to stay region-local i1:\n{artifact:#}" + "a reassignment must drop the declaration-derived local i1 proof:\n{artifact:#}" ); assert!( records.iter().any(|record| { @@ -1849,47 +1899,37 @@ fn representation_first_boolean_locals_stay_i1_until_abi() { "expected numeric comparison to produce region-local i1:\n{artifact:#}" ); assert!( - records.iter().any(|record| { + !records.iter().any(|record| { record["expr_kind"] == "Compare" && record["consumer"] == "ordinary_expr_value.boolean_compare_i1" && record["native_rep_name"] == "i1" && record["llvm_ty"] == "i1" && record["native_value_state"] == "region_local" }), - "expected boolean comparison to consume and produce region-local i1:\n{artifact:#}" + "a comparison that reads the reassigned local must stay off raw i1 lowering:\n{artifact:#}" ); assert!( - records.iter().any(|record| { + !records.iter().any(|record| { record["expr_kind"] == "Unary" && record["consumer"] == "ordinary_expr_value.boolean_not_i1" && record["native_rep_name"] == "i1" && record["llvm_ty"] == "i1" && record["native_value_state"] == "region_local" }), - "expected boolean not to stay region-local i1:\n{artifact:#}" + "a boolean operation derived from the reassigned local must stay off raw i1 lowering:\n{artifact:#}" ); let materialized: Vec<_> = records .iter() .filter(|record| record["native_value_state"] == "materialized") .collect(); - assert_eq!( - materialized.len(), - 1, - "boolean locals should materialize only at the return ABI boundary:\n{artifact:#}" - ); - let return_materialization = materialized[0]; - assert_eq!(return_materialization["consumer"], "materialize_js_value"); - assert_eq!( - return_materialization["materialization_reason"], - "return_abi" - ); - assert_eq!( - return_materialization["native_abi_transition"]["from_native_rep"], - "i1" - ); - assert_eq!( - return_materialization["native_abi_transition"]["op"], - "bool_to_js_value" + assert!( + materialized.iter().any(|record| { + record["consumer"] == "materialize_js_value" + && record["materialization_reason"] == "runtime_api" + && record["native_abi_transition"]["from_native_rep"] == "i1" + && record["native_abi_transition"]["op"] == "bool_to_js_value" + }), + "the conservative truthiness path should materialize the boolean at its runtime boundary:\n{artifact:#}" ); } @@ -2186,9 +2226,10 @@ fn packed_f64_loop_store_update_versions_with_side_exit() { let module = module_with_classes_and_params( "packed_f64_store_update_side_exit.ts", Vec::new(), - vec![param(2, "delta", Type::Number)], + Vec::new(), Type::Number, vec![ + number_let(2, "delta", false, number(0.5)), number_array_let(1, "values", vec![1, 2, 3]), for_loop( 4, @@ -2242,9 +2283,11 @@ fn packed_f64_loop_store_update_versions_with_side_exit() { let slow_start = ir .find("for.packed_f64_slow") .expect("expected packed-f64 slow clone"); + let slow_clone = &ir[slow_start..]; assert!( - ir[slow_start..].contains("call double @js_typed_feedback_array_index_set_fallback_boxed"), - "packed store side exit must preserve the generic boxed fallback in the slow clone:\n{ir}" + slow_clone.contains("call void @js_gc_note_slot_layout") + && slow_clone.contains("call void @js_write_barrier_slot"), + "packed store side exit must preserve a generic boxed store, including layout and GC bookkeeping, in the slow clone:\n{ir}" ); let artifact = compile_artifact_json_for_module(module); @@ -2840,12 +2883,11 @@ fn map_string_number_set_has_use_string_key_specialization() { let module = module_with_classes_and_params( "map_string_number_specialization.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Number), - ], + Vec::new(), Type::Number, vec![ + string_let(2, "key", "key"), + number_let(3, "value", false, number(7.0)), Stmt::Let { id: 1, name: "m".to_string(), @@ -3004,12 +3046,10 @@ fn map_number_key_string_value_set_uses_string_ref_until_slot() { let module = module_with_classes_and_params( "map_number_string_value_specialization.ts", Vec::new(), - vec![ - param(2, "key", Type::Number), - param(3, "value", Type::String), - ], + vec![param(2, "key", Type::Number)], Type::Boolean, vec![ + string_let(3, "value", "value"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3161,9 +3201,10 @@ fn map_string_key_has_delete_specialize_independent_of_value_type() { let module = module_with_classes_and_params( "map_string_boolean_delete_specialization.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3234,12 +3275,10 @@ fn map_string_boolean_param_without_native_i1_proof_uses_generic_value_helper() let module = module_with_classes_and_params( "map_string_boolean_param_fallback.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Boolean), - ], + vec![param(3, "value", Type::Boolean)], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3280,9 +3319,10 @@ fn map_string_int32_set_uses_typed_i32_value_helper() { let module = module_with_classes_and_params( "map_string_int32_value_specialization.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3330,12 +3370,10 @@ fn map_string_int32_param_without_native_i32_proof_uses_f64_helper() { let module = module_with_classes_and_params( "map_string_int32_param_fallback.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Int32), - ], + vec![param(3, "value", Type::Int32)], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3372,9 +3410,10 @@ fn map_string_u32_set_uses_typed_u32_value_helper() { let module = module_with_classes_and_params( "map_string_u32_value_specialization.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3422,12 +3461,10 @@ fn map_string_u32_param_without_native_u32_proof_uses_generic_value_helper() { let module = module_with_classes_and_params( "map_string_u32_param_fallback.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Named("PerryU32".to_string())), - ], + vec![param(3, "value", Type::Named("PerryU32".to_string()))], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3464,9 +3501,10 @@ fn map_string_f32_set_uses_typed_f32_value_helper() { let module = module_with_classes_and_params( "map_string_f32_value_specialization.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3514,12 +3552,10 @@ fn map_string_f32_param_without_native_f32_proof_uses_generic_value_helper() { let module = module_with_classes_and_params( "map_string_f32_param_fallback.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Named("PerryF32".to_string())), - ], + vec![param(3, "value", Type::Named("PerryF32".to_string()))], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3556,12 +3592,11 @@ fn map_string_string_set_uses_typed_string_value_helper() { let module = module_with_classes_and_params( "map_string_string_value_specialization.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::String), - ], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), + string_let(3, "value", "value"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3605,9 +3640,10 @@ fn map_string_any_set_uses_generic_value_string_key_helper() { let module = module_with_classes_and_params( "map_string_any_value_specialization.ts", Vec::new(), - vec![param(2, "key", Type::String), param(3, "value", Type::Any)], + vec![param(3, "value", Type::Any)], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3732,9 +3768,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_module = module_with_classes_and_params( "artifact_map_string_key_selection.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3838,12 +3875,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let boolean_fallback_module = module_with_classes_and_params( "artifact_map_string_boolean_value_rejection.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Boolean), - ], + vec![param(3, "value", Type::Boolean)], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3895,9 +3930,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_i32_value_module = module_with_classes_and_params( "artifact_map_string_i32_value_selection.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -3956,9 +3992,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_u32_value_module = module_with_classes_and_params( "artifact_map_string_u32_value_selection.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -4017,9 +4054,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_f32_value_module = module_with_classes_and_params( "artifact_map_string_f32_value_selection.ts", Vec::new(), - vec![param(2, "key", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -4078,12 +4116,11 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_string_value_module = module_with_classes_and_params( "artifact_map_string_value_selection.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::String), - ], + Vec::new(), Type::Boolean, vec![ + string_let(2, "key", "key"), + string_let(3, "value", "value"), Stmt::Let { id: 1, name: "m".to_string(), @@ -4142,9 +4179,10 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let generic_value_module = module_with_classes_and_params( "artifact_map_string_any_value_selection.ts", Vec::new(), - vec![param(2, "key", Type::String), param(3, "value", Type::Any)], + vec![param(3, "value", Type::Any)], Type::Boolean, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 1, name: "m".to_string(), @@ -4179,12 +4217,11 @@ fn artifact_records_map_string_key_helper_selection_and_rejection() { let selected_get_module = module_with_classes_and_params( "artifact_map_string_get_selection.ts", Vec::new(), - vec![ - param(2, "key", Type::String), - param(3, "value", Type::Number), - ], + Vec::new(), Type::Number, vec![ + string_let(2, "key", "key"), + number_let(3, "value", false, number(7.0)), Stmt::Let { id: 1, name: "m".to_string(), @@ -4534,9 +4571,10 @@ fn set_string_add_has_delete_use_string_specialization() { let module = module_with_classes_and_params( "set_string_specialization.ts", Vec::new(), - vec![param(2, "value", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "value", "value"), Stmt::Let { id: 1, name: "s".to_string(), @@ -5233,9 +5271,10 @@ fn artifact_records_set_string_key_helper_selection_and_rejection() { let selected_module = module_with_classes_and_params( "artifact_set_string_key_selection.ts", Vec::new(), - vec![param(2, "value", Type::String)], + Vec::new(), Type::Boolean, vec![ + string_let(2, "value", "value"), Stmt::Let { id: 1, name: "s".to_string(), @@ -6430,11 +6469,11 @@ fn artifact_records_array_runtime_key_index_set_value_bits_before_helper() { Vec::new(), vec![ param(1, "xs", Type::Array(Box::new(Type::Any))), - param(2, "key", Type::Number), param(3, "value", Type::Any), ], Type::Number, vec![ + number_let(2, "key", false, number(1.5)), Stmt::Expr(Expr::IndexSet { object: Box::new(local(1)), index: Box::new(local(2)), @@ -6464,13 +6503,11 @@ fn artifact_records_direct_f64_to_js_value_bits_for_write_barrier() { let module = module_with_classes_and_params( "artifact_write_barrier_f64_to_js_value_bits.ts", Vec::new(), - vec![ - param(1, "xs", Type::Array(Box::new(Type::Any))), - param(2, "key", Type::String), - param(3, "value", Type::Number), - ], + vec![param(1, "xs", Type::Array(Box::new(Type::Any)))], Type::Number, vec![ + string_let(2, "key", "key"), + number_let(3, "value", false, number(1.5)), Stmt::Expr(Expr::IndexSet { object: Box::new(local(1)), index: Box::new(local(2)), @@ -6508,12 +6545,10 @@ fn artifact_records_direct_i1_to_js_value_bits_for_write_barrier() { let module = module_with_classes_and_params( "artifact_write_barrier_i1_to_js_value_bits.ts", Vec::new(), - vec![ - param(1, "xs", Type::Array(Box::new(Type::Any))), - param(2, "key", Type::String), - ], + vec![param(1, "xs", Type::Array(Box::new(Type::Any)))], Type::Number, vec![ + string_let(2, "key", "key"), Stmt::Let { id: 3, name: "value".to_string(), @@ -6558,12 +6593,10 @@ fn artifact_records_static_write_barrier_elision_for_primitive_array_store() { let module = module_with_classes_and_params( "artifact_write_barrier_elided_primitive.ts", Vec::new(), - vec![ - param(1, "xs", Type::Array(Box::new(Type::Any))), - param(2, "key", Type::String), - ], + vec![param(1, "xs", Type::Array(Box::new(Type::Any)))], Type::Number, vec![ + string_let(2, "key", "key"), Stmt::Expr(Expr::IndexSet { object: Box::new(local(1)), index: Box::new(local(2)), @@ -7313,7 +7346,7 @@ fn compiler_private_async_iter_result_annotated_boolean_payload_stays_generic() } #[test] -fn compiler_private_async_iter_result_annotated_i32_payload_stays_off_raw_i32_slot() { +fn compiler_private_async_iter_result_annotated_i32_payload_stays_generic() { let ir = compile_ir_for_module_with_opts( module_with_classes_and_params( "compiler_private_async_iter_result_annotated_i32_param.ts", @@ -7331,8 +7364,12 @@ fn compiler_private_async_iter_result_annotated_i32_payload_stays_off_raw_i32_sl "annotation-only Int32 async payloads must not use the raw i32 slot without proof:\n{ir}" ); assert!( - ir.contains("call double @js_iter_result_set_f64"), - "annotation-only Int32 async payloads should keep the existing numeric-compatible raw f64 slot:\n{ir}" + ir.contains("call double @js_iter_result_set("), + "annotation-only Int32 async payloads must preserve the runtime JSValue:\n{ir}" + ); + assert!( + !ir.contains("call double @js_iter_result_set_f64"), + "annotation-only Int32 async payloads must not use the raw f64 slot without proof:\n{ir}" ); } @@ -12024,23 +12061,24 @@ fn typed_f64_receiver_method_clone_raw_loads_after_composed_guards() { .unwrap_or_else(|| { panic!("the Ptr clone must tag-dispatch declared-only `+`:\n{pshape_ir}") }); - let result_coerce = pshape_ir - .find("call double @js_number_coerce(") + let multiply = pshape_ir + .find("call double @js_dynamic_mul(") .unwrap_or_else(|| { panic!( - "the Ptr clone must ToNumber the possibly boxed `+` result:\n\ + "the Ptr clone must dynamically coerce the possibly boxed `+` result and annotation-only argument:\n\ {pshape_ir}" ) }); - let multiply = pshape_ir - .find(" fmul ") - .unwrap_or_else(|| panic!("expected score's multiply in `$pshape`:\n{pshape_ir}")); assert!( - dynamic_add < result_coerce && result_coerce < multiply, + dynamic_add < multiply, "the Ptr clone reached on raw-f64 guard FAILURE must \ - ToNumber the possibly boxed `+` result before multiplying it:\n\ + keep the possibly boxed `+` result on semantically dynamic multiplication:\n\ {pshape_ir}" ); + assert!( + !pshape_ir.contains(" fmul "), + "annotation-only operands must not reach raw f64 multiplication in `$pshape`:\n{pshape_ir}" + ); } assert!( caller_ir.contains("call double @js_native_call_method_by_id"), @@ -12211,14 +12249,25 @@ fn typed_f64_closure_clone_accepts_immutable_numeric_capture() { compile_module(&typed_f64_closure_clone_module("capture"), empty_opts()).unwrap(), ) .unwrap(); + let public = "perry_closure_typed_f64_closure_abi_ts__300"; let typed = "perry_closure_typed_f64_closure_abi_ts__300$typed_f64"; let typed_ir = defined_function_ir_section(&ir, typed); + let wrapper_ir = function_ir_section(&ir, public); assert!( typed_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") && typed_ir.contains("bitcast i64") && typed_ir.contains("call double @js_typed_f64_arg_to_raw"), "typed-f64 captured closure should load immutable numeric capture as JSValue bits through the closure handle:\n{typed_ir}" ); + assert!( + wrapper_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") + && wrapper_ir.contains("call i32 @js_typed_f64_arg_guard"), + "public typed-f64 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" + ); + assert!( + ir.contains("closure_direct.typed_f64") && ir.contains("call i32 @js_typed_f64_arg_guard"), + "direct typed-f64 calls must guard captures and retain their generic branch:\n{ir}" + ); assert!( ir.contains(&format!("call double @{typed}(i64 ")), "typed direct call should pass the closure handle to the captured clone:\n{ir}" @@ -12338,14 +12387,25 @@ fn typed_i32_closure_clone_accepts_immutable_i32_capture() { compile_module(&typed_i32_closure_clone_module("capture"), empty_opts()).unwrap(), ) .unwrap(); + let public = "perry_closure_typed_i32_closure_capture_ts__303"; let typed = "perry_closure_typed_i32_closure_capture_ts__303$typed_i32"; let typed_ir = defined_function_ir_section(&ir, typed); + let wrapper_ir = function_ir_section(&ir, public); assert!( typed_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") && typed_ir.contains("bitcast i64") && typed_ir.contains("call i32 @js_typed_i32_arg_to_raw"), "typed-i32 captured closure should load immutable Int32 capture through the closure handle:\n{typed_ir}" ); + assert!( + wrapper_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") + && wrapper_ir.contains("call i32 @js_typed_i32_arg_guard"), + "public typed-i32 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" + ); + assert!( + ir.contains("closure_direct.typed_i32") && ir.contains("call i32 @js_typed_i32_arg_guard"), + "direct typed-i32 calls must guard captures and retain their generic branch:\n{ir}" + ); assert!( ir.contains(&format!("call i32 @{typed}(i64 ")), "typed direct call should pass the closure handle to the captured clone:\n{ir}" @@ -12597,14 +12657,25 @@ fn typed_i1_closure_clone_accepts_immutable_boolean_capture() { compile_module(&typed_i1_closure_clone_module("capture"), empty_opts()).unwrap(), ) .unwrap(); + let public = "perry_closure_typed_i1_closure_capture_ts__301"; let typed = "perry_closure_typed_i1_closure_capture_ts__301$typed_i1"; let typed_ir = defined_function_ir_section(&ir, typed); + let wrapper_ir = function_ir_section(&ir, public); assert!( typed_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") && typed_ir.contains("bitcast i64") && typed_ir.contains("call i32 @js_typed_i1_arg_to_raw"), "typed-i1 captured closure should load immutable boolean capture as JSValue bits through the closure handle:\n{typed_ir}" ); + assert!( + wrapper_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") + && wrapper_ir.contains("call i32 @js_typed_i1_arg_guard"), + "public typed-i1 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" + ); + assert!( + ir.contains("closure_direct.typed_i1") && ir.contains("call i32 @js_typed_i1_arg_guard"), + "direct typed-i1 calls must guard captures and retain their generic branch:\n{ir}" + ); assert!( ir.contains(&format!("call i1 @{typed}(i64 ")), "typed direct call should pass the closure handle to the captured clone:\n{ir}" @@ -13914,23 +13985,23 @@ fn static_put_value_uses_write_pic_for_call_free_rhs() { let module = module_with_classes_and_params( "static_put_value_write_pic", Vec::new(), + vec![param(object, "object", Type::Any)], + Type::Any, vec![ - param(object, "object", Type::Any), - param(left, "left", Type::Number), - param(right, "right", Type::Number), + number_let(left, "left", false, number(1.0)), + number_let(right, "right", false, number(2.0)), + Stmt::Return(Some(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(object)), + key: Box::new(Expr::String("x".to_string())), + value: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(left)), + right: Box::new(Expr::LocalGet(right)), + }), + receiver: Box::new(Expr::LocalGet(object)), + strict: false, + })), ], - Type::Any, - vec![Stmt::Return(Some(Expr::PutValueSet { - target: Box::new(Expr::LocalGet(object)), - key: Box::new(Expr::String("x".to_string())), - value: Box::new(Expr::Binary { - op: BinaryOp::Add, - left: Box::new(Expr::LocalGet(left)), - right: Box::new(Expr::LocalGet(right)), - }), - receiver: Box::new(Expr::LocalGet(object)), - strict: false, - }))], ); let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); diff --git a/crates/perry-codegen/tests/native_proof_regressions/artifact_records.rs b/crates/perry-codegen/tests/native_proof_regressions/artifact_records.rs index 12269a1f7c..bb8970f24c 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/artifact_records.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/artifact_records.rs @@ -174,11 +174,17 @@ fn artifact_records_write_barrier_child_js_value_bits() { Vec::new(), vec![ param(1, "xs", Type::Array(Box::new(Type::Any))), - param(2, "key", Type::String), param(3, "value", Type::Any), ], Type::Number, vec![ + Stmt::Let { + id: 2, + name: "key".to_string(), + ty: Type::String, + mutable: false, + init: Some(Expr::String("key".to_string())), + }, Stmt::Expr(Expr::IndexSet { object: Box::new(local(1)), index: Box::new(local(2)), diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9d2e6a76b4..8f7128c8d4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -181,6 +181,7 @@ - [Garbage Collector](internals/garbage-collector.md) - [Explicit Memory Control](internals/explicit-memory.md) - [The GC rooting invariant (codegen)](internals/gc-rooting-invariant.md) +- [Local binding type evidence](internals/local-binding-type-evidence.md) - [Incremental GC step bounds](internals/gc-step-bounds.md) - [RFC: rooting by construction](internals/rfc-rooting-by-construction.md) diff --git a/docs/src/internals/local-binding-type-evidence.md b/docs/src/internals/local-binding-type-evidence.md new file mode 100644 index 0000000000..7ecb0d63e4 --- /dev/null +++ b/docs/src/internals/local-binding-type-evidence.md @@ -0,0 +1,104 @@ +# Local binding type evidence + +Perry compiles TypeScript, but TypeScript annotations do not exist at runtime. +The compiler must therefore distinguish three statements that look similar in +source code: + +1. a binding is *declared* as `T`; +2. a binding's initializer happened to produce `T`; and +3. the value at this use site is proven to have representation `T`. + +Only the third statement can license an unguarded specialized lowering or a +compile-time answer. An initializer can establish it only when the expression's +own runtime semantics fix the kind and no later write can replace the value. + +## The invariant + +> A type or kind fact attached to a local binding is usable only if every write +> that could invalidate it is excluded. A declared TypeScript type is never a +> runtime representation proof by itself. + +A later assignment is the invalidating event. The current implementation uses +a conservative whole-region rule: if a binding is assigned anywhere after its +declaration, initializer-derived evidence disappears for the whole region. +Declared annotations never enter the proof map. This can miss an optimization +before the write, but it cannot let a non-dominating write silently change the +meaning of a later operation. + +This is intentionally stricter than statement-order tracking. A future CFG +analysis may recover hints at sites dominated by a validating guard, but the +fallback must remain the runtime path. + +## Codegen APIs + +`FnCtx` exposes two reads: + +- `stable_local_type_proof(id)` reads a separate map populated only by + initializer expressions whose runtime semantics establish their outer kind, + and returns no type when the region contains a write to `id`. +- `local_type_hint(id)` is the narrow escape hatch for a consumer with an + independent representation proof or a runtime guard that validates the + current value. It deliberately preserves the hint across assignment. + +Initializer proofs are deliberately syntax-based. Literal primitives, +primitive operators whose operands are already proven, array/object literals, +closures, and the outer Object result of `new` qualify. A specialized method +HIR node does not: it may retain an override-aware fallback with a different +result kind. Class identity and generic element types are likewise not inferred +from the outer allocation alone. + +Examples of valid escape-hatch uses include a typed-array runtime helper that +checks the receiver's actual GC kind, a scalar clone entered behind a public tag +guard, and a buffer-view slot whose pointer state every write invalidates. + +Examples that are not valid proofs include `number`, `boolean`, or `string` on +a local declaration; a property or function return type propagated from an +annotation; and a method name guessed without validating the receiver. Nested +generic claims are erased: an intrinsic can prove Array without proving the +declared types of its future elements. + +The precise-GC pointer collector follows the same rule independently. It roots +every generic-ABI parameter and every local until the complete write set proves +that all values are non-pointers. A scalar annotation can therefore never +suppress a root for an object actually stored in that binding. Typed closure +capture annotations are only candidates: both the public trampoline and the +direct local-call path validate every current capture slot and branch to the +generic body on failure. + +Module globals used by worker-thread admission have a separate structural +initializer map and a module-wide write check. Missing evidence is hazardous; +a transferable-looking annotation cannot make an arbitrary main-heap value +safe to read from another worker arena. + +For operator selection, use the stronger per-value predicates where available. +For example, `string_value_is_runtime_guaranteed` separates a value constructed +as a string from one that is merely declared as a string. Bare local truthiness +always uses `js_is_truthy`, because numeric and boolean annotations can hold +any NaN-boxed value. + +## Static inventory and CI gate + +`scripts/local_binding_type_audit.py` scans production HIR/codegen sources for +both accessors and for remaining lower-level type-map reads. Its count-exact +inventory is `scripts/local_binding_type_allowlist.json`. + +Each inventory group states one of three verdicts: + +- `runtime-validated`: emitted code checks the current runtime value; +- `representation-proven`: another analysis establishes the storage fact and + invalidates it on writes; +- `metadata-only`: the type read cannot select a runtime answer or layout. + +The gate fails when a consumer is added without a verdict, when a count changes, +when an entry matches nothing, or when code bypasses the accessors with a direct +read of the hint or proof maps. Its self-test plants both bypass forms, all +inventory drift modes, and an empty scan, so a stale scanner cannot report a +vacuous success. + +Run it locally with: + +```sh +python3 scripts/local_binding_type_audit.py --self-test +python3 scripts/local_binding_type_audit.py +python3 scripts/local_binding_type_audit.py --list +``` diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json new file mode 100644 index 0000000000..6c5b5389fa --- /dev/null +++ b/scripts/local_binding_type_allowlist.json @@ -0,0 +1,501 @@ +{ + "schema": 1, + "entries": [ + { + "path": "crates/perry-codegen/src/codegen/closure.rs", + "function": "compile_closure", + "access": "raw:module_receiver_types", + "count": 1, + "classification": "metadata-only", + "reason": "The module map is copied into the closure's source-type metadata; representation consumers use the separate runtime-proof map or emit a live-value guard." + }, + { + "path": "crates/perry-codegen/src/collectors/hir_facts.rs", + "function": "seed_module_bindings", + "access": "raw:binding_types", + "count": 1, + "classification": "runtime-validated", + "reason": "Declared array kinds only seed packed-loop candidates; the loop entry guard validates the actual array kind and window." + }, + { + "path": "crates/perry-codegen/src/codegen/typed_abi.rs", + "function": "typed_closure_capture_reps", + "access": "raw:module_local_types", + "count": 1, + "classification": "runtime-validated", + "reason": "The annotation selects only a candidate capture rep; both the public trampoline and direct-call path guard every current capture slot and use the generic body on failure." + }, + { + "path": "crates/perry-codegen/src/codegen/typed_abi.rs", + "function": "typed_arg_is_guard_candidate", + "access": "local_type_hint", + "count": 2, + "classification": "runtime-validated", + "reason": "Binding and array-element hints only nominate typed routes; every consumer checks or coerces the live JSValue with the selected representation contract and keeps the generic body or semantic runtime helper on failure." + }, + { + "path": "crates/perry-codegen/src/expr/array_methods.rs", + "function": "lower", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "Array.isArray constant-folding uses only runtime-derived initializer evidence and rejects every binding written in the region." + }, + { + "path": "crates/perry-codegen/src/expr/array_push.rs", + "function": "guarded_numeric_add_push_candidate", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint admits only the inline push tier whose live-bits test sends heap-valued + results through layout-note, addref, and barrier bookkeeping." + }, + { + "path": "crates/perry-codegen/src/expr/binary.rs", + "function": "lower", + "access": "local_type_hint", + "count": 2, + "classification": "runtime-validated", + "reason": "A real string operand fixes + as concatenation; the numeric hint selects a JSValue-taking concat helper that converts the current other operand at runtime." + }, + { + "path": "crates/perry-codegen/src/expr/index_get.rs", + "function": "is_width_tracked_typed_array_receiver", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint selects a typed-array helper that checks the runtime GC kind, or a buffer-view fact invalidated by every write." + }, + { + "path": "crates/perry-codegen/src/expr/index_set.rs", + "function": "is_width_tracked_typed_array_receiver", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint selects a typed-array store helper that checks the runtime GC kind, or a write-invalidated buffer-view fact." + }, + { + "path": "crates/perry-codegen/src/expr/literals_vars.rs", + "function": "lower", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "String self-append tag-dispatches the current destination and falls back to ordinary dynamic JavaScript addition." + }, + { + "path": "crates/perry-codegen/src/expr/mod.rs", + "function": "is_plain_f64_local", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/expr/mod.rs", + "function": "is_plain_i1_local", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/expr/native_memory.rs", + "function": "proven_view", + "access": "local_type_hint", + "count": 1, + "classification": "representation-proven", + "reason": "buffer_view_slots supplies the independent view proof and every local write invalidates its pointer state before this access." + }, + { + "path": "crates/perry-codegen/src/expr/new_dynamic.rs", + "function": "lower", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/expr/property_get.rs", + "function": "guarded_declared_class_get_candidate", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The class hint selects guarded field or runtime method/property routing; raw field access validates the live class id and keys token before dereference and all misses retain dynamic lookup." + }, + { + "path": "crates/perry-codegen/src/expr/property_get.rs", + "function": "lower", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/expr/shadow_slot.rs", + "function": "expr_is_known_non_pointer_shadow_value", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/lower_call/closure_analysis.rs", + "function": "hazardous_module_global_ids", + "access": "raw:proven_types", + "count": 1, + "classification": "representation-proven", + "reason": "The map contains only structural module-initializer kinds and omits every binding with a module-wide write." + }, + { + "path": "crates/perry-codegen/src/lower_call/console_promise.rs", + "function": "util_types_arg_is_async_function_static", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/lower_call/early_branches.rs", + "function": "try_lower_closure_typed_local_call", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The hint selects js_closure_unbox_callee_checked, which validates the current NaN-boxed callee before invoking it." + }, + { + "path": "crates/perry-codegen/src/lower_call/native/mod.rs", + "function": "util_types_arg_is_async_function_static", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/lower_call/options/abort.rs", + "function": "is_abort_controller_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/lower_call/options/abort.rs", + "function": "is_abort_signal_typed_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs", + "function": "guarded_declared_receiver_class_candidate", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The annotation selects only a direct-method candidate; the emitted guard validates the live class id, keys token, own override, and resolved method pointer, with every miss using dynamic dispatch." + }, + { + "path": "crates/perry-codegen/src/lower_call/scalar_method.rs", + "function": "walk", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The scalar clone entry path emits a tag guard for every collected local before evaluating the numeric expression in raw representation." + }, + { + "path": "crates/perry-codegen/src/lower_call/scalar_method.rs", + "function": "local_can_use_public_arg_guard", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The emitted public scalar argument guard validates each current local value before entering the typed method clone." + }, + { + "path": "crates/perry-codegen/src/lower_string_method.rs", + "function": "lower_string_method_dispatch", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/rooting/temp_root.rs", + "function": "local_is_inert_primitive", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/expr/proxy_reflect.rs", + "function": "guarded_declared_class_property_candidate", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The class hint only routes a strict static-name PutValue through PropertySet; its live class/shape/descriptor guard dominates the raw slot access and every miss retains ordinary runtime assignment." + }, + { + "path": "crates/perry-codegen/src/expr/property_set.rs", + "function": "guarded_declared_class_store_candidate", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The class hint selects the plain-field store guard; the live class id, keys token, descriptor state, field bounds, and value representation are checked before a raw slot store, while accessors stay dynamic." + }, + { + "path": "crates/perry-codegen/src/stmt/element_shape_loop.rs", + "function": "declared_array_element_type_hint", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The annotation only selects a versioned clone; its preheader validates receiver kind, array head, shape, and key token and falls back on every guard miss." + }, + { + "path": "crates/perry-codegen/src/stmt/element_shape_loop.rs", + "function": "match_element_shape_versioned_loop", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The numeric annotation admits a candidate clone; the preheader checks the accumulator's current Number tag and the fact is scoped to a store-free, numeric-preserving fast clone." + }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "local_array_element_type", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The element annotation only selects a versioned numeric-loop candidate; every caller validates the live array kind, layout, and covered range before entering its raw clone." + }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "local_is_int32_value", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "set_static_type_args", + "access": "local_type_hint", + "count": 1, + "classification": "representation-proven", + "reason": "The source type arguments are read only after stable initializer evidence independently proves that the binding holds a Set; argument representations are separately proven or guarded at each helper selection." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "map_static_type_args", + "access": "local_type_hint", + "count": 1, + "classification": "representation-proven", + "reason": "The source type arguments are read only after stable initializer evidence independently proves that the binding holds a Map; key and value representations are separately proven or guarded at each helper selection." + }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "local_is_untyped_candidate", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "match_numeric_bulk_fill_loop", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/stmt/masked_window_region.rs", + "function": "expr_is_number_under", + "access": "local_type_hint", + "count": 1, + "classification": "representation-proven", + "reason": "The active masked-window refinement set and preheader guards prove numeric storage for the scoped cloned region." + }, + { + "path": "crates/perry-codegen/src/stmt/masked_window_region.rs", + "function": "lower_region_copy", + "access": "local_type_hint", + "count": 1, + "classification": "metadata-only", + "reason": "This read saves and restores the map entry around a scoped refinement; it does not itself select a representation or answer." + }, + { + "path": "crates/perry-codegen/src/stmt/masked_window_region.rs", + "function": "try_match_masked_window_region", + "access": "local_type_hint", + "count": 1, + "classification": "representation-proven", + "reason": "The candidate is admitted only to a masked-window clone whose runtime preheader guard proves the receiver and index window." + }, + { + "path": "crates/perry-codegen/src/type_analysis/numeric.rs", + "function": "is_bigint_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/numeric.rs", + "function": "is_bool_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/numeric.rs", + "function": "is_numeric_expr", + "access": "stable_local_type_proof", + "count": 3, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/predicates.rs", + "function": "declared_type_overrides_shape_proof", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/predicates.rs", + "function": "is_promise_expr", + "access": "stable_local_type_proof", + "count": 2, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/predicates.rs", + "function": "receiver_class_name", + "access": "stable_local_type_proof", + "count": 2, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/predicates.rs", + "function": "static_type_of", + "access": "local_type_hint", + "count": 1, + "classification": "metadata-only", + "reason": "This generic query returns source-level metadata; answer-folding and raw-representation consumers use the separate runtime-derived proof API." + }, + { + "path": "crates/perry-codegen/src/type_analysis/refine.rs", + "function": "declared_property_type_from_annotation", + "access": "local_type_hint", + "count": 1, + "classification": "metadata-only", + "reason": "This read propagates a declared property claim into metadata; representation consumers must independently guard the value or use the separate runtime-derived proof map." + }, + { + "path": "crates/perry-codegen/src/type_analysis/refine.rs", + "function": "proven_type_from_init", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/refine.rs", + "function": "refine_type_from_init", + "access": "stable_local_type_proof", + "count": 2, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_declared_string_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "A declared local string selects only the pairwise boxed concat helper, which tag-dispatches both values and falls back to full dynamic addition." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_definitely_string_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "This is a boxed string-dispatch candidate; raw-pointer and typed-ABI consumers use string_value_is_runtime_guaranteed instead." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_map_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_set_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_string_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "string_value_is_runtime_guaranteed", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_url_search_params_expr", + "access": "stable_local_type_proof", + "count": 2, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "map_static_type_args", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "set_static_type_args", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." + }, + { + "path": "crates/perry-hir/src/analysis/value_types.rs", + "function": "local_type", + "access": "raw:HashMapLocalTypeFacts", + "count": 1, + "classification": "metadata-only", + "reason": "The generic HIR environment exposes source-level metadata only; codegen supplies its separate runtime-proof facts adapter." + }, + { + "path": "crates/perry-hir/src/analysis/value_types.rs", + "function": "local_type", + "access": "raw:self.locals", + "count": 2, + "classification": "metadata-only", + "reason": "The generic HIR environment exposes source-level metadata only; codegen supplies its separate runtime-proof facts adapter." + } + ] +} diff --git a/scripts/local_binding_type_audit.py b/scripts/local_binding_type_audit.py new file mode 100644 index 0000000000..7ecad3376a --- /dev/null +++ b/scripts/local_binding_type_audit.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +"""Inventory codegen reads of local-binding type hints. + +TypeScript annotations are erased, and initializer-derived types become stale +after assignment. Codegen may therefore use a local type only when its runtime +provenance and write stability are explicit, as a dispatch hint for a +runtime-validated path, or alongside an independent representation proof. This +audit keeps that choice explicit: + +* ordinary consumers call ``stable_local_type_proof``; its whole-region write + set conservatively invalidates runtime-derived evidence after any assignment; +* exceptional consumers call ``local_type_hint`` and need an allowlist entry + explaining the runtime guard or independent proof; +* remaining raw reads in pre-codegen collectors are inventoried as well; and +* a direct read of ``ctx.local_types`` or ``ctx.proven_local_types`` outside the + accessors is always an error. + +The allowlist is count-exact. A new use, a removed use, and a renamed/moved use +all fail until the inventory is reviewed, and an entry matching nothing fails +too. ``--self-test`` plants each failure mode so the gate cannot go vacuously +green. + +Usage: + python3 scripts/local_binding_type_audit.py + python3 scripts/local_binding_type_audit.py --self-test + python3 scripts/local_binding_type_audit.py --list +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_ALLOWLIST = REPO_ROOT / "scripts" / "local_binding_type_allowlist.json" +SCAN_ROOTS = ( + REPO_ROOT / "crates" / "perry-codegen" / "src", + REPO_ROOT / "crates" / "perry-hir" / "src", +) +MINIMUM_SITES = 40 + +SKIP_PARTS = {"tests"} +SKIP_SUFFIXES = ("_tests.rs",) + +FN_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)") +ACCESS_RE = re.compile( + r"\bctx\s*\.\s*(?Pstable_local_type_proof|local_type_hint)\s*\(" +) +RAW_RE = re.compile( + r"\b(?P(?:ctx|self)\s*\.\s*(?:local_types|proven_local_types)|self\s*\.\s*locals|module_local_types|" + r"module_receiver_types|binding_types|proven_types|local_types)\s*\.\s*get\s*\(" +) +GENERIC_LOCAL_FACT_RE = re.compile(r"\bself\s*\.\s*get\s*\(") + +CLASSIFICATIONS = { + "runtime-validated", + "representation-proven", + "metadata-only", +} + + +@dataclass(frozen=True, order=True) +class SiteKey: + path: str + function: str + access: str + + +@dataclass(frozen=True) +class Site: + key: SiteKey + line: int + text: str + + +@dataclass(frozen=True) +class Entry: + key: SiteKey + count: int + classification: str + reason: str + + +def repo_rel(path: Path) -> str: + try: + return path.resolve().relative_to(REPO_ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def production_rust_files() -> Iterable[Path]: + for root in SCAN_ROOTS: + for path in sorted(root.rglob("*.rs")): + rel_parts = path.relative_to(root).parts + if any(part in SKIP_PARTS for part in rel_parts): + continue + if path.name == "tests.rs" or path.name.endswith(SKIP_SUFFIXES): + continue + yield path + + +def strip_rust_comments(text: str) -> str: + """Remove line/block comments while preserving newlines and strings.""" + out: list[str] = [] + i = 0 + block_depth = 0 + in_string = False + in_char = False + escaped = False + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if block_depth: + if ch == "/" and nxt == "*": + block_depth += 1 + out.extend((" ", " ")) + i += 2 + continue + if ch == "*" and nxt == "/": + block_depth -= 1 + out.extend((" ", " ")) + i += 2 + continue + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + if not in_string and not in_char and ch == "/" and nxt == "*": + block_depth = 1 + out.extend((" ", " ")) + i += 2 + continue + if not in_string and not in_char and ch == "/" and nxt == "/": + while i < len(text) and text[i] != "\n": + out.append(" ") + i += 1 + continue + out.append(ch) + if escaped: + escaped = False + elif (in_string or in_char) and ch == "\\": + escaped = True + elif not in_char and ch == '"': + in_string = not in_string + elif not in_string and ch == "'": + # Lifetimes (`'a`) are common. Treat only a quote followed by a + # single character and another quote as a character literal. + if in_char: + in_char = False + elif i + 2 < len(text) and text[i + 2] == "'": + in_char = True + i += 1 + return "".join(out) + + +def scan_text(path: Path, text: str) -> tuple[list[Site], list[str]]: + clean = strip_rust_comments(text) + raw_lines = text.splitlines() + sites: list[Site] = [] + errors: list[str] = [] + rel = repo_rel(path) + + functions = [(match.start(), match.group(1)) for match in FN_RE.finditer(clean)] + + def location(offset: int) -> tuple[int, str, str]: + line_no = clean.count("\n", 0, offset) + 1 + current_fn = "" + for fn_offset, fn_name in functions: + if fn_offset > offset: + break + current_fn = fn_name + line_text = raw_lines[line_no - 1].strip() if raw_lines else "" + return line_no, current_fn, line_text + + for match in ACCESS_RE.finditer(clean): + line_no, current_fn, line_text = location(match.start()) + sites.append( + Site( + SiteKey(rel, current_fn, match.group("api")), + line_no, + line_text, + ) + ) + + for match in RAW_RE.finditer(clean): + line_no, current_fn, line_text = location(match.start()) + receiver = re.sub(r"\s*\.\s*", ".", match.group("receiver")) + if receiver == "self.local_types" and ( + rel == "crates/perry-codegen/src/expr/mod.rs" + and current_fn == "local_type_hint" + ): + continue + if receiver == "self.proven_local_types" and ( + ( + rel == "crates/perry-codegen/src/expr/mod.rs" + and current_fn == "stable_local_type_proof" + ) + or ( + rel == "crates/perry-codegen/src/type_analysis_facts.rs" + and current_fn == "local_type" + ) + ): + continue + if receiver in {"ctx.local_types", "ctx.proven_local_types"}: + errors.append( + f"{rel}:{line_no}: direct {receiver} read bypasses the " + "proof API; use stable_local_type_proof or the audited " + "local_type_hint escape hatch" + ) + continue + sites.append( + Site( + SiteKey(rel, current_fn, f"raw:{receiver}"), + line_no, + line_text, + ) + ) + + if rel == "crates/perry-hir/src/analysis/value_types.rs": + for match in GENERIC_LOCAL_FACT_RE.finditer(clean): + line_no, current_fn, line_text = location(match.start()) + if current_fn == "local_type": + sites.append( + Site( + SiteKey(rel, current_fn, "raw:HashMapLocalTypeFacts"), + line_no, + line_text, + ) + ) + return sites, errors + + +def scan_repo() -> tuple[list[Site], list[str]]: + sites: list[Site] = [] + errors: list[str] = [] + for path in production_rust_files(): + found, bad = scan_text(path, path.read_text(encoding="utf-8")) + sites.extend(found) + errors.extend(bad) + return sites, errors + + +def load_allowlist(path: Path) -> tuple[list[Entry], list[str]]: + errors: list[str] = [] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [], [f"cannot read allowlist {path}: {exc}"] + if payload.get("schema") != 1: + errors.append(f"{path}: schema must be 1") + entries: list[Entry] = [] + seen: set[SiteKey] = set() + for index, raw in enumerate(payload.get("entries", []), 1): + where = f"{path}: entry {index}" + try: + key = SiteKey(raw["path"], raw["function"], raw["access"]) + count = raw["count"] + classification = raw["classification"] + reason = raw["reason"].strip() + except (KeyError, AttributeError) as exc: + errors.append(f"{where}: malformed entry ({exc})") + continue + if key in seen: + errors.append(f"{where}: duplicate key {key}") + continue + seen.add(key) + if not isinstance(count, int) or count < 1: + errors.append(f"{where}: count must be a positive integer") + if classification not in CLASSIFICATIONS: + errors.append( + f"{where}: classification must be one of " + f"{sorted(CLASSIFICATIONS)}" + ) + if len(reason) < 24: + errors.append(f"{where}: reason must explain the validating fact") + entries.append(Entry(key, count, classification, reason)) + return entries, errors + + +def audit( + sites: list[Site], + raw_errors: list[str], + entries: list[Entry], + allowlist_errors: list[str], + *, + minimum_sites: int = MINIMUM_SITES, +) -> list[str]: + errors = [*raw_errors, *allowlist_errors] + grouped: dict[SiteKey, list[Site]] = defaultdict(list) + for site in sites: + grouped[site.key].append(site) + + if len(sites) < minimum_sites: + errors.append( + f"candidate floor failed: found {len(sites)} local-type reads, " + f"expected at least {minimum_sites}; the scan scope may be stale" + ) + + allowed = {entry.key: entry for entry in entries} + for key, found in sorted(grouped.items()): + entry = allowed.get(key) + rendered = f"{key.path}::{key.function} [{key.access}]" + if entry is None: + lines = ", ".join(str(site.line) for site in found) + errors.append(f"unclassified local-type read: {rendered} at lines {lines}") + elif entry.count != len(found): + lines = ", ".join(str(site.line) for site in found) + errors.append( + f"count drift for {rendered}: allowlist={entry.count}, " + f"source={len(found)} at lines {lines}" + ) + + for key in sorted(set(allowed) - set(grouped)): + errors.append( + f"stale allowlist entry matches nothing: " + f"{key.path}::{key.function} [{key.access}]" + ) + return errors + + +def print_inventory(sites: list[Site], entries: list[Entry]) -> None: + grouped: dict[SiteKey, int] = Counter(site.key for site in sites) + allowed = {entry.key: entry for entry in entries} + for key, count in sorted(grouped.items()): + entry = allowed.get(key) + classification = entry.classification if entry else "UNCLASSIFIED" + print( + f"{key.path}::{key.function} [{key.access}] " + f"count={count} class={classification}" + ) + if entry: + print(f" {entry.reason}") + counts = Counter( + allowed[key].classification if key in allowed else "UNCLASSIFIED" + for key in grouped + ) + print(f"sites={len(sites)} groups={len(grouped)} classes={dict(sorted(counts.items()))}") + + +def self_test() -> int: + fixture_path = REPO_ROOT / "crates" / "perry-codegen" / "src" / "fixture.rs" + fixture = """ +fn stable(ctx: &FnCtx<'_>, id: &u32) { + let _ = ctx.stable_local_type_proof(id); +} +fn guarded(ctx: &FnCtx<'_>, id: &u32) { + let _ = ctx.local_type_hint(id); +} +fn collector(local_types: &Map, id: &u32) { + let _ = local_types.get(id); +} +""" + sites, raw = scan_text(fixture_path, fixture) + entries = [ + Entry( + SiteKey(repo_rel(fixture_path), "stable", "stable_local_type_proof"), + 1, + "representation-proven", + "Fixture reads runtime-derived evidence invalidated by every write.", + ), + Entry( + SiteKey(repo_rel(fixture_path), "guarded", "local_type_hint"), + 1, + "runtime-validated", + "Fixture emits a runtime tag guard before specialization.", + ), + Entry( + SiteKey(repo_rel(fixture_path), "collector", "raw:local_types"), + 1, + "representation-proven", + "Fixture collector scans every write before selecting a rep.", + ), + ] + cases = 0 + + def expect(label: str, errors: list[str], needle: str) -> None: + nonlocal cases + cases += 1 + if not any(needle in error for error in errors): + raise AssertionError(f"{label}: expected {needle!r}, got {errors!r}") + + clean = audit(sites, raw, entries, [], minimum_sites=3) + cases += 1 + if clean: + raise AssertionError(f"clean fixture failed: {clean}") + + added_sites, added_raw = scan_text( + fixture_path, + fixture + + "\nfn new_consumer(ctx: &FnCtx<'_>, id: &u32) {" + + " let _ = ctx.stable_local_type_proof(id); }\n", + ) + expect( + "new consumer", + audit(added_sites, added_raw, entries, [], minimum_sites=3), + "unclassified local-type read", + ) + + expect( + "removed consumer", + audit(sites[:-1], raw, entries, [], minimum_sites=2), + "stale allowlist entry", + ) + + expect( + "count drift", + audit(sites + [sites[0]], raw, entries, [], minimum_sites=3), + "count drift", + ) + + bypass = "fn bad(ctx: &FnCtx<'_>, id: &u32) { let _ = ctx.local_types.get(id); }" + bypass_sites, bypass_raw = scan_text(fixture_path, bypass) + expect( + "raw bypass", + audit(bypass_sites, bypass_raw, [], [], minimum_sites=0), + "direct ctx.local_types read bypasses the proof API", + ) + + proof_bypass = ( + "fn bad(ctx: &FnCtx<'_>, id: &u32) { " + "let _ = ctx.proven_local_types.get(id); }" + ) + proof_bypass_sites, proof_bypass_raw = scan_text(fixture_path, proof_bypass) + expect( + "proof bypass", + audit(proof_bypass_sites, proof_bypass_raw, [], [], minimum_sites=0), + "direct ctx.proven_local_types read bypasses the proof API", + ) + + expect( + "candidate floor", + audit([], [], [], [], minimum_sites=1), + "candidate floor failed", + ) + + print(f"local_binding_type_audit self-test: OK ({cases} cases)") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--list", action="store_true", help="print the classified inventory") + parser.add_argument("--allowlist", type=Path, default=DEFAULT_ALLOWLIST) + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + sites, raw_errors = scan_repo() + entries, allowlist_errors = load_allowlist(args.allowlist) + errors = audit(sites, raw_errors, entries, allowlist_errors) + if args.list: + print_inventory(sites, entries) + if errors: + for error in errors: + print(f"local-binding-type audit: {error}", file=sys.stderr) + return 1 + classes = Counter(entry.classification for entry in entries) + print( + "local-binding-type audit: OK " + f"({len(sites)} sites, {len(entries)} groups; " + + ", ".join(f"{name}={count}" for name, count in sorted(classes.items())) + + ")" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pre-tag-check.sh b/scripts/pre-tag-check.sh index cc8c8dd5c6..ac6c34121f 100755 --- a/scripts/pre-tag-check.sh +++ b/scripts/pre-tag-check.sh @@ -118,6 +118,11 @@ run_check "public benchmark evidence freshness" \ # 5. File-size cap — a long doc comment is enough to trip this. run_check "file size limit" ./scripts/check_file_size.sh +run_check "local binding type-proof audit self-test" \ + python3 scripts/local_binding_type_audit.py --self-test +run_check "local binding type-proof audit" \ + python3 scripts/local_binding_type_audit.py + # 6. GC store-site inventory: every raw heap-slot store must be barriered # or carry a justified marker. run_check "GC store-site inventory self-test" \ diff --git a/test-files/test_gap_7846_local_binding_type_proofs.ts b/test-files/test_gap_7846_local_binding_type_proofs.ts new file mode 100644 index 0000000000..3a1445b183 --- /dev/null +++ b/test-files/test_gap_7846_local_binding_type_proofs.ts @@ -0,0 +1,86 @@ +// A local's declared or initializer-refined type is not proof about the value +// currently in its slot. Exercise both ways that proof becomes invalid: an +// erased annotation can lie at initialization, and a later assignment can +// replace an honestly initialized value with another kind. + +const declaredNumberHoldsObject: number = { kind: "object" } as any; +console.log( + "declared-number-object", + declaredNumberHoldsObject ? "truthy" : "falsy", + !declaredNumberHoldsObject, + Boolean(declaredNumberHoldsObject), +); + +const declaredBooleanHoldsNumber: boolean = 7 as any; +console.log( + "declared-boolean-number", + declaredBooleanHoldsNumber ? "truthy" : "falsy", + !declaredBooleanHoldsNumber, + Boolean(declaredBooleanHoldsNumber), +); + +const declaredNumberHoldsEmptyString: number = "" as any; +console.log( + "declared-number-empty-string", + declaredNumberHoldsEmptyString ? "truthy" : "falsy", + !declaredNumberHoldsEmptyString, + Boolean(declaredNumberHoldsEmptyString), +); + +let refinedNumber: any = 0; +refinedNumber = { after: "write" }; +console.log( + "refined-number-reassigned", + refinedNumber ? "truthy" : "falsy", + !refinedNumber, + Boolean(refinedNumber), +); + +let refinedBoolean: any = false; +refinedBoolean = "now a string"; +console.log( + "refined-boolean-reassigned", + refinedBoolean ? "truthy" : "falsy", + !refinedBoolean, + Boolean(refinedBoolean), +); + +// Pin the #7844 directions beside the broader truthiness cases: the same +// whole-region write set must invalidate positive and negative array folds. +let arrayToNumber: any = [1, 2, 3]; +arrayToNumber = 42; +let numberToArray: any = 0; +numberToArray = [numberToArray]; +console.log( + "array-folds", + Array.isArray(arrayToNumber), + Array.isArray(numberToArray), +); + +// Typed closure clones may use an annotation only as a guarded candidate. +// The capture is immutable but its erased declaration lies immediately, so +// both public and direct typed entry paths must take the generic fallback. +const declaredNumericCapture: number = "capture" as any; +const appendCaptured = (value: number): number => + declaredNumericCapture + value; +console.log("guarded-capture", appendCaptured(7)); + +// Scalar-replaced constructors inline their parameter bindings into the +// caller. The parameter's `number` annotation still cannot turn the live +// string argument into raw-f64 field evidence. +class Counter { + value: number; + + constructor(value: number) { + this.value = value; + } + + bump(): number { + this.value = this.value + 1; + return this.value; + } +} + +const declaredNumericCtorArg: number = "counter" as any; +const counter = new Counter(declaredNumericCtorArg); +console.log("scalar-constructor-arg", counter.bump()); From 788dff712c952cc0b6e9e81ad9cb395f51dbc51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 14:43:21 +0200 Subject: [PATCH 2/2] fix(codegen): address binding proof review --- .../8033-local-binding-runtime-proofs.md | 3 + .../src/codegen/module_globals_emit.rs | 107 +++++++++++++++- crates/perry-codegen/src/codegen/typed_abi.rs | 38 +++++- .../src/codegen/typed_abi/tests.rs | 36 ++++++ .../src/collectors/pointer_locals.rs | 110 ++++++++++++++-- crates/perry-codegen/src/expr/array_push.rs | 24 ++-- .../src/expr/array_push_guard_tests.rs | 48 +++++-- .../src/expr/boolean_number_tests.rs | 4 +- crates/perry-codegen/src/expr/misc_methods.rs | 7 +- crates/perry-codegen/src/expr/property_get.rs | 41 ++++-- crates/perry-codegen/src/expr/shadow_slot.rs | 14 ++- .../src/stmt/masked_window_region.rs | 2 +- .../perry-codegen/src/type_analysis/refine.rs | 4 +- .../tests/native_proof_regressions.rs | 117 ++++++++++++++---- scripts/local_binding_type_allowlist.json | 56 ++++++++- scripts/local_binding_type_audit.py | 33 ++++- ...test_gap_7846_local_binding_type_proofs.ts | 95 ++++++++++++++ 17 files changed, 649 insertions(+), 90 deletions(-) create mode 100644 changelog.d/8033-local-binding-runtime-proofs.md diff --git a/changelog.d/8033-local-binding-runtime-proofs.md b/changelog.d/8033-local-binding-runtime-proofs.md new file mode 100644 index 0000000000..f07a531fbe --- /dev/null +++ b/changelog.d/8033-local-binding-runtime-proofs.md @@ -0,0 +1,3 @@ +### Bug fixes + +- Code generation now requires runtime-derived evidence before treating local bindings as non-pointer or native scalar values, preserving JavaScript semantics for erased annotations and keeping BigInt and object values rooted across collection points. diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index e79c725c18..51eb5f3f52 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -31,7 +31,10 @@ pub(crate) struct ModuleGlobals { /// Runtime kinds established without consulting a TypeScript annotation. /// This deliberately covers only the module-global values the thread transfer /// check can safely permit; every unrecognized expression stays hazardous. -fn module_global_runtime_type(init: &perry_hir::Expr) -> Option { +fn module_global_runtime_type( + init: &perry_hir::Expr, + shared_array_buffer_is_intrinsic: bool, +) -> Option { use perry_hir::types::Type; use perry_hir::Expr; match init { @@ -43,13 +46,56 @@ fn module_global_runtime_type(init: &perry_hir::Expr) -> Option { Some(Type::String) } - Expr::New { class_name, .. } if class_name == "SharedArrayBuffer" => { + Expr::New { class_name, .. } + if class_name == "SharedArrayBuffer" && shared_array_buffer_is_intrinsic => + { Some(Type::Named(class_name.clone())) } _ => None, } } +fn module_shadows_shared_array_buffer_intrinsic( + hir: &HirModule, + imported_classes: &[ImportedClass], + init_lets: &[&perry_hir::Stmt], +) -> bool { + init_lets.iter().any(|stmt| { + matches!( + stmt, + perry_hir::Stmt::Let { name, .. } if name == "SharedArrayBuffer" + ) + }) || hir + .globals + .iter() + .any(|global| global.name == "SharedArrayBuffer") + || hir + .functions + .iter() + .any(|function| function.name == "SharedArrayBuffer") + || hir + .classes + .iter() + .any(|class| class.name == "SharedArrayBuffer") + || hir + .enums + .iter() + .any(|enum_decl| enum_decl.name == "SharedArrayBuffer") + || hir.imports.iter().any(|import| { + !import.type_only + && import.specifiers.iter().any(|specifier| match specifier { + perry_hir::ImportSpecifier::Named { local, .. } + | perry_hir::ImportSpecifier::Default { local } + | perry_hir::ImportSpecifier::Namespace { local } => { + local == "SharedArrayBuffer" + } + }) + }) + || imported_classes + .iter() + .any(|class| class.local_alias.as_deref().unwrap_or(&class.name) == "SharedArrayBuffer") +} + /// Emit module-level globals (with exported-var getters) and static-class-field /// globals. `compile_time_constants` supplies init values for known synthetic /// consts (`__platform__`, `__plugins__`). @@ -263,6 +309,13 @@ pub(crate) fn emit_module_globals( } let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); collect_init_lets(&hir.init, &mut init_lets); + // `Expr::New { class_name }` does not retain whether an unqualified name + // came from the intrinsic or a same-named runtime binding. Mirror HIR's + // `shadows_unqualified_global` categories here, plus the module-level HIR + // forms available to codegen, and keep the worker-transfer proof + // conservative whenever the constructor's provenance is ambiguous. + let shared_array_buffer_is_intrinsic = + !module_shadows_shared_array_buffer_intrinsic(hir, imported_classes, &init_lets); let let_counts = init_lets.iter().fold(HashMap::new(), |mut counts, stmt| { if let perry_hir::Stmt::Let { id, .. } = stmt { *counts.entry(*id).or_insert(0usize) += 1; @@ -283,7 +336,7 @@ pub(crate) fn emit_module_globals( if let Some(proven) = init .as_ref() .filter(|_| let_counts.get(id) == Some(&1) && !reassigned.contains(id)) - .and_then(module_global_runtime_type) + .and_then(|init| module_global_runtime_type(init, shared_array_buffer_is_intrinsic)) { module_global_proven_types.insert(*id, proven); } @@ -494,3 +547,51 @@ pub(crate) fn emit_module_globals( static_field_globals, } } + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::{Expr, Function, Module, Stmt}; + + #[test] + fn shared_array_buffer_provenance_rejects_let_and_function_bindings() { + let mut let_module = Module::new("sab_let_shadow.ts"); + let_module.init.push(Stmt::Let { + id: 1, + name: "SharedArrayBuffer".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Undefined), + }); + let init_lets = vec![&let_module.init[0]]; + assert!(module_shadows_shared_array_buffer_intrinsic( + &let_module, + &[], + &init_lets, + )); + + let mut function_module = Module::new("sab_function_shadow.ts"); + function_module.functions.push(Function { + id: 2, + name: "SharedArrayBuffer".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + assert!(module_shadows_shared_array_buffer_intrinsic( + &function_module, + &[], + &[], + )); + } +} diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index c29f9a380f..9dbbed42c9 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -116,17 +116,43 @@ fn typed_closure_capture_reps( expr: &Expr, module_local_types: &HashMap, ) -> Option> { - let Expr::Closure { captures, .. } = expr else { + let Expr::Closure { + params, + body, + captures, + .. + } = expr + else { return None; }; - let mut reps = Vec::with_capacity(captures.len()); - for id in captures { + // Match the capture allocator/body map order: explicit captures first, + // followed by referenced outer ids in deterministic order. Module globals + // are absent from the typed-ABI type oracle, so encountering one rejects + // the clone; emitted capture slots filter the same ids. This keeps every + // enumerated representation aligned with the slot index guarded by the + // trampoline and loaded by the typed body, even when HIR capture + // conversion omitted an id that codegen auto-detects. + let mut effective = captures.clone(); + let mut referenced = HashSet::new(); + crate::collectors::collect_ref_ids_in_stmts(body, &mut referenced); + let mut inner_lets = HashSet::new(); + crate::collectors::collect_let_ids(body, &mut inner_lets); + let param_ids: HashSet = params.iter().map(|param| param.id).collect(); + let explicit: HashSet = effective.iter().copied().collect(); + let mut auto: Vec = referenced.into_iter().collect(); + auto.sort_unstable(); + effective.extend(auto.into_iter().filter(|id| { + !param_ids.contains(id) && !inner_lets.contains(id) && !explicit.contains(id) + })); + + let mut reps = Vec::with_capacity(effective.len()); + for id in effective { // This is a candidate representation derived from source metadata, // not a proof. Both typed-clone entry paths guard the current capture // bits against `rep` before entering the raw ABI. - let ty = module_local_types.get(id)?; + let ty = module_local_types.get(&id)?; let rep = typed_param_rep_for_type(ty)?; - reps.push((*id, rep)); + reps.push((id, rep)); } Some(reps) } @@ -692,7 +718,7 @@ pub(crate) fn typed_i1_closure_rejection_reason_with_types( if *captures_new_target { return Some(TypedCloneRejectionReason::CapturesNewTarget); } - if captures.iter().any(|id| mutable_captures.contains(id)) { + if !mutable_captures.is_empty() || captures.iter().any(|id| mutable_captures.contains(id)) { return Some(TypedCloneRejectionReason::Captures); } diff --git a/crates/perry-codegen/src/codegen/typed_abi/tests.rs b/crates/perry-codegen/src/codegen/typed_abi/tests.rs index 8a3ef8f3fb..be5b6917b8 100644 --- a/crates/perry-codegen/src/codegen/typed_abi/tests.rs +++ b/crates/perry-codegen/src/codegen/typed_abi/tests.rs @@ -200,3 +200,39 @@ fn closure_capture_representations_are_runtime_guard_candidates() { Some(vec![(30, TypedParamRep::I32), (31, TypedParamRep::I1)]) ); } + +#[test] +fn closure_capture_representations_follow_effective_slot_order() { + let expr = Expr::Closure { + func_id: 8, + params: vec![param(20, "scale", Type::Number)], + return_type: Type::Number, + body: ret(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(20)), + // Deliberately absent from the explicit capture list: closure + // allocation and body lowering append this auto-capture. + right: Box::new(Expr::LocalGet(29)), + }), + captures: vec![30, 31], + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }; + let module_local_types = + HashMap::from([(29, Type::Number), (30, Type::Int32), (31, Type::Boolean)]); + + assert_eq!( + typed_f64_closure_capture_reps(&expr, &module_local_types), + Some(vec![ + (30, TypedParamRep::I32), + (31, TypedParamRep::I1), + (29, TypedParamRep::F64), + ]) + ); +} diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index f75a5f1224..cc8b23c4ab 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -306,12 +306,22 @@ pub fn collect_pointer_typed_locals( None } }), - Expr::Unary { op, .. } => Some(match op { - perry_hir::UnaryOp::Not => Type::Boolean, - perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot => { - Type::Number - } - }), + Expr::Unary { op, operand } => match op { + perry_hir::UnaryOp::Not => Some(Type::Boolean), + // Unary plus either produces a Number or throws for BigInt; it + // can never bind a pointer-bearing result. + perry_hir::UnaryOp::Pos => Some(Type::Number), + // Negation and bit-not preserve BigInt. Only discard the root + // when the operand has runtime-derived non-pointer evidence; + // declared types are intentionally absent from this proof. + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::BitNot => expr_is_known_non_pointer( + operand, + local_types, + local_value_types, + non_pointer_locals, + ) + .then_some(Type::Number), + }, Expr::Binary { op, left, right } => { if matches!(op, BinaryOp::Add) { if expr_is_known_non_pointer( @@ -329,8 +339,27 @@ pub fn collect_pointer_typed_locals( } else { None } - } else { + } else if expr_is_known_non_pointer( + left, + local_types, + local_value_types, + non_pointer_locals, + ) || expr_is_known_non_pointer( + right, + local_types, + local_value_types, + non_pointer_locals, + ) { + // Every non-Add arithmetic/bitwise operator can preserve + // BigInt only when both operands convert to BigInt. A + // runtime-proven non-pointer operand converts to Number, + // so the expression either yields a scalar Number or + // throws on a mixed Number/BigInt pair. With no such + // evidence the result may be a heap BigInt and must keep a + // precise root. Some(Type::Number) + } else { + None } } Expr::Conditional { @@ -1340,6 +1369,73 @@ mod tests { assert!(slots.contains_key(&2)); } + #[test] + fn bigint_arithmetic_results_keep_shadow_slots() { + let bigint = || Expr::BigInt("1".to_string()); + let cases = [ + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(bigint()), + right: Box::new(bigint()), + }, + Expr::Unary { + op: perry_hir::UnaryOp::Neg, + operand: Box::new(bigint()), + }, + Expr::Unary { + op: perry_hir::UnaryOp::BitNot, + operand: Box::new(bigint()), + }, + ]; + + for (index, init) in cases.into_iter().enumerate() { + let id = index as u32 + 1; + let stmts = vec![Stmt::Let { + id, + name: format!("bigint_result_{id}"), + ty: Type::BigInt, + mutable: false, + init: Some(init), + }]; + let slots = collect_pointer_typed_locals(&[], &stmts, &HashSet::new()); + assert!( + slots.contains_key(&id), + "BigInt-producing arithmetic must remain visible to the precise GC" + ); + } + } + + #[test] + fn proven_number_arithmetic_still_avoids_shadow_slots() { + let stmts = vec![ + Stmt::Let { + id: 1, + name: "product".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::Integer(6)), + right: Box::new(Expr::Integer(7)), + }), + }, + Stmt::Let { + id: 2, + name: "negative".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Unary { + op: perry_hir::UnaryOp::Neg, + operand: Box::new(Expr::LocalGet(1)), + }), + }, + ]; + + let slots = collect_pointer_typed_locals(&[], &stmts, &HashSet::new()); + assert!(!slots.contains_key(&1)); + assert!(!slots.contains_key(&2)); + } + /// Every `Type` variant, with the answer pinned. /// /// #7236: `is_definitely_non_pointer_type` and `typed_shape`'s diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index dbce1afe9c..1613868ca5 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -582,6 +582,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> layout_note_needed }; let write_barrier_needed = array_store_needs_write_barrier(ctx, value); + let value_is_statically_numeric = is_numeric_expr(ctx, value); let value_is_numeric = guarded_numeric_add_push_candidate(ctx, value); let require_numeric_layout = value_is_numeric && expr_has_numeric_pointer_free_array_layout(ctx, &array_expr); @@ -628,15 +629,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // numeric values (e.g. a read fallback's INT32-boxed bits) keep // the runtime-guarded tier: stored verbatim they would corrupt // the dense raw-f64 invariant. + // Metadata-selected `+` is only a guarded candidate: a declared + // number may hold a string, boolean, or any other JS value. Keep + // that shape on the runtime numeric guard even when feedback + // emission is disabled. A guard miss reaches `js_array_push_f64`, + // which performs the generic store and revokes raw-f64 layout for + // every non-number (including non-pointer tags). let inline_value_shape = - crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value) - || matches!( - value.as_ref(), - Expr::Binary { - op: perry_hir::BinaryOp::Add, - .. - } - ) && value_is_numeric; + crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value); let keep_guarded_numeric_push = super::typed_feedback_emission_enabled() || !inline_value_shape; if require_numeric_layout @@ -1047,7 +1047,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // #7469: provably dead under `declared_all_pointer` — the // `nofwd` admission test proved both raw-f64 bits already // clear, and clearing them is this call's only effect. - if !value_is_numeric && !declared_all_pointer { + // A metadata-only numeric candidate has not established + // the live value's kind. If this generic inline tier is + // used (for an array without a static raw-f64 fact), let + // the runtime note inspect every stored tag and revoke a + // dynamically active numeric layout on strings, booleans, + // undefined, and all other non-number values. + if !value_is_statically_numeric && !declared_all_pointer { let value_bits = barrier_value_bits .clone() .or(value_bits) diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index 89d3241509..e426bc2e85 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -16,10 +16,10 @@ //! predicate for a test that always says "yes" — and, more importantly, the //! bookkeeping calls must still be REACHABLE from the guarded arm. The change //! is "skip the calls when the live bits prove them dead", never "elide them -//! outright": a `number`-annotated parameter that actually holds a string at -//! runtime (Perry does not validate declared types) takes the guarded arm and -//! records the slot exactly as it always did. A test that asserted the calls -//! ABSENT would be pinning silent heap corruption. +//! outright": an unexpected tag-shaped NaN payload takes the guarded arm and +//! records the slot exactly as it always did. Metadata-only numeric candidates +//! are tested separately below and stay on the runtime number guard. A test +//! that asserted the bookkeeping calls ABSENT would pin silent heap corruption. use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; @@ -228,11 +228,21 @@ fn inbounds_block(ir: &str) -> String { rest[..end].to_string() } -/// `keep.push(base + j)` — `push_num.ts` verbatim. `Expr::Binary { Add }` is -/// the shape no static non-pointer proof can admit (`+` is string -/// concatenation for non-numeric operands), which is exactly why the live test -/// is what retires the calls here. +/// A canonical numeric `+` whose operands have runtime-derived evidence. The +/// live-bits guard remains useful because NaN payloads still require GC-layout +/// bookkeeping even though neither operand rests on source metadata. fn numeric_add_push() -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(COUNTER_ID)), + right: Box::new(Expr::LocalGet(COUNTER_ID)), + } +} + +/// The benchmark-like shape whose left operand is only a declared-number +/// parameter. Perry does not enforce that annotation, so this candidate must +/// keep the live runtime-number guard and generic push fallback. +fn metadata_numeric_add_push() -> Expr { Expr::Binary { op: BinaryOp::Add, left: Box::new(Expr::LocalGet(BASE_ID)), @@ -275,8 +285,7 @@ fn a_numeric_push_moves_its_gc_bookkeeping_behind_one_live_test() { #[test] fn the_guarded_arm_still_reaches_every_call_it_moved() { let ir = ir_for(push_module(Type::Number, numeric_add_push(), Vec::new())); - // Not an elision. A `number`-annotated value that is a heap string at - // runtime (Perry does not validate declared types) takes this arm. + // Not an elision. A tag-shaped live payload still takes this arm. assert!( ir.contains(NOTE_CALL), "the layout note was ELIDED rather than guarded — a pointer reaching \ @@ -403,3 +412,22 @@ fn the_guard_branches_on_the_live_bits_not_on_a_constant() { ); } } + +#[test] +fn a_metadata_selected_add_keeps_the_runtime_number_guard() { + let ir = ir_for(push_module( + Type::Number, + metadata_numeric_add_push(), + Vec::new(), + )); + assert!( + ir.contains("call i32 @js_typed_feedback_numeric_array_push_guard") + && ir.contains("call i64 @js_array_numeric_push_f64_unboxed") + && ir.contains("call i64 @js_array_push_f64"), + "a declared-number addition must validate the live value and retain the generic push fallback:\n{ir}" + ); + assert!( + !ir.contains(GUARD_BLOCK), + "metadata alone must not reach the pointer-only inline bookkeeping guard:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/boolean_number_tests.rs b/crates/perry-codegen/src/expr/boolean_number_tests.rs index ba1bab7c22..c8ab09e160 100644 --- a/crates/perry-codegen/src/expr/boolean_number_tests.rs +++ b/crates/perry-codegen/src/expr/boolean_number_tests.rs @@ -156,7 +156,7 @@ fn invalidated_boolean_proof_keeps_dynamic_coercion_and_add_dispatch() { "a Boolean local invalidated by a string write must preserve concat semantics:\n{ir}" ); assert!( - ir.contains("call double @js_number_coerce("), - "an invalidated Boolean proof must preserve runtime numeric coercion:\n{ir}" + ir.contains("call double @js_dynamic_mul("), + "an invalidated Boolean proof must preserve BigInt-aware runtime ToNumeric coercion:\n{ir}" ); } diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index 66aff54cd4..430394f9c2 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -9,6 +9,7 @@ use perry_hir::{Expr, UnaryOp}; use crate::nanbox::double_literal; use crate::native_value::{ExpectedNativeRep, LoweredValue, MaterializationReason, NativeRep}; +use crate::type_analysis::{is_numeric_expr, is_provably_not_bigint}; use crate::types::{DOUBLE, F32, I1, I32, I64, I8, PTR}; use super::{ @@ -839,11 +840,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { vec!["slot_kind=raw_i32_proven".to_string()], ); Ok(result) - } else if crate::codegen::typed_arg_is_guard_candidate( - ctx, - crate::codegen::TypedParamRep::F64, - value, - ) { + } else if is_numeric_expr(ctx, value) && is_provably_not_bigint(ctx, value) { let (raw, slot_note) = lower_iter_result_f64_payload(ctx, value)?; let result = ctx.block().call( DOUBLE, diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 6e3d160470..cb892b4347 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1279,10 +1279,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // the property is registered as a getter, call the // synthesized __get_ method instead of doing a // raw field load. - if let Some(class_name) = receiver_class_name(ctx, object) + let proven_receiver_class = receiver_class_name(ctx, object); + if let Some(class_name) = proven_receiver_class + .clone() .or_else(|| guarded_declared_class_get_candidate(ctx, object)) { - if class_name == "URLPattern" && is_url_pattern_data_property(property) { + // A metadata-only class name may nominate the guarded plain- + // field IC below, whose live class-id/keys check owns a + // semantic fallback. It must never select class behavior by + // itself: direct accessors and bound methods have no receiver + // shape guard and would otherwise invoke the declared class on + // an unrelated live instance. + let receiver_class_is_proven = + proven_receiver_class.as_deref() == Some(class_name.as_str()); + if receiver_class_is_proven + && class_name == "URLPattern" + && is_url_pattern_data_property(property) + { let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = @@ -1304,7 +1317,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `class Headers` — a user class of that name owns the // receiver type, so fall through to the user-class // getter/method dispatch below. - if class_name == "Headers" + if receiver_class_is_proven + && class_name == "Headers" && !ctx.classes.contains_key(&class_name) && matches!( property.as_str(), @@ -1335,13 +1349,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(DOUBLE, &recv_box), (I64, &bytes_i64), (I64, &len_str)], )); } - if class_name == "ClientRequest" && is_http_client_request_method_name(property) { + if receiver_class_is_proven + && class_name == "ClientRequest" + && is_http_client_request_method_name(property) + { return lower_class_method_bind(ctx, object, property); } - if class_name == "Agent" && is_http_agent_method_name(property) { + if receiver_class_is_proven + && class_name == "Agent" + && is_http_agent_method_name(property) + { return lower_class_method_bind(ctx, object, property); } - if is_net_native_method_value(&class_name, property) { + if receiver_class_is_proven && is_net_native_method_value(&class_name, property) { return lower_class_method_bind(ctx, object, property); } if class_has_computed_runtime_members(ctx, &class_name) { @@ -1359,7 +1379,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .get(&class_name) .map(|c| c.static_accessor_names.iter().any(|n| n == property)) .unwrap_or(false); - if !is_static_accessor { + if receiver_class_is_proven && !is_static_accessor { if let Some(fn_name) = ctx.methods.get(&getter_key).cloned() { let recv_box = lower_expr(ctx, object)?; return Ok(ctx.block().call(DOUBLE, &fn_name, &[(DOUBLE, &recv_box)])); @@ -1387,7 +1407,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "write" | "close" | "abort" | "releaseLock" ) ); - if class_name == "Headers" + if receiver_class_is_proven + && class_name == "Headers" && !ctx.classes.contains_key(&class_name) && is_headers_method_name(property) { @@ -1406,7 +1427,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &obj_bits), (I64, &key_handle)], )); } - if is_web_stream_method { + if receiver_class_is_proven && is_web_stream_method { return lower_class_method_bind(ctx, object, property); } // Fast path: known class instance + plain instance field @@ -1883,7 +1904,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // prototype methods — every method reference returned // `undefined`. let method_key = (class_name.clone(), property.clone()); - if ctx.methods.contains_key(&method_key) { + if receiver_class_is_proven && ctx.methods.contains_key(&method_key) { return lower_class_method_bind(ctx, object, property); } } diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 14e181a6b0..923c76a7df 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -8,7 +8,7 @@ use super::*; use anyhow::{anyhow, Result}; use perry_hir::types::Type as HirType; -use perry_hir::{BinaryOp, Expr}; +use perry_hir::{Expr, UnaryOp}; use crate::types::{I32, I64, PTR}; @@ -76,9 +76,15 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp }) } Expr::Compare { .. } | Expr::Void(_) => true, - Expr::Unary { .. } => true, - Expr::Binary { op, .. } => { - !matches!(op, BinaryOp::Add) || crate::type_analysis::is_numeric_expr(ctx, expr) + Expr::Unary { op, operand } => match op { + UnaryOp::Not | UnaryOp::Pos => true, + UnaryOp::Neg | UnaryOp::BitNot => { + crate::type_analysis::is_provably_not_bigint(ctx, operand) + } + }, + Expr::Binary { .. } => { + crate::type_analysis::is_numeric_expr(ctx, expr) + && crate::type_analysis::is_provably_not_bigint(ctx, expr) } // #6750 follow-up: a masked-index read covered by an ACTIVE // masked-window fact is a guard-proven numeric element load — never diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index 51bd160568..447755c198 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -178,7 +178,7 @@ fn expr_is_number_under( Expr::LocalGet(id) => { refined.contains(id) || matches!( - ctx.local_type_hint(id), + ctx.stable_local_type_proof(id), Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32) ) } diff --git a/crates/perry-codegen/src/type_analysis/refine.rs b/crates/perry-codegen/src/type_analysis/refine.rs index c655dc6166..f9de5a2cad 100644 --- a/crates/perry-codegen/src/type_analysis/refine.rs +++ b/crates/perry-codegen/src/type_analysis/refine.rs @@ -224,7 +224,9 @@ pub(crate) fn proven_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option + if is_numeric_expr(ctx, left) + && is_numeric_expr(ctx, right) + && is_provably_not_bigint(ctx, init) => { Some(HirType::Number) } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index d8a72d1d2c..d31bc3ed27 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -2187,6 +2187,58 @@ fn small_bigint_literal_stays_i128_until_js_boundary() { ); } +#[test] +fn bigint_binary_result_live_across_collection_keeps_a_precise_root() { + let _pin = NativeRootsPin::shadow(); + let module = module_with_classes_and_params( + "bigint_binary_root_across_collection.ts", + Vec::new(), + Vec::new(), + Type::BigInt, + vec![ + Stmt::Let { + id: 10, + name: "value".to_string(), + ty: Type::BigInt, + mutable: false, + init: Some(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::BigInt("1n".to_string())), + right: Box::new(Expr::BigInt("2n".to_string())), + }), + }, + Stmt::Expr(native_module_call( + "console", + "log", + vec![Expr::String("collection point".to_string())], + )), + Stmt::Return(Some(local(10))), + ], + ); + + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + let probe = defined_function_ir_section( + &ir, + "perry_fn_bigint_binary_root_across_collection_ts__probe", + ); + let collection = probe + .find("call void @js_console_log_spread") + .expect("fixture should contain a collection-capable console call"); + assert!( + probe[..collection].contains("call void @js_shadow_slot_bind"), + "the BigInt result local must be bound as a precise root before the collection point:\n{probe}" + ); + assert_eq!( + probe.matches("call void @js_shadow_slot_bind").count(), + 1, + "the fixture has exactly one pointer-bearing frame local, so its root must be the BigInt result:\n{probe}" + ); + assert!( + probe[collection..].contains("load double, ptr "), + "the returned BigInt must be reloaded from its rooted slot after the collection point:\n{probe}" + ); +} + #[test] fn oversized_bigint_literal_records_small_bigint_rejection_and_falls_back() { let too_wide = format!("0x1{}n", "0".repeat(32)); @@ -2281,9 +2333,15 @@ fn packed_f64_loop_store_update_versions_with_side_exit() { "packed fast clone must not perform a boxed fallback before side-exiting:\n{fallback_block}\n\n{ir}" ); let slow_start = ir - .find("for.packed_f64_slow") - .expect("expected packed-f64 slow clone"); - let slow_clone = &ir[slow_start..]; + .find("\nfor.packed_f64_slow.") + .map(|pos| pos + 1) + .expect("expected packed-f64 slow-clone block"); + let slow_tail = &ir[slow_start..]; + let slow_end = slow_tail + .find("\n}\n") + .map(|offset| slow_start + offset) + .expect("expected packed-f64 slow-clone function boundary"); + let slow_clone = &ir[slow_start..slow_end]; assert!( slow_clone.contains("call void @js_gc_note_slot_layout") && slow_clone.contains("call void @js_write_barrier_slot"), @@ -7294,7 +7352,7 @@ fn compiler_private_async_iter_result_i32_slot_uses_typed_handoff() { } #[test] -fn compiler_private_async_iter_result_annotated_numeric_payload_is_coerced_before_raw_slot() { +fn compiler_private_async_iter_result_annotated_numeric_payload_stays_generic() { let ir = compile_ir_for_module_with_opts( module_with_classes_and_params( "compiler_private_async_iter_result_annotated_numeric_param.ts", @@ -7308,16 +7366,12 @@ fn compiler_private_async_iter_result_annotated_numeric_payload_is_coerced_befor .unwrap(); assert!( - ir.contains("call double @js_number_coerce"), - "annotation-only numeric async payloads must be coerced before raw f64 storage:\n{ir}" - ); - assert!( - ir.contains("call double @js_iter_result_set_f64"), - "coerced numeric async payload should still use the raw f64 scratch slot:\n{ir}" + ir.contains("call double @js_iter_result_set("), + "annotation-only numeric async payloads must preserve the live JSValue:\n{ir}" ); assert!( - !ir.contains("call double @js_iter_result_set("), - "coerced numeric async payload should avoid the generic JSValue setter:\n{ir}" + !ir.contains("call double @js_iter_result_set_f64"), + "annotation-only numeric async payloads must not use the unguarded raw-f64 slot:\n{ir}" ); } @@ -12250,7 +12304,9 @@ fn typed_f64_closure_clone_accepts_immutable_numeric_capture() { ) .unwrap(); let public = "perry_closure_typed_f64_closure_abi_ts__300"; + let generic_body = "perry_closure_typed_f64_closure_abi_ts__300$generic"; let typed = "perry_closure_typed_f64_closure_abi_ts__300$typed_f64"; + let caller_ir = defined_function_ir_section(&ir, "perry_fn_typed_f64_closure_abi_ts__probe"); let typed_ir = defined_function_ir_section(&ir, typed); let wrapper_ir = function_ir_section(&ir, public); assert!( @@ -12265,8 +12321,11 @@ fn typed_f64_closure_clone_accepts_immutable_numeric_capture() { "public typed-f64 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" ); assert!( - ir.contains("closure_direct.typed_f64") && ir.contains("call i32 @js_typed_f64_arg_guard"), - "direct typed-f64 calls must guard captures and retain their generic branch:\n{ir}" + caller_ir.contains("closure_direct.typed_f64") + && caller_ir.contains("call i64 @js_closure_get_capture_bits") + && caller_ir.contains("call i32 @js_typed_f64_arg_guard") + && caller_ir.contains(&format!("call double @{generic_body}(i64 ")), + "direct typed-f64 calls must guard captures and retain their generic branch:\n{caller_ir}" ); assert!( ir.contains(&format!("call double @{typed}(i64 ")), @@ -12388,7 +12447,10 @@ fn typed_i32_closure_clone_accepts_immutable_i32_capture() { ) .unwrap(); let public = "perry_closure_typed_i32_closure_capture_ts__303"; + let generic_body = "perry_closure_typed_i32_closure_capture_ts__303$generic"; let typed = "perry_closure_typed_i32_closure_capture_ts__303$typed_i32"; + let caller_ir = + defined_function_ir_section(&ir, "perry_fn_typed_i32_closure_capture_ts__probe"); let typed_ir = defined_function_ir_section(&ir, typed); let wrapper_ir = function_ir_section(&ir, public); assert!( @@ -12403,8 +12465,11 @@ fn typed_i32_closure_clone_accepts_immutable_i32_capture() { "public typed-i32 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" ); assert!( - ir.contains("closure_direct.typed_i32") && ir.contains("call i32 @js_typed_i32_arg_guard"), - "direct typed-i32 calls must guard captures and retain their generic branch:\n{ir}" + caller_ir.contains("closure_direct.typed_i32") + && caller_ir.contains("call i64 @js_closure_get_capture_bits") + && caller_ir.contains("call i32 @js_typed_i32_arg_guard") + && caller_ir.contains(&format!("call double @{generic_body}(i64 ")), + "direct typed-i32 calls must guard captures and retain their generic branch:\n{caller_ir}" ); assert!( ir.contains(&format!("call i32 @{typed}(i64 ")), @@ -12658,7 +12723,9 @@ fn typed_i1_closure_clone_accepts_immutable_boolean_capture() { ) .unwrap(); let public = "perry_closure_typed_i1_closure_capture_ts__301"; + let generic_body = "perry_closure_typed_i1_closure_capture_ts__301$generic"; let typed = "perry_closure_typed_i1_closure_capture_ts__301$typed_i1"; + let caller_ir = defined_function_ir_section(&ir, "perry_fn_typed_i1_closure_capture_ts__probe"); let typed_ir = defined_function_ir_section(&ir, typed); let wrapper_ir = function_ir_section(&ir, public); assert!( @@ -12673,8 +12740,11 @@ fn typed_i1_closure_clone_accepts_immutable_boolean_capture() { "public typed-i1 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" ); assert!( - ir.contains("closure_direct.typed_i1") && ir.contains("call i32 @js_typed_i1_arg_guard"), - "direct typed-i1 calls must guard captures and retain their generic branch:\n{ir}" + caller_ir.contains("closure_direct.typed_i1") + && caller_ir.contains("call i64 @js_closure_get_capture_bits") + && caller_ir.contains("call i32 @js_typed_i1_arg_guard") + && caller_ir.contains(&format!("call double @{generic_body}(i64 ")), + "direct typed-i1 calls must guard captures and retain their generic branch:\n{caller_ir}" ); assert!( ir.contains(&format!("call i1 @{typed}(i64 ")), @@ -13775,7 +13845,7 @@ fn static_name_spread_method_fallback_uses_method_id_wrapper() { } #[test] -fn static_name_class_method_value_uses_method_id_bind_wrapper() { +fn annotated_class_method_value_uses_generic_lookup() { let mut calc = class(209, "Calc", Vec::new()); calc.methods.push(Function { id: 2090, @@ -13807,12 +13877,13 @@ fn static_name_class_method_value_uses_method_id_bind_wrapper() { let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); assert!( - ir.contains("call double @js_class_method_bind_by_id"), - "static-name class method value reads should use method-id bind ABI:\n{ir}" + ir.contains("call double @js_object_get_field_ic_miss"), + "an annotation-only class receiver should preserve generic property lookup:\n{ir}" ); assert!( - !ir.contains("call double @js_class_method_bind(double"), - "static-name class method value reads should not pass raw name bytes:\n{ir}" + !ir.contains("call double @js_class_method_bind_by_id") + && !ir.contains("call double @js_class_method_bind(double"), + "an annotation-only class receiver must not select a direct class-method bind ABI:\n{ir}" ); } diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 6c5b5389fa..9190e081f3 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -9,6 +9,38 @@ "classification": "metadata-only", "reason": "The module map is copied into the closure's source-type metadata; representation consumers use the separate runtime-proof map or emit a live-value guard." }, + { + "path": "crates/perry-codegen/src/codegen/closure.rs", + "function": "compile_closure", + "access": "raw:local_types", + "count": 1, + "classification": "metadata-only", + "reason": "This entry operation merges source-type metadata for closure receivers; raw representation consumers require separate runtime proof or a live guard." + }, + { + "path": "crates/perry-codegen/src/codegen/mod.rs", + "function": "unique_global", + "access": "raw:module_local_types", + "count": 1, + "classification": "metadata-only", + "reason": "This iteration derives the typed-ABI metadata oracle while filtering globals; typed entry paths independently guard live argument and capture values." + }, + { + "path": "crates/perry-codegen/src/collectors/byte_read_key.rs", + "function": "is_numeric", + "access": "raw:binding_types", + "count": 1, + "classification": "metadata-only", + "reason": "This collector inventories declared numeric key candidates; the map contains source metadata and is not itself runtime-value evidence." + }, + { + "path": "crates/perry-codegen/src/collectors/pointer_locals.rs", + "function": "collect_facts", + "access": "raw:local_types", + "count": 1, + "classification": "representation-proven", + "reason": "The membership check prunes the complete-write fixpoint to frame-owned locals; it cannot remove a required root and unknown values remain conservatively pointer-capable." + }, { "path": "crates/perry-codegen/src/collectors/hir_facts.rs", "function": "seed_module_bindings", @@ -47,7 +79,7 @@ "access": "local_type_hint", "count": 1, "classification": "runtime-validated", - "reason": "The hint admits only the inline push tier whose live-bits test sends heap-valued + results through layout-note, addref, and barrier bookkeeping." + "reason": "The hint admits a numeric-push candidate; raw-layout arrays keep the runtime number guard and generic inline stores inspect live bits to invalidate numeric layout on every non-number." }, { "path": "crates/perry-codegen/src/expr/binary.rs", @@ -119,7 +151,7 @@ "access": "local_type_hint", "count": 1, "classification": "runtime-validated", - "reason": "The class hint selects guarded field or runtime method/property routing; raw field access validates the live class id and keys token before dereference and all misses retain dynamic lookup." + "reason": "The class hint selects only the guarded plain-field IC; its live class-id and keys-token checks dominate raw access, while accessors and method binding require proven receiver provenance." }, { "path": "crates/perry-codegen/src/expr/property_get.rs", @@ -308,10 +340,10 @@ { "path": "crates/perry-codegen/src/stmt/masked_window_region.rs", "function": "expr_is_number_under", - "access": "local_type_hint", + "access": "stable_local_type_proof", "count": 1, "classification": "representation-proven", - "reason": "The active masked-window refinement set and preheader guards prove numeric storage for the scoped cloned region." + "reason": "The proof API supplies runtime-derived initializer evidence and rejects the binding after any write before the scoped region refinement is installed." }, { "path": "crates/perry-codegen/src/stmt/masked_window_region.rs", @@ -496,6 +528,22 @@ "count": 2, "classification": "metadata-only", "reason": "The generic HIR environment exposes source-level metadata only; codegen supplies its separate runtime-proof facts adapter." + }, + { + "path": "crates/perry-hir/src/lower/context.rs", + "function": "define_sloppy_implicit_global", + "access": "raw:self.locals", + "count": 1, + "classification": "metadata-only", + "reason": "This scope-stack iteration resolves an existing lexical binding during HIR construction and does not establish a codegen runtime representation." + }, + { + "path": "crates/perry-hir/src/lower/context.rs", + "function": "lookup_local_in_current_scope", + "access": "raw:self.locals", + "count": 1, + "classification": "metadata-only", + "reason": "This indexed scope-stack iteration performs lexical name resolution during HIR construction and does not establish a codegen runtime representation." } ] } diff --git a/scripts/local_binding_type_audit.py b/scripts/local_binding_type_audit.py index 7ecad3376a..392a8ba3ba 100644 --- a/scripts/local_binding_type_audit.py +++ b/scripts/local_binding_type_audit.py @@ -9,8 +9,11 @@ * ordinary consumers call ``stable_local_type_proof``; its whole-region write set conservatively invalidates runtime-derived evidence after any assignment; -* exceptional consumers call ``local_type_hint`` and need an allowlist entry - explaining the runtime guard or independent proof; +* exceptional consumers call ``local_type_hint`` and must explain the runtime + guard or independent proof; +* every accessor group needs an allowlist classification and reason, including + ``stable_local_type_proof`` groups; a missing entry fails as an unclassified + local-type read; * remaining raw reads in pre-codegen collectors are inventoried as well; and * a direct read of ``ctx.local_types`` or ``ctx.proven_local_types`` outside the accessors is always an error. @@ -51,11 +54,13 @@ FN_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)") ACCESS_RE = re.compile( - r"\bctx\s*\.\s*(?Pstable_local_type_proof|local_type_hint)\s*\(" + r"\b(?P[A-Za-z_][A-Za-z0-9_]*|self)\s*\.\s*" + r"(?Pstable_local_type_proof|local_type_hint)\s*\(" ) RAW_RE = re.compile( r"\b(?P(?:ctx|self)\s*\.\s*(?:local_types|proven_local_types)|self\s*\.\s*locals|module_local_types|" - r"module_receiver_types|binding_types|proven_types|local_types)\s*\.\s*get\s*\(" + r"module_receiver_types|binding_types|proven_types|local_types)\s*" + r"(?:\.\s*(?:get|get_key_value|get_mut|contains_key|entry|iter|iter_mut|keys|values|values_mut)\s*\(|\[)" ) GENERIC_LOCAL_FACT_RE = re.compile(r"\bself\s*\.\s*get\s*\(") @@ -410,7 +415,7 @@ def expect(label: str, errors: list[str], needle: str) -> None: expect( "count drift", - audit(sites + [sites[0]], raw, entries, [], minimum_sites=3), + audit([*sites, sites[0]], raw, entries, [], minimum_sites=3), "count drift", ) @@ -433,6 +438,24 @@ def expect(label: str, errors: list[str], needle: str) -> None: "direct ctx.proven_local_types read bypasses the proof API", ) + for label, bypass in [ + ( + "contains-key bypass", + "fn bad(ctx: &FnCtx<'_>, id: &u32) { " + "let _ = ctx.local_types.contains_key(id); }", + ), + ( + "index bypass", + "fn bad(ctx: &FnCtx<'_>, id: &u32) { let _ = ctx.local_types[id]; }", + ), + ]: + bypass_sites, bypass_raw = scan_text(fixture_path, bypass) + expect( + label, + audit(bypass_sites, bypass_raw, [], [], minimum_sites=0), + "direct ctx.local_types read bypasses the proof API", + ) + expect( "candidate floor", audit([], [], [], [], minimum_sites=1), diff --git a/test-files/test_gap_7846_local_binding_type_proofs.ts b/test-files/test_gap_7846_local_binding_type_proofs.ts index 3a1445b183..c5e7854aee 100644 --- a/test-files/test_gap_7846_local_binding_type_proofs.ts +++ b/test-files/test_gap_7846_local_binding_type_proofs.ts @@ -3,6 +3,12 @@ // erased annotation can lie at initialization, and a later assignment can // replace an honestly initialized value with another kind. +function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + const declaredNumberHoldsObject: number = { kind: "object" } as any; console.log( "declared-number-object", @@ -10,6 +16,8 @@ console.log( !declaredNumberHoldsObject, Boolean(declaredNumberHoldsObject), ); +assert(Boolean(declaredNumberHoldsObject), "declared number object lost truthiness"); +assert(!(!declaredNumberHoldsObject), "declared number object negation is wrong"); const declaredBooleanHoldsNumber: boolean = 7 as any; console.log( @@ -18,6 +26,8 @@ console.log( !declaredBooleanHoldsNumber, Boolean(declaredBooleanHoldsNumber), ); +assert(Boolean(declaredBooleanHoldsNumber), "declared boolean number lost truthiness"); +assert(!(!declaredBooleanHoldsNumber), "declared boolean number negation is wrong"); const declaredNumberHoldsEmptyString: number = "" as any; console.log( @@ -26,6 +36,8 @@ console.log( !declaredNumberHoldsEmptyString, Boolean(declaredNumberHoldsEmptyString), ); +assert(!Boolean(declaredNumberHoldsEmptyString), "empty string became truthy"); +assert(!declaredNumberHoldsEmptyString, "empty string negation is wrong"); let refinedNumber: any = 0; refinedNumber = { after: "write" }; @@ -35,6 +47,8 @@ console.log( !refinedNumber, Boolean(refinedNumber), ); +assert(Boolean(refinedNumber), "reassigned object lost truthiness"); +assert(!(!refinedNumber), "reassigned object negation is wrong"); let refinedBoolean: any = false; refinedBoolean = "now a string"; @@ -44,6 +58,8 @@ console.log( !refinedBoolean, Boolean(refinedBoolean), ); +assert(Boolean(refinedBoolean), "reassigned string lost truthiness"); +assert(!(!refinedBoolean), "reassigned string negation is wrong"); // Pin the #7844 directions beside the broader truthiness cases: the same // whole-region write set must invalidate positive and negative array folds. @@ -56,6 +72,8 @@ console.log( Array.isArray(arrayToNumber), Array.isArray(numberToArray), ); +assert(!Array.isArray(arrayToNumber), "reassigned array still folded true"); +assert(Array.isArray(numberToArray), "reassigned number still folded false"); // Typed closure clones may use an annotation only as a guarded candidate. // The capture is immutable but its erased declaration lies immediately, so @@ -64,6 +82,7 @@ const declaredNumericCapture: number = "capture" as any; const appendCaptured = (value: number): number => declaredNumericCapture + value; console.log("guarded-capture", appendCaptured(7)); +assert(appendCaptured(7) === ("capture7" as any), "typed capture skipped fallback"); // Scalar-replaced constructors inline their parameter bindings into the // caller. The parameter's `number` annotation still cannot turn the live @@ -84,3 +103,79 @@ class Counter { const declaredNumericCtorArg: number = "counter" as any; const counter = new Counter(declaredNumericCtorArg); console.log("scalar-constructor-arg", counter.bump()); +assert(counter.value === ("counter1" as any), "constructor argument became raw f64"); + +// A metadata-selected numeric `+` push must retain its live number guard even +// when typed-feedback instrumentation is disabled. Pointer-only bookkeeping is +// insufficient because false/undefined are non-number values too. +const numericLayoutArray: number[] = [0, 1]; +const declaredPushNumber: number = "push" as any; +numericLayoutArray.push(declaredPushNumber + 2); +console.log("guarded-array-push", numericLayoutArray[2]); +assert( + (numericLayoutArray[2] as any) === "push2", + "array push stored an annotation lie as raw f64", +); + +// Declared class metadata may select the guarded plain-field IC, but it must +// not directly call an accessor or bind a method from the declared class. +class DeclaredReceiver { + get label(): string { + return "declared"; + } + + method(): string { + return "declared-method"; + } +} + +class LiveReceiver { + get label(): string { + return "live"; + } + + method(): string { + return "live-method"; + } +} + +function readDeclaredReceiver(value: DeclaredReceiver): string { + const bound = value.method; + return value.label + ":" + bound(); +} + +const declaredReceiverResult = readDeclaredReceiver(new LiveReceiver() as any); +console.log("declared-class-fallback", declaredReceiverResult); +assert( + declaredReceiverResult === "live:live-method", + "declared class selected direct behavior for another live class", +); + +// Eight static-window reads trigger the straight-line masked-region matcher. +// The first assignment must not refine `maskedAccum` from a lying annotation +// on its other operand; otherwise later `+` operations lose concatenation. +const maskedValues: number[] = [1, 2, 3, 4, 5, 6, 7, 8]; +const declaredMaskedNumber: number = "m" as any; +let maskedAccum: any = 0; +maskedAccum = declaredMaskedNumber + maskedValues[0]; +maskedAccum = maskedAccum + maskedValues[1]; +maskedAccum = maskedAccum + maskedValues[2]; +maskedAccum = maskedAccum + maskedValues[3]; +maskedAccum = maskedAccum + maskedValues[4]; +maskedAccum = maskedAccum + maskedValues[5]; +maskedAccum = maskedAccum + maskedValues[6]; +maskedAccum = maskedAccum + maskedValues[7]; +console.log("masked-window-annotation", maskedAccum); +assert(maskedAccum === "m12345678", "masked-window refinement trusted metadata"); + +// Nested arithmetic over `any` operands can still be BigInt throughout. The +// initializer must not publish Number evidence for the result local. +const bigintA: any = 9n; +const bigintB: any = 4n; +const bigintC: any = 7n; +const bigintD: any = 2n; +const nestedBigInt = (bigintA - bigintB) * (bigintC - bigintD); +const negatedNestedBigInt = -nestedBigInt; +console.log("nested-bigint-proof", typeof nestedBigInt, negatedNestedBigInt); +assert(nestedBigInt === 25n, "nested BigInt arithmetic was refined as Number"); +assert(negatedNestedBigInt === -25n, "BigInt local used raw numeric negation");