Skip to content

refactor: consume z-engine through its documented API only (audit follow-up) - #14

Merged
lisachenko merged 3 commits into
mainfrom
claude/userland-php-generics-audit-1t6xy7
Aug 9, 2026
Merged

refactor: consume z-engine through its documented API only (audit follow-up)#14
lisachenko merged 3 commits into
mainfrom
claude/userland-php-generics-audit-1t6xy7

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

Audit goal: keep all CData/FFI concerns inside z-engine and have this package consume only its API, with no internal knowledge leaking out.

The FFI containment was already clean — no FFI\CData, FFI::, or raw struct pointer anywhere in this package. What the audit found instead were internal-knowledge leaks in non-CData form, two of them latent bugs:

  1. isset(Core::$executor) as an "engine booted" probe (Monomorphizer, RequiresEngine) — the wrappers are assigned mid-boot, so a failed boot read as ready.
  2. tests/bootstrap.php re-derived z-engine's environment rule with filter_var(ini_get('ffi.enable'), FILTER_VALIDATE_BOOL) — wrong in both directions, and both observed: it rejects the supported preload mode (silently skipping the whole engine suite) and it boots on hosts z-engine refuses (fataling the suite at load).
  3. The eviction test drove the @internal HashTable::delete() through Core::$executor, reconstructing an internal z-engine procedure.
  4. EngineFacts received a raw FFI\CType via Core::sizeof(Core::type(...)) — the one FFI object crossing the boundary.
  5. The whole Strategy layer imported ZEngine\Reflection\* value objects, so "Monomorphizer is the only class that talks to z-engine" (AGENTS.md item 13, README) was false.

What

  • Package-local DTOs: new SlotAddress (on the existing Template\SlotKind enum) plus native-array SubstitutionPlan/SubstitutionRequest. Monomorphizer translates them into TypeSlot/TypeSubstitutionMap/SlotSubstitutionMap at the single point where the engine is asked — item 13 is now true as written. Empty maps stay null at the boundary so placeholder-only templates keep working on z-engine lines predating slot substitution.
  • Nothing here boots the engine any more. Following Add automatic self-registration via Composer z-engine#21, z-engine initializes itself from its Composer bootstrap — including during the opcache.preload stage — so require vendor/autoload.php is the whole boot. tests/bootstrap.php just requires the autoloader; preload.php drops its Core::preload() call entirely.
  • Monomorphizer::boot() is the idempotent Core::init(): free after a successful auto-boot, and the explanation on a host where that boot was deliberately silent. Generic::bootstrap() still exists for exactly that — it moves the report to application start instead of the first specialization. RequiresEngine asks Core::isInitialized().
  • Eviction test uses the new ClassSpecializer::evict(); EngineFacts uses Core::sizeOfType().
  • Docs: AGENTS.md item 13 rewritten and a new item 14 recording the boot rule and both failure modes of the hand-rolled check; README steps 4–5 and docs/long-running.md updated; EngineCapabilities' deliberate class_exists() feature-probe documented so it isn't "fixed" later (it is the only detection that works on the old z-engine lines it exists to detect).

Depends on

lisachenko/z-engine#183 — the Composer bootstrap, ClassSpecializer::evict() and Core::sizeOfType(). Must land on the resolved z-engine line (and merge up to master for the PHP 8.5 jobs) before CI here can go green.

CI is currently red for exactly that reason and no other: composer resolves z-engine from packagist, which has none of the new API — e.g. Benchmarks dies at EngineFacts.php:44 on Core::sizeOfType() being undefined.

