Skip to content

refactor(core): improvements on merged topics#347

Draft
Guikingone wants to merge 34 commits into
illegalstudio:mainfrom
Guikingone:fix/audit-remediation
Draft

refactor(core): improvements on merged topics#347
Guikingone wants to merge 34 commits into
illegalstudio:mainfrom
Guikingone:fix/audit-remediation

Conversation

@Guikingone

@Guikingone Guikingone commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This branch remediates the findings from a full compiler audit (correctness, memory safety, PHP compatibility, performance), grouped by theme and verified across all three supported targets (macOS arm64, Linux x86_64, Linux arm64). 34 commits, rebased clean onto main.

Critical — foreach-var heap release gated on ownership (double-free), x86_64 NaN-compare parity guard, PHP_INT_MIN % -1 fold panic.
High — cast precedence + name resolution, Mixed/Union math builtins / floatval / is_a, substr neg length, float sort, variadic array_merge/diff/intersect, in_array/array_search $strict, SORT_* flags.
Medium — DCE keeps fatal-capable builtins, div/mod-by-zero fatals, array_merge string corruption, infer↔emitter alignment, round $mode, array_slice preserve_keys, number_format multi-byte separators.
Low — array_sum/product float, settype, str_split, neg-key auto-increment, NaN-ordering docs.
Perf — @src alloc, is_subclass_of clone.
Features — array_map($cb, $a, $b) two-array form (int + string, all callback forms); fixed untyped-closure-over-string-array.

Guikingone added 30 commits June 8, 2026 13:23
A by-value foreach value var is a Borrowed alias into the array's own
storage, yet unset() and reassignment freed its slot purely by type,
double-freeing storage owned by the array (heap free-list corruption,
deterministic on outer aliases) and never decref'ing boxed Mixed values.

Add Context::var_owns_heap_slot(name) (ownership != Borrowed && not a
ref param), mirroring the epilogue-cleanup predicate, and gate both the
unset free and release_owned_slot on it. Collapse unset's free ladder to
Str + is_refcounted() so it also decrefs Mixed/Union (prior leak).

Fixes:
- C2: unset($v) on a foreach value var no longer double-frees
- C3: reassigning a foreach value var no longer frees the borrowed element
- H13: unset($mixed) now decrefs the boxed cell

Verified: runtime_gc 34/0, unset 41/0, foreach 74/0, full local suite,
Linux x86_64 + arm64 runtime_gc 93/0 each.
Float comparisons mishandled NaN. On x86_64 `ucomisd` sets ZF/CF/PF for an
unordered (NaN) compare, so `==`/`!=`/`<`/`<=` (sete/setne/setb/setbe) read NaN
as equal/less; on arm64 `cset lt`/`le` read the unordered N!=V as true. PHP makes
every NaN comparison false except `!=`. Add NaN-aware conditions: arm64 uses
`mi`/`ls` for `<`/`<=`, and x86_64 guards every affected setcc with the parity
flag (PF, set on unordered).

The same unordered miscompile affected `===`/`!==` (strict.rs), `match` float
arms (match_expr.rs), and `(bool)`/`empty()` of floats and boxed Mixed floats
(empty.rs, mixed_cast_bool.rs, mixed_is_empty.rs) — all guarded.

Adds regression tests covering comparisons, strict equality, bool/empty (float
and Mixed), and match, verified against PHP on arm64 and x86_64.
try_fold_int_mod computed `left % right` with i64::%, which overflows for
i64::MIN % -1 — panicking this debug build (and trapping with SIGFPE at runtime).
PHP yields 0 for `x % -1`. Special-case `right == -1` to fold directly to 0,
avoiding both the compiler panic and the runtime trap, and keep divide-by-zero
deferred to the runtime. Adds a constant-folding regression test.
- Cast binding power was 27, so `(int)1.9 * 2` parsed as `(int)(1.9 * 2)` = 3.
  Casts bind at the unary level (tighter than `*`/`/`/`%`, looser than `**`) in
  PHP, so parse the cast operand at bp 35: `(int)1.9 * 2` is now `((int)1.9) * 2`
  = 2, while `**` still binds inside the cast.

- The name resolver's expression catch-all skipped `Assignment`, `Yield`,
  `YieldFrom`, `NewDynamic`, and `NewDynamicObject`, so names inside an
  expression-level assignment or yield were never rewritten to their FQN —
  `($x = new Greeter())->hi()` and `($y += FOO)` in a namespace failed to resolve.
  Add recursing arms for those variants.

