Skip to content

feat(instrument): add the zengine weaving driver on top of lisachenko/z-engine - #765

Draft
lisachenko wants to merge 11 commits into
masterfrom
feature/zengine-driver
Draft

lisachenko wants to merge 11 commits into
masterfrom
feature/zengine-driver

Conversation

@lisachenko

@lisachenko lisachenko commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

What and why

Proof of concept of a second weaving driver that keeps the whole existing stream-filter engine untouched and adds runtime weaving through lisachenko/z-engine (PHP FFI) as a parallel path, selected by the new driver kernel option (Go\Aop\WeavingDriver: stream, the default, or zengine).

With the engine driver classes load natively: ZEngineComposerLoader includes the original file exactly as composer would, and ZEngineClassWeaver rewires the class right after the include. The body of every advised method is swapped in place with a generated dispatcher while the original body stays callable under the private <method>OriginalAlias (ReflectionMethod::redefine($donorMethod, preserveAs: ...)); an inherited method gets an engine-published override reaching the original through parent:: (ReflectionClass::addMethod()). No source rewriting, no stream filter, no proxy class, no include interception. Advices, pointcuts, the advisor cache and the join-point API are shared with the stream driver; the functional tests run the same fixtures through both drivers and compare results, advice log and reflection.

Main pieces:

  • Go\Aop\WeavingDriver, the driver option in AspectKernel (the transformer-only Features refused with the engine driver), ZEngineDriver::boot() (requirements check, JitGuard, Core::init(), services, loader).
  • Go\Proxy\DonorClassGenerator (extends ClassProxyGenerator): the abstract donor Foo__AopDonor next to the woven class, same imports/parent/interfaces, only the advised methods with the usual dispatcher bodies. Because the donor is compiled in another class, the joinpoint names the woven class explicitly (createJoinpointClassReference() hook on ClassProxyGenerator) and self/parent in signatures are spelled out (TypeGenerator::resolveScopeKeywords(), ParameterGenerator::withType(), MethodGenerator::get/setParameters()); the engine compares the donor signature with the replaced entry.
  • DonorCacheIndex ({cacheDir}/_zengine.cache, donors below {cacheDir}/_zengine/) with the freshness rule extracted into CachePathManager::isFreshRecord(), shared with the stream driver's metadata. Sources outside the application root are never woven, as with the stream driver.
  • MethodTableRewriter: the whole engine contract, through z-engine's public reflection API only. The rewired class is marked with Go\Aop\Proxy (ReflectionClass::addInterfaces()), so instanceof Proxy (the fluent-interface demo, the Doctrine bridge) answers the same under both drivers.
  • Function interception (Features::INTERCEPT_FUNCTIONS) without any engine mutation: the stream driver's namespaced function proxies (FunctionProxyGenerator) are generated per namespace into {cacheDir}/_zengine/_functions/, recorded in the donor index, and included right after the file declaring the first loaded class of the namespace; PHP resolves an unqualified call to the namespaced function before the global one. Same semantics as the stream driver: qualified calls and the global namespace are never intercepted.
  • JitGuard: z-engine needs the opcache JIT off; on PHP 8.5 the tracing JIT also miscompiles the framework (known PHP bug), so there the guard switches it off at runtime and reports it in the boot report; other versions get an InvalidConfigurationException. The functional test base now passes opcache.jit=off to every PHP subprocess on PHP 8.5 for the same reason (the suite failed under a php.ini with the tracing JIT on).
  • ComposerLoaderDecorator: both drivers decorate composer's loader; AopComposerLoader::init()/findOriginalFile() unwrap either.
  • Method execution join points and function interception. Property access, introductions, initialization join points, include interception, prebuilt caches, traits/enums as targets stay with the stream driver: UnsupportedJoinpointException on the first load of a class matched by such an advisor; cache:warmup:aop/debug:weaving refuse the driver.
  • The demos run through the engine driver with GO_AOP_DRIVER=zengine.

