From dd078f7773a2708891351751c329efd0295ded9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 16:46:52 +0200 Subject: [PATCH 1/2] fix(codegen): invalidate Array.isArray local folds --- .../perry-codegen/src/expr/array_methods.rs | 15 +++++- .../tests/native_proof_regressions.rs | 48 +++++++++++++++++ ...gap_7844_array_isarray_reassigned_local.ts | 52 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_7844_array_isarray_reassigned_local.ts diff --git a/crates/perry-codegen/src/expr/array_methods.rs b/crates/perry-codegen/src/expr/array_methods.rs index 93638003ea..a177e0c9e9 100644 --- a/crates/perry-codegen/src/expr/array_methods.rs +++ b/crates/perry-codegen/src/expr/array_methods.rs @@ -84,7 +84,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `Tuple(_)` types short-circuit; anything Union-shaped // falls through to the runtime. let v = lower_expr(ctx, o)?; - if let Some(ty) = crate::type_analysis::static_type_of(ctx, o) { + // #7844: a local's type may have been refined from its initializer, + // but reassignment invalidates that proof in both directions. A + // number-initialized local can now hold an array, and an + // 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() + { if matches!( ty, perry_hir::types::Type::Array(_) | perry_hir::types::Type::Tuple(_) diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index a7f22ee600..e790dcfaea 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -690,6 +690,54 @@ 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; +#[test] +fn array_isarray_reassigned_local_uses_runtime_predicate() { + let body = vec![ + Stmt::Let { + id: 1, + name: "number_to_array".to_string(), + ty: Type::Any, + mutable: true, + init: Some(int(0)), + }, + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Array(vec![int(1)])))), + Stmt::Expr(Expr::ArrayIsArray(Box::new(local(1)))), + Stmt::Let { + id: 2, + name: "array_to_number".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Array(vec![int(1)])), + }, + Stmt::Expr(Expr::LocalSet(2, Box::new(int(0)))), + Stmt::Expr(Expr::ArrayIsArray(Box::new(local(2)))), + Stmt::Let { + id: 3, + name: "unchanged_array".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(vec![int(1)])), + }, + Stmt::Expr(Expr::ArrayIsArray(Box::new(local(3)))), + Stmt::Return(Some(int(0))), + ]; + let ir = String::from_utf8( + compile_module( + &module("array_isarray_reassignment_7844.ts", body), + empty_opts(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + ir.matches("call double @js_array_is_array(").count(), + 2, + "both reassigned locals must use the runtime predicate, while the unchanged array may \ + retain its compile-time true fold:\n{ir}" + ); +} + #[test] fn artifact_schema_v6_records_consumed_native_facts_for_buffer_region() { let body = vec![ diff --git a/test-files/test_gap_7844_array_isarray_reassigned_local.ts b/test-files/test_gap_7844_array_isarray_reassigned_local.ts new file mode 100644 index 0000000000..533ea815f9 --- /dev/null +++ b/test-files/test_gap_7844_array_isarray_reassigned_local.ts @@ -0,0 +1,52 @@ +let numberToArray: any = 0; +numberToArray = [numberToArray]; +console.log( + "number-to-array", + Array.isArray(numberToArray), + numberToArray instanceof Array, + typeof numberToArray, +); + +let stringToArray: any = "s"; +stringToArray = [stringToArray]; +console.log( + "string-to-array", + Array.isArray(stringToArray), + stringToArray instanceof Array, + typeof stringToArray, +); + +let nullToArray: any = null; +nullToArray = [nullToArray]; +console.log( + "null-to-array", + Array.isArray(nullToArray), + nullToArray instanceof Array, + typeof nullToArray, +); + +let arrayToNumber: any = [9]; +arrayToNumber = 42; +console.log( + "array-to-number", + Array.isArray(arrayToNumber), + arrayToNumber instanceof Array, + typeof arrayToNumber, +); + +let arrayToString: any = [9]; +arrayToString = "s"; +console.log( + "array-to-string", + Array.isArray(arrayToString), + arrayToString instanceof Array, + typeof arrayToString, +); + +const unchanged: any = [1]; +console.log( + "unchanged-array", + Array.isArray(unchanged), + unchanged instanceof Array, + typeof unchanged, +); From 750617924de19d8a1ab84b7176c839c2af2cd8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 16:48:41 +0200 Subject: [PATCH 2/2] docs(changelog): record Array.isArray reassignment fix --- changelog.d/7855-array-isarray-reassignment.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/7855-array-isarray-reassignment.md diff --git a/changelog.d/7855-array-isarray-reassignment.md b/changelog.d/7855-array-isarray-reassignment.md new file mode 100644 index 0000000000..6223fb396e --- /dev/null +++ b/changelog.d/7855-array-isarray-reassignment.md @@ -0,0 +1,3 @@ +### Fixed + +- `Array.isArray` now checks the current runtime value of reassigned locals instead of folding from the binding's initializer. This fixes both false negatives after assigning an array and false positives after assigning a non-array (#7844).