Adds regression tests for both.
array_merge over arrays of string elements lost every element past the first
(array_merge(["a","b"], ["c"]) returned ["a","",""]). String indexed arrays use
16-byte (ptr+len) element slots, but `Str` is not is_refcounted(), so string
arrays were routed through the scalar __rt_array_merge, which assumes 8-byte
payloads and mishandles the 16-byte slots.

Add __rt_array_merge_str (both targets): allocate a 16-byte-slot destination and
append every element of both arrays via __rt_array_push_str, which persists each
string, so the merged array owns its own copies and is GC-clean. Route Array(Str)
to it, choosing the helper from EITHER argument's element type so an empty `[]`
first argument (the common `$r = []; array_merge($r, ...)` pattern) still uses
the string path; the result type follows the same logic.

Adds regression tests for string elements, the empty-first case, and a heap-clean check.
Builtins that are side-effect-free but throw a PHP fatal on bad input
(str_repeat/str_pad/explode/str_split/wordwrap/hash/array_combine/
array_chunk/array_fill/range/min/max) were classified pure-non-throwing,
so a bare-statement call with an unused result was eliminated by both the
control-flow pruning pass and DCE — silently skipping the fatal PHP would
raise. Split the classifier into pure-non-throwing vs pure-but-may-throw,
model the latter as may_throw, and retain a bare expression statement when
it has side effects OR may throw. Unevaluated subexpressions (untaken
ternary/coalesce/short-circuit branches) are still pruned.
substr($s, $offset, $length) with a negative $length clamped the result
to an empty string instead of dropping that many characters from the end,
and the omitted-length case used a runtime -1 sentinel that collided with an
explicit length of -1 (so substr($s, $o, -1) returned the whole tail). The
presence of a length argument is known at compile time, so the sentinel is
removed: a non-negative length keeps min(length, remaining); a negative
length keeps max(0, remaining + length). Both targets updated.
sort()/rsort() of a float[] dispatched to __rt_sort_int, which compares
the raw 64-bit IEEE bit-patterns as signed integers — so negative and
fractional values came out in the wrong order. Add __rt_sort_float /
__rt_rsort_float (insertion sort comparing doubles via fcmp on ARM64 and
ucomisd + jbe/jae on x86_64) and route Array(Float) to them. NaN ordering
stays unspecified (as in PHP) but never loops or traps. asort/arsort use a
separate value-aware helper and are unaffected.
array_sum()/array_product() always called the integer runtime, which added/
multiplied the raw 64-bit IEEE bit-patterns of a float[] as integers — so
array_sum([1.1, 2.2]) returned garbage (9219769157152879412) instead of 3.3,
and array_product of floats returned 0. Add __rt_array_sum_float /
__rt_array_product_float (accumulating with fadd/fmul on ARM64 and addsd/mulsd
on x86_64, result in d0/xmm0) and route an indexed float[] to them, returning
PhpType::Float. The checker already typed these as float; the codegen and the
local-type inference table now agree. Int arrays are unchanged.
PHP throws DivisionByZeroError for $a/0, $a%0, and intdiv($a,0); elephc
silently returned 0 for modulo (a deliberate no-crash stopgap) and INF for
division, diverging from PHP. Route all three to a fatal: a new shared
abi::emit_fatal_to_stderr helper (extracted from intdiv's inline fatal) is
called from intdiv, the integer-modulo zero path, and a new float-division
divisor==0.0 guard (with an x86_64 parity check so a NaN divisor is left
alone). The fatal is not yet catchable by try/catch — elephc has no
runtime-error exception mechanism — documented in types.md/operators.md.
floor/ceil/sqrt/sin/cos/tan/asin/acos/atan/sinh/cosh/tanh/log2/log10/exp/
deg2rad/rad2deg converted their argument to float with scvtf/cvtsi2sd (or
abi::emit_int_result_to_float_result) for any non-Float static type. For a
boxed Mixed/Union argument the integer register holds the cell pointer, not a
number, so the conversion produced garbage. Add a shared coerce_to_float helper
(Float no-op; Mixed/Union -> __rt_mixed_cast_float; else int->float) and route
these builtins through it. Multi-argument math builtins (fmod/fdiv/min/max/
atan2/hypot/pow/log) are a separate follow-up.
fmod/fdiv/pow/atan2/hypot/log converted each operand to float with scvtf/
cvtsi2sd (or abi::emit_int_result_to_float_result) only for non-Float static
types, so a boxed Mixed/Union operand had its cell pointer treated as a number
(garbage). Route every operand through the shared coerce_to_float helper. log
covers both the 1-arg and 2-arg (change-of-base) forms. min/max are deferred:
their static int-vs-float comparison path needs a runtime-type-aware rework to
handle Mixed operands correctly.
floatval() ran the integer->float conversion on any non-Float arg, so a
string argument had its pointer treated as an integer (garbage) and a boxed
Mixed argument was likewise mis-handled. Dispatch on type like intval():
Str -> __rt_str_to_number (libc strtod, PHP's lenient leading-numeric
semantics; the numeric flag is ignored so "3.14abc"->3.14 and "abc"->0.0),
Mixed/Union -> __rt_mixed_cast_float (unboxes numeric strings too), and
int/bool/null convert via coerce_to_float. Result type was already float.
…e (H4)

is_a()/is_subclass_of() were folded entirely at compile time, so a boxed
Mixed/Union receiver (a static non-Object type — e.g. Foo|false from PDO, a
heterogeneous array element, or an untyped param) always returned false even
when it held a matching object. Keep the constant fold for static Object
receivers, and add a runtime path for a Mixed/Union receiver with a literal
target: unbox the cell (__rt_mixed_unbox), and for an object compare its header
class id against the compile-time set of class ids that satisfy the relation
(target + subclasses + implementers; self excluded for is_subclass_of). A
non-object receiver returns false. Still folded/false: a non-literal target
class name and the $allow_string string-receiver form (documented limitations).
settype($x, "integer"/"float") zeroed a string or boxed Mixed source instead
of parsing/unboxing it, and "bool" used a raw non-zero test that ignored PHP
string truthiness ("0" -> false) and Mixed payloads. Route the conversions
through the shared helpers: "int" -> __rt_str_to_int / __rt_mixed_cast_int,
"float" -> __rt_str_to_number / coerce_to_float, "bool" -> coerce_to_truthiness.
"array"/"null" targets and non-literal type names remain unsupported (documented).
…g (L3)

A chunk length below 1 made the runtime loop advance by 0 and hang; PHP throws a
ValueError. Both arches now emit a fatal diagnostic and exit(1) (uncatchable, per
the div-by-zero policy). An empty source string returned an empty array; PHP returns
a single "" element (str_split("") === [""]), so the helper now pushes one empty
element for a zero-length input. New _str_split_length_msg runtime data symbol;
fixed the stale x86_64 ABI docblock (rax/rdx/rdi, not rdi/rsi/rdx).
PHP 8.3+ continues the implicit integer key from the first explicit key even when
it is negative: [-5 => "a", "b"] places "b" at -4, not 0. The parser only advanced
the cursor for non-negative IntLiteral keys (a negative literal parses as
Negate(IntLiteral(n)) and was skipped), so it reset to 0 — the pre-8.3 rule. Added
static_int_key_value() to read negated literals and a seeded flag so the first
integer key (any sign) sets the baseline while later smaller keys do not lower it.
The runtime append path ($a[] =) already matched 8.3; this aligns the literal path.
…/column/map/rand (M8)

The codegen local-type table lumped these four into a generic Array(elem-of-args[0])
arm that disagreed with what each emitter actually produces, mis-typing locals:
- array_chunk: nests one level (Array(Array(inner))); the generic arm dropped it.
- array_column: returns the column value type, not the row element type.
- array_map: element type follows the CALLBACK return (args[0]) not the array (args[1]);
  now Array(Str) when the callback returns a string (callback_returns_str, matching the
  __rt_array_map_str emitter) else Array(Int).
- array_rand: returns a single key (Int), not an array.

Each now has a dedicated arm matching its emitter. Widened callback_returns_str to
pub(crate) to share the callback-return analysis. 4 regression tests, incl. an inline
foreach whose value-var slot is sized from the array_map element type.
…old desugar (H9)

array_merge/array_diff/array_intersect/array_diff_key/array_intersect_key are
left-associative, but the checker hard-rejected more than two arguments, so common
calls like array_merge($a, $b, $c) failed to compile. The name resolver now folds a
variadic call into nested two-argument calls — array_merge(a, b, c) becomes
array_merge(array_merge(a, b), c) — before the type checker runs, reusing the existing,
fully-tested two-argument codegen with no new N-ary runtime. Spreads are left unfolded
(count not statically known); 0/1-arg calls still error as before. Note: nested calls
leak the intermediate result like other inline owned-array temporaries (pre-existing
follow-up), and diff/intersect still reindex keys (separate pre-existing gap).
Both builtins hard-rejected a third argument at the checker, so the idiomatic strict
forms in_array($x, $a, true) / array_search($x, $a, true) failed to compile (the
signatures already declared $strict). The checker now accepts 2 or 3 arguments and
type-checks the flag. elephc's element comparison is already value/byte-exact, so it
yields strict (===) semantics for a needle whose type matches the array element type
— the common case — and a non-literal flag is evaluated for its side effects. Cross-type
and Mixed strict comparison, plus PHP 8 loose-compare coercion, remain separate
pre-existing gaps. Updated the two arity error messages to "takes 2 or 3 arguments".
sort/rsort/asort/arsort/ksort/krsort hard-rejected a second argument at the checker, so
sort($a, SORT_STRING) and friends failed to compile. Added a SORT_* constants table
(sort_constants.rs), registered as predefined integer constants in both the type checker
and the codegen prescan so the names resolve to their PHP values as expressions too, and
relaxed the checker and signatures to accept an optional $flags argument for those six
functions (shuffle/natsort/natcasesort still take only the array). Sorting is driven by
the array element family, so SORT_REGULAR and a flag matching the element type
(SORT_STRING on a string array, SORT_NUMERIC on a numeric array) sort correctly with no
sort-codegen change; a flag mismatched to the element type is not yet specialized, and
the return type stays void (PHP returns bool) — both noted as follow-ups. Updated docs
and the six arity error messages to "1 or 2 arguments".
round() rejected a third argument at the checker, so round($x, $p, PHP_ROUND_HALF_EVEN)
failed to compile. Added a PHP_ROUND_HALF_* constants table (round_constants.rs),
registered in both the type checker and the codegen prescan, and relaxed round() to
accept an optional $mode. PHP_ROUND_HALF_UP (the default, ties away from zero) keeps the
existing frinta/libc-round path; PHP_ROUND_HALF_EVEN (banker's rounding) uses frintn
(AArch64) / rint (x86_64), both with and without a precision. PHP_ROUND_HALF_DOWN and
PHP_ROUND_HALF_ODD need bespoke tie-breaking and are rejected with a checker diagnostic
for now. Verified against PHP (round(2.5,0,EVEN)=2, round(3.5,0,EVEN)=4, round(1.45,1,
EVEN)=1.4). Updated docs and the arity error message to "1 to 3 arguments".
is_subclass_of() walked the parent chain by cloning each class.parent String per
ancestor. Borrow the parent name as &str via Option::as_deref() instead, eliminating
one allocation per ancestor on every subclass check. Behavior-preserving (no codegen
or output change); covered by the existing instanceof/inheritance codegen tests.
The spaceship operator yields 0 when either operand is NAN (PHP yields 1), and
min()/max() propagate NAN whereas PHP is argument-order dependent. Documented in the
known-incompatibilities section so users rely on is_nan() rather than a defined NaN
ordering. Equality/strict/loose comparisons against NAN remain always false (matching
PHP, per the earlier x86_64 NaN-parity fix). Documentation only.
…search $strict

Update the array builtin reference for the shipped H9 (array_merge/array_diff/
array_intersect/array_diff_key/array_intersect_key accept 2+ arrays) and H10
(in_array/array_search accept the optional $strict flag). Documentation only.
array_slice() rejected a fourth argument, so the preserve_keys form failed to compile.
The checker now accepts 2-4 args; with a literal preserve_keys=true on an int/float/bool
indexed array it infers an integer-keyed associative result (and diagnoses other element
types as not-yet-supported). A new __rt_array_slice_preserve runtime (both arches, mirroring
__rt_array_slice normalization + __rt_array_flip int-key hash insertion) builds the hash with
the original offsets as keys, carrying the source element value_type tag. The literal-true +
scalar predicate is shared via types::array_slice_literal_preserve_keys so the checker, codegen
emitter, and codegen local-type inference agree on the Array-vs-AssocArray result shape (a
mismatch would be heap corruption). Verified vs PHP (array_slice([10,20,30,40,50],1,3,true) ===
[1=>20,2=>30,3=>40]); heap-clean. Updated docs and the array_slice arity error message.
…nters (H1-part-3)

A boxed Mixed/Union operand (e.g. an element of a heterogeneous array) holds a cell
pointer, so min()/max() compared the pointer and returned garbage. Each operand whose
static type is Mixed/Union is now coerced to a float (unboxing via __rt_mixed_cast_float)
and treated as a float operand, so the comparison uses the numeric value. The result
widens to float for such operands (documented incompatibility: the displayed value matches
PHP but a strict === <int> can differ). Completes the H1 Mixed-arg sweep; verified vs PHP
(max($mixedInt5, 3) === 5, max($mixedFloat, 1) === 2.5).
…(T9 P3)

emit_stmt() formatted the @src source-position comment via
format!("@src line={} col={}", ...) and passed the resulting String to
Emitter::comment(), which then copied it into the output buffer — one
heap allocation per statement node in the program.

Add Emitter::comment_src(line, col) that formats the integers directly
into the output buffer via write!, eliminating the intermediate String.
Output is byte-identical (verified: "    ; @src line=2 col=1").
… (M4)

number_format() read only the first byte of each separator: the codegen
loaded one byte (movzx/ldrb) and the runtime inserted a single char. A
multi-byte separator — e.g. a UTF-8 non-breaking space (the standard
French/European thousands grouping) — was truncated to its first byte,
producing mojibake.

Pass each separator to __rt_number_format as a full (ptr, len) pair
instead of a char, and have the runtime copy the whole separator string
at the decimal point and at every thousands group boundary (byte-copy
loops, both arches). A length of 0 inserts nothing, which also replaces
the previous empty-thousands 0-sentinel and naturally supports an empty
decimal separator. Defaults ('.'/',') are interned via add_string.

ABI: ARM d0/x1/x2-x3/x4-x5, x86 xmm0/rdi/rsi-rdx/rcx-r8. The x86 frame
grows 104->120 (kept 0-mod-16 before the snprintf call); ARM frame 128->160.

3-target green (macOS arm64, Linux x86_64, Linux arm64): 11 codegen +
named-arg + error tests, incl. multi-byte ASCII and the UTF-8 NBSP case.

Note: an exact-half value at the rounding boundary (number_format(1234.5, 0))
still follows snprintf round-half-to-even -> '1,234' vs PHP's
round-half-away-from-zero '1,235'. That is a pre-existing, separate
rounding discrepancy (the integer digits come verbatim from snprintf,
untouched here) and is left for a dedicated fix.
array_map() rejected more than two arguments ("takes exactly 2 arguments"),
so the common multi-array form array_map($cb, $a, $b) was a compile error
even though the signature is variadic.

Add the two-input-array form as a bounded first increment:
- Checker (callables.rs): relax arity to >= 2; route the multi-array form to
  a gated path that accepts exactly two integer arrays with a non-capturing
  callback (named function or capture-less closure) returning an integer.
  Out-of-subset shapes (more arrays, non-int arrays, capturing/exotic
  callbacks, string/float returns, array_map(null, ...) zipping) get a clear
  "not yet supported" diagnostic instead of miscompiling.
- Codegen (array_map.rs): emit_two_array_map evaluates callback then both
  arrays in source order and calls the new runtime.
- Runtime (array_map2.rs): __rt_array_map2 zips the two arrays element-wise
  through cb(elem0, elem1, env), result length max(len0,len1), padding the
  shorter with 0 (PHP passes null, which is 0 in int context). Both arches.

The callback is invoked as cb(elem0, elem1, env); env is 0 for non-capturing
callbacks, so the same runtime serves the future captured-closure path
(the wrapper machinery is already N-visible-arg aware).

4 codegen tests (named + arrow callback, both unequal-length directions),
4 error tests (arity + the three gated diagnostics), docs, and an example.
3-target green (macOS arm64, Linux x86_64, Linux arm64): 40 array_map
codegen + 4 error tests each.

Later increments: captured closures, >2 arrays, string/float elements, and
the array_map(null, ...) zip form.
…crement 2)

Extend the two-input-array array_map() to accept closures that capture
variables (arrow-function auto-capture and function(...) use (...)), not
just named functions and capture-less closures.

The wrapper machinery is already N-visible-argument aware (spill_visible_args /
materialize_spilled_args / incoming_env_reg all loop over visible_arg_types),
and __rt_array_map2 already invokes the callback as cb(elem0, elem1, env). So
this is codegen-only: emit_two_array_map now builds a two-visible-argument
capture environment (emit_captured_callback_env with visible types [Int, Int])
and passes the wrapper entry + env pointer to the runtime. The two array
pointers stay in their argument registers across the env build, which only
clobbers the result and scratch registers (verified for both value and by-ref
capture loads), so no array reload or runtime change is needed.

Checker: the multi-array callback gate now allows any closure literal (capturing
included); indirect callable forms (callable variable, first-class callable,
[obj, method], runtime-string name) remain deferred with a clear diagnostic.

2 codegen tests (capturing arrow + function-use closure) and an updated error
test (indirect callable-variable callback). 3-target green: 42 array_map
codegen + 4 error tests each (macOS arm64, Linux x86_64, Linux arm64).
… (H11, increment 3)

Allow array_map($cb, $a, $b) where $cb is a variable that holds a closure,
in addition to named functions and closure literals.

This is checker-only: the increment-2 codegen already drives closure variables
correctly through materialize_callback_address (a closure descriptor carries its
own captures, so it is invoked directly — verified to preserve capture-by-value
semantics even when the captured variable is reassigned after the closure is
created). The multi-array callback gate now also accepts a Variable, but only
when the checker knows it holds a closure (present in closure_return_types and
absent from callable_array_targets / first_class_callable_targets). Callable-array
variables, string-name variables, and first-class callables are materialized
through other dispatch paths the two-array runtime does not yet drive, so they
remain rejected with a clear diagnostic (the gate errs tight: a misclassified
variable produces a compile error, never a miscompile).

2 codegen tests (closure variable, capturing closure variable surviving capture
reassignment) and an updated error test (callable-array variable rejected).
3-target green: 44 array_map codegen + 4 error tests each; callables suite 402/0.
…ment type

array_map(fn($s) => strtoupper($s), $strings) and similar untyped inline
closures over a string array were broken: the untyped closure parameter
defaulted to the integer register class, so the runtime's string element
(passed as a pointer/length pair in x0/x1 on ARM, rdi/rsi on x86) was read
as a single integer — the closure saw the string pointer as an int and
produced garbage.

For a non-capturing inline closure callback, specialize the most recently
deferred closure's untyped parameters to the source element type before the
closure is emitted (the same technique preg_replace_callback already uses),
so the closure is compiled for the correct register class. This routes the
untyped case through the already-working typed-closure compilation path.

Scoped to the non-capturing path (the closure is invoked directly by the
runtime); the captured-closure path goes through a wrapper and is left
unchanged. User-declared parameter types are respected. 2 regression tests.

macOS arm64 verified (array_map suite 46/0). Linux x86_64/arm64 Docker
verification pending a transient container network outage.
…elements)