PHP versions. Verified on PHP 8.4 (8.4.19) and PHP 8.5 (8.5.11); the engine API the driver needs (lisachenko/z-engine#284 for 8.4, lisachenko/z-engine#282 for 8.5) is merged into both version branches, and the public surface of the driver is the same on both. .github/workflows/zengine.yml runs the group on both minors against 8.4.x-dev and 8.5.x-dev.

Tests and coverage. The zengine PHPUnit group (excluded from the default run, composer test:zengine, self-skipping where the engine cannot boot) has two layers: tests/Functional/ZEngine/ runs the driver in PHP subprocesses (both opcache legs, debug and production, warm index reuse, stale source regeneration, on-demand weaving, function interception, refused join points, cache warmer refusal, JIT guard) and compares it with the stream driver on the same fixtures; tests/Instrument/ZEngine/InProcess/ boots z-engine inside the test runner and weaves dedicated stubs for real, which is what gives the driver line coverage. The coverage job of phpunit.yml installs z-engine (8.4.x-dev) with FFI on and the JIT off and includes the group.

Dependency handling: z-engine stays an optional dependency (each of its branches targets one PHP minor, so it cannot be in require-dev without breaking the other legs). phpstan/zengine-stubs.php (scanFiles) declares the API the driver uses for the analysis without the package (verified with the package hidden).

Known deviations, documented in docs/zengine-driver.md: reflection of an advised method reports the donor file as the file of its body; a private/final method whose body is a literal may have been inlined by the opcache optimizer before the swap; a call site that ran before the function proxies were declared keeps the global function it resolved.

Verified: composer check green on PHP 8.5 (3155 tests, PHPStan level 10 with 2.3.1, PER-CS) and the default suite plus PHPStan green on PHP 8.4; composer test:zengine on both minors against the engine heads (27 tests each); the fluent-interface and function-interceptor demos under GO_AOP_DRIVER=zengine, cold and warm with opcache.

Checklist

  • The title follows Conventional Commits (type(scope): summary)
  • Tests cover the change
  • composer check passes
  • User-facing or breaking changes are in CHANGELOG.md (and the upgrade guide for breaking changes)

🤖 Generated with Claude Code

https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2

…/z-engine

A second weaving driver, selected by the new `driver` kernel option
(`Go\Aop\WeavingDriver`: `stream`, the default, or `zengine`). The engine
driver weaves at runtime through lisachenko/z-engine (PHP FFI) in place of
the stream filter: ZEngineComposerLoader includes the original file natively
and ZEngineClassWeaver rewires the class right after the include - the body
of every advised method is swapped in place with a generated dispatcher
while the original body stays callable under the private
`<method>OriginalAlias` (`ReflectionMethod::redefine($donor, preserveAs:)`),
an inherited method gets an engine-published override reaching the original
through `parent::` (`ReflectionClass::addMethod()`). No source rewriting, no
proxy class, no include interception.

The dispatcher bodies are compiled in a donor class `Foo__AopDonor`
(DonorClassGenerator, extending ClassProxyGenerator): abstract, same
namespace, imports, parent and interfaces, only the advised methods; the
joinpoint names the woven class explicitly and `self`/`parent` in signatures
are spelled out (TypeGenerator::resolveScopeKeywords()), since the donor file
is compiled in another class. Donors are cached below `{cacheDir}/_zengine/`
and indexed in `_zengine.cache` (DonorCacheIndex) with the freshness rule
shared with the stream driver (CachePathManager::isFreshRecord()).

Method execution join points only: property access, introductions,
initialization join points, function interception and prebuilt caches stay
with the stream driver (UnsupportedJoinpointException on the first load of a
class matched by such an advisor, stream-only features refused by
normalizeOptions(), cache:warmup:aop and debug:weaving refuse the driver).

JitGuard: z-engine needs the opcache JIT off; on PHP 8.5 the tracing JIT
also miscompiles the framework (known PHP bug), so the guard switches it off
at runtime there and reports it, other versions get a configuration error.
The functional test base passes `opcache.jit=off` to every PHP subprocess on
PHP 8.5 for the same reason.

z-engine is an optional dependency (each branch targets one PHP minor):
phpstan/zengine-stubs.php declares the API for the analysis without the
package, the `zengine` PHPUnit group (`composer test:zengine`, both opcache
legs, compared with the stream driver on the same fixtures) is excluded
from the default run and self-skips without the engine, and
.github/workflows/zengine.yml installs the package on PHP 8.5.

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

codecov Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.06338% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.87%. Comparing base (f8e80c1) to head (a4e7792).

Files with missing lines Patch % Lines
src/Instrument/ZEngine/DonorCacheIndex.php 96.29% 4 Missing ⚠️
src/Instrument/ZEngine/ZEngineClassWeaver.php 97.40% 4 Missing ⚠️
src/Instrument/ZEngine/MethodTableRewriter.php 90.90% 2 Missing ⚠️
src/Instrument/ZEngine/ZEngineDriver.php 97.61% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #765      +/-   ##
============================================
+ Coverage     97.81%   97.87%   +0.05%     
- Complexity     1841     2050     +209     
============================================
  Files           104      114      +10     
  Lines          4849     5404     +555     
============================================
+ Hits           4743     5289     +546     
- Misses          106      115       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

lisachenko and others added 2 commits October 10, 2026 01:32
…gine branch of each minor

The zengine workflow becomes a matrix over the PHP minors the driver is
verified on, each leg installing the lisachenko/z-engine branch that
targets that minor (feature/redefine-preserve-alias-8.4 for 8.4,
feature/redefine-preserve-alias for 8.5). docs/zengine-driver.md names
the branch per minor and the functional test base's skip message no
longer hardcodes one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
The stream driver maps a source outside the application root to itself
and never weaves it; the engine driver's loader applied the same path
filter, but the on-demand weave() entry point did not, and the donor
index placed the donor of such a source below the donor directory by
its absolute path, which is not a valid path on Windows (the drive
colon). The weaver now skips such sources and the index refuses them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
lisachenko and others added 2 commits October 10, 2026 01:58
…overage job

The driver had no line coverage: its functional tests run it in PHP
subprocesses. New in-process tests (tests/Instrument/ZEngine/InProcess/,
group zengine) boot z-engine inside the test runner and weave dedicated
stub classes (tests/Stubs/ZEngine) for real: the boot report and the
services, own methods swapped and inherited methods overridden, a
refused join point kind remembered per class, on-demand weaving of a
class declared before the kernel, the warm path applying a donor record
without loading the aspects, sources outside the application root. The
kernel is created without the per-process singleton and installed as the
singleton only for the duration of a test, since woven code resolves its
aspects through AspectKernel::getInstance().

The coverage job installs lisachenko/z-engine (the branch of PHP 8.4)
with FFI enabled and the JIT off, and includes the zengine group. Unit
tests for the cache warmer refusal and UnsupportedJoinpointException.

JitGuard::hasTracingJitBug() replaces the PHP_VERSION_ID comparisons of
the tests, which PHPStan 2.3 folds to a constant verdict on PHP 8.4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
The in-process tests now exercise the paths the functional probe never
reaches: the engine boot refusals of ZEngineDriver (missing package, an
engine that cannot boot, the JIT guard) through injectable seams, the
rewriter's refusals (a class the engine class table does not know, a donor
without the dispatcher method), a pointcut failing while matching (an
ordinary class fails its load, an aspect is skipped), the parser fallback
to native reflection, an aspect matched by its own pointcut, the records
of a previous request served without the aspects, and the skips of
internal classes, enums and classes without a source file. A readonly
class gets a readonly donor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
claude and others added 6 commits October 10, 2026 16:11
…is merged there

