Skip to content

feat(driver): pluggable weaving drivers: stream filter, stream wrapper and ffi (z-engine) - #765

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

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

Conversation

@lisachenko

@lisachenko lisachenko commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

What and why

Weaving becomes a pluggable driver of the kernel: the driver option names the class that decides how advised classes get their advices woven in, everything else (aspects, pointcuts, advisor cache, join-point runtime) is shared. Three drivers, two of them packages of their own under packages/:

Driver Where Weaves by
Go\Driver\StreamFilter\StreamFilterDriver (default) core source transformation at load time through the php://filter stream filter behind composer's loader (the existing engine, untouched)
Go\Driver\StreamWrapper\StreamWrapperDriver packages/stream-wrapper-driver (goaop/stream-wrapper-driver) stream_wrapper_unregister('file') + stream_wrapper_register('file', AopStreamWrapper::class): every include within the weaving scope is served transformed, includes are intercepted by nature, opcache caches the woven sources under their original path
Go\Driver\Ffi\FfiDriver (experimental) packages/ffi-driver (goaop/ffi-driver) runtime method-table weaving through lisachenko/z-engine (PHP FFI): classes load natively, advised method bodies are swapped in place with the dispatchers of a cached donor trait, the original body stays callable under <method>OriginalAlias

Core (diff against master: src/Driver/, 13 files)

  • Go\Driver\WeavingDriver::boot(AspectKernel, AspectContainer): AspectKernel::init() instantiates the configured class, registers it in the container and boots it once (after the framework services, before configureAop()). An unknown class or a class that is no driver is refused by normalizeOptions().
  • Go\Driver\SourceTransformingDriver (marker): only for such a driver the kernel registers the transformer chain (registerTransformerServices()) and the cache warmer builds a cache ahead of time; any other driver is refused by cache:warmup:aop / debug:weaving.
  • SourceTransformingLoader::transformSource($uri, $source, $stream): the cache-or-transform logic extracted from filter(), shared by the stream filter and the stream wrapper.
  • CachePathManager::isFreshRecord() / writeIndexFile() (the one freshness rule and the portable index format, used by the ffi index), ComposerLoaderDecorator (every driver wrapping composer's loader), generic code-gen helpers (TypeGenerator::resolveScopeKeywords(), ParameterGenerator::withType(), MethodGenerator::get/setParameters()), exception constructors following master's convention.

ffi driver package

FfiDriver::boot() refuses Features::INTERCEPT_INCLUDES, requires z-engine, runs the JIT guard (an active JIT is switched off at runtime and reported), boots the engine and wraps composer's loader with FfiComposerLoader; ClassWeaver matches advices on the parsed source and generates the donor trait Ns\FooOriginalTrait (DonorTraitGenerator, the suffix and structure of the trait codegen: in a trait self::class and parent:: bind at run time to the woven class, only the self/parent keywords of signatures are spelled out because the engine compares signatures against the declaring type); MethodTableRewriter swaps the bodies (redefine(..., preserveAs:), addMethod()) and marks the class with Go\Aop\Proxy; DonorCacheIndex keeps {cacheDir}/_ffi.cache. Function pointcuts are served by the stream driver's namespaced function proxies, enabled from the registered pointcuts (AdvisorIndex), no flag. Everything but method execution and functions is refused with UnsupportedJoinpointException. Exceptions: EngineConfigurationException (configuration), FfiWeavingException / UnsupportedJoinpointException (weave time).

Tests and CI

  • Root testsuites framework, stream-wrapper-driver (part of composer test) and ffi-driver (composer test:ffi, excluded from composer test, self-skips without the engine; composer analyze:ffi analyses the package with the real engine). The ffi package has unit tests, in-process tests weaving its stubs through z-engine inside the runner, and functional tests comparing the fixture probe tests/Fixtures/project/bin/driver-weaving.php with the stream filter driver; the wrapper package runs the core class weaving and magic constant suites through the wrapper and compares the same probe.
  • .github/workflows/ffi-driver.yml is triggered by packages/ffi-driver/**, src/Driver/**, the workflow and composer.json only, on PHP 8.4 and 8.5, with a plain composer require lisachenko/z-engine; the coverage job no longer installs z-engine (package coverage comes from the ffi workflow under the ffi-driver codecov flag).
  • Needs z-engine releases: the packagist releases 8.4.2 / 8.5.0 predate the redefine(preserveAs:) / addMethod() API; the 8.4 and 8.5 heads must be tagged 8.4.3 / 8.5.1 for the ffi workflow (and the package constraint ~8.4.3 || ~8.5.1) to resolve.

Docs: docs/driver/README.md (the concept, the interface, how to write a driver), stream-filter.md, stream-wrapper.md, ffi.md; AGENTS.md per package.

Verified locally on PHP 8.5.11 and 8.4.19: composer check (3284 tests, PHPStan level 10, PER-CS) and composer test:ffi (52 tests) green on both.

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 99.29825% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.19%. Comparing base (a4ead81) to head (41ef03a).

Files with missing lines Patch % Lines
...ges/stream-wrapper-driver/src/AopStreamWrapper.php 98.75% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #765      +/-   ##
============================================
+ Coverage     98.13%   98.19%   +0.05%     
- Complexity     1891     2021     +130     
============================================
  Files           113      118       +5     
  Lines          5037     5203     +166     
============================================
+ Hits           4943     5109     +166     
  Misses           94       94              
Flag Coverage Δ
framework 98.19% <99.29%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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

@lisachenko lisachenko left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too much changes in the framework + scattered implementation. I propose to refactor the implementation via low coupling - to define the driver as a plugin, which is stored in isolated package + namespace. And has minimum tangling.

You can restruct existing code to organize drivers first and to define interfaces and way how to use different weaving drivers.

Comment thread .github/workflows/phpunit.yml Outdated
Comment thread .github/workflows/phpunit.yml
Comment thread .github/workflows/phpunit.yml Outdated
Comment thread .github/workflows/phpunit.yml Outdated
Comment thread .github/workflows/phpunit.yml Outdated
Comment thread src/Aop/Exception/UnsupportedJoinpointException.php Outdated
Comment thread src/Aop/WeavingDriver.php Outdated
Comment thread src/Core/AspectContainer.php Outdated
Comment thread src/Instrument/AGENTS.md Outdated
Comment thread packages/ffi-driver/src/DonorTraitGenerator.php
…engine driver into packages/ffi-driver

The `driver` kernel option names the class of a `Go\Driver\WeavingDriver`: the kernel
instantiates it, registers it in the container and boots it once; a driver running the
transformer chain implements `Go\Driver\SourceTransformingDriver`, for which alone the
kernel registers the transformer services and the cache warmer builds a cache. The stream
filter driver stays the built-in default (`Go\Driver\StreamFilter\StreamFilterDriver`),
`SourceTransformingLoader::transformSource()` is the shared entry point of the
source-transforming drivers.

The z-engine driver becomes the ffi driver of its own package (`packages/ffi-driver`,
`Go\Driver\Ffi`): the donor is a trait named `<Short>OriginalTrait` (the suffix of the
trait codegen; `self::class` and `parent::` then bind at run time, so the joinpoint
reference hook leaves the core), functions are proxied from the registered pointcuts
through `AdvisorIndex` (no feature flag), the JIT guard switches an active JIT off on any
PHP version, exceptions use named constructors of the package's own classes. The package
has its own tests, composer.json and phpstan config; the root `composer test` excludes the
`ffi-driver` testsuite, `composer test:ffi` / `composer analyze:ffi` run it. CI: the
path-triggered `ffi-driver.yml` installs `lisachenko/z-engine` plainly (no branches, no
vcs repositories), the coverage job no longer touches z-engine. Docs per driver live in
`docs/driver/`, the option is documented as a class name, the demo is back to master's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
`goaop/stream-wrapper-driver` (`Go\Driver\StreamWrapper\StreamWrapperDriver`, a
SourceTransformingDriver) takes the place of the `file://` stream wrapper
(`stream_wrapper_unregister('file')` + `stream_wrapper_register('file', AopStreamWrapper::class)`):
every file included from within the weaving scope is served through
`SourceTransformingLoader::transformSource()`, every other file operation is delegated to the
native wrapper. Same join points, transformer chain, caches and warmup as the stream filter
driver; includes are intercepted by nature (`Features::INTERCEPT_INCLUDES` is refused as implied)
and opcache caches the woven sources under their original path, which it never does for a
`php://filter` include. The functional tests run the core class weaving and magic constant
suites through the wrapper and compare the fixture probe with the stream filter driver.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
@lisachenko lisachenko changed the title feat(instrument): add the zengine weaving driver on top of lisachenko/z-engine feat(driver): pluggable weaving drivers: stream filter, stream wrapper and ffi (z-engine) Oct 18, 2026
Boot of the ffi driver: the JIT guard runs before anything of z-engine is autoloaded, the
installed engine is probed without autoloading, and an engine release without the
method-table API (redefine() with preserveAs, addMethod(), fromClassTable()) is refused at
boot instead of failing at the first weave; the functional tests skip on such an engine.
ClassWeaver keys its results by the declared class name, never lets a file outside the
application root decide for its namespace, and takes no "no functions" decision while no
advisor is registered yet. The class-table refusal gets a named constructor of its own.

Core: an init()-level test pins the driver boot (once, registered in the container, before
configureAop(), transformer chain for source-transforming drivers only); the matrix test job
excludes the ffi suite like the coverage job; the init-flow notes of src/Instrument/AGENTS.md
follow transformSource() and isFreshRecord(); codecov flags are scoped to their paths; the
ffi workflow also reacts to the fixture project it consumes.

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

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

Copy link
Copy Markdown
Member Author

Status after the review round (head 12013dd, master #771 merged in): every check is green or running except the two ffi driver (PHP 8.4 / 8.5) jobs, which fail in composer analyze:ffi with

Parameter #1 $newCode of method ZEngine\Reflection\ReflectionMethod::redefine() expects Closure, ZEngine\Reflection\ReflectionMethod given.
Unknown parameter $preserveAs in call to method ZEngine\Reflection\ReflectionMethod::redefine().

That is not this PR's code: the plain composer require --dev lisachenko/z-engine asked for in the review resolves to the packagist releases 8.4.2 / 8.5.0, which predate the method-table API (redefine(..., preserveAs:), addMethod() with a method donor, fromClassTable()) merged into the z-engine 8.4 and 8.5 branches (lisachenko/z-engine#284, #282 and the follow-ups). The fix is on the z-engine side: tag the branch heads as releases, 8.4.3 on origin/8.4 and 8.5.1 on origin/8.5 (the package constraint is ~8.4.3 || ~8.5.1, and FfiDriver::boot() refuses an older engine with a message naming it). I could not push release tags from this session; once they exist, re-running the ffi weaving driver workflow is enough, nothing in this PR has to change.


Generated by Claude Code

…on messages

Ownership metadata, symlink stats, every operation of an unopened instance, the stat of a
deleted served file and an include through an unregistered instance are exercised; the three
guards PHP cannot trigger (a refused registration, an unreadable native handle, a php://memory
that does not open) are marked as not coverable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
__FILE__ and the opened include-path entry are compared through realpath() (backslashes on
Windows), the exact mode after chmod() is asserted on POSIX only, and the ownership/symlink
test skips where chown()/chgrp() and unprivileged symlinks do not exist.

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

The transformation record is keyed by the resolved path of the included file, which Windows
spells with backslashes; the unreachable registration failure moves into a method of its own
that coverage ignores.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…ealpath() as well

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