From ba3ac2b9c7ed0a18b6594c7e88d606dc9925275b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 04:36:41 +0200 Subject: [PATCH 1/2] fix(hir): do not inherit input element types for array mapping --- .../11446-array-mapping-result-types.md | 9 ++ crates/perry-hir/src/lower_types.rs | 10 ++- .../src/lower_types/array_mapping_tests.rs | 85 +++++++++++++++++++ ...st_gap_11446_array_mapping_result_types.ts | 24 ++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 changelog.d/11446-array-mapping-result-types.md create mode 100644 crates/perry-hir/src/lower_types/array_mapping_tests.rs create mode 100644 test-files/test_gap_11446_array_mapping_result_types.ts diff --git a/changelog.d/11446-array-mapping-result-types.md b/changelog.d/11446-array-mapping-result-types.md new file mode 100644 index 0000000000..3d9b5b5232 --- /dev/null +++ b/changelog.d/11446-array-mapping-result-types.md @@ -0,0 +1,9 @@ +Stop inferring the input element type for Array.map and Array.flatMap results. +Their callbacks can return entirely different values; inheriting string[] from +the receiver sent class methods such as node-cron's task.match() through the +String.match fast path and returned null instead of calling the task method. + +Use an unknown element type in the early callee-only inference table and retain +the existing callback-aware HIR refinement. Add lowering regressions and native +parity coverage for mapped class instances, flatMap, and ordinary string and +numeric results. diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 53e905a8bb..2bf7ff36de 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -1497,8 +1497,13 @@ pub(crate) fn infer_call_return_type(callee: &ast::Expr, ctx: &LoweringContext) "join" => Type::String, "includes" | "every" | "some" => Type::Boolean, "pop" | "shift" | "find" | "at" => *elem_ty.clone(), - "map" | "filter" | "slice" | "concat" | "flat" | "flatMap" | "reverse" - | "sort" | "splice" => obj_ty.clone(), + // Mapping callbacks can replace every element (#11446). + // This early table sees only the callee, so defer the + // result element type to callback-aware HIR inference. + "map" | "flatMap" => Type::Array(Box::new(Type::Any)), + "filter" | "slice" | "concat" | "flat" | "reverse" | "sort" | "splice" => { + obj_ty.clone() + } "reduce" => Type::Any, // depends on accumulator "fill" => obj_ty.clone(), "forEach" => Type::Void, @@ -1638,6 +1643,7 @@ pub(crate) fn infer_call_return_type(callee: &ast::Expr, ctx: &LoweringContext) } } +mod array_mapping_tests; mod branded_intersection_tests; mod buffer_backed_generic_tests; mod extract; diff --git a/crates/perry-hir/src/lower_types/array_mapping_tests.rs b/crates/perry-hir/src/lower_types/array_mapping_tests.rs new file mode 100644 index 0000000000..9757c8e1b3 --- /dev/null +++ b/crates/perry-hir/src/lower_types/array_mapping_tests.rs @@ -0,0 +1,85 @@ +//! #11446: mapping callbacks can change an array's element type. +#![cfg(test)] + +use crate::types::Type; +use crate::{infer_expr_type, HirTypeEnv}; +use crate::{lower_module, Expr, Module, Stmt}; +use perry_diagnostics::SourceCache; +use perry_parser::parse_typescript_with_cache; + +fn lower(src: String) -> Module { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache).unwrap(); + lower_module(&parsed.module, "test", "test.ts").unwrap() + }) + .unwrap() + .join() + .unwrap() +} + +fn binding<'a>(module: &'a Module, name: &str) -> (&'a Type, &'a Expr) { + module + .init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name: n, + ty, + init: Some(init), + .. + } if n == name => Some((ty, init)), + _ => None, + }) + .unwrap_or_else(|| panic!("missing {name}")) +} + +#[test] +fn mapping_does_not_reuse_the_input_element_type() { + for input in ["['x']", "[1]", "new Array(1)"] { + for method in ["map", "flatMap"] { + let module = lower(format!( + "function make(value: any): any {{ return {{ match() {{ return true; }} }}; }}\n\ + const input = {input}; const output = input.{method}(value => make(value));" + )); + assert_eq!( + binding(&module, "output").0, + &Type::Array(Box::new(Type::Any)), + "{input}.{method} must not inherit the receiver's element type" + ); + } + } +} + +#[test] +fn element_preserving_methods_keep_the_input_type() { + let module = lower( + "const input = ['x']; const output = input.filter(value => true).slice().reverse();".into(), + ); + assert_eq!( + binding(&module, "output").0, + &Type::Array(Box::new(Type::String)) + ); +} + +#[test] +fn lowered_callbacks_still_supply_precise_mapping_types() { + let module = lower( + "function lengthOf(value: string): number { return value.length; }\n\ + function lengthsOf(value: string): number[] { return [value.length]; }\n\ + const input = ['x'];\n\ + const mapped = input.map(lengthOf);\n\ + const flattened = input.flatMap(lengthsOf);" + .into(), + ); + for name in ["mapped", "flattened"] { + assert_eq!( + infer_expr_type(binding(&module, name).1, &HirTypeEnv::from_module(&module)), + Type::Array(Box::new(Type::Number)), + "{name} retains the lowered callback's return type: {:?}", + binding(&module, name).1 + ); + } +} diff --git a/test-files/test_gap_11446_array_mapping_result_types.ts b/test-files/test_gap_11446_array_mapping_result_types.ts new file mode 100644 index 0000000000..4bc115684d --- /dev/null +++ b/test-files/test_gap_11446_array_mapping_result_types.ts @@ -0,0 +1,24 @@ +// #11446: map/flatMap results have their callbacks' element types. +class Matcher11446 { + value: string; + constructor(value: string) { this.value = value; } + match(value: string): boolean { return this.value === value; } +} +function makeMatcher11446(value: string): any { + return new Matcher11446(value); +} + +const source11446 = ['yes', 'no']; +const mapped11446 = source11446.map(value => makeMatcher11446(value)); +const flat11446 = source11446.flatMap(value => [makeMatcher11446(value)]); +console.log('direct', makeMatcher11446('yes').match('yes')); +console.log('map', mapped11446[0].match('yes'), mapped11446[1].match('yes')); +console.log('flatMap', flat11446[0].match('yes'), flat11446[1].match('yes')); +console.log('chain', mapped11446.filter(value => true).slice()[0].match('yes')); + +const lengths11446 = source11446.map((value: string): number => value.length); +const flatLengths11446 = source11446.flatMap((value: string): number[] => [value.length]); +console.log('numbers', lengths11446[0] + lengths11446[1], flatLengths11446.join(',')); +const strings11446 = [1, 2].map(value => 'word' + value); +console.log('strings', strings11446[0].toUpperCase(), strings11446[1].match(/word/)?.[0]); +console.log('preserved', source11446.filter(value => true).slice().reverse().join(',')); From 77db230f6afe91a490d7fba3ded5ed9c8fb98555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 04:43:09 +0200 Subject: [PATCH 2/2] docs: key mapping changeset to PR 11461 --- ...apping-result-types.md => 11461-array-mapping-result-types.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{11446-array-mapping-result-types.md => 11461-array-mapping-result-types.md} (100%) diff --git a/changelog.d/11446-array-mapping-result-types.md b/changelog.d/11461-array-mapping-result-types.md similarity index 100% rename from changelog.d/11446-array-mapping-result-types.md rename to changelog.d/11461-array-mapping-result-types.md