fix(security): close every valid finding of the 2026-08-03 audit - #678
Merged
Conversation
String and stream builtins appended their result at _concat_buf + _concat_off with no bound, so any result over 64 KiB ran off the end and silently overwrote the BSS globals that follow it: _concat_off itself, the stream handle table, exception and fiber state, GC counters and the heap. Adds a shared, target-aware reservation helper (__rt_concat_reserve / __rt_concat_publish / __rt_concat_grow) that sizes the result up front, rejects a size computation that wraps, and falls back to owned heap storage when the scratch cannot hold the result, so large results are now produced correctly instead of corrupting runtime state. Converted: the `.` operator (which previously segfaulted past 64 KiB, and whose `.=` accumulation now allocates and frees exactly one block per append), str_repeat, str_pad, bin2hex, hex2bin, base64_encode/decode, urlencode, rawurlencode, urldecode, addslashes, stripslashes, htmlspecialchars, html_entity_decode, nl2br, wordwrap, str_replace, str_ireplace, substr_replace, number_format, fread, fgets, stream_get_contents and stream_get_line. str_repeat's `$length * $times` product is now overflow-checked instead of wrapping past the scratch check, and the ARM64 heap bump check compares unsigned so a size above 2^63 can no longer slip through.
…ites __rt_sprintf copied each conversion specifier byte-for-byte into a 32-byte stack buffer with no bound, so a long enough specifier reached the saved frame pointer and return address (~290 characters), and the binaries carry no stack canary. It then used snprintf's return value — the number of bytes that *would* have been written — as the copy length out of a 128-byte stack buffer, so any conversion wider than 128 bytes copied live stack memory into the resulting PHP string, leaking pointers. The specifier is now parsed into numeric frame slots; no program byte ever reaches the C format string, which makes the overflow structurally impossible. Padding is emitted by the helper, libc only renders the unpadded numeric body into a bounded scratch, and the copy out is clamped. Widths outside PHP's accepted range and results larger than the string buffer are controlled errors instead of crashes. Also closes a reachable %n: an unrecognized conversion character used to be concatenated into the libc format, handing PHP source an arbitrary-write primitive. Conversion characters are now a whitelist. Fixes %s truncating at 127 bytes, too-few-arguments reading past the pushed argument records, and a format string with a long positional run sizing a Vec at compile time. Adds the missing PHP conversions and fixes the divergent ones: %b, %F, %E, positional %1$s, custom padding %'x, PHP's non-zero-padded %e exponent, and %f of -0.0.
buffer_new<T> multiplied length by stride with no check and handed the wrapped product to the allocator while storing the pre-overflow length in the header, so buffer_new<int>(0x2000000000000002) allocated 32 bytes and declared a length of 2^61. The bounds checks compare the index against that header, so both reads and writes far outside the allocation passed them — verified reading ~8 MB past the block with no error. A negative length passed too, via the unsigned comparison. The array path had the same flaw: __rt_array_new and __rt_hash_new sized capacity * elem_size without a check, reachable from array_fill, range, array_pad and SplFixedArray. range() could also overflow computing end - start + 1. The intended guard message existed in the runtime data section but nothing ever referenced it. Every size computation is now overflow-checked on both targets before it reaches the allocator: buffers reject negative and overflowing lengths outright, arrays and hashes clamp a negative count to an empty payload and report a controlled fatal on overflow. Clean heap exhaustion for legitimate large-but-unaffordable allocations is unchanged. Also fixes an x86_64 hash entry-zeroing loop that tested for equality instead of a signed comparison, so a negative capacity looped ~2^64 times writing out of bounds.
mangle_fqn escapes each name injectively, but the joins that build composite symbols used a bare underscore separator, and mangled fragments contain underscores. So class `a` with property `$u_b` and class `a_u` with property `$b` both produced _static_prop_a_u_u_b, and the duplicate .comm directives merged silently: two unrelated classes shared one storage cell (1,2 became 2,2). The same ambiguity hit methods, static methods and enum cases, where it instead surfaced as an assembler duplicate-symbol error on valid PHP. Static locals had two further problems: the initialization flag was derived by suffixing the storage symbol with _init, so a PHP variable literally named $x_init aliased the init flag of $x and defeated one-time initialization; and the per-function fragment mapped every non-ASCII byte to an underscore, so two distinct functions could share one static cell. Internal labels used the same lossy fragment, in nine copied implementations, and could emit duplicate labels that fail to assemble. Composite symbols now go through one shared joiner: fragments that contain no underscore keep the existing compact spelling, so the vast majority of symbols are byte-identical; anything else is joined with a separator that provably cannot occur inside a mangled fragment. Labels no longer depend on the readable part at all — the counter is module-wide, and the nine label_fragment copies are now one shared, documented decoration helper. Also separates the static-local namespace from static methods, which both used the _static_ prefix and could collide a .comm with a code label.
--debug-info spliced the source path into `.file` and `.asciz` directives escaping only the double quote, and emitted the subprogram entry symbol as a bare unquoted operand. A path containing a backslash broke the build outright, and a crafted path escaped the string literal entirely: the pair `\"` rendered as an escaped backslash followed by a real closing quote, so the rest of the path was assembled as directives. Confirmed end to end by feeding the emitted line to GNU as, which defined the injected symbol. Every quoted operand now goes through the escaper that already backs the runtime data section, and the unquoted symbol operand is validated instead of spliced. Linux executables and shared libraries are now linked with -z noexecstack, -z relro and -z now. elephc assembles its objects with `as`, which emits no .note.GNU-stack section, so GNU ld was inferring an executable stack (and warning about it); nothing elephc produces needs one. macOS is unchanged: ld64 rejects -z, and its binaries are already PIE with platform NX. -static-pie is deliberately not adopted: it requires a libc built with static-PIE support that several distributions do not ship, and a missing rcrt1.o is a hard link failure. Documented, along with the fact that whether a static Linux executable is position-independent is decided by the system toolchain.
var_dump() printed an enum case as an ordinary object (object(Status)#1 { ["name"]=> ... }) instead of PHP's enum(Status::Active), and for backed enums it listed the value before the name. print_r() of an object was not a wrong rendering but a hard compile error — "unsupported EIR backend feature: print_r for PHP type Object" — and a nested object rendered as nothing. var_export() of any object returned the empty string, which its own documentation described as out of scope. All three now match PHP byte for byte, including pure/int-backed/string-backed enums, arbitrary nesting of objects and arrays at every indentation level, print_r's visibility annotations ([p:protected], [p:Cls:private]) and *RECURSION* marker, print_r's return mode, and var_export's three object forms: (object) array( ... ) for stdClass, \Cls::__set_state(array( ... )) otherwise, and \Enum::Case for enum cases. var_export is an injected elephc-PHP prelude with no way to enumerate an object's properties, so this adds four internal builtins for that; the value accessor returns a freshly boxed cell so the caller can never free object storage. Also fixes a pre-existing leak in every program that var_exports a string: the escape helper took `mixed`, so passing a string boxed it into a cell nothing released. Documents the one remaining divergence: all three renderers list an object's declared properties only, so dynamic properties are not shown.
Division and modulo by zero returned 0 and INF instead of raising PHP 8's catchable DivisionByZeroError, so a `catch (DivisionByZeroError $e)` block never ran — the integer modulo path explicitly fell back to "return zero for modulo by zero", and float division had no guard at all. On x86_64, PHP_INT_MIN % -1 additionally raised SIGFPE, because the branch checked only for a zero divisor before running idiv; ARM64 and PHP both give 0. The guard already existed for intdiv() and is now shared by `%`, `/` and their compound-assignment forms on every supported target. Shift amounts were masked to 6 bits, which is raw hardware behavior on both targets, so `1 << 64` was 1 and `1 << 100` was 68719476736 where PHP gives 0, and a negative shift count silently produced PHP_INT_MIN instead of raising ArithmeticError. Both operators now saturate like PHP (0 for `<<`, sign-fill for `>>`) and reject negative counts. abs(PHP_INT_MIN) wrapped to a negative integer; it now promotes to float the way PHP does. Float-to-int conversion diverged from PHP on both targets and from each other: ARM64 fcvtzs saturates to INT64_MAX while x86_64 cvttsd2si yields INT64_MIN, where PHP's zend_dval_to_lval gives 0 for NaN and out-of-range values. A single shared helper now implements PHP's rules with integer instructions only, so the two targets are bit-identical, and every conversion site is routed through it — including float array keys, which previously let $a[NAN] and $a[INF] exhaust the heap. The IR effect model is updated to match: these operators can now throw, so they are marked accordingly (otherwise dead-code elimination removed the very catch blocks under test), with the effect refined away again when a literal operand rules the error out, keeping them optimizable.
…es and namespace aliases Four idiomatic PHP shapes were rejected at compile time. The classic singleton failed with "return type expects Object(S), got Union([Object(S), Void])": assigning to a nullable static property inside a null-check branch did not narrow it, so the merge at the return still carried the null half. Static properties are now narrowable places, isset() and !== join === as guards, and a guarded branch that writes its guarded place joins its exit fact with the guard complement. Return validation became flow-sensitive in the process, which also closed a pre-existing hole where a narrowing established late in a body was applied to earlier returns. Genuinely unsound shapes stay rejected, including an intervening call that could reassign the property, and static:: under late static binding. isset($neverDefined) was an "Undefined variable" error, which defeats the entire purpose of isset(). The spine of isset/empty/unset/??/??= may now bottom out in an undeclared name, while index and property-name subexpressions still report, matching PHP's own warning. Acceptance is deferred to the end of the top-level pass, because a probed name is only representable while it stays null for the whole scope. Untyped closure parameters passed to array builtins were left Mixed and checked against a fallback type, so usort with strlen() was rejected. Callback positions that a builtin types contextually are now declared in one place and skipped by the eager pre-inference passes, so array_filter, array_map, array_walk and array_reduce get the same treatment usort already had. uksort now types its comparator from the key type, array_walk passes the key as a second parameter, and array_filter's USE_KEY/USE_BOTH slot uses the real key type. A namespace alias was not expanded in a qualified name, so `use App\Math as M; M\double(5)` resolved to App\Main\M\double. The first segment of a qualified name now expands through the import table for classes, functions and constants alike, while a leading backslash suppresses expansion.
Several folds computed a different answer than PHP would, silently, at
compile time.
Integer comparisons went through f64, so `9223372036854775806 <
9223372036854775807` folded to false and `<=>` to 0; numeric-string
comparison had the same problem, making `9223372036854775806 ==
"9223372036854775807"` true. Comparison is now a port of PHP 8's
zend_compare and its numeric-string classification, with the overflow marker
that makes same-side-overflowed integer strings fall back to byte
comparison.
`switch` folded `case true` as the integer 1, so `switch (2) { case true: }`
took the default branch. It now uses the same loose comparison PHP does.
`PHP_INT_MIN % -1` panicked the compiler outright ("attempt to calculate the
remainder with overflow"), which is a debug-build crash and undefined
behavior in release. Every arithmetic fold that can overflow now either
produces PHP's exact result or declines to fold, which also fixed `6 / 3` and
`2 ** 3` folding to float, `1 << 64` folding to 1, and `-PHP_INT_MIN` wrapping
negative.
Folding an array-literal access compared raw key variants, ignoring PHP's key
normalization, so `[0 => "a", false => "b"][0]` folded to "a" instead of "b".
Constant propagation merged 0.0 with -0.0 because IEEE equality considers
them equal, so a branch selecting -0.0 printed 0. Identity now compares bit
patterns, in the propagator and in the two places that merge constants across
control flow. The dead-code guard evaluator had the mirror-image bug — it
compared float guards bit-wise, where PHP's === says 0.0 === -0.0 — so a
guarded branch could be pruned wrongly; it now uses IEEE equality. The audit
could not build a repro for that one; it needs the literal on the left.
`(float)"INF"` and `(float)"nan"` folded to INF and NAN because Rust's parser
accepts those spellings and PHP's numeric-string grammar does not. String
casts now follow PHP's leading-numeric-prefix rules.
Folding literal string comparisons is deliberately kept even though the
checker still rejects the runtime form (issue #507): the fold is
PHP-correct, so the literal case is now right and the inconsistency is
narrowed rather than widened. Documented under known incompatibilities.
…declared property Three shapes of ordinary PHP failed to compile with "unsupported EIR backend feature": `==` between two objects, `==` between an array and a scalar or null, and `unset($obj->prop)`. Adds three runtime routines implementing PHP 8's `==` table: a dispatcher that applies the coercion rules in PHP's own order, a structural array comparison that is count-checked and order-independent and probes for key existence before reading a value (so a missing key never matches a stored null), and an object comparison that is identity-first — which is also what gives enum cases PHP's compare-by-identity — then class-equal, then a loose per-property walk over the same descriptor the value renderers use. All three carry an explicit depth limit and are reentrant. `unset()` on an accessible declared property now clears the slot to exactly the state a typed property without a default starts in, so the surrounding behavior falls out of existing machinery: isset() is false, print_r and var_export omit it, var_dump shows it uninitialized, reading it raises PHP's "must not be accessed before initialization" Error, and assigning restores it. __unset on inaccessible properties is unchanged. Untyped properties and stdClass are deliberately still rejected, with a diagnostic that now names the supported shapes: elephc's fixed per-class slots have no "removed" state for an untyped slot, and every representable encoding produced an observably wrong read or isset(). A loud compile error is better than a wrong value; documented in docs/php/classes.md. A cyclic object graph compared with `==` returns false past 256 levels instead of PHP's "Nesting level too deep" fatal — the program terminates normally rather than overflowing the stack. Documented.
…ntiation
Float output diverged from PHP under two separate rules. Echo and string
conversion use PHP's precision=14 formatting, which keeps a fraction in the
mantissa of exponential form and does not zero-pad the exponent, so PHP
prints 1.0E+300 and 1.0E-7 where elephc printed 1E+300 and 1E-07. var_dump
and var_export instead use serialize_precision=-1 — the shortest decimal
string that round-trips — and prefer plain notation over a much wider range,
so PHP prints float(1000000000000000) and float(9.223372036854776E+18) where
elephc printed float(1E+15) and a 14-digit approximation. Both rules are now
implemented, and the shortest-round-trip formatter had a bug of its own: when
the decimal exponent exceeded the significant digits it printed the double's
exact expansion instead of the round-trip digits zero-padded, so
json_encode, serialize and var_dump could each print a different number than
PHP for the same value.
String-to-number conversion accepted Rust and C spellings PHP's numeric-string
grammar does not, so (float)"INF" was INF, (float)"0x1A" was 26.0 and
(int)"1e400" saturated — and since the constant folder was already corrected,
a literal and a runtime value disagreed. A shared scanner now implements
PHP's grammar once, and is_numeric() consumes the same scanner instead of its
own, so the predicate and the casts can no longer disagree; is_numeric(" 42 ")
is now true as PHP says.
fmod() computed x - trunc(x/y)*y on ARM64, which loses the sign of a negative
zero result; it now calls libc fmod like the x86_64 path.
`2 ** 3` produced a float at runtime. PHP keeps an integer when both operands
are integers, the exponent is non-negative and the result fits, promoting to
float exactly at the overflowing multiply; that algorithm is now reproduced,
so the runtime agrees with the constant folder.
pow() the function still returns float, and `**` on a runtime numeric string
still promotes — both documented under known incompatibilities.
… looping
Several builtins skipped the argument validation PHP performs, and the
failure modes were memory corruption or hangs rather than errors.
str_pad() with an empty pad string read uninitialized memory into the result.
str_split() with a chunk length of 0 looped until the heap was exhausted, and
with a negative length crashed. random_int() with min above max returned a
garbage integer. array_fill() with a negative count built an array whose
header length was negative, so count() answered -1. str_repeat() with a
negative count was an uncatchable fatal — and worse, the call was eliminated
entirely when its result was unused, so a try/catch around it printed
nothing. number_format() with negative decimals produced garbage instead of
rounding to fewer significant digits, and its 48-byte formatting buffer was
overrun for large magnitudes, assembling the result from adjacent stack bytes.
All of these now raise the catchable ValueError PHP raises, with PHP's own
message text, emitted while the argument is still in its ABI register so one
guard covers every runtime variant on all supported targets. The builtins are
marked as able to throw, which is what makes catching them work.
Found and fixed in the same pass: explode("") and array_chunk($a, 0) each
looped until the heap was exhausted; str_pad() accepted an invalid pad type;
wordwrap() silently returned its input for two argument combinations PHP
rejects; mt_rand() with inverted bounds returned garbage while rand() should
swap them.
explode() gains its missing third parameter with PHP's full $limit semantics,
including negative limits, and the STR_PAD_LEFT/RIGHT/BOTH constants now
exist, so the new pad-type check is usable.
Not fixed here, and worth its own change: array_slice() and array_splice()
with a negative $length are memory-unsafe — the runtime uses -1 as a
"slice to end" sentinel, which collides with PHP's -1 meaning "drop the last
element", and other negative lengths are never clamped.
Completes 452f2e0, which added the Pow variant to the boxed numeric operation enum but left the dispatch arm that maps it to its runtime helper out of the commit, so that commit alone did not compile. Also brings in the fmod negative-zero regression test that belonged with it.
…of crashing Unbounded recursion in a compiled program was a raw SIGSEGV with the stack pointer outside its mapping. PHP 8.3+ detects the same condition and reports "Maximum call stack size reached", so elephc now does too, with the same non-zero exit status. Every function prologue compares the stack pointer against a low-water limit and branches to a fatal helper — five instructions on AArch64, two on x86_64, clobbering at most one scratch register and writing no memory. The helper is entered by a branch rather than a call, so it needs no usable stack. Leaf functions are deliberately NOT skipped: ops that look scalar can re-enter user PHP through destructors, __toString and output handlers, so "makes no call" does not imply "cannot recurse", and a wrong classification would silently reinstate the crash. The limit is computed once at process entry from getrlimit(RLIMIT_STACK) and the entry stack pointer, minus the same 32 KiB reserve PHP uses. A failed getrlimit, an implausible bound, or a wrapping subtraction publishes zero, which disables the guard — an unknown bound degrades to the old behavior rather than to false positives. Fibers and generators run on separately allocated 256 KiB stacks at unrelated addresses, where a main-thread floor would fire on the first frame, so the fiber switch swaps the limit alongside the exception and cleanup chain heads it already swaps, and each coroutine carries its own floor. The existing PROT_NONE guard page remains as a backstop. Measured on macOS arm64: with an 8 MiB stack, 52000 frames succeed and 54000 report the fatal; with a 1 MiB stack, 6000 succeed and 8000 report it, so the floor tracks RLIMIT_STACK. Two known limits: a cdylib has no process entry to run the initializer, so the guard stays inert there, and eval'd PHP runs in the interpreter on the Rust stack, outside the prologue guard. Like PHP's, this fatal is not catchable.
…length The runtime helpers used -1 as the "no length given" sentinel, which is exactly PHP's most common negative length — "stop one element before the end" — so `array_slice([1,2,3,4], 2, -1)` returned two elements instead of one. Other negative lengths were never clamped, so the result carried a negative header length: `count(array_slice([1,2,3,4], 0, -10))` answered -10, `array_splice($a, 0, -10)` returned 32770 elements of out-of-bounds garbage and emptied the source array, and a negative offset on the ARM64 splice path crashed outright. No 64-bit value can serve as the sentinel, because every one of them is a legal PHP length and PHP_INT_MIN is observably different from an omitted argument. "Length present" is therefore carried in its own argument register: an immediate for a statically known operand, and derived from the boxed tag when only the runtime knows whether the value is null — which also fixes a runtime null length producing an empty result instead of the whole array. The window arithmetic now lives in one shared emitter used by all four helpers, so the offset and length clamps cannot drift apart again, and neither addition can overflow because each mixes a non-negative operand with a negative one. Found in the same code and not fixed here: array_pad() with a large negative size reports a negative count and crashes for PHP_INT_MIN.
…ed arguments A generator's implicit keys restarted from 0 after an explicit key, so `yield 5 => "five"; yield "six";` produced keys 5 then 0 where PHP produces 5 then 6. PHP tracks the auto-key counter the way an array does: a non-negative integer key pushes it to key+1, while negative, string, float and bool keys leave it alone, and PHP_INT_MAX does not wrap. `yield from` forwards inner keys verbatim without advancing the outer counter — reproducing PHP's duplicate keys — so delegated yields now take a variant of the suspend helper that skips the bookkeeping. Argument unpacking after a named argument is a compile-time fatal in PHP; elephc compiled and ran it. The check now lives in the shared call-argument planner, so every call surface inherits it rather than each emitter checking locally, and the legal neighbours stay legal: a spread before a named argument, several spreads, a string-keyed spread on its own, and a spread after a positional argument.
…entable __rt_array_pad negated its length argument unchecked. PHP_INT_MIN negates to itself, so it stayed negative, the "already long enough" early-out fired and the call silently returned an unpadded copy; a large negative length such as -2000000000 instead passed the size check and asked for a 16 GiB payload while publishing a header claiming two billion elements. The length is now validated at the lowering site, before any allocation and while the argument is still in its ABI register, so one guard covers both helpers and every element type on every target. The check is on the signed value, never on abs(), so PHP_INT_MIN fails instead of wrapping. PHP's bound for this argument is 2^30 and is checked before it looks at the array, so that is the bound elephc enforces, raising PHP's own catchable ValueError with PHP's message text. Adjacent error-class gaps left for a follow-up, all memory-safe today: array_fill() and range() report heap exhaustion where PHP raises a ValueError for a count above the maximum array size.
Completes 7d7ca2fc2, which left out the shared signed-magnitude guard the lowering calls and the effect flag that makes the new error catchable, so that commit did not compile on its own. Marking array_pad as able to throw is required, not cosmetic: without it the optimizer hoisted the call out of its enclosing try block and the ValueError could never be caught. Also corrects the bound: PHP's limit for this argument is 2^30, checked before it looks at the array, not the maximum array size.
…n/max Four shapes of valid PHP were rejected by the parser or the checker. `<>`, PHP's alias for `!=`, was not a token at all. It now shares the not-equal operator, so folding, effects, inference and lowering follow automatically, and it binds exactly like `!=`. `$this->n++`, `++$this->n` and `$this->arr[0]++` failed to parse while the same forms on any other object worked. `$this` was parsed by a dedicated path that returned before the postfix/property loop, so property access on `$this` was only reachable through the assignment-statement parser. `$this` now flows through the ordinary primary-expression path, which also fixes `$this->n--`, `$this->arr[$i]++`, `$this->obj->n++` and chained index increments. `foreach ($rows as ["name" => $n])` and `foreach ($m as [$a, $b])` were rejected although `list()`/`[]` destructuring worked outside foreach. Nested patterns, keyed patterns, skipped elements and `$k => [$a, $b]` are covered. `min()`/`max()` accept a single array, as PHP does, and raise PHP's ValueError for an empty one. This is deliberately limited to indexed arrays whose elements share one scalar type: strings, associative arrays and heterogeneous arrays such as `min([1, 2.5])` are still rejected, because selecting the smallest boxed element needs PHP's full comparison table. The limitation is documented rather than silently wrong. Increment and decrement of floats now work. Strings remain rejected, with a diagnostic that explains why: `"9"++` is `int(10)` in PHP, so the local's type widens, which a statically typed slot cannot express today.
Completes 37556dd90, whose documentation page was left out of the commit.
The operator page claimed mixed values increment like PHP. They do not: a boxed mixed holding a string increments numerically instead of performing PHP's perl-style alphanumeric carry, and no path implements the carry today. Says so plainly, so nobody relies on `++` to advance a string.
`if: … endif;`, `foreach: … endforeach;`, `while:`, `for:` and `switch:`, including `elseif:`/`else:` chains, were rejected with "Unexpected token at statement position: Colon"; only `declare(...): … enddeclare;` worked. They desugar into the same AST as the brace forms, so nothing downstream of the parser changed. `goto` and its labels now report that they are not supported, naming the construct, instead of surfacing as "Unexpected token" followed by a cascade of "Expected ';'". Implementing it for real would mean arbitrary intra-function jumps, which the statement-shaped lowering passes (termination analysis, flow-sensitive narrowing, loop pruning, constant propagation) are not built for; rejecting it clearly is the honest outcome for now. Reference elements in array literals (`[&$x]`) get the same treatment: a diagnostic that names the construct and suggests `$b =& $a[0]`, instead of a bare "Unexpected token: Ampersand". elephc's arrays are copy-on-write values with nothing to pin a fresh literal's storage, so an alias into one could dangle; a compile error is better than that.
`.comm`'s third operand means different things to the two assemblers:
Mach-O reads it as a power-of-two exponent, ELF as a byte count. elephc
emitted the Mach-O spelling on every target, so `.comm sym, 8, 3` asked
for 8-byte alignment on macOS and 3-byte alignment on Linux.
Under-aligned common storage assembles without complaint and only fails
at link time, once per program rather than once per symbol:
relocation truncated to fit: R_AARCH64_LDST64_ABS_LO12_NC
against symbol `_stack_limit' defined in COMMON section
That relocation encodes its displacement pre-shifted by 3, so it cannot
name an address that is not 8-byte aligned.
The defect predates this branch but was unreachable: no common symbol was
read through an alignment-sensitive relocation. The stack-exhaustion
guard is the first to do so — it emits `ldr x9, [x9, :lo12:_stack_limit]`
in every function prologue — which turned a latent misdeclaration into a
total linux-aarch64 link failure, all 16 CI shards.
All 172 emission sites now go through one target-aware
`comm_directive()`, and `DataSection::emit()`, `emit_runtime_data_user()`
and `emit_builtin_callable_data()` take the target that decides it.
`_empty_str` and `_url_stat_matched`, two hand-written byte-sized
symbols, are included: their own operand was equally ambiguous and
harmless only by accident, and leaving exceptions is how this class of
bug returns.
Verified by a sweep over the whole fixed runtime data section rather than
a spot check on `_stack_limit`, because the failure is invisible per
symbol — the sweep is what found those last two. The CI-failing test
links and passes on linux-x86_64 under Docker.
`test_division_by_zero_inf` asserted that `1.0 / 0.0` prints `INF`. That is pre-PHP-8 behaviour: PHP 5/7 warned and yielded INF, PHP 8 made every `/` by zero throw, floats included. $ php -r 'echo 1.0 / 0.0;' PHP Fatal error: Uncaught DivisionByZeroError: Division by zero Commit 0790af3 made elephc match PHP here, so the test has been failing on this branch since then. It went unnoticed because CI had never run on the branch and the local runs were filtered to the areas under change. `fdiv(1.0, 0.0)` still returns `INF` in both elephc and PHP — verified — and it is the reason the old expectation looked plausible.
…'s span `call_user_func($variable, ...)` on a statically resolvable builtin produced a wrong result layout, and for a bool- or int-returning builtin it segfaulted: $f = "count"; call_user_func($f, [1,2,3]); // SIGSEGV $f = "in_array"; call_user_func($f, 2, [1,2,3]); // SIGSEGV $g = "array_reverse"; call_user_func($g, [1,2,3]); // bool(true) $s = "array_slice"; call_user_func($s, $a, 1, 2); // compile abort The checker cannot resolve a variable callback, so it types the call runtime-opaque `mixed` and records that under the *outer* `call_user_func` span. Constant propagation runs afterwards and folds the variable to a literal, so lowering does resolve the callee — and `static_callable_builtin_result_type` then read the checked type back from `expr.span`, which belongs to the dispatching expression, not to the builtin. A raw bool return got labelled a boxed Mixed cell and `is_truthy` dereferenced it as a heap pointer. The checked-span type is now passed explicitly and is `None` on the callable-dispatch path: a result type recorded at a span is authoritative only for the builtin the checker actually examined there, and a builtin reached through a callable binding derives its type from its own descriptor and lowered operands. Introduced by 5789c93, whose own subject was the `fallback_result_type` layout; the span lookup leaked in with it. Surfaced by codegen::lfc::lfc_mixed_strict_project_keeps_per_file_extensions_and_defines, which passes on main. There is no user-function-vs-builtin precedence bug: resolution is source-mode dependent and already matches the direct-call rule — in strict-PHP source the user function wins, in .lfc source the extension builtin does, and shadowing a PHP-visible builtin is already rejected. tests/codegen/strict_php.rs now pins that rule independently of the LFC fixture. All three targets. The four regression tests in expr_calls.rs were confirmed to fail without the fix.
Eight codegen tests passed on macos-aarch64 and failed on linux-x86_64. All three causes are the same shape: an x86_64 emitter calling a helper with the AArch64 register assignment, or with a documented one that was wrong. str_word_count returned shifted, out-of-bounds words while the count stayed correct: expected 6|Hello,friend,you're,looking,good,today x86_64 6| frie,, you', looki, good t, tod,!Fata __rt_array_push_str takes the string pointer in rsi; the emitter passed it in rax and left rsi holding the live scan cursor, which at that point is the END of the word. Every word was therefore read from word_start + word_len for word_len bytes, and the last ones ran past the subject into the rodata that holds the fatal-error text. ucwords died on a signal: it called __rt_strcopy with its own SysV arguments untouched, but strcopy takes the source in rax and the length in rdx — and rdx still held the separator POINTER, a multi-billion byte count, so the copy left _concat_buf immediately. Loose equality died on a signal for every array-versus-scalar case. __rt_mixed_cast_bool and __rt_mixed_cast_float take their boxed cell in rax, not rdi, because they forward it untouched to __rt_mixed_unbox. The dispatcher loaded rdi only, so unbox dereferenced whatever was in rax — the runtime tag left by the previous unbox, i.e. address 0x3, 0x4 or 0x8. The docblocks of both cast helpers stated rdi, which is exactly what the dispatcher followed, so the wrong documentation caused the bug. They now state rax and say why. That is a doc-only change; no shared code needed a behaviour change, and the AArch64 paths were correct throughout. Verified in Docker on linux-x86_64 (20 passed, 0 failed, including object loose equality, which shares mixed_loose_eq) and on macos-aarch64 (608 passed across strings, operators and objects). Audited for the same bug class and found correct: __rt_obj_loose_eq and __rt_mixed_array_loose_eq. Left alone but worth knowing: __rt_strtolower takes its string in rax/rdx while __rt_ucwords takes SysV rdi/rsi — two sibling string helpers with different input conventions.
`array_map_result_element_type_is_the_callback_return_type` proves that array_map()'s checker result element type follows the CALLBACK's return type rather than the input array's. It did so by mapping an int[] through a bool-returning callback and passing the result into a `string` parameter, expecting a rejection that names Bool. That probe stopped erroring once coercive parameter binding landed (ad22f63), and correctly so: bool -> string is a legal coercive binding. Reference PHP 8.4 accepts it — $ php -r 'function f(string $s){return $s;} var_dump(f(true));' string(1) "1" — and elephc now prints the same. With no error left, there was nothing for the test to read the element type out of. The probe now declares strict_types=1, where PHP does throw `TypeError: ... must be of type string, bool given` and elephc reports `expects Str, got Bool`. The assertion is unchanged, so the test still proves exactly what it was written to prove. The sibling `..._follows_a_string_callback` needs no directive: its "n1" is a non-numeric string, which PHP rejects for an int parameter in both modes.
Main split 397 new files out of the oversized modules while this branch was
open: ir_lower/expr/mod.rs went from 15050 lines to 318, lower_inst.rs from
7776 to 346, builtins/io.rs from 9222 to 170. Fifteen files conflicted, and
32 of this branch's 64 commits touch the split ones.
Conflicts were resolved by taking main's module structure and porting this
branch's net changes into whichever submodule now hosts the code. The
mechanical part of that is unreliable — a hunk header names the function
containing its FIRST line, not the one the added code belongs to, so
mapping targets by header sends every hunk to the wrong file — and a
partial application is silent. What follows was found by building and
testing, not by any conflict marker:
- lower_generator_yield kept main's extra __rt_gen_suspend call, so every
generator emitted a NULL after each yielded value.
- materialize_mixed_slice_args lost the two lines that pass the
length-present flag, so array_slice() through an untyped parameter
segfaulted.
- emit_sort_int/emit_sort_str take an ascending/descending flag and emit
DIFFERENT symbols per call; deduplicating them left __rt_rsort_int
undefined and broke every assoc-array program.
- The `??` null-probe arm and the dynamic-constructor spread-after-named
guard were lost to a directory-wide checkout.
- yield from over an assoc array reverted to main's narrower rule.
- `++`/`--` on a string or float reverted to a compile error.
- file_get_contents, range and array_search kept main's narrow arity
checks, rejecting the widened signatures this branch added.
Verified: the full codegen suite (6416 passed, 0 failed, codegen::eval left
to CI), error_tests 1282, parser_tests 393, ir_backend_smoke_test 255, the
magician lib 1144, every other test binary, and both eval/AOT parity gates.
Ten differential probes against php 8.4 cover the branch's own fixes —
string-array splice, splice replacement, by-ref unshift, ksort, the
file_get_contents window, DivisionByZeroError, sprintf specifiers, string
and float increment, generators, and array_slice through an untyped
parameter — all byte-identical.
Zero build warnings. Generated builtin docs regenerated against the merged
registry.
This was referenced Aug 7, 2026
Resolves the five code conflicts by keeping both sides: - ir/runtime_fn.rs: parse_url joins the MAY_THROW list rather than replacing it, so wordwrap() keeps the throwing effect this branch gave it while parse_url() keeps main's. - ir_lower/stmt/typed_foreach.rs: main's lower_foreach_source composes with this branch's retain_object_foreach_source. The two are orthogonal — a borrowed hash element is never an object, and the object retain runs before IterStart while the element pin runs after. - codegen/lower_inst/hashes.rs, magician string mod, CHANGELOG: additive. parse_url's lowering moved a module deeper, so its arity check now resolves through super::super:: like its siblings. The 153 conflicting builtin pages are generated: regenerated with gen_builtins + extract_builtins.py instead of hand-merged, both doc gates green.
nahime0
marked this pull request as ready for review
August 8, 2026 13:09
|
Too many files changed for review (1625 files, 100 file limit). |
Guikingone
approved these changes
Aug 8, 2026
Aligns onto PR #559 (PDO parity). 11 code conflicts; the docs conflicts are generated pages, regenerated rather than hand-merged. Both sides kept where additive: web heap-guard flag alongside the stack limit init, array_strict_eq alongside array_splice_str, main's ScopedConstantAccess arm alongside the closure array-literal return typing, and all 24 operator tests. Three resolutions chose between mechanisms: - codegen_support/runtime/data/fixed.rs: main's new _web_heap_guard_enabled arrived as a hardcoded `.comm ..., 8, 3`. Routed through comm_directive instead — the literal form is the Mach-O spelling and breaks the ELF link. - codegen/lower_inst/hashes.rs: dropped main's source_load_local_slot. ReceiverPlace::resolve already covers LoadLocal and LoadRefCell and also decides where to write back, so the helper was dead. Verified against main's own repro (41-key hash written through a reference). - types/checker/stmt_check/narrowing.rs: took main's GuardTarget refactor (is_array/is_callable) and re-grafted isset(), static-property receivers and php_symbol_key normalization on top. `negated` already folds the self-negating flag, so the old XOR at the use site collapses. ir_lower/stmt/mod.rs: main moved the terminated-block guard up into lower_stmt, above the new representation fixpoint; lower_stmt_once keeps the array-pointer cursor predeclaration. Dynamic `new` now falls back to main's runtime argument container for call shapes that survive static spread expansion and planned dispatch.
Both broke the `codegen_tests` build, so every CI job that archives test binaries failed before running anything. - tests/codegen/operators.rs: the merge conflict cut through the middle of a test, so keeping both sides left an orphan function tail. Rebuilt the file as the real union — main's 77 tests plus the 22 only this branch has. The 69 shared ones are byte-identical on both sides, so taking main's copy loses nothing. - tests/codegen/support/compiler.rs: auto-merged without a conflict but into an inconsistent shape. main threads `php_version` into the PDO prelude injection, and this branch had extracted that pipeline into `try_compile_source_to_asm_with_defines_repr` to return a Result; the parameter now goes through the extraction.
This was referenced Aug 8, 2026
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.
Summary
Resolves every valid finding of the internal audit dated 2026-08-03, across its A, B, C,
D, E, F, H, I and J sections, plus the defects found while fixing them. 59 commits.
The audit's two halves are memory safety (sections H/I/J) and PHP semantic parity
(A/B/C/D/E/F). Several of the parity findings turned out to sit on top of memory-safety
bugs that the audit had not reached — those are called out below, because they are the
reason this branch is larger than the audit.
Each finding was reproduced before being fixed and re-verified afterwards against
LC_ALL=C php8.4 on the same fixture. Findings that turned out not to be valid, or notimplementable without a wrong answer, are listed under Deliberate limitations rather
than silently dropped.
Issues closed
Each was verified by running the issue's own reproducer against this branch and diffing
against PHP.
.=corrupted the heap. Now correct; thebounded 64 KiB concat scratch is reserved and published through shared helpers instead
of being written past its end.
Mixedvalue stored into an untyped object property or an array elementleaked its previous box on every overwrite. The issue's 5M-iteration loop now balances:
allocs=199999 frees=199999on the scaled-down fixture.[]corruptedimplode()andglob()/scandir()reads.print_r()on a null-container sentinel segfaulted.foreachby reference followed by value iteration segfaults on indexed arrays #345 — by-referenceforeachfollowed by value iteration segfaulted on indexedarrays. The issue's exact repro now prints
246.foreach-rebuiltArray(Mixed)result was rejectedat compile time. The issue's exact repro now prints
Alice..commalignment operand is a power-of-twoexponent on Mach-O and a byte count on ELF, and elephc emitted the Mach-O spelling on
every target, so Linux declared 3-byte alignment for 8-byte slots. It assembles cleanly
and then fails to link with
relocation truncated to fit: R_AARCH64_LDST64_ABS_LO12_NCas soon as such a symbol is reached by a 64-bit load — the stack guard's
_stack_limitwas the first, which took out linux-aarch64 entirely. All 172 emission sites now render
the directive through one target-aware helper.
Issues addressed but NOT closed
htmlspecialchars($s, ENT_QUOTES)andstrpos($h, $n, $offset)nowcompile and match PHP.
mkdir($dir, $perms, $recursive)andfile_put_contents($f, $d, FILE_APPEND)are still capped at their short arity.Please keep this open for those two.
call_user_func("untyped", "hello")andarray_map("untyped", …)now return
string(5) "hello"instead ofint(0). The variable-dispatch form(
$f = "untyped"; $f("hello")) still returnsint(0)— a silent wrong answer, not adiagnostic. Keep open for that form.
Issues that
mainresolved while this branch was openmain, not by this branch; both now closed. PR feat: expand PDO parity across maintained PHP drivers #559 added__rt_array_strict_eq, so array and hash===(and the empty-array case of Strict comparison with an empty array evaluates incorrectly in compiled binary #621) work.Verified against pristine
mainbefore closing, with 25 cases for Array/hash equality (== and ===) is unsupported by the EIR backend #424 (key ordering —===is order-sensitive,==is not; key/value coercion; nesting; and the same shapesre-run with
$argc-derived operands so the constant folder cannot answer for the runtimehelper) and 13 for Strict comparison with an empty array evaluates incorrectly in compiled binary #621 (including empty arrays produced at runtime, which is what
actually exercises the
Array(Never)arm). This branch'se9d753263had alreadyimplemented loose equality for objects and for arrays in the contexts it could reach.
Issue whose failure mode this branch changes
array_fill() in a method with a property-derived count receives a garbage count (heap exhausted) #502 — still broken, new symptom, and narrower than its title. The trigger is not
"a property-derived count":
array_fill(0, $this->n, 0)is correct. It is arithmetic onproperty reads inside a method:
Commit
79c1f12aeadded PHP'sValueErrorfor genuinely oversized counts, so the bugnow surfaces as:
Not a practical regression — the program dies either way — but the message is actively
misleading (a count of 62 is reported as "too large"), and
ValueErroris catchable,so a
try/catchwould swallow a compiler bug. The same$nis correct in every otherconsumer in the same method (
echo→ 880,var_dump→int(880),$n + 1→ 881,str_repeat("x", $n)→ 880), so this is specific toarray_fill()'s count operand.Full reduction posted on the issue.
Issue re-verified and found fixed
1999999000000and, under--heap-debug, reportsallocs=2000001 frees=2000001 live_blocks=0 peak_live_bytes=40— so the boxes really are released rather than the loop merely staying under the heap
ceiling, which is the check that mattered. Verified on pristine
main. An earlier draftof this description claimed the symptom had changed from "leak" to "wrong answer"; that
was wrong — it was describing a different program (
$x = 1; $x = $x * 1.5;→1.5instead of
7.59375), which is Checker has no back-edge type widening: null-initialized statics and loop-carried locals are re-typed with first-iteration flow types and miscompile #562's back-edge type-widening gap.Verified after all
empty(NAN)returns true on Linux x86_64 but false on ARM64/PHP #627 (empty(NAN)diverges on Linux x86_64) — fixed, including the boxed-Mixedaudit the issue asked for. The x86_64 arm of
emit_float_result_zero_boolnow emitssete+setnp+and, so an unordered compare no longer materializestrue;confirmed by cross-emitting the issue's repro with
--target linux-x86_64 --emit-asm.__rt_mixed_is_emptyand the eval-bridge comparator carry the same parity fixup, andtests/nan_bool_coercion_tests.rspinsempty(NAN)→bool(false)end-to-end on bothpaths. Not executed on x86_64 from this ARM64 host — cross-linking fails at the runtime
object, so the linux-x86_64 CI shard is the confirmation.
What is in the branch
Memory safety (audit H, I, J)
sprintfrebuilt. The formatter parsed the user's format string and handed programbytes to libc.
%nwas reachable from PHP source — an arbitrary write. The specifier isnow parsed into numeric frame slots and no program byte reaches the C format string.
This also removes an out-of-bounds read and an unbounded stack write.
_concat_bufnow reservesthrough
__rt_concat_reserve/__rt_concat_publish/__rt_concat_grow. This coversthe string builtins and, in a second pass, the
.operator itself, which segfaultedpast 64 KiB.
buffer_new<T>and thearray_newfamily computedlen * stridewrapped, allocating a tiny block with a huge header — bounds checks thenpassed against the pre-overflow length. Sizes that overflow a machine word are rejected.
a compact
prefix_a_bwhen no fragment contains_, otherwise a___separator, whichis provably impossible inside a mangled fragment (max underscore run there is 2).
.filedirective breakout allowed defining anarbitrary symbol from source; operands are escaped and ELF output hardened.
fatal. Leaf functions are not skipped — destructor and
__toStringre-entry make thatunsound — so the cost is 5 instructions on AArch64 in every function.
array_slice,array_spliceandarray_pad.fread/stream_get_contentsunbounded writes.Found while fixing the above, not in the audit
usort($obj->prop, …)silently did nothing — 16 of 32 receiver/builtin combinationswere wrong.
array_unshiftheap buffer overflow that segfaulted.array_splice()on an indexedarray<string>leaked a raw pointer into a PHP value(
[1, 4362860248]where PHP gives[b, c]), because the helpers used 8-byte slots whilestring arrays use 16-byte
{ptr,len}pairs. Pre-existing — reproduced on a build of theparent commit before fixing.
source_load_local_slotonlyrecognized
LoadLocal. Every shape-changing builtin on a by-ref parameter produced awrong answer, and
array_unshiftprinted nothing (it grows, so it read storage__rt_array_growhad already freed).?intthrough a boxed receiver segfaulted (the payload was loaded into the register still
carrying the object pointer).
base64_decodereturned wrong bytes in three ordinary non-strict cases.PHP semantics (audit A, B, C, F)
Catchable
ValueErrorfor builtin validation instead of memory corruption or an infiniteloop; division by zero, shifts and float-to-int conversion; float printing, numeric
strings,
fmodand integer exponentiation;PHP_INT_MIN % -1on x86_64; constant foldingmade to match PHP; object and enum case rendering in
var_dump/print_r/var_export;loose equality for objects and arrays;
unset()on declared and dynamic objectproperties;
ksort/krsort/asort/arsort, whose helpers were literally onereteach;declare(strict_types=1)taking effect; generator key numbering, generators from methods,keyed
yield from, and theforeachobject leak.Syntax previously rejected (audit D)
Alternative control-flow syntax,
<>,$this->n++,foreachdestructuring, stringincrement/decrement, string callables and PHP's scalar parameter coercion,
func_num_args/
func_get_args/func_get_arg, dynamicnewwith spread arguments.Builtins (audit E)
New:
join,substr_count,strtr,count_chars,str_word_count,stripos,strripos,quoted_printable_encode, base conversion, the array internal pointer family,PHP_ROUND_HALF_*andCOUNT_RECURSIVE.Widened to PHP's real parameter lists:
array_slice(..., $preserve_keys),array_chunk(..., $preserve_keys),array_splice(..., $replacement),file_get_contents(..., $offset, $length),file($p, $flags),range($a, $b, $step),array_reverse,array_search/in_array$strict,intval($v, $base),ucwords($s, $separators),base64_decode($s, $strict),count($a, COUNT_RECURSIVE),min/maxover any element type and over a single array.Deliberate limitations
Documented rather than implemented, because every representation tried produced a wrong
answer:
unset()on an untyped declared property. Three representations were tried. Theblocker is the read, not the unset: PHP yields
nullafterwards, and anInt-typedfixed slot has no encoding for that.
[&$x]— a by-reference binding inside an array literal would dangle.goto— 32 passes assume the statement tree describes the CFG.Each raises an explicit compile error naming the reason, not a silent miscompile. The same
rule was applied to the new
array_splicework: a type-changing replacement through aby-ref receiver, and a
Str-slot receiver with a replacement, refuse rather than publishboxed cells through a slot the caller still reads as
array<int>.Follow-ups opened
Everything known-broken and left out of this branch has an issue, each with a reproducer
that was actually run:
array_filter()renumbers keys instead of preserving them (the only one of thesix that gives a silent wrong answer)
yieldis in a constant-folded branch hangs forever(confirmed by SIGALRM;
return; yield;hangs the same way)array_slice/chunk/pad/reverse/merge/unique/diff/shufflerejectindexed
array<string>(same 8-byte slot assumption; related to implode() over a homogeneous float array prints nothing (raw doubles read through the string-slot layout) #640)array_splice()with a self-referential$replacementproduces a wrong arrayarray_push()accepts only one value;unset()on a by-ref array parameterelement is rejected
--emit-asmwrites a stale.swhen the compile for a cross target fails++/--on a property or array element is rejected in expression position(
return $this->n++;) although the statement form works; the AST node carries a variablename, so the construct cannot be represented as an expression
array_slice/array_chunkreject every associative receiver, string-keyed andinteger-keyed alike, with and without
preserve_keys(
public int $w = 40, $h = 22;,const A = 1, B = 2;) are rejected at parse timenew $class(...$assoc)with a string-keyed spread held in a variable fails EIRvalidation; reproduced on pristine
main, so it is not specific to this branchaborts without a diagnostic; reproduces under
--check, so it is front-end recursionVerification
Both targets throughout — every runtime helper landed with an ARM64 and an x86_64
implementation in the same change, and the x86_64 runs were executed under Docker rather
than asserted on assembly text. That paid for itself: they caught register-ABI bugs ARM64
could not see (
__rt_heap_freeand__rt_mixed_unboxtake their argument inrax, notrdi).Ownership was validated with
--heap-debugon the paths that allocate; the string-spliceand insertion fixtures report
leak summary: clean.Differential regression corpus. Every PHP fixture written while working through the
audit — 2030 of them, one per behaviour each fix was validated against — is replayed
against the pre-merge and post-merge compilers after each
mainmerge, and any behaviouraldifference is treated as lost work until explained. The last run was 2014/2030 identical;
of the 16 differences, 5 are fixtures that print closure handles as integers (ASLR, proven
non-deterministic under the same binary), 10 are shapes
mainnewly supports, and 1 is apre-existing
maindefect (new $c(...$assoc)with a dynamic class name fails to compilethere too — verified against pristine
mainin a separate worktree).eval/AOT parity is enforced, not bypassed. No entries were added to
STATIC_ONLY_REGISTRY_BUILTINSor any other allowlist; two pre-existing entries that weresuppressing the gate for
array_count_valuesandconstantwere removed and themissing eval-interpreter implementations written.
Notes for reviewers
mainthree times as it moved underneath (the builtinfile split,
parse_url, then the PDO parity work of feat: expand PDO parity across maintained PHP drivers #559). Conflicts that chose betweentwo mechanisms rather than combining them are called out in the merge commit messages —
notably the
.commdirective (a newmaincommon symbol arrived in the Mach-O-onlyspelling and had to be routed through the target-aware helper),
ReceiverPlacevssource_load_local_slot, and theGuardTargetnarrowing refactor.line-number movement, kept out of the substantive commits so they stay readable. After
each
mainmerge the pages are regenerated withgen_builtins+extract_builtins.pyrather than hand-merged, withaudit_builtins.pyandvalidate_site_compat.pygreen.CHANGELOG.mdis maintained by hand in dedicated commits.