Extend array_map($cb, $a, $b) to two string arrays, building on the
single-array untyped-closure-string fix. Restricted (checker-gated) to a
capture-less closure returning a string: the captured-closure wrapper path
is not yet wired for string elements, and a named function's return type is
not statically known here, so only a closure whose string return is inferred
from its body can be confirmed to produce the strings the runtime collects.

- Runtime array_map2_str.rs (__rt_array_map2_str, both arches): loads two
  16-byte string elements into the string ABI registers (x0/x1 + x2/x3;
  rdi/rsi + rdx/rcx), env as the fifth integer argument, and appends each
  callback result via __rt_array_push_str (persist + grow). Result length
  max(len0,len1); the shorter array is padded with the empty string.
- Codegen: emit_two_array_map detects string element arrays, specializes the
  closure params to Str (so it reads ptr/len, not a single int), and calls
  __rt_array_map2_str returning Array(Str).
- Checker: int-or-string element types (must match), with the callback gate
  and return-type check per element type. The infer table already yields
  Array(Str) for a string-returning callback.

3 codegen tests (concat, builtin, unequal-length padding) + 2 error tests
(capturing closure, non-string callback). Verified vs PHP. macOS arm64 +
Linux arm64 green (array_map 49 codegen + 6 error); Linux x86_64 in flight.

Note: both single- and two-array string array_map leave the result strings
live at exit (and array-literal sources leak) — a pre-existing array_map
string-result cleanup gap (array_merge frees its results), tracked separately
and not specific to the two-array form.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant