refactor(core): improvements on merged topics#347
Draft
Guikingone wants to merge 34 commits into
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
@srcalloc, is_subclass_of clone.Features — array_map($cb, $a, $b) two-array form (int + string, all callback forms); fixed untyped-closure-over-string-array.