lisachenko/z-engine#282 was merged into 8.5 and its feature branch deleted, so
the PHP 8.5 leg and the install instructions point at dev-8.5. PHP 8.4 keeps
pinning the feature branch until lisachenko/z-engine#284 lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
Composer exposes a branch named like a version as 8.5.x-dev, not dev-8.5:
the PHP 8.5 leg could not resolve the package after the previous commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…is merged there

lisachenko/z-engine#284 was merged into 8.4, so the PHP 8.4 driver leg, the
coverage job and the install instructions require 8.4.x-dev instead of the
feature branch; both minors now run against the version branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…roxy

Two gaps the demos exposed under the engine driver:

The fluent-interface demo guards the chain with `instanceof Go\Aop\Proxy`,
which a class woven in place did not implement: the aspect ran, the demo
still reported AOP as unavailable. MethodTableRewriter now adds the marker
interface to the rewired class through z-engine's ReflectionClass::addInterfaces(),
so `instanceof Proxy` (the demo, the Doctrine bridge) answers the same under
both drivers; an unadvised class stays unmarked.

Function interception was refused with the engine driver, although it needs
no engine mutation at all: the stream driver's function proxies are namespaced
functions shadowing the advised internal ones, and PHP resolves an unqualified
call to the namespaced function before the global one. ZEngineClassWeaver
now generates the same proxies per namespace (FunctionProxyGenerator, written
to {cacheDir}/_zengine/_functions/<Ns/Path>.php and recorded in the donor index
with the function names) and includes them right after the file declaring the
first loaded class of the namespace, before any code of it runs. A warm
request includes the recorded file without the aspects; functions already
declared in the process (another kernel of a test runner) are never included
twice; the global namespace is left alone, as with the stream driver.
INTERCEPT_FUNCTIONS is accepted by the kernel for the engine driver.

The demos select the driver through GO_AOP_DRIVER=zengine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…ngine driver

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants