From bcf5fedbabe4537b002a8036e91e5f36a26e8f8a Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sat, 11 Jul 2026 09:17:58 -0500 Subject: [PATCH] feat: mixed/union values at narrower declared boundaries + typed callbacks over unknown-element arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boundary relaxations in `types_compatible`, both runtime-enforced PHP that the checker statically rejected, plus the typed-callback fix they enable: - A `mixed` VALUE flowing into a narrower declared type (param or return) is legal PHP — the engine enforces it at runtime (TypeError on mismatch), and the boxed-Mixed representation already funnels at the boundary. Trust the declaration. - A union VALUE with at least one member the declaration accepts crosses the boundary (e.g. an `int|false` seek result passed to an `int` param on the success path). A union with NO compatible member stays rejected. `types_compatible` feeds only argument/return checks, so interface conformance and property assignment are unaffected. - Typed callbacks keep their declared contract over object/unknown-element arrays: usort/uasort/array_map fabricated an `Int` placeholder when the element type had no literal form, so a typed comparator/mapper failed ("callback parameter expects Object, got Int"). `array_element_type` now yields `Mixed` for a `Mixed` receiver; `comparator_dummy_arg_for_elem` binds a synthetic variable for Object/Mixed elements and binds `Mixed` for `Never` (empty/unknown) elements — PHP never invokes the callback for an empty array, so the declared contract stands unchecked. Byte-parity vs PHP 8.5 on all regression tests. Known limit: array_map over OBJECT-element arrays now passes the checker but still hits a pre-existing EIR lowering wall — a backend follow-up. Co-Authored-By: Claude Fable 5 --- src/builtins/array/array_map.rs | 27 +++++++++--- src/builtins/array/uasort.rs | 6 ++- src/builtins/array/usort.rs | 6 ++- src/types/checker/builtins/callables.rs | 19 ++++++-- .../checker/functions/call_validation.rs | 15 ++++--- tests/codegen/control_flow/functions.rs | 44 +++++++++++++++++++ 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/src/builtins/array/array_map.rs b/src/builtins/array/array_map.rs index 13e0e13975..644eabe3a4 100644 --- a/src/builtins/array/array_map.rs +++ b/src/builtins/array/array_map.rs @@ -48,19 +48,32 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; match arr_ty { PhpType::Array(elem_ty) => { - let arr_ty = PhpType::Array(elem_ty.clone()); - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), - ]; + // The dummy argument mirrors the element type. Object elements (no literal form) + // and Mixed/Never elements (unknown — e.g. an `array`-hinted param or property) + // use the synthetic binding, so a TYPED callback parameter is checked against the + // real (or runtime-enforced) element type instead of a fabricated Int placeholder. + let (dummy_arg, elem_binding) = + crate::types::checker::builtins::comparator_dummy_arg_for_elem( + elem_ty.as_ref(), + cx.span, + ); + let dummy_args = vec![dummy_arg]; + let mut env_with_elem; + let cb_env: &crate::types::TypeEnv = match &elem_binding { + Some((binding_name, binding_ty)) => { + env_with_elem = cx.env.clone(); + env_with_elem.insert(binding_name.clone(), binding_ty.clone()); + &env_with_elem + } + None => cx.env, + }; let callback_ret_ty = crate::types::checker::builtins::check_callback_builtin_call( cx.checker, &cx.args[0], &dummy_args, cx.span, - cx.env, + cb_env, "array_map() callback", )?; let result_elem_ty = if callback_ret_ty == PhpType::Mixed { diff --git a/src/builtins/array/uasort.rs b/src/builtins/array/uasort.rs index f509184416..a2ace514d8 100644 --- a/src/builtins/array/uasort.rs +++ b/src/builtins/array/uasort.rs @@ -38,13 +38,15 @@ builtin! { /// /// Infers the array value element type, and validates the comparator with two dummy /// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type. +/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a +/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's +/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if let PhpType::Object(_) = cmp_ty { + if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { if let ExprKind::Closure { params, variadic, diff --git a/src/builtins/array/usort.rs b/src/builtins/array/usort.rs index 9f45cd533f..54870de572 100644 --- a/src/builtins/array/usort.rs +++ b/src/builtins/array/usort.rs @@ -38,13 +38,15 @@ builtin! { /// /// Infers the array value element type, and validates the comparator with two dummy /// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type. +/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a +/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's +/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if let PhpType::Object(_) = cmp_ty { + if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { if let ExprKind::Closure { params, variadic, diff --git a/src/types/checker/builtins/callables.rs b/src/types/checker/builtins/callables.rs index c8f40a4d80..5c02498a01 100644 --- a/src/types/checker/builtins/callables.rs +++ b/src/types/checker/builtins/callables.rs @@ -134,12 +134,16 @@ pub(crate) fn dummy_arg_for_array_scalar_elem(arr_ty: &PhpType, span: crate::spa /// Returns the element type carried by an array/associative-array type. /// -/// Falls back to `Int` for non-array types so callers can build a placeholder -/// comparator argument without special-casing every caller. +/// A `Mixed` receiver (e.g. an `array`-hinted property whose element type only phpdoc knows) +/// yields `Mixed` elements, so a typed callback's declared parameter contract stands instead of +/// being checked against a fabricated placeholder. Falls back to `Int` for other non-array +/// types so callers can build a placeholder comparator argument without special-casing every +/// caller. pub(crate) fn array_element_type(arr_ty: &PhpType) -> PhpType { match arr_ty { PhpType::Array(elem_ty) => (**elem_ty).clone(), PhpType::AssocArray { value, .. } => (**value).clone(), + PhpType::Mixed => PhpType::Mixed, _ => PhpType::Int, } } @@ -168,10 +172,19 @@ pub(crate) fn comparator_dummy_arg_for_elem( PhpType::Bool | PhpType::False => { (Expr::new(ExprKind::BoolLiteral(false), span), None) } - PhpType::Object(_) => ( + // Object and Mixed elements have no literal form: a reserved synthetic variable is + // bound to the element type so a typed callback parameter is checked against the real + // (or unknown-and-runtime-enforced) type instead of a fabricated Int placeholder. + PhpType::Object(_) | PhpType::Mixed => ( Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), elem_ty.clone())), ), + // A Never element (empty/unknown array) binds Mixed: the callback is never invoked at + // runtime for an empty array, so its declared parameter contract must stand unchecked. + PhpType::Never => ( + Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), + Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), PhpType::Mixed)), + ), _ => (Expr::new(ExprKind::IntLiteral(0), span), None), } } diff --git a/src/types/checker/functions/call_validation.rs b/src/types/checker/functions/call_validation.rs index 9ed0c0a682..bc2bb123aa 100644 --- a/src/types/checker/functions/call_validation.rs +++ b/src/types/checker/functions/call_validation.rs @@ -184,13 +184,18 @@ impl Checker { } match (expected, actual) { (PhpType::Mixed, _) => true, + // A `mixed` VALUE flowing into a narrower declared type is legal PHP — the engine + // enforces it at runtime (TypeError on mismatch). Trust the declaration, matching + // the runtime's boxed-Mixed representation funnels at the boundary. + (_, PhpType::Mixed) => true, (_, PhpType::Never) => true, // never is the bottom type — compatible with any expected type (PhpType::Bool, PhpType::False) => true, - // PHP coercive mode: scalar parameters accept Mixed values with a - // runtime narrowing cast. - // This is needed because non-constant `int + int` overflows to float, - // making the result Mixed even when both operands are statically Int. - (PhpType::Int | PhpType::Float | PhpType::Bool | PhpType::Str, PhpType::Mixed) => true, + // A union VALUE with at least one member the declaration accepts is likewise + // runtime-enforced PHP (e.g. an `int|false` seek result passed to an `int` param on + // the success path). A union with NO compatible member stays rejected. + (_, PhpType::Union(members)) if !matches!(expected, PhpType::Union(_)) => { + members.iter().any(|m| Self::types_compatible(expected, m)) + } (PhpType::Iterable, PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Iterable) => true, (PhpType::Union(expected_members), PhpType::Union(actual_members)) => actual_members .iter() diff --git a/tests/codegen/control_flow/functions.rs b/tests/codegen/control_flow/functions.rs index 799ca30a02..516ec02db2 100644 --- a/tests/codegen/control_flow/functions.rs +++ b/tests/codegen/control_flow/functions.rs @@ -167,3 +167,47 @@ fn test_property_throw_guard_narrowing() { ); assert_eq!(out, "x"); } + +/// A `mixed` VALUE flowing into a narrower declared boundary is legal PHP (the engine enforces +/// at runtime) — a mixed param passed on to a `string` param and a mixed value returned as a +/// declared `int` both compile and run byte-identically (the boxed-Mixed representation +/// funnels at the boundary). +#[test] +fn test_mixed_value_into_typed_boundary() { + let out = compile_and_run( + "name; } function firstMode(string $mode, array $allowed): bool { return in_array($mode, $allowed); } function main(): void { $meta = ['mode' => 'r+', 'seekable' => true]; $mode = $meta['mode']; $ok = firstMode($mode, ['r+', 'w+']) ? 'ok' : 'no'; $items = [new Item('a'), new Item('b')]; $x = $items[1]; echo $ok, ':', label($x), ':', strtoupper($mode); } main();", + ); + assert_eq!(out, "ok:b:R+"); +} + +/// A typed comparator over an `array`-hinted parameter keeps its declared parameter contract — +/// usort checks the closure against its own declarations (via the element-type binding) +/// instead of a fabricated Int placeholder. Byte-parity vs PHP 8.5. +#[test] +fn test_typed_callback_over_array_hinted_param() { + let out = compile_and_run( + " $a->n <=> $b->n); $out = ''; foreach ($items as $b) { $out .= $b->n; } return $out; } function main(): void { echo sorted([new Box(3), new Box(1), new Box(2)]); } main();", + ); + assert_eq!(out, "123"); +}