Verified locally (PHP 8.5 container, z-engine#183 via path repo)

  • phpstan, php-cs-fixer, stubs:check green; analysis suite 15/15 without ext-ffi — a stronger check than before, since the Strategy layer must now load with no z-engine classes resolvable.
  • Full suite: 140 tests, 61 orderly skips. Under the old bootstrap the same host fatals at load; the new one turns that into skips, which is finding 2 fixed.
  • One environmental failure: PreloadTest's child runs on the pinned 8.4 z-engine line under PHP 8.5, so z-engine correctly refuses; unrelated to this diff, green on CI's PHP 8.4.
  • Grep gates: ZEngine\ in src/ → only Monomorphizer.php + the name-only reference in EngineCapabilities.php; Core::$executor, Core::type( and isUsable → zero matches.

🤖 Generated with Claude Code

https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ

The audit goal: no engine internals outside z-engine. FFI/CData containment
was already clean - what leaked was internal knowledge in non-CData form,
in two cases as latent bugs.

- Strategy layer speaks the package's own vocabulary: SlotAddress plus
  native-array SubstitutionPlan/SubstitutionRequest replace the ZEngine
  TypeSlot/TypeSubstitutionMap/SlotSubstitutionMap imports. Monomorphizer
  translates at the single point where the engine is asked, making AGENTS.md
  item 13 true as written - it is now the only src/ class talking to z-engine
  (EngineCapabilities keeps its deliberate name-only class_exists probe).
- 'Is the engine booted' is Core::isInitialized(), not isset(Core::$executor):
  the wrappers are assigned mid-boot, so the probe reported a half-booted
  bridge as ready. Generic::bootstrap() now delegates to Monomorphizer::boot().
- 'Can the engine run here' is Core::isUsable(), not a hand-rolled
  filter_var(ini_get('ffi.enable')) - which rejected the supported 'preload'
  mode (silently skipping the suite) and booted on hosts z-engine refuses
  (fataling the whole suite; both observed). The benchmark harness, which
  cannot run without the engine, just boots and relays Core::init()'s
  explanation on failure.
- The eviction test uses the new ClassSpecializer::evict() instead of
  reconstructing an internal z-engine procedure via Core::$executor and the
  @internal HashTable::delete().
- EngineFacts reads struct sizes via Core::sizeOfType(), so no raw FFI\CType
  crosses the package boundary.

Requires the z-engine side of this change (Core::isUsable/sizeOfType,
ClassSpecializer::evict) on the resolved z-engine line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ

Copy link
Copy Markdown
Owner Author

CI status: all failing jobs share one root cause — Call to undefined method ZEngine\Core::isUsable(). Composer resolves lisachenko/z-engine 8.4.x-dev @ 8419047 / 8.5.x-dev from packagist, which doesn't yet contain the API this PR consumes. This is the dependency declared in the description: CI here goes green once lisachenko/z-engine#183 lands on the 8.4 branch (and merges up to master for the PHP 8.5 jobs). No changes needed on this branch; I'll re-check after #183 merges.


Generated by Claude Code

Follows z-engine's Composer bootstrap (lisachenko/z-engine#21): requiring the
autoloader is now the whole boot, in the opcache.preload stage as well, so
nothing here initializes the engine and nothing re-derives z-engine's
environment rules.

- tests/bootstrap.php just requires the autoloader. The
  filter_var(ini_get('ffi.enable')) check it carried was wrong in both
  directions - it rejected the supported 'preload' mode (silently skipping the
  whole engine suite) and booted on hosts z-engine refuses (fataling it); both
  observed on this container.
- preload.php drops its Core::preload() call: z-engine's bootstrap recognises
  the preload stage and publishes the engine definitions itself.
- Monomorphizer::boot() is the idempotent Core::init() - free after a
  successful auto-boot, and the explanation on a host where that boot was
  deliberately silent. Generic::bootstrap() still exists for exactly that: it
  moves the report to application start instead of the first specialization.
- benchmarks/bootstrap.php relays that same explanation instead of failing
  later with a stack trace.
- AGENTS.md gains item 14 recording the rule and both failure modes of the
  hand-rolled check, so it does not come back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ

Copy link
Copy Markdown
Owner Author

Status update — my earlier comment named Core::isUsable(), which no longer exists.

Following the maintainer's proposal, this branch now boots nothing: z-engine initializes itself from its Composer bootstrap (lisachenko/z-engine#21), including during the opcache.preload stage, so require vendor/autoload.php is the whole boot. tests/bootstrap.php and preload.php lost their engine calls entirely; Monomorphizer::boot() is the idempotent Core::init(), kept only to turn a deliberately-silent failed boot into its explanation.

CI here is still red for the one known reason, unchanged: composer resolves lisachenko/z-engine from packagist, which has none of the new API. Every failing job traces to it — e.g. Benchmarks dies at EngineFacts.php:44 on Core::sizeOfType() being undefined. The other jobs fail the same way on ClassSpecializer::evict() and the missing bootstrap.

This goes green once lisachenko/z-engine#183 lands on the 8.4 branch and merges up to master (for the PHP 8.5 jobs). Nothing to change on this branch until then.

Locally, against z-engine#183 via a path repository: phpstan, php-cs-fixer, stubs:check green, analysis suite 15/15 without ext-ffi, full suite 140 tests with 61 orderly skips.


Generated by Claude Code

The debug-build internal group went red on PHP 8.5 with '4 destructive
test(s) skipped - the debug container cannot reach the engine', and the log
said nothing more: RequiresEngine checked Core::isInitialized() and skipped
in silence, so a gate that fails on skips gave nobody anything to act on.

That silence is the regression. z-engine's automatic boot is deliberately
quiet on a host it cannot run on, which makes it the caller's job to turn
that into an explanation - the contract this package already documents and
applies in Monomorphizer::boot(), and that the test trait was left out of.

So the trait now does what any code needing the engine does: call the
idempotent Core::init(). Free after a successful automatic boot; where that
boot never happened at all - an old z-engine without the bootstrap, or
ZENGINE_AUTOBOOT=0 - it simply boots and the tests run; and where the engine
genuinely cannot start, the skip carries z-engine's own sentence saying why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ
@lisachenko
lisachenko marked this pull request as ready for review August 9, 2026 22:01
@lisachenko
lisachenko merged commit 32807c3 into main Aug 9, 2026
16 checks passed
@lisachenko
lisachenko deleted the claude/userland-php-generics-audit-1t6xy7 branch August 9, 2026 22:01
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.

2 participants