Skip to content

[MEASUREMENT - do not merge] Windows codegen full-suite parity - #1

Closed
Guikingone wants to merge 170 commits into
mainfrom
windows-codegen-measure
Closed

[MEASUREMENT - do not merge] Windows codegen full-suite parity#1
Guikingone wants to merge 170 commits into
mainfrom
windows-codegen-measure

Conversation

@Guikingone

Copy link
Copy Markdown
Owner

Temporary measurement PR to run the non-blocking windows-codegen-parity CI job (16 shards, MinGW + wine64) which runs the full codegen suite under --target windows-x86_64. This measures how many tests pass on Windows to decide gate vs informational vs curated-subset. Not for merge; will be closed once numbers are collected. Base = feature branch + ELEPHC_TEST_TARGET harness + parity job.

Guikingone and others added 30 commits June 24, 2026 17:58
A `foreach($src as $k=>$v) $dst[$k]=$v` rebuild into a statically
`Array(Mixed)` destination collapsed every string key onto int 0, dropping
all but the last entry. EIR foreach keys are always boxed `Mixed` cells
(`Op::IterCurrentKey`), and `lower_array_assign` coerced the Mixed index
to int for indexed destinations.

Add `Op::ArraySetMixedKey` + the `__rt_array_set_mixed_key` runtime helper
(dual-arch), which tag-dispatches the Mixed key at runtime: integer/bool/
float keys stay on indexed storage (preserving indexed consumers like
`implode`), and string keys promote the destination to a hash. The promote
path runs `__rt_hash_to_mixed` so scalar slots copied from the indexed
source are boxed as Mixed cells before the new entry is inserted.

Two coupled runtime fixes make the promote path read back correctly:
- `__rt_array_push_int` first-write shape-specialization now clears the
  `Never` placeholder value_type (8) stamped by `[]`, not just the string
  fallback tag (1), so a prior int push copies with the correct int tag.
- `__rt_array_hash_union` copies scalar slots using the source value_type
  tag, so `__rt_hash_to_mixed` must box them or a seeded int reads back empty.

Route `Array(Mixed)`/`Array(Union)` iterator sources through
`DynamicIterable` so a runtime-promoted hash iterates correctly, while
concrete-element indexed arrays keep the fast static path.

Add checker foreach-key tracking (`foreach_key_locals`, per-function
lifetime mirroring the lowering's `foreach_int_key_locals`) so
`$dst[$k]=$v` under a foreach key defers to `ArraySetMixedKey`
(`Array(Mixed)`) while a non-foreach string-typed key (e.g. `"k".$i`)
still promotes to `AssocArray` for direct string-key reads. This restores
the three `test_assoc_array_dynamic_string_key_*` tests that the prior
Mixed-key routing had regressed.

Five regression tests cover string-key rebuild, entry count, int-key
stays-indexed (implode), mixed int/string keys, and string-after-int-seed.
3-target green: macOS-aarch64 full codegen suite 4138/0; linux-x86_64 and
linux-arm64 foreach_mixed 6/0, assoc 3/0, implode 5/0, runtime_gc 95/0,
foreach 84/0.
Auto-free native stream fds (kind 1 → close) and unfinalized HashContext
handles (kind 2 → elephc_crypto_free) when their Mixed box is released at
scope exit. The resource-kind subtype lives in the high payload word of the
Mixed cell; kind 0 resources (synthetic user-wrapper handles) are skipped.

Wiring:
- __rt_mixed_free_deep: tag-9 dispatch with kind 0/1/2 on AArch64 + x86_64
- __rt_hash_ctx_free: new runtime helper calling elephc_crypto_free
- _elephc_crypto_free_fn slot + publish entry
- hash_init/hash_copy box with kind=2, fopen boxes with kind=1

Closes the fd leak (unclosed fopen) and HashContext leak (unfinalized
hash_init) documented in ROADMAP v0.26.x.
Emit each __rt_* runtime helper into its own text section on Linux
(.section .text.<name>) so that --gc-sections can eliminate unreachable
helpers at link time. Add -Wl,--gc-sections to the Linux linker for
executables (cdylibs are unaffected).

macOS dead stripping is deferred: .subsections_via_symbols breaks
conditional branches to local labels in the runtime, and per-symbol
Mach-O sections require underscore-aware symbol handling that is not
trivial to retrofit. The runtime .o remains monolithic on macOS for
now; Linux binaries benefit immediately.

The runtime cache key (FNV-1a hash of generated assembly) automatically
invalidates when the section directives change, so no cache migration
is needed.

7 new tests verify that programs using regex, hash, classes, fopen,
arrays, and exceptions still run correctly after dead stripping.
Implements array_is_list, array_key_first/last, array_replace and
array_replace_recursive, array_diff_assoc and array_intersect_assoc,
array_merge_recursive, array_walk_recursive, array_find/any/all (PHP 8.4),
array_udiff/uintersect, and array_multisort through EIR lowering, reusing the
shared __rt_* runtime helpers (the legacy direct-AST emitters are not touched).

Hash-based set operations accept associative arrays and scalar-element indexed
arrays (converted to integer-keyed hashes via __rt_array_to_hash; result
keys/values widen to mixed for heterogeneous inputs). The predicate/comparator
builtins use the EIR descriptor-callback machinery (string, function, and
non-capturing closure callbacks). All are target-aware (ARM64 + x86_64) with
codegen and error tests, an examples/array-parity demo, and docs/php/arrays.md,
ROADMAP, and CHANGELOG updates.
Add the public serialize()/unserialize() builtins covering scalars,
nested arrays, and objects — including the __serialize/__unserialize/
__sleep/__wakeup magic methods and r:/R: object back-references
(repeated objects rebuild as one shared instance) — byte-for-byte
compatible with PHP's wire format.

Persist Phar/PharData state into the archive across all three formats
(native PHAR, tar, zip), round-tripping across objects, processes, and
the PHP interpreter:
- global metadata and stub via setMetadata()/getMetadata()/hasMetadata()/
  delMetadata() and setStub()/getStub()
- PharFileInfo per-file metadata on $phar["entry"]
- whole-archive tar compression for PharData::compress()/decompress()
  (sibling .tar.gz/.tar.bz2)
- signatures for native PHAR, tar, and zip, including Phar::OPENSSL
  RSA-SHA1 signing with a PEM private key (verifiable by PHP), alongside
  MD5/SHA1/SHA256/SHA512; tar/zip use a .phar/signature.bin control entry

Complete the ZIP phar surface: read entries written with a streaming
data descriptor, read and write ZIP64 archives, and read/write
traditional-PKWARE (ZipCrypto) encrypted entries via the setZipPassword()
compiler extension (entries incl. the stub encrypted, signature.bin in
the clear; cipher kept only for legacy compatibility).

EIR/checker correctness fixes surfaced along the way: dispatch synthetic
SPL methods reached through a mixed receiver or an object-iterator
foreach value, decompress compress.zlib:// and compress.bzip2:// fopen
wrappers on the EIR backend, infer mixed-receiver method calls as the
union of candidate return types instead of int, preserve callee-saved
r12-r15 in the x86 __rt_array_grow runtime helper, and route in-dir
codegen fixtures through the EIR backend.
… key reuse

Follow-up correctness fixes to the foreach Mixed-key array-write path:

- __rt_array_set_mixed_key promotes the destination to a hash when an integer
  key is negative or past the logical end, instead of the packed indexed path
  dropping negative writes or zero-filling gaps. Sparse and negative integer
  keys from a rebuild now survive like PHP. Dual-arch (arm64 + x86_64).

- Reset foreach_key_locals per function in with_local_storage_context so a
  foreach key name no longer leaks its boxed-Mixed-key classification into
  other functions that reuse the name as a genuine string key (which
  previously miscompiled the later direct string-key read).

- Drop the foreach-key marker on a direct reassignment ($k = ...) so a later
  $dst[$k] is routed by $k's real type, matching the lowering and fixing a
  spurious "AssocArray -> Array(Mixed)" backend error.

Adds 4 regression tests (sparse int, negative int, cross-function name reuse,
key reassigned to string). Verified on macOS-aarch64, linux-x86_64, linux-arm64.
…h-key-write

fix(eir): keep foreach string keys through Mixed-key array writes
An enum used as a class property type or a promoted-constructor-param type
(`private Tag $tag`) failed with "Unknown type: Tag", even though the same
enum resolves fine as a value and as a function-parameter type.

Class member types are resolved during the class schema pass, which runs
before the enum-processing phase populates `enums`. Class and interface
names are pre-declared into `declared_classes` before that pass, but enum
names were not, so `resolve_type_expr` fell through to "Unknown type".

Pre-declare enum names into `declared_classes` after the final assignment
(the earlier one is overwritten by the builtin-injection block), mirroring
the insert already done later in `schema::enums`.

Adds a codegen regression test for an enum as a promoted-constructor-param type.
Parameterize __rt_json_ftoa with the exponent marker char (w0/dil). serialize()
now passes 'E' so exponential floats render as d:1.0E+20; like PHP, while
json_encode keeps the lowercase 'e' JSON layout. Add regression tests for both.
…ta-persist

feat(streams|zip): Phar metadata/stub + per-file metadata persistence, serialize()/unserialize(), and full ZIP phar support
…ndir, fd reuse)

- elephc_crypto_final finalizes a clone, leaving the HashContext owned by its
  Mixed box so the kind-2 destructor frees it exactly once (no double-free / UAF
  on explicit-final-then-scope-exit or double-final)
- popen pipes (kind 3 -> __rt_pclose, reaps child) and opendir streams
  (kind 4 -> __rt_closedir) get proper scope-cleanup destructors
- explicit fclose/pclose/closedir stamp a -1 sentinel into the Mixed box so an
  already-released descriptor (whose fd may be reused) is never closed twice
- refresh crypto/runtime docstrings, memory-model/runtime docs, ROADMAP
- add regression tests for the above
Run scripts/docs/extract_builtins.py --render --force so the generated internals
pages track the io.rs line shifts from the new scope-cleanup helpers. Only
codegen_line link refs change.
…ope-cleanup

fix(core): resource scope-cleanup for Mixed-boxed tag-9 resources
Prepare runtime helpers for macOS per-symbol dead stripping, where
.subsections_via_symbols makes the Mach-O assembler reject any conditional
branch whose target lies in another atom (another helper).

- Rewrite cross-helper conditional branches (feof, fread, fwrite, fd_write,
  json_encode_array_int, buffer_len) as an inverted conditional skip over an
  unconditional branch, which may cross atoms.
- Make the generator done/epilogue labels local instead of global so each
  generator helper stays one atom; this also fixes a latent cross-atom
  fall-through from __rt_gen_send_done into __rt_gen_send_epilogue.

No behavior change: the generated code is equivalent on current builds.
Emit the macOS executable runtime object with .alt_entry internal labels and a
.subsections_via_symbols footer so each __rt_* helper is a single collectable
atom, then link with -dead_strip. Unreferenced runtime helpers are dropped from
the binary, the macOS analogue of the Linux per-section --gc-sections path.
cdylibs (pic) keep the full runtime.

The emitter only marks named identifier labels .alt_entry; numeric (1:/2:) and
L-prefixed labels are already assembler-local on Mach-O.

Add a guard test that assembles the full all-features runtime under dead
stripping so any new cross-atom conditional branch fails at build time.
Add a linking-page section describing the automatic, per-target runtime dead
stripping (Linux --gc-sections, macOS -dead_strip) and a CHANGELOG entry for
the completed feature.
Conflict resolution:
- generators/mod.rs: took main's stackful-coroutine generators (issue illegalstudio#329),
  which moved the helpers into the `coro` submodule and made my old-generator
  label changes obsolete. The dead-strip assemble guard confirms the new
  coroutine helpers are already single-atom safe (no cross-atom conditional
  branches), so no re-port was needed.
- CHANGELOG.md: kept both [Unreleased] sets — the dead-stripping entry plus
  main's new entries.

Re-validated on the merged tree: full all-features runtime assembles under
dead stripping, and dead_strip/generators/io/buffer/json tests pass.
The prior macOS approach marked every internal runtime label `.alt_entry`.
Older `as` (the CI Xcode) rejects conditional branches to `.alt_entry` labels
as "external", failing all macOS jobs. Rename internal labels to Mach-O
assembler-local `L`-locals instead: valid conditional-branch targets on every
toolchain that still do not start an atom under `.subsections_via_symbols`.

The few helpers reached by an unconditional `b`/`bl` from another atom
(`__rt_mixed_numeric_common`, `__rt_json_validate_string`/`_number`,
`__rt_date_entry`) must stay real symbols so `-dead_strip` keeps their atom
alive; emit those via the new `label_shared` (`.alt_entry`), which only
unconditional branches ever target. Add a guard test asserting no internal
`L__rt_*` label is referenced across atoms, the failure that segfaulted
`foreach` over an associative array.
…d-stripping

feat(core): runtime dead stripping via per-symbol sections and linker GC
# Conflicts:
#	docs/internals/builtins/_internal/__elephc_phar_list_entries.md
#	docs/internals/builtins/_internal/__elephc_phar_set_compression.md
#	docs/internals/builtins/array/count.md
#	docs/internals/builtins/class/function_exists.md
#	docs/internals/builtins/math/pi.md
#	docs/internals/builtins/misc/define.md
#	docs/internals/builtins/misc/defined.md
#	docs/internals/builtins/misc/empty.md
#	docs/internals/builtins/misc/phpversion.md
#	docs/internals/builtins/misc/unset.md
#	docs/internals/builtins/misc/var_dump.md
#	docs/internals/builtins/pointer/ptr.md
#	docs/internals/builtins/pointer/ptr_get.md
#	docs/internals/builtins/pointer/ptr_is_null.md
#	docs/internals/builtins/pointer/ptr_null.md
#	docs/internals/builtins/pointer/ptr_offset.md
#	docs/internals/builtins/pointer/ptr_read16.md
#	docs/internals/builtins/pointer/ptr_read32.md
#	docs/internals/builtins/pointer/ptr_read8.md
#	docs/internals/builtins/pointer/ptr_read_string.md
#	docs/internals/builtins/pointer/ptr_set.md
#	docs/internals/builtins/pointer/ptr_sizeof.md
#	docs/internals/builtins/pointer/ptr_write16.md
#	docs/internals/builtins/pointer/ptr_write32.md
#	docs/internals/builtins/pointer/ptr_write8.md
#	docs/internals/builtins/pointer/ptr_write_string.md
#	docs/internals/builtins/process/die.md
#	docs/internals/builtins/process/exec.md
#	docs/internals/builtins/process/exit.md
#	docs/internals/builtins/process/passthru.md
#	docs/internals/builtins/process/pclose.md
#	docs/internals/builtins/process/popen.md
#	docs/internals/builtins/process/readline.md
#	docs/internals/builtins/process/shell_exec.md
#	docs/internals/builtins/process/sleep.md
#	docs/internals/builtins/process/system.md
#	docs/internals/builtins/process/usleep.md
#	docs/internals/builtins/regex/preg_match.md
#	docs/internals/builtins/regex/preg_match_all.md
#	docs/internals/builtins/regex/preg_replace.md
#	docs/internals/builtins/regex/preg_replace_callback.md
#	docs/internals/builtins/regex/preg_split.md
#	docs/internals/builtins/spl/iterator_apply.md
#	docs/internals/builtins/spl/iterator_count.md
#	docs/internals/builtins/spl/iterator_to_array.md
#	docs/internals/builtins/spl/spl_autoload.md
#	docs/internals/builtins/spl/spl_autoload_call.md
#	docs/internals/builtins/spl/spl_autoload_extensions.md
#	docs/internals/builtins/spl/spl_autoload_functions.md
#	docs/internals/builtins/spl/spl_autoload_register.md
#	docs/internals/builtins/spl/spl_autoload_unregister.md
#	docs/internals/builtins/spl/spl_classes.md
#	docs/internals/builtins/spl/spl_object_hash.md
#	docs/internals/builtins/spl/spl_object_id.md
#	docs/internals/builtins/streams/fsockopen.md
#	docs/internals/builtins/streams/pfsockopen.md
#	docs/internals/builtins/streams/stream_bucket_append.md
#	docs/internals/builtins/streams/stream_bucket_prepend.md
#	docs/internals/builtins/streams/stream_filter_append.md
#	docs/internals/builtins/streams/stream_filter_prepend.md
#	docs/internals/builtins/string/addslashes.md
#	docs/internals/builtins/string/base64_decode.md
#	docs/internals/builtins/string/base64_encode.md
#	docs/internals/builtins/string/bin2hex.md
#	docs/internals/builtins/string/chop.md
#	docs/internals/builtins/string/chr.md
#	docs/internals/builtins/string/crc32.md
#	docs/internals/builtins/string/explode.md
#	docs/internals/builtins/string/grapheme_strrev.md
#	docs/internals/builtins/string/gzcompress.md
#	docs/internals/builtins/string/gzdeflate.md
#	docs/internals/builtins/string/gzinflate.md
#	docs/internals/builtins/string/gzuncompress.md
#	docs/internals/builtins/string/hash.md
#	docs/internals/builtins/string/hash_algos.md
#	docs/internals/builtins/string/hash_copy.md
#	docs/internals/builtins/string/hash_equals.md
#	docs/internals/builtins/string/hash_final.md
#	docs/internals/builtins/string/hash_hmac.md
#	docs/internals/builtins/string/hash_init.md
#	docs/internals/builtins/string/hash_update.md
#	docs/internals/builtins/string/hex2bin.md
#	docs/internals/builtins/string/html_entity_decode.md
#	docs/internals/builtins/string/htmlentities.md
#	docs/internals/builtins/string/htmlspecialchars.md
#	docs/internals/builtins/string/implode.md
#	docs/internals/builtins/string/inet_ntop.md
#	docs/internals/builtins/string/inet_pton.md
#	docs/internals/builtins/string/ip2long.md
#	docs/internals/builtins/string/lcfirst.md
#	docs/internals/builtins/string/long2ip.md
#	docs/internals/builtins/string/ltrim.md
#	docs/internals/builtins/string/md5.md
#	docs/internals/builtins/string/nl2br.md
#	docs/internals/builtins/string/number_format.md
#	docs/internals/builtins/string/ord.md
#	docs/internals/builtins/string/printf.md
#	docs/internals/builtins/string/rawurldecode.md
#	docs/internals/builtins/string/rawurlencode.md
#	docs/internals/builtins/string/rtrim.md
#	docs/internals/builtins/string/sha1.md
#	docs/internals/builtins/string/sprintf.md
#	docs/internals/builtins/string/sscanf.md
#	docs/internals/builtins/string/str_contains.md
#	docs/internals/builtins/string/str_ends_with.md
#	docs/internals/builtins/string/str_ireplace.md
#	docs/internals/builtins/string/str_pad.md
#	docs/internals/builtins/string/str_repeat.md
#	docs/internals/builtins/string/str_replace.md
#	docs/internals/builtins/string/str_split.md
#	docs/internals/builtins/string/str_starts_with.md
#	docs/internals/builtins/string/strcasecmp.md
#	docs/internals/builtins/string/strcmp.md
#	docs/internals/builtins/string/stripslashes.md
#	docs/internals/builtins/string/strlen.md
#	docs/internals/builtins/string/strpos.md
#	docs/internals/builtins/string/strrev.md
#	docs/internals/builtins/string/strrpos.md
#	docs/internals/builtins/string/strstr.md
#	docs/internals/builtins/string/strtolower.md
#	docs/internals/builtins/string/strtoupper.md
#	docs/internals/builtins/string/substr.md
#	docs/internals/builtins/string/substr_replace.md
#	docs/internals/builtins/string/trim.md
#	docs/internals/builtins/string/ucfirst.md
#	docs/internals/builtins/string/ucwords.md
#	docs/internals/builtins/string/urldecode.md
#	docs/internals/builtins/string/urlencode.md
#	docs/internals/builtins/string/vprintf.md
#	docs/internals/builtins/string/vsprintf.md
#	docs/internals/builtins/string/wordwrap.md
#	docs/internals/builtins/type/boolval.md
#	docs/internals/builtins/type/ctype_alnum.md
#	docs/internals/builtins/type/ctype_alpha.md
#	docs/internals/builtins/type/ctype_digit.md
#	docs/internals/builtins/type/ctype_space.md
#	docs/internals/builtins/type/floatval.md
#	docs/internals/builtins/type/get_resource_id.md
#	docs/internals/builtins/type/get_resource_type.md
#	docs/internals/builtins/type/gettype.md
#	docs/internals/builtins/type/intval.md
#	docs/internals/builtins/type/is_array.md
#	docs/internals/builtins/type/is_bool.md
#	docs/internals/builtins/type/is_callable.md
#	docs/internals/builtins/type/is_float.md
#	docs/internals/builtins/type/is_int.md
#	docs/internals/builtins/type/is_iterable.md
#	docs/internals/builtins/type/is_null.md
#	docs/internals/builtins/type/is_numeric.md
#	docs/internals/builtins/type/is_object.md
#	docs/internals/builtins/type/is_resource.md
#	docs/internals/builtins/type/is_scalar.md
#	docs/internals/builtins/type/is_string.md
#	docs/internals/builtins/type/settype.md
#	docs/php/builtins/misc/unset.md
#	docs/php/builtins/misc/var_dump.md
#	docs/php/builtins/pointer/ptr.md
#	docs/php/builtins/pointer/ptr_get.md
#	docs/php/builtins/pointer/ptr_is_null.md
#	docs/php/builtins/pointer/ptr_null.md
#	docs/php/builtins/pointer/ptr_offset.md
#	docs/php/builtins/pointer/ptr_read16.md
#	docs/php/builtins/pointer/ptr_read32.md
#	docs/php/builtins/pointer/ptr_read8.md
#	docs/php/builtins/pointer/ptr_read_string.md
#	docs/php/builtins/pointer/ptr_set.md
#	docs/php/builtins/pointer/ptr_sizeof.md
#	docs/php/builtins/pointer/ptr_write16.md
#	docs/php/builtins/pointer/ptr_write32.md
#	docs/php/builtins/pointer/ptr_write8.md
#	docs/php/builtins/pointer/ptr_write_string.md
#	docs/php/builtins/process/die.md
#	docs/php/builtins/process/exec.md
#	docs/php/builtins/process/exit.md
#	docs/php/builtins/process/passthru.md
#	docs/php/builtins/process/pclose.md
#	docs/php/builtins/process/popen.md
#	docs/php/builtins/process/readline.md
#	docs/php/builtins/process/shell_exec.md
#	docs/php/builtins/process/sleep.md
#	docs/php/builtins/process/system.md
#	docs/php/builtins/process/usleep.md
#	docs/php/builtins/regex/preg_match.md
#	docs/php/builtins/regex/preg_match_all.md
#	docs/php/builtins/regex/preg_replace.md
#	docs/php/builtins/regex/preg_replace_callback.md
#	docs/php/builtins/regex/preg_split.md
#	docs/php/builtins/spl/iterator_apply.md
#	docs/php/builtins/spl/iterator_count.md
#	docs/php/builtins/spl/iterator_to_array.md
#	docs/php/builtins/spl/spl_autoload.md
#	docs/php/builtins/spl/spl_autoload_call.md
#	docs/php/builtins/spl/spl_autoload_extensions.md
#	docs/php/builtins/spl/spl_autoload_functions.md
#	docs/php/builtins/spl/spl_autoload_register.md
#	docs/php/builtins/spl/spl_autoload_unregister.md
#	docs/php/builtins/spl/spl_classes.md
#	docs/php/builtins/spl/spl_object_hash.md
#	docs/php/builtins/spl/spl_object_id.md
#	docs/php/builtins/streams/fsockopen.md
#	docs/php/builtins/streams/pfsockopen.md
#	docs/php/builtins/streams/stream_bucket_append.md
#	docs/php/builtins/streams/stream_bucket_prepend.md
#	docs/php/builtins/streams/stream_filter_append.md
#	docs/php/builtins/streams/stream_filter_prepend.md
#	docs/php/builtins/string/addslashes.md
#	docs/php/builtins/string/base64_decode.md
#	docs/php/builtins/string/base64_encode.md
#	docs/php/builtins/string/bin2hex.md
#	docs/php/builtins/string/chop.md
#	docs/php/builtins/string/chr.md
#	docs/php/builtins/string/crc32.md
#	docs/php/builtins/string/explode.md
#	docs/php/builtins/string/grapheme_strrev.md
#	docs/php/builtins/string/gzcompress.md
#	docs/php/builtins/string/gzdeflate.md
#	docs/php/builtins/string/gzinflate.md
#	docs/php/builtins/string/gzuncompress.md
#	docs/php/builtins/string/hash.md
#	docs/php/builtins/string/hash_algos.md
#	docs/php/builtins/string/hash_copy.md
#	docs/php/builtins/string/hash_equals.md
#	docs/php/builtins/string/hash_final.md
#	docs/php/builtins/string/hash_hmac.md
#	docs/php/builtins/string/hash_init.md
#	docs/php/builtins/string/hash_update.md
#	docs/php/builtins/string/hex2bin.md
#	docs/php/builtins/string/html_entity_decode.md
#	docs/php/builtins/string/htmlentities.md
#	docs/php/builtins/string/htmlspecialchars.md
#	docs/php/builtins/string/implode.md
#	docs/php/builtins/string/inet_ntop.md
#	docs/php/builtins/string/inet_pton.md
#	docs/php/builtins/string/ip2long.md
#	docs/php/builtins/string/lcfirst.md
#	docs/php/builtins/string/long2ip.md
#	docs/php/builtins/string/ltrim.md
#	docs/php/builtins/string/md5.md
#	docs/php/builtins/string/nl2br.md
#	docs/php/builtins/string/number_format.md
#	docs/php/builtins/string/ord.md
#	docs/php/builtins/string/printf.md
#	docs/php/builtins/string/rawurldecode.md
#	docs/php/builtins/string/rawurlencode.md
#	docs/php/builtins/string/rtrim.md
#	docs/php/builtins/string/sha1.md
#	docs/php/builtins/string/sprintf.md
#	docs/php/builtins/string/sscanf.md
#	docs/php/builtins/string/str_contains.md
#	docs/php/builtins/string/str_ends_with.md
#	docs/php/builtins/string/str_ireplace.md
#	docs/php/builtins/string/str_pad.md
#	docs/php/builtins/string/str_repeat.md
#	docs/php/builtins/string/str_replace.md
#	docs/php/builtins/string/str_split.md
#	docs/php/builtins/string/str_starts_with.md
#	docs/php/builtins/string/strcasecmp.md
#	docs/php/builtins/string/strcmp.md
#	docs/php/builtins/string/stripslashes.md
#	docs/php/builtins/string/strlen.md
#	docs/php/builtins/string/strpos.md
#	docs/php/builtins/string/strrev.md
#	docs/php/builtins/string/strrpos.md
#	docs/php/builtins/string/strstr.md
#	docs/php/builtins/string/strtolower.md
#	docs/php/builtins/string/strtoupper.md
#	docs/php/builtins/string/substr.md
#	docs/php/builtins/string/substr_replace.md
#	docs/php/builtins/string/trim.md
#	docs/php/builtins/string/ucfirst.md
#	docs/php/builtins/string/ucwords.md
#	docs/php/builtins/string/urldecode.md
#	docs/php/builtins/string/urlencode.md
#	docs/php/builtins/string/vprintf.md
#	docs/php/builtins/string/vsprintf.md
#	docs/php/builtins/string/wordwrap.md
#	docs/php/builtins/type/boolval.md
#	docs/php/builtins/type/ctype_alnum.md
#	docs/php/builtins/type/ctype_alpha.md
#	docs/php/builtins/type/ctype_digit.md
#	docs/php/builtins/type/ctype_space.md
#	docs/php/builtins/type/floatval.md
#	docs/php/builtins/type/get_resource_id.md
#	docs/php/builtins/type/get_resource_type.md
#	docs/php/builtins/type/gettype.md
#	docs/php/builtins/type/intval.md
#	docs/php/builtins/type/is_array.md
#	docs/php/builtins/type/is_bool.md
#	docs/php/builtins/type/is_callable.md
#	docs/php/builtins/type/is_float.md
#	docs/php/builtins/type/is_int.md
#	docs/php/builtins/type/is_iterable.md
#	docs/php/builtins/type/is_null.md
#	docs/php/builtins/type/is_numeric.md
#	docs/php/builtins/type/is_object.md
#	docs/php/builtins/type/is_resource.md
#	docs/php/builtins/type/is_scalar.md
#	docs/php/builtins/type/is_string.md
#	docs/php/builtins/type/settype.md
#	scripts/docs/builtin_registry.json
#	tests/codegen/arrays/mod.rs
The hash-descent path stashed the iterator value low word at [rbp-24],
which aliases the pushed callee-saved r14 (callback environment). Walking
an associative array therefore corrupted the env register, crashing the
generated binary on Linux x86_64. Move the scratch to the hash-path-unused
length slot [rbp-40]. ARM64 was unaffected (keeps the value in a register).
nahime0 and others added 21 commits July 5, 2026 13:13
fix(arrays): string-key access on empty arrays, null key normalization, destructuring OOB, array spread
fix(quick-wins): static closure isset($this), recursive closures, foreach snapshot, typed property Error
fix: IteratorAggregate Traversable dispatch, array elem by-ref, catchable access Errors
…ructor-parentheses

# Conflicts:
#	tests/parser_tests/classes/declarations.rs
…ut-constructor-parentheses

fix: allow new without constructor parentheses (see illegalstudio#442)
…es builtin

Reconstruct PR illegalstudio#446's feature set on top of current main.

Windows x86_64 PE target:
- Platform::Windows with the MSx64 ABI (rcx/rdx/r8/r9, 32-byte shadow, 16-byte
  alignment) and ~40 Win32 shim wrappers converting SysV->MSx64 (WriteFile/ReadFile,
  sockets, file/dir ops, VirtualAlloc/Heap, BCryptGenRandom, ...).
- Syscall->shim transform wired into the pipeline + runtime cache (Windows-only) so
  PE binaries contain no raw Linux syscalls; unmapped syscalls route to
  __rt_unsupported_syscall instead of a silent trap.
- Entry wrapper (MSx64->SysV), MinGW-w64 assembler/linker, kernel32/msvcrt/winmm/
  ws2_32/bcrypt/shlwapi link set. --target windows-x86_64 (alias
  x86_64-pc-windows-gnu); .exe / .dll outputs. CI windows-pe-cross-compile job
  (MinGW + Wine) wired into the gate.

Fix the implicit end-of-main exit code on Windows: emit_exit and
emit_exit_with_result_reg now load the code into edi and call the __rt_sys_exit
(ExitProcess) shim directly, identical to an explicit exit($n), instead of returning
through the MinGW CRT. The CRT's exit() reaches the same rdi-consuming shim, so the
old return path left rdi holding leftover data and exited with a garbage nonzero code.

random_bytes(int $length): string:
- Cryptographically secure random byte string on every supported target
  (arc4random_buf / getrandom / BCryptGenRandom), fatal on entropy failure or a
  constant length below 1.
- Expressed as a single-source registry home file (src/builtins/math/random_bytes.rs)
  instead of the legacy hand-maintained tables; excluded from the legacy
  runtime-callable wrapper as an EIR-only builtin. Builtin docs + registry JSON
  regenerated.
Lock the currently-passing windows-x86_64 codegen tests so parity can only
improve, never regress, while the full run stays informational.

- Add allow-list (3120 known-good tests) and companion known-failures list
  (1874) under tests/codegen/support/, derived from the ci-profile runnable
  set minus the measured Windows-under-wine failures.
- Add scripts/gen_windows_codegen_allowlist.py: `generate` (rebuild both
  lists deterministically from a nextest list JSON + JUnit/plain failures)
  and `gate` (fail iff actual_failures ∩ allow_list is non-empty; emit
  passed/failed/parity% to the job summary).
- Emit JUnit from the `ci` nextest profile (additive; other jobs unaffected).
- Unify the windows-codegen-parity job to both measure and gate per shard,
  and add an aggregating windows-codegen-gate job wired into the `test` gate.
- Document the gate and allow-list refresh flow in docs/compiling/targets.md.
@Guikingone

Copy link
Copy Markdown
Owner Author

Folded into upstream PR illegalstudio#446 (head c60d605). This throwaway PR existed only to trigger CI on the measurement branch.

@Guikingone Guikingone closed this Jul 6, 2026
Guikingone added a commit that referenced this pull request Jul 27, 2026
…x what auditing it against PHP 8.5.6 uncovered

Emulates the observable Zend OPcache API on top of elephc's AOT model, then
fixes the PHP-compliance gaps and general compiler bugs that auditing that
surface against reference PHP 8.5.6 uncovered.

elephc has no opcode cache to introspect, so there is nothing to report. But a
script calling `opcache_get_status()` or reading `ini_get('opcache.enable')`
should not fatal, and should not be lied to either. The binary IS the cache:
every script was compiled ahead of time, so the manifest of compiled scripts is
the honest projection of "what is cached". The six `opcache_*` functions, the
54-entry `opcache.*` INI surface and `opcache_get_configuration()` are answered
from that manifest; the parts an AOT build genuinely cannot have
(`opcache_invalidate()` doing real work, JIT counters, restart statistics) are
documented as such rather than faked.

OPcache surface
- `opcache_get_status`, `opcache_get_configuration`, `opcache_reset`,
  `opcache_invalidate`, `opcache_compile_file`, `opcache_is_script_cached` over
  a compile-time script manifest.
- The full 8.2-8.5 `opcache.*` directive matrix as a single source of truth
  (`src/opcache/directives.rs`): raw INI projection, per-directive access class,
  runtime overridability, the JIT CRTO parser, prime table for `max_cached_keys`.
- Compile-time overrides via `--ini KEY=VALUE` and `ELEPHC_INI_*`.
- `ini_get` / `ini_set` / `ini_get_all` in CLI and under `--web`;
  `extension_loaded()` / `get_loaded_extensions()` report bridge-linked
  extensions.
- `docs/php/opcache.md` carries a 24-row limitations table.

PHP-compliance gaps found by auditing that surface (~150 differential probes)
- `var_dump()` recurses into nested arrays AND renders object bodies; it
  previously printed every nested array as `NULL` and every object as a bare
  `object(C)`. Closes illegalstudio#388.
- `function_exists()` accepts a dynamic argument.
- `foreach` over a non-iterable warns and continues instead of failing.
- The version surface reports PHP's version, not the compiler's.
- `implode()` renders bools PHP-style; `array_keys()` accepts mixed.
- The built-in `Error` hierarchy is complete; `intdiv()` by zero throws
  `DivisionByZeroError`.
- `array_flip()` and `array_map()` accept associative sources, through two new
  hash-walking runtime helpers (`__rt_hash_flip`, `__rt_hash_map`), both
  backends.
- NAN coerced to bool emits PHP 8.5's `unexpected NAN value was coerced to bool`
  warning, gated on `--php-version` and silent for INF/-INF/0.0/-0.0.
- Objects carry a real PHP object handle: `var_dump` prints `object(C)#N`,
  `spl_object_id()` returns that same small integer instead of a heap address,
  and `spl_object_hash()` its 32-char form. Handles are minted from a direct-
  mapped side table (one `u32` per 16-byte heap granule, so the mapping is exact
  with no hashing and no capacity failure) and RE-USED LIFO on free, matching
  php-src. Closures, arrow functions, first-class callables and generators
  consume a handle exactly as they do in PHP -- which required allocating a
  runtime descriptor for capture-free closures that previously collapsed to a
  static address, since a handle needs storage whose lifetime can carry it.
  Without that, `$f = function(){}; new P()` would print `#1` where PHP prints
  `#2`.

General compiler bugs the audit surfaced
- Static-local string use-after-free. The retain was missing on a string stored
  into a `static` local. Ownership split, now documented on site and pinned by
  an invariant test in `src/ir/value.rs`: ir_lower owns the RETAIN (the backend
  incref skips `Str`, which `is_refcounted()` does not list), the BACKEND owns
  the RELEASE of what it overwrites. A first attempt added a second release and
  produced a double free -- see the caveat below.
- Float element misses had no null sentinel, so a missed lookup returned a
  plausible float instead of null: a silently wrong value. `??` also collapsed
  the hit arm's type onto the default's.
- `var_export($x, true)` stopped returning a string; `strstr()` never returned
  `false` (it returned `""`). Contributes to illegalstudio#398.
- Recursive free functions with a non-`int` return hint did not compile. The
  provisional signature published before the body walk hardcoded
  `PhpType::Int`, so a self-call typed as `Int`. The placeholder was ALWAYS
  `Int` -- recursive `int`/`float`/`bool` functions agreed with it by accident,
  which is why it survived. It was the type of the self-call EXPRESSION, so it
  also broke argument checking and leaked to outside callers; mutual recursion
  and recursive generators failed too. Methods were never affected because the
  class-schema pass already seeds the declared type.
- `print_r($v, true)` leaked its result string, and `array_flip` leaked its
  source when the argument was an owned temporary. Both were result-ownership
  misclassifications: `PrintR` and `ArrayFlip` sat in the default
  `MayAliasArguments` bucket while every lowering allocates fresh storage.
- Every call through the uniform callable invoker returning a string leaked one
  block. The return was persisted once (OWNED) and then boxed with the BORROWED
  contract, whose `__rt_mixed_from_value` persists a second copy, orphaning the
  first. Fixed by boxing through the owning path: no release was added, an
  allocation was removed. This surfaced as an `array_map` leak, but reproduces
  with no `array_map` in the program -- closures reach the invoker because
  `clear_static_callable_locals()` drops the direct-call binding inside loops.

Verification

Every expected value was taken from reference PHP 8.5.6 run with
`-d xdebug.mode=off` (Xdebug overloads `var_dump`, which silently invalidates
any probe of it). Each fix was sentinel-audited: the fix is neutralised, the
build repeated, and the guarding tests must fail. Two tests that did NOT move
under their sentinel are called out in the PR description rather than presented
as proof.

Known divergences, each pinned by a test rather than left to look correct
- Enum cases are materialised eagerly at program start, PHP creates them lazily
  on first access, so a program that touches an enum shifts every later handle.
- The AOT eval bridge re-mints the handle of an object that outlives an `eval()`.
- PHP 8's `hash_init()` returns a `HashContext` object and so consumes a handle;
  elephc's hash state is not an object.
- `var_dump` of a `stdClass` omits dynamic-property bodies (pre-existing; the
  `#N` is correct).
- Runtime warnings carry no ` in <file> on line <n>` tail, matching the existing
  house convention.

Left unfixed, with reasoning
- Two residual uniform-invoker leaks: a string ARGUMENT passed into a
  descriptor-invoked callable, and a freshly allocated string temporary
  concatenated inside one. Both are one block per call and both would require
  ADDING a free on a path shared by every callable call, whose safety depends on
  no callee ever retaining a borrowed string parameter -- which could not be
  bounded here, and which macOS would hide (see the double-free note above).
- `return array_map(...)` from a function declared `: array` returns garbage or
  exhausts the heap. Verified to behave identically with the invoker fix
  neutralised, so it is pre-existing and not caused by this work.

Closes illegalstudio#388.
Guikingone added a commit that referenced this pull request Jul 27, 2026
…x what auditing it against PHP 8.5.6 uncovered

Emulates the observable Zend OPcache API on top of elephc's AOT model, then
fixes the PHP-compliance gaps and general compiler bugs that auditing that
surface against reference PHP 8.5.6 uncovered.

elephc has no opcode cache to introspect, so there is nothing to report. But a
script calling `opcache_get_status()` or reading `ini_get('opcache.enable')`
should not fatal, and should not be lied to either. The binary IS the cache:
every script was compiled ahead of time, so the manifest of compiled scripts is
the honest projection of "what is cached". The six `opcache_*` functions, the
54-entry `opcache.*` INI surface and `opcache_get_configuration()` are answered
from that manifest; the parts an AOT build genuinely cannot have
(`opcache_invalidate()` doing real work, JIT counters, restart statistics) are
documented as such rather than faked.

OPcache surface
- `opcache_get_status`, `opcache_get_configuration`, `opcache_reset`,
  `opcache_invalidate`, `opcache_compile_file`, `opcache_is_script_cached` over
  a compile-time script manifest.
- The full 8.2-8.5 `opcache.*` directive matrix as a single source of truth
  (`src/opcache/directives.rs`): raw INI projection, per-directive access class,
  runtime overridability, the JIT CRTO parser, prime table for `max_cached_keys`.
- Compile-time overrides via `--ini KEY=VALUE` and `ELEPHC_INI_*`.
- `ini_get` / `ini_set` / `ini_get_all` in CLI and under `--web`;
  `extension_loaded()` / `get_loaded_extensions()` report bridge-linked
  extensions.
- `docs/php/opcache.md` carries a 24-row limitations table.

PHP-compliance gaps found by auditing that surface (~150 differential probes)
- `var_dump()` recurses into nested arrays AND renders object bodies; it
  previously printed every nested array as `NULL` and every object as a bare
  `object(C)`. Closes illegalstudio#388.
- `function_exists()` accepts a dynamic argument.
- `foreach` over a non-iterable warns and continues instead of failing.
- The version surface reports PHP's version, not the compiler's.
- `implode()` renders bools PHP-style; `array_keys()` accepts mixed.
- The built-in `Error` hierarchy is complete; `intdiv()` by zero throws
  `DivisionByZeroError`.
- `array_flip()` and `array_map()` accept associative sources, through two new
  hash-walking runtime helpers (`__rt_hash_flip`, `__rt_hash_map`), both
  backends.
- NAN coerced to bool emits PHP 8.5's `unexpected NAN value was coerced to bool`
  warning, gated on `--php-version` and silent for INF/-INF/0.0/-0.0.
- Objects carry a real PHP object handle: `var_dump` prints `object(C)#N`,
  `spl_object_id()` returns that same small integer instead of a heap address,
  and `spl_object_hash()` its 32-char form. Handles are minted from a direct-
  mapped side table (one `u32` per 16-byte heap granule, so the mapping is exact
  with no hashing and no capacity failure) and RE-USED LIFO on free, matching
  php-src. Closures, arrow functions, first-class callables and generators
  consume a handle exactly as they do in PHP -- which required allocating a
  runtime descriptor for capture-free closures that previously collapsed to a
  static address, since a handle needs storage whose lifetime can carry it.
  Without that, `$f = function(){}; new P()` would print `#1` where PHP prints
  `#2`.
- Enum cases are materialised on FIRST ACCESS instead of in `main`'s prologue,
  so a program that touches an enum no longer burns a handle per case of every
  referenced enum. Reproduces PHP's two rules: a pure enum materialises only the
  case touched, a BACKED enum materialises all of them in declaration order on
  the first touch of any one. Fixing this also fixed a use-after-free that eager
  creation was hiding: `E::A` handed out the singleton with no incref, so a case
  passed into a typed parameter was freed while its slot still pointed at it.
- Resource ids are minted from a counter instead of being derived from the
  payload pointer. Every display path did `payload + 1`, which is fine for a file
  descriptor and catastrophic for a heap address: `var_dump(hash_init('md5'))`
  printed `resource(4318862849)`, nondeterministic across runs and leaking an
  address. Resource ids stay a SEPARATE numbering space from object handles, as
  in php-src, and are not re-used after close.
- `hash_init()` returns a real `HashContext` OBJECT, as in PHP 8, instead of a
  stream-typed resource: `is_object`, `get_class`, `instanceof`, a typed
  parameter and `var_dump` all match reference. It lives in an injected prelude
  because `hash` is bridge-gated, so non-hashing programs neither declare the
  class nor link the bridge -- the same shape `GdImage` uses for the same PHP 8
  resource-to-object migration. `var_dump` matches because `__debugInfo()` is now
  folded into the class descriptor at COMPILE time for the projection shape
  (`return ['k' => $this->prop]`); a dynamic `__debugInfo` would have meant new
  hand-written assembly on both arches inside the var_dump walker, which is
  exactly where this branch already shipped a double free.
- Using a `HashContext` after `hash_final()` raises PHP's exact `TypeError` for
  `hash_update` / `hash_final` / `hash_copy` instead of silently continuing to
  hash and returning a plausible wrong digest.

General compiler bugs the audit surfaced
- Static-local string use-after-free. The retain was missing on a string stored
  into a `static` local. Ownership split, now documented on site and pinned by
  an invariant test in `src/ir/value.rs`: ir_lower owns the RETAIN (the backend
  incref skips `Str`, which `is_refcounted()` does not list), the BACKEND owns
  the RELEASE of what it overwrites. A first attempt added a second release and
  produced a double free -- see the caveat below.
- Float element misses had no null sentinel, so a missed lookup returned a
  plausible float instead of null: a silently wrong value. `??` also collapsed
  the hit arm's type onto the default's.
- `var_export($x, true)` stopped returning a string; `strstr()` never returned
  `false` (it returned `""`). Contributes to illegalstudio#398.
- Recursive free functions with a non-`int` return hint did not compile. The
  provisional signature published before the body walk hardcoded
  `PhpType::Int`, so a self-call typed as `Int`. The placeholder was ALWAYS
  `Int` -- recursive `int`/`float`/`bool` functions agreed with it by accident,
  which is why it survived. It was the type of the self-call EXPRESSION, so it
  also broke argument checking and leaked to outside callers; mutual recursion
  and recursive generators failed too. Methods were never affected because the
  class-schema pass already seeds the declared type.
- `print_r($v, true)` leaked its result string, and `array_flip` leaked its
  source when the argument was an owned temporary. Both were result-ownership
  misclassifications: `PrintR` and `ArrayFlip` sat in the default
  `MayAliasArguments` bucket while every lowering allocates fresh storage.
- Every call through the uniform callable invoker returning a string leaked one
  block. The return was persisted once (OWNED) and then boxed with the BORROWED
  contract, whose `__rt_mixed_from_value` persists a second copy, orphaning the
  first. Fixed by boxing through the owning path: no release was added, an
  allocation was removed. This surfaced as an `array_map` leak, but reproduces
  with no `array_map` in the program -- closures reach the invoker because
  `clear_static_callable_locals()` drops the direct-call binding inside loops.

Verification

Every expected value was taken from reference PHP 8.5.6 run with
`-d xdebug.mode=off` (Xdebug overloads `var_dump`, which silently invalidates
any probe of it). Each fix was sentinel-audited: the fix is neutralised, the
build repeated, and the guarding tests must fail. Two tests that did NOT move
under their sentinel are called out in the PR description rather than presented
as proof.

Two divergences reported during this work turned out not to exist
- The "eval bridge re-mints object handles" divergence was a MISDIAGNOSIS,
  reached independently by two investigations: the shifted handle was visible on
  an object dumped BEFORE the `eval()` ever ran, and no staging behaviour can
  renumber the past. The real cause was that `eval` marked every enum reachable,
  so prelude enum cases took handles 1-4. Nothing in the eval bridge was at
  fault, and object identity across `eval()` was never broken -- `===`,
  write-through and the shared `#N` all hold, because the bridge passes the same
  heap object rather than copying it.
- `hash_init()` not consuming a handle is fixed by the `HashContext` object
  above, not by anything in the handle pool.

Known divergences, each pinned by a test rather than left to look correct
- `__debugInfo()` is honoured only for the projection shape; any other body
  falls back to the declared-property list, which is what every class did
  before, so nothing that compiled now errors.
- `serialize()` on a `HashContext` throws. PHP emits the full digest state and
  round-trips it; elephc cannot, and was emitting a plausible-but-wrong
  `O:11:"HashContext":2:{...}`, so it now refuses instead of lying.
- `var_dump` of a `stdClass` omits dynamic-property bodies (pre-existing; the
  `#N` is correct).
- Runtime warnings carry no ` in <file> on line <n>` tail, matching the existing
  house convention.

One bounded regression, measured and pinned rather than papered over
- The new post-`hash_final()` `TypeError` throws through a wrapper frame, which
  exposes a PRE-EXISTING leak of one block per builtin-thrown exception crossing
  a PHP frame. `rejected_reuse_leaves_the_heap_clean` became
  `rejected_reuse_leaks_one_block_per_rejected_call`, asserting EXACTLY 2 live
  blocks so the number cannot drift in either direction. Success paths stay
  strictly clean. Five pure-PHP workarounds were measured; none help, and
  catch-and-rethrow leaks two.

Left unfixed, with reasoning
- Two residual uniform-invoker leaks: a string ARGUMENT passed into a
  descriptor-invoked callable, and a freshly allocated string temporary
  concatenated inside one. Both are one block per call and both would require
  ADDING a free on a path shared by every callable call, whose safety depends on
  no callee ever retaining a borrowed string parameter -- which could not be
  bounded here, and which macOS would hide (see the double-free note above).
- `return array_map(...)` from a function declared `: array` returns garbage or
  exhausts the heap. Verified to behave identically with the invoker fix
  neutralised, so it is pre-existing and not caused by this work.
- Object `===` frees an operand too early: `feed($x, "abc") === $x` runs `$x`'s
  destructor DURING the comparison. A plain object survives on macOS because
  freed memory still reads back; a `HashContext` does not. Reproduced with no
  hashing involved, so pre-existing.
- One block leaks per builtin-thrown exception crossing a PHP frame -- the cause
  of the bounded regression above. Also reproduced with no hashing involved.
- `implode()` over an `AssocArray` is refused by the EIR backend, and
  `(string)$res` / `print_r($res)` return an empty string where PHP prints
  `Resource id #N`. Both pre-existing, surfaced while probing.

Closes illegalstudio#388.
Guikingone added a commit that referenced this pull request Jul 28, 2026
…x what auditing it against PHP 8.5.6 uncovered

Emulates the observable Zend OPcache API on top of elephc's AOT model, then
fixes the PHP-compliance gaps and general compiler bugs that auditing that
surface against reference PHP 8.5.6 uncovered.

elephc has no opcode cache to introspect, so there is nothing to report. But a
script calling `opcache_get_status()` or reading `ini_get('opcache.enable')`
should not fatal, and should not be lied to either. The binary IS the cache:
every script was compiled ahead of time, so the manifest of compiled scripts is
the honest projection of "what is cached". The six `opcache_*` functions, the
54-entry `opcache.*` INI surface and `opcache_get_configuration()` are answered
from that manifest; the parts an AOT build genuinely cannot have
(`opcache_invalidate()` doing real work, JIT counters, restart statistics) are
documented as such rather than faked.

OPcache surface
- `opcache_get_status`, `opcache_get_configuration`, `opcache_reset`,
  `opcache_invalidate`, `opcache_compile_file`, `opcache_is_script_cached` over
  a compile-time script manifest.
- The full 8.2-8.5 `opcache.*` directive matrix as a single source of truth
  (`src/opcache/directives.rs`): raw INI projection, per-directive access class,
  runtime overridability, the JIT CRTO parser, prime table for `max_cached_keys`.
- Compile-time overrides via `--ini KEY=VALUE` and `ELEPHC_INI_*`.
- `ini_get` / `ini_set` / `ini_get_all` in CLI and under `--web`;
  `extension_loaded()` / `get_loaded_extensions()` report bridge-linked
  extensions.
- `docs/php/opcache.md` carries a 24-row limitations table.

PHP-compliance gaps found by auditing that surface (~150 differential probes)
- `var_dump()` recurses into nested arrays AND renders object bodies; it
  previously printed every nested array as `NULL` and every object as a bare
  `object(C)`. Closes illegalstudio#388.
- `function_exists()` accepts a dynamic argument.
- `foreach` over a non-iterable warns and continues instead of failing.
- The version surface reports PHP's version, not the compiler's.
- `implode()` renders bools PHP-style; `array_keys()` accepts mixed.
- The built-in `Error` hierarchy is complete; `intdiv()` by zero throws
  `DivisionByZeroError`.
- `array_flip()` and `array_map()` accept associative sources, through two new
  hash-walking runtime helpers (`__rt_hash_flip`, `__rt_hash_map`), both
  backends.
- NAN coerced to bool emits PHP 8.5's `unexpected NAN value was coerced to bool`
  warning, gated on `--php-version` and silent for INF/-INF/0.0/-0.0.
- Objects carry a real PHP object handle: `var_dump` prints `object(C)#N`,
  `spl_object_id()` returns that same small integer instead of a heap address,
  and `spl_object_hash()` its 32-char form. Handles are minted from a direct-
  mapped side table (one `u32` per 16-byte heap granule, so the mapping is exact
  with no hashing and no capacity failure) and RE-USED LIFO on free, matching
  php-src. Closures, arrow functions, first-class callables and generators
  consume a handle exactly as they do in PHP -- which required allocating a
  runtime descriptor for capture-free closures that previously collapsed to a
  static address, since a handle needs storage whose lifetime can carry it.
  Without that, `$f = function(){}; new P()` would print `#1` where PHP prints
  `#2`.
- Enum cases are materialised on FIRST ACCESS instead of in `main`'s prologue,
  so a program that touches an enum no longer burns a handle per case of every
  referenced enum. Reproduces PHP's two rules: a pure enum materialises only the
  case touched, a BACKED enum materialises all of them in declaration order on
  the first touch of any one. Fixing this also fixed a use-after-free that eager
  creation was hiding: `E::A` handed out the singleton with no incref, so a case
  passed into a typed parameter was freed while its slot still pointed at it.
- Resource ids are minted from a counter instead of being derived from the
  payload pointer. Every display path did `payload + 1`, which is fine for a file
  descriptor and catastrophic for a heap address: `var_dump(hash_init('md5'))`
  printed `resource(4318862849)`, nondeterministic across runs and leaking an
  address. Resource ids stay a SEPARATE numbering space from object handles, as
  in php-src, and are not re-used after close.
- `hash_init()` returns a real `HashContext` OBJECT, as in PHP 8, instead of a
  stream-typed resource: `is_object`, `get_class`, `instanceof`, a typed
  parameter and `var_dump` all match reference. It lives in an injected prelude
  because `hash` is bridge-gated, so non-hashing programs neither declare the
  class nor link the bridge -- the same shape `GdImage` uses for the same PHP 8
  resource-to-object migration. `var_dump` matches because `__debugInfo()` is now
  folded into the class descriptor at COMPILE time for the projection shape
  (`return ['k' => $this->prop]`); a dynamic `__debugInfo` would have meant new
  hand-written assembly on both arches inside the var_dump walker, which is
  exactly where this branch already shipped a double free.
- Using a `HashContext` after `hash_final()` raises PHP's exact `TypeError` for
  `hash_update` / `hash_final` / `hash_copy` instead of silently continuing to
  hash and returning a plausible wrong digest.

General compiler bugs the audit surfaced
- Static-local string use-after-free. The retain was missing on a string stored
  into a `static` local. Ownership split, now documented on site and pinned by
  an invariant test in `src/ir/value.rs`: ir_lower owns the RETAIN (the backend
  incref skips `Str`, which `is_refcounted()` does not list), the BACKEND owns
  the RELEASE of what it overwrites. A first attempt added a second release and
  produced a double free -- see the caveat below.
- Float element misses had no null sentinel, so a missed lookup returned a
  plausible float instead of null: a silently wrong value. `??` also collapsed
  the hit arm's type onto the default's.
- `var_export($x, true)` stopped returning a string; `strstr()` never returned
  `false` (it returned `""`). Contributes to illegalstudio#398.
- Recursive free functions with a non-`int` return hint did not compile. The
  provisional signature published before the body walk hardcoded
  `PhpType::Int`, so a self-call typed as `Int`. The placeholder was ALWAYS
  `Int` -- recursive `int`/`float`/`bool` functions agreed with it by accident,
  which is why it survived. It was the type of the self-call EXPRESSION, so it
  also broke argument checking and leaked to outside callers; mutual recursion
  and recursive generators failed too. Methods were never affected because the
  class-schema pass already seeds the declared type.
- `print_r($v, true)` leaked its result string, and `array_flip` leaked its
  source when the argument was an owned temporary. Both were result-ownership
  misclassifications: `PrintR` and `ArrayFlip` sat in the default
  `MayAliasArguments` bucket while every lowering allocates fresh storage.
- Every call through the uniform callable invoker returning a string leaked one
  block. The return was persisted once (OWNED) and then boxed with the BORROWED
  contract, whose `__rt_mixed_from_value` persists a second copy, orphaning the
  first. Fixed by boxing through the owning path: no release was added, an
  allocation was removed. This surfaced as an `array_map` leak, but reproduces
  with no `array_map` in the program -- closures reach the invoker because
  `clear_static_callable_locals()` drops the direct-call binding inside loops.

Verification

Every expected value was taken from reference PHP 8.5.6 run with
`-d xdebug.mode=off` (Xdebug overloads `var_dump`, which silently invalidates
any probe of it). Each fix was sentinel-audited: the fix is neutralised, the
build repeated, and the guarding tests must fail. Two tests that did NOT move
under their sentinel are called out in the PR description rather than presented
as proof.

Two divergences reported during this work turned out not to exist
- The "eval bridge re-mints object handles" divergence was a MISDIAGNOSIS,
  reached independently by two investigations: the shifted handle was visible on
  an object dumped BEFORE the `eval()` ever ran, and no staging behaviour can
  renumber the past. The real cause was that `eval` marked every enum reachable,
  so prelude enum cases took handles 1-4. Nothing in the eval bridge was at
  fault, and object identity across `eval()` was never broken -- `===`,
  write-through and the shared `#N` all hold, because the bridge passes the same
  heap object rather than copying it.
- `hash_init()` not consuming a handle is fixed by the `HashContext` object
  above, not by anything in the handle pool.

Known divergences, each pinned by a test rather than left to look correct
- `__debugInfo()` is honoured only for the projection shape; any other body
  falls back to the declared-property list, which is what every class did
  before, so nothing that compiled now errors.
- `serialize()` on a `HashContext` throws. PHP emits the full digest state and
  round-trips it; elephc cannot, and was emitting a plausible-but-wrong
  `O:11:"HashContext":2:{...}`, so it now refuses instead of lying.
- `var_dump` of a `stdClass` omits dynamic-property bodies (pre-existing; the
  `#N` is correct).
- Runtime warnings carry no ` in <file> on line <n>` tail, matching the existing
  house convention.

One bounded regression, measured and pinned rather than papered over
- The new post-`hash_final()` `TypeError` throws through a wrapper frame, which
  exposes a PRE-EXISTING leak of one block per builtin-thrown exception crossing
  a PHP frame. `rejected_reuse_leaves_the_heap_clean` became
  `rejected_reuse_leaks_one_block_per_rejected_call`, asserting EXACTLY 2 live
  blocks so the number cannot drift in either direction. Success paths stay
  strictly clean. Five pure-PHP workarounds were measured; none help, and
  catch-and-rethrow leaks two.

Left unfixed, with reasoning
- Two residual uniform-invoker leaks: a string ARGUMENT passed into a
  descriptor-invoked callable, and a freshly allocated string temporary
  concatenated inside one. Both are one block per call and both would require
  ADDING a free on a path shared by every callable call, whose safety depends on
  no callee ever retaining a borrowed string parameter -- which could not be
  bounded here, and which macOS would hide (see the double-free note above).
- `return array_map(...)` from a function declared `: array` returns garbage or
  exhausts the heap. Verified to behave identically with the invoker fix
  neutralised, so it is pre-existing and not caused by this work.
- Object `===` frees an operand too early: `feed($x, "abc") === $x` runs `$x`'s
  destructor DURING the comparison. A plain object survives on macOS because
  freed memory still reads back; a `HashContext` does not. Reproduced with no
  hashing involved, so pre-existing.
- One block leaks per builtin-thrown exception crossing a PHP frame -- the cause
  of the bounded regression above. Also reproduced with no hashing involved.
- `implode()` over an `AssocArray` is refused by the EIR backend, and
  `(string)$res` / `print_r($res)` return an empty string where PHP prints
  `Resource id #N`. Both pre-existing, surfaced while probing.

Closes illegalstudio#388.
Guikingone added a commit that referenced this pull request Jul 28, 2026
…x what auditing it against PHP 8.5.6 uncovered

Emulates the observable Zend OPcache API on top of elephc's AOT model, then
fixes the PHP-compliance gaps and general compiler bugs that auditing that
surface against reference PHP 8.5.6 uncovered.

elephc has no opcode cache to introspect, so there is nothing to report. But a
script calling `opcache_get_status()` or reading `ini_get('opcache.enable')`
should not fatal, and should not be lied to either. The binary IS the cache:
every script was compiled ahead of time, so the manifest of compiled scripts is
the honest projection of "what is cached". The six `opcache_*` functions, the
54-entry `opcache.*` INI surface and `opcache_get_configuration()` are answered
from that manifest; the parts an AOT build genuinely cannot have
(`opcache_invalidate()` doing real work, JIT counters, restart statistics) are
documented as such rather than faked.

OPcache surface
- `opcache_get_status`, `opcache_get_configuration`, `opcache_reset`,
  `opcache_invalidate`, `opcache_compile_file`, `opcache_is_script_cached` over
  a compile-time script manifest.
- The full 8.2-8.5 `opcache.*` directive matrix as a single source of truth
  (`src/opcache/directives.rs`): raw INI projection, per-directive access class,
  runtime overridability, the JIT CRTO parser, prime table for `max_cached_keys`.
- Compile-time overrides via `--ini KEY=VALUE` and `ELEPHC_INI_*`.
- `ini_get` / `ini_set` / `ini_get_all` in CLI and under `--web`;
  `extension_loaded()` / `get_loaded_extensions()` report bridge-linked
  extensions.
- `docs/php/opcache.md` carries a 24-row limitations table.

PHP-compliance gaps found by auditing that surface (~150 differential probes)
- `var_dump()` recurses into nested arrays AND renders object bodies; it
  previously printed every nested array as `NULL` and every object as a bare
  `object(C)`. Closes illegalstudio#388.
- `function_exists()` accepts a dynamic argument.
- `foreach` over a non-iterable warns and continues instead of failing.
- The version surface reports PHP's version, not the compiler's.
- `implode()` renders bools PHP-style; `array_keys()` accepts mixed.
- The built-in `Error` hierarchy is complete; `intdiv()` by zero throws
  `DivisionByZeroError`.
- `array_flip()` and `array_map()` accept associative sources, through two new
  hash-walking runtime helpers (`__rt_hash_flip`, `__rt_hash_map`), both
  backends.
- NAN coerced to bool emits PHP 8.5's `unexpected NAN value was coerced to bool`
  warning, gated on `--php-version` and silent for INF/-INF/0.0/-0.0.
- Objects carry a real PHP object handle: `var_dump` prints `object(C)#N`,
  `spl_object_id()` returns that same small integer instead of a heap address,
  and `spl_object_hash()` its 32-char form. Handles are minted from a direct-
  mapped side table (one `u32` per 16-byte heap granule, so the mapping is exact
  with no hashing and no capacity failure) and RE-USED LIFO on free, matching
  php-src. Closures, arrow functions, first-class callables and generators
  consume a handle exactly as they do in PHP -- which required allocating a
  runtime descriptor for capture-free closures that previously collapsed to a
  static address, since a handle needs storage whose lifetime can carry it.
  Without that, `$f = function(){}; new P()` would print `#1` where PHP prints
  `#2`.
- Enum cases are materialised on FIRST ACCESS instead of in `main`'s prologue,
  so a program that touches an enum no longer burns a handle per case of every
  referenced enum. Reproduces PHP's two rules: a pure enum materialises only the
  case touched, a BACKED enum materialises all of them in declaration order on
  the first touch of any one. Fixing this also fixed a use-after-free that eager
  creation was hiding: `E::A` handed out the singleton with no incref, so a case
  passed into a typed parameter was freed while its slot still pointed at it.
- Resource ids are minted from a counter instead of being derived from the
  payload pointer. Every display path did `payload + 1`, which is fine for a file
  descriptor and catastrophic for a heap address: `var_dump(hash_init('md5'))`
  printed `resource(4318862849)`, nondeterministic across runs and leaking an
  address. Resource ids stay a SEPARATE numbering space from object handles, as
  in php-src, and are not re-used after close.
- `hash_init()` returns a real `HashContext` OBJECT, as in PHP 8, instead of a
  stream-typed resource: `is_object`, `get_class`, `instanceof`, a typed
  parameter and `var_dump` all match reference. It lives in an injected prelude
  because `hash` is bridge-gated, so non-hashing programs neither declare the
  class nor link the bridge -- the same shape `GdImage` uses for the same PHP 8
  resource-to-object migration. `var_dump` matches because `__debugInfo()` is now
  folded into the class descriptor at COMPILE time for the projection shape
  (`return ['k' => $this->prop]`); a dynamic `__debugInfo` would have meant new
  hand-written assembly on both arches inside the var_dump walker, which is
  exactly where this branch already shipped a double free.
- Using a `HashContext` after `hash_final()` raises PHP's exact `TypeError` for
  `hash_update` / `hash_final` / `hash_copy` instead of silently continuing to
  hash and returning a plausible wrong digest.

General compiler bugs the audit surfaced
- Static-local string use-after-free. The retain was missing on a string stored
  into a `static` local. Ownership split, now documented on site and pinned by
  an invariant test in `src/ir/value.rs`: ir_lower owns the RETAIN (the backend
  incref skips `Str`, which `is_refcounted()` does not list), the BACKEND owns
  the RELEASE of what it overwrites. A first attempt added a second release and
  produced a double free -- see the caveat below.
- Float element misses had no null sentinel, so a missed lookup returned a
  plausible float instead of null: a silently wrong value. `??` also collapsed
  the hit arm's type onto the default's.
- `var_export($x, true)` stopped returning a string; `strstr()` never returned
  `false` (it returned `""`). Contributes to illegalstudio#398.
- Recursive free functions with a non-`int` return hint did not compile. The
  provisional signature published before the body walk hardcoded
  `PhpType::Int`, so a self-call typed as `Int`. The placeholder was ALWAYS
  `Int` -- recursive `int`/`float`/`bool` functions agreed with it by accident,
  which is why it survived. It was the type of the self-call EXPRESSION, so it
  also broke argument checking and leaked to outside callers; mutual recursion
  and recursive generators failed too. Methods were never affected because the
  class-schema pass already seeds the declared type.
- `print_r($v, true)` leaked its result string, and `array_flip` leaked its
  source when the argument was an owned temporary. Both were result-ownership
  misclassifications: `PrintR` and `ArrayFlip` sat in the default
  `MayAliasArguments` bucket while every lowering allocates fresh storage.
- Every call through the uniform callable invoker returning a string leaked one
  block. The return was persisted once (OWNED) and then boxed with the BORROWED
  contract, whose `__rt_mixed_from_value` persists a second copy, orphaning the
  first. Fixed by boxing through the owning path: no release was added, an
  allocation was removed. This surfaced as an `array_map` leak, but reproduces
  with no `array_map` in the program -- closures reach the invoker because
  `clear_static_callable_locals()` drops the direct-call binding inside loops.

Verification

Every expected value was taken from reference PHP 8.5.6 run with
`-d xdebug.mode=off` (Xdebug overloads `var_dump`, which silently invalidates
any probe of it). Each fix was sentinel-audited: the fix is neutralised, the
build repeated, and the guarding tests must fail. Two tests that did NOT move
under their sentinel are called out in the PR description rather than presented
as proof.

Two divergences reported during this work turned out not to exist
- The "eval bridge re-mints object handles" divergence was a MISDIAGNOSIS,
  reached independently by two investigations: the shifted handle was visible on
  an object dumped BEFORE the `eval()` ever ran, and no staging behaviour can
  renumber the past. The real cause was that `eval` marked every enum reachable,
  so prelude enum cases took handles 1-4. Nothing in the eval bridge was at
  fault, and object identity across `eval()` was never broken -- `===`,
  write-through and the shared `#N` all hold, because the bridge passes the same
  heap object rather than copying it.
- `hash_init()` not consuming a handle is fixed by the `HashContext` object
  above, not by anything in the handle pool.

Known divergences, each pinned by a test rather than left to look correct
- `__debugInfo()` is honoured only for the projection shape; any other body
  falls back to the declared-property list, which is what every class did
  before, so nothing that compiled now errors.
- `serialize()` on a `HashContext` throws. PHP emits the full digest state and
  round-trips it; elephc cannot, and was emitting a plausible-but-wrong
  `O:11:"HashContext":2:{...}`, so it now refuses instead of lying.
- `var_dump` of a `stdClass` omits dynamic-property bodies (pre-existing; the
  `#N` is correct).
- Runtime warnings carry no ` in <file> on line <n>` tail, matching the existing
  house convention.

One bounded regression, measured and pinned rather than papered over
- The new post-`hash_final()` `TypeError` throws through a wrapper frame, which
  exposes a PRE-EXISTING leak of one block per builtin-thrown exception crossing
  a PHP frame. `rejected_reuse_leaves_the_heap_clean` became
  `rejected_reuse_leaks_one_block_per_rejected_call`, asserting EXACTLY 2 live
  blocks so the number cannot drift in either direction. Success paths stay
  strictly clean. Five pure-PHP workarounds were measured; none help, and
  catch-and-rethrow leaks two.

Left unfixed, with reasoning
- Two residual uniform-invoker leaks: a string ARGUMENT passed into a
  descriptor-invoked callable, and a freshly allocated string temporary
  concatenated inside one. Both are one block per call and both would require
  ADDING a free on a path shared by every callable call, whose safety depends on
  no callee ever retaining a borrowed string parameter -- which could not be
  bounded here, and which macOS would hide (see the double-free note above).
- `return array_map(...)` from a function declared `: array` returns garbage or
  exhausts the heap. Verified to behave identically with the invoker fix
  neutralised, so it is pre-existing and not caused by this work.
- Object `===` frees an operand too early: `feed($x, "abc") === $x` runs `$x`'s
  destructor DURING the comparison. A plain object survives on macOS because
  freed memory still reads back; a `HashContext` does not. Reproduced with no
  hashing involved, so pre-existing.
- One block leaks per builtin-thrown exception crossing a PHP frame -- the cause
  of the bounded regression above. Also reproduced with no hashing involved.
- `implode()` over an `AssocArray` is refused by the EIR backend, and
  `(string)$res` / `print_r($res)` return an empty string where PHP prints
  `Resource id #N`. Both pre-existing, surfaced while probing.

Closes illegalstudio#388.
Guikingone added a commit that referenced this pull request Aug 2, 2026
… class name is not literal

`get_class_methods()` refused anything but a literal class name or a statically-typed object,
in BOTH the checker and the backend. The shape real code uses — a name computed at runtime,
e.g. `symfony/error-handler`'s `get_class_methods($className)` on a name parsed out of an error
message — was rejected outright, and took a second error with it (`$methods` never became
defined, so its every use was an undefined variable).

Resolution now goes through `_class_methods_table`, a closed-world per-class payload registry,
exactly as `class_implements()`/`class_parents()`/`class_uses()` already resolve a runtime name.
Two things keep this small:

Rows are 32 bytes (`{name_ptr, name_len, list_ptr, list_count}`), NOT the 64-byte layout the
three relation tables share. `__rt_sorted_name_search` takes `entry_size` as an argument, so a
narrower private table costs nothing at the search site while leaving `_class_relation_table`
and its fixed-size `__rt_class_relation_probe` untouched.

Only the PUBLIC list is stored. Calling-scope visibility is what makes PHP's answer differ, and
the calling scope is ALWAYS compile-time known — it is the enclosing class of the EIR function
being lowered. So the runtime name is compared against that one class name and a match branches
to the self-scope list baked inline; no per-class visibility data is emitted, and the comparison
is skipped entirely for a class whose two filters coincide.

A miss THROWS. `class_implements()` returns `false` for an unknown name, but PHP 8.5 raises
`TypeError: get_class_methods(): Argument #1 ($object_or_class) must be an object or a valid
class name, string given` (php -n measured — NOT `null`, which is what Symfony's own
`null === $methods` guard expects and which no longer happens). Returning `false` would be a
silently-wrong value rather than a degraded one. Routing an undeclared LITERAL name through the
same path also retires a compile error for something PHP compiles and rejects at the call.

The object path deliberately stays a compile-time bake against the declared type, keeping its
documented residual, so no shape that already compiled changes behavior.

Verified: --web ledger 86 -> 84, both targeted errors and nothing else moving. error_tests
1614/8 with the 8 failures NAME-identical to baseline; codegen::oop 852/6 (+5 new tests),
::callables 536/3, ::types 321/3, ::arrays 370/6, runtime_gc 271/2, references 9 failures
name-identical. Every runtime shape diffed against `php -n`: public-only from outside,
own-privates-plus-inherited-protected from inside, case-insensitive lookup, and the catchable
TypeError message byte for byte.
Guikingone added a commit that referenced this pull request Aug 7, 2026
`resource_id_and_hash_context_tests` was 7/24. Two independent defects, both
measured against PHP 8.5.6 rather than reasoned about.

Resource ids were shifted by one. `_resource_id_next` seeded at 5 on the
assumption — written into the comment — that "id 4 is consumed by the SAPI
before user code runs". It is not: id 4 is the REQUEST DEFAULT STREAM CONTEXT.
That is directly observable, and the probe that settles it is
`$d = opendir("."); var_dump(get_resource_id($d));` printing 5 while a following
`stream_context_get_default()` prints 4 — the context is minted BEFORE the
stream, by the stream open itself, so it cannot be a startup artefact. Two
`stream_context_get_default()` calls both answer 4, so it is created once and
retained.

The registry mints an id at allocation, so the lazily created default context
took 5 and pushed every user resource to 6. The cursor now seeds at 4 and the
context takes it, leaving 5 for the first stream.

`opendir` did not create the default context at all, which is why a directory
handle happened to land on 5 while `fopen` did not. PHP creates it on the first
stream open of ANY kind, so `lower_opendir` now does the same. Both orders —
fopen-first and opendir-first — match the reference byte for byte.

`hash_update`, `hash_final` and `hash_copy` resolved their operand through
`load_stream_fd_to_result`, which since the registry migration goes through
`__rt_stream_fd` and rejects anything that is not a stream:
`hash_copy(): Argument #1 ($stream) must be an open stream resource` on a
perfectly valid HashContext. `load_resource_payload_to_result` existed for
exactly this — a HashContext keeps a native pointer in its tag-9 payload, not a
registry handle — but had never been wired to a caller. It is now.

24 tests: 20 pass, up from 7.

Still failing, and not addressed here: `__rt_resource_type_name` decides "closed"
from the sign bit of the `-id` sentinel, which registry close no longer stamps,
so `var_dump($closed)` prints `(stream)` where `get_resource_type($closed)`
correctly answers `"Unknown"`. Extending it to consult the registry has to keep a
fallback for the paths that still pass a raw descriptor, and it is declared a
leaf helper, so it also needs an LR save on AArch64.
Guikingone pushed a commit that referenced this pull request Aug 9, 2026
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.
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.

5 participants