From e1a51e0538bd19ef2756f4f5903981e908fceac5 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:08:42 +0000 Subject: [PATCH 01/10] feat(instrument): add the zengine weaving driver on top of lisachenko/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 `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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/zengine.yml | 79 +++++ AGENTS.md | 4 +- CHANGELOG.md | 1 + README.md | 16 + composer.json | 13 +- docs/configuration.md | 4 +- docs/zengine-driver.md | 125 +++++++ phpstan-baseline-strict.php | 6 + phpstan.neon | 4 + phpstan/zengine-stubs.php | 54 +++ phpunit.xml.dist | 6 + .../UnsupportedJoinpointException.php | 53 +++ src/Aop/WeavingDriver.php | 62 ++++ src/Core/AspectContainer.php | 6 + src/Core/AspectKernel.php | 55 ++- src/Instrument/AGENTS.md | 8 + .../ClassLoading/AopComposerLoader.php | 15 +- .../ClassLoading/CachePathManager.php | 47 ++- src/Instrument/ClassLoading/CacheWarmer.php | 9 + .../ClassLoading/ComposerLoaderDecorator.php | 31 ++ .../ClassLoading/ZEngineComposerLoader.php | 142 ++++++++ src/Instrument/ZEngine/DonorCacheIndex.php | 225 +++++++++++++ src/Instrument/ZEngine/JitGuard.php | 106 ++++++ .../ZEngine/MethodTableRewriter.php | 78 +++++ src/Instrument/ZEngine/WeaveOutcome.php | 31 ++ src/Instrument/ZEngine/WeaveResult.php | 58 ++++ src/Instrument/ZEngine/ZEngineClassWeaver.php | 312 ++++++++++++++++++ src/Instrument/ZEngine/ZEngineDriver.php | 112 +++++++ src/Proxy/AGENTS.md | 6 + src/Proxy/ClassProxyGenerator.php | 16 +- src/Proxy/DonorClassGenerator.php | 201 +++++++++++ src/Proxy/Generator/MethodGenerator.php | 18 + src/Proxy/Generator/ParameterGenerator.php | 11 + src/Proxy/Generator/TypeGenerator.php | 65 ++++ tests/AGENTS.md | 6 + tests/Aop/WeavingDriverTest.php | 59 ++++ tests/Core/AspectKernelTest.php | 45 ++- tests/Core/Cache/CachedAspectLoaderTest.php | 2 + tests/Core/ContainerTest.php | 2 + .../Fixtures/project/bin/zengine-weaving.php | 126 +++++++ .../project/src/Application/ZEngineChild.php | 32 ++ .../project/src/Application/ZEngineLate.php | 16 + .../project/src/Application/ZEngineParent.php | 38 +++ .../src/Application/ZEngineUnadvised.php | 15 + .../src/Application/ZEngineUnsupported.php | 17 + .../project/src/Aspect/ZEngineAspect.php | 45 +++ .../src/Aspect/ZEngineUnsupportedAspect.php | 20 ++ .../src/Kernel/ZEngineAspectKernel.php | 19 ++ .../Kernel/ZEngineUnsupportedAspectKernel.php | 21 ++ tests/Fixtures/project/web/configuration.php | 55 +++ tests/Functional/BaseFunctionalTestCase.php | 18 + tests/Functional/CachePortabilityTest.php | 2 +- tests/Functional/ClassWeavingTest.php | 8 +- tests/Functional/DebugModeLoadingTest.php | 2 +- tests/Functional/DoctrineBridgeTest.php | 2 +- tests/Functional/MagicConstantTest.php | 2 +- tests/Functional/ProxyNameCollisionTest.php | 2 +- .../ZEngine/ZEngineFunctionalTestCase.php | 246 ++++++++++++++ .../Functional/ZEngine/ZEngineWeavingTest.php | 190 +++++++++++ .../ClassLoading/AopComposerLoaderTest.php | 39 ++- .../ClassLoading/CachePathManagerTest.php | 46 +++ .../ClassLoading/CacheWarmerTest.php | 2 + .../SourceTransformingLoaderTest.php | 2 + .../ZEngineComposerLoaderTest.php | 159 +++++++++ .../ZEngine/DonorCacheIndexTest.php | 181 ++++++++++ tests/Instrument/ZEngine/JitGuardTest.php | 110 ++++++ .../TransformationPerformanceTest.php | 2 + tests/Proxy/DonorClassGeneratorTest.php | 157 +++++++++ tests/Proxy/Generator/MethodGeneratorTest.php | 16 + .../Generator/ParameterGeneratorTest.php | 15 + tests/Proxy/Generator/TypeGeneratorTest.php | 30 ++ 71 files changed, 3686 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/zengine.yml create mode 100644 docs/zengine-driver.md create mode 100644 phpstan/zengine-stubs.php create mode 100644 src/Aop/Exception/UnsupportedJoinpointException.php create mode 100644 src/Aop/WeavingDriver.php create mode 100644 src/Instrument/ClassLoading/ComposerLoaderDecorator.php create mode 100644 src/Instrument/ClassLoading/ZEngineComposerLoader.php create mode 100644 src/Instrument/ZEngine/DonorCacheIndex.php create mode 100644 src/Instrument/ZEngine/JitGuard.php create mode 100644 src/Instrument/ZEngine/MethodTableRewriter.php create mode 100644 src/Instrument/ZEngine/WeaveOutcome.php create mode 100644 src/Instrument/ZEngine/WeaveResult.php create mode 100644 src/Instrument/ZEngine/ZEngineClassWeaver.php create mode 100644 src/Instrument/ZEngine/ZEngineDriver.php create mode 100644 src/Proxy/DonorClassGenerator.php create mode 100644 tests/Aop/WeavingDriverTest.php create mode 100644 tests/Fixtures/project/bin/zengine-weaving.php create mode 100644 tests/Fixtures/project/src/Application/ZEngineChild.php create mode 100644 tests/Fixtures/project/src/Application/ZEngineLate.php create mode 100644 tests/Fixtures/project/src/Application/ZEngineParent.php create mode 100644 tests/Fixtures/project/src/Application/ZEngineUnadvised.php create mode 100644 tests/Fixtures/project/src/Application/ZEngineUnsupported.php create mode 100644 tests/Fixtures/project/src/Aspect/ZEngineAspect.php create mode 100644 tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php create mode 100644 tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php create mode 100644 tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php create mode 100644 tests/Functional/ZEngine/ZEngineFunctionalTestCase.php create mode 100644 tests/Functional/ZEngine/ZEngineWeavingTest.php create mode 100644 tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php create mode 100644 tests/Instrument/ZEngine/DonorCacheIndexTest.php create mode 100644 tests/Instrument/ZEngine/JitGuardTest.php create mode 100644 tests/Proxy/DonorClassGeneratorTest.php diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml new file mode 100644 index 00000000..9f158290 --- /dev/null +++ b/.github/workflows/zengine.yml @@ -0,0 +1,79 @@ +name: "z-engine weaving driver" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - master + +# The zengine driver weaves loaded classes through lisachenko/z-engine (PHP FFI). The package is an optional +# dependency: its branches target one PHP minor each, so it is installed here only, on that minor, and the +# `zengine` PHPUnit group (excluded from the default run) exercises the driver in subprocesses with the JIT off. +env: + PHP_MINOR: "8.5" + ZENGINE_BRANCH: "dev-feature/redefine-preserve-alias" + +jobs: + zengine: + name: "z-engine driver (PHP ${{ matrix.php-version }}, opcache legs)" + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + php-version: + - "8.5" + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: shivammathur/setup-php@v2 + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: ffi, opcache + # The JIT rewrites the executor internals z-engine hooks into, and the PHP 8.5 tracing JIT + # miscompiles the framework: off for the test runner and (through the test base) its subprocesses + ini-values: memory_limit=-1, ffi.enable=1, opcache.enable=1, opcache.enable_cli=0, opcache.jit=off, opcache.jit_buffer_size=0 + tools: composer:v2 + + - name: "Assert ext-ffi and opcache are available" + run: | + php -r 'exit(extension_loaded("FFI") && extension_loaded("Zend OPcache") ? 0 : 1);' \ + || { echo "::error::ext-ffi or Zend OPcache is missing - the zengine group would silently skip"; exit 1; } + + - name: "Cache dependencies" + uses: actions/cache@v6 + with: + path: | + ~/.composer/cache + vendor + key: "composer-zengine-${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}-${{ env.ZENGINE_BRANCH }}" + restore-keys: "composer-zengine-${{ runner.os }}-php-${{ matrix.php-version }}-" + + - name: "Install dependencies with lisachenko/z-engine" + run: | + composer config repositories.zengine vcs https://github.com/lisachenko/z-engine + composer require --dev --no-update "lisachenko/z-engine:${ZENGINE_BRANCH}" + composer update --no-interaction --no-progress + + - name: "Assert the engine boots" + run: | + ZENGINE_AUTOBOOT=0 php -d ffi.enable=1 -r 'require "vendor/autoload.php"; ZEngine\Core::init(); echo "z-engine booted", PHP_EOL;' + + - name: "Static analysis with the real z-engine API" + run: "vendor/bin/phpstan analyze --memory-limit=1G" + + - name: "z-engine driver tests" + run: "composer test:zengine" + + - name: "Default test suite with z-engine installed" + run: "composer test" diff --git a/AGENTS.md b/AGENTS.md index 891ee8b1..dc27b5d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,12 @@ Intercepts PHP class loading pipeline: source stream filter transforms source - Init: AspectKernel::init() → stream filter → transformers → configureAop() - Main transformer: WeavingTransformer (class→trait, proxy class re-inherits parent+interfaces) - Proxy dispatch: per-method static $__joinPoint → InterceptorInjector → interceptor chain of first-class advice callables (The::aspect(X::class)->m(...), The::advice('id') for closure advices) +- Parallel driver (`driver` kernel option, Go\Aop\WeavingDriver): `zengine` weaves at runtime through lisachenko/z-engine (FFI) instead of the stream filter — ZEngineComposerLoader loads natively, ZEngineClassWeaver matches + generates the donor class `Foo__AopDonor` (DonorClassGenerator, cached by DonorCacheIndex), MethodTableRewriter swaps the advised bodies in place (`redefine(..., preserveAs: 'OriginalAlias')`) or publishes an override for inherited methods (`addMethod()`). Method execution only. docs/zengine-driver.md ## Directory → AGENTS.md map | Directory | Sub-AGENTS.md | Covers | |-------------------|----------------------------|---------------------------------------------------------------| -| `src/Instrument/` | `src/Instrument/AGENTS.md` | Init flow, transformers, trait engine, line numbers, Override | +| `src/Instrument/` | `src/Instrument/AGENTS.md` | Init flow, transformers, trait engine, line numbers, Override, zengine driver | | `src/Proxy/` | `src/Proxy/AGENTS.md` | Proxy generators, code-gen, readonly, hooks, enums | | `src/Aop/` | `src/Aop/AGENTS.md` | Interfaces, generics, implementations, pointcuts, attributes | | `src/Core/` | `src/Core/AGENTS.md` | Container, aspect loading, advice matching, bridge | @@ -45,4 +46,5 @@ Intercepts PHP class loading pipeline: source stream filter transforms source - New classes are `final` unless they are meant to be extended. - No anonymous classes in `src/`: extract a named `final` class (`@internal` for machinery); anonymous classes are for tests and mocks. - Commit messages and PR titles must follow Conventional Commits (`type(scope): summary`, e.g. `fix(proxy): ...`). +- lisachenko/z-engine is an optional dependency (its branches target one PHP minor each): never add it to require/require-dev; `phpstan/zengine-stubs.php` (scanFiles) declares the API the zengine driver uses, keep it in sync; the `zengine` PHPUnit group runs with `composer test:zengine` (JIT off, ffi on) and self-skips without the engine; CI installs z-engine only in .github/workflows/zengine.yml. - Generated code (proxies, woven traits, advisor caches) must stay clean and readable: reference classes through short `use` aliases, never replace them with fully-qualified names. Name collisions are detected and resolved by adjusting the imports automatically (`Go\Proxy\Generator\ProxyImports`, see `src/Proxy/AGENTS.md`). diff --git a/CHANGELOG.md b/CHANGELOG.md index c8bdff5b..f0e94abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide. * [BC BREAK] **Dependencies** — `ext-tokenizer`, `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0 and `symfony/finder` ^7.4 || ^8.0. `nikic/php-parser` 5 comes through parser-reflection and is no longer required directly. `symfony/console` ^7.4 || ^8.0 is needed for the `bin/aspect` tool. **Added** +* [Feature] **zengine weaving driver (experimental)** — the new `driver` kernel option selects how advised classes are rewired: `stream` (the default, source transformation through the stream filter) or `zengine`, which weaves at runtime through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI). With the engine driver classes load natively and, right after a class is loaded, the body of every advised method is swapped in place with a generated dispatcher while the original body stays callable under the private `OriginalAlias` (inherited methods get an engine-published override): no source rewriting, no stream filter, no proxy class, no include interception. Dispatcher bodies are compiled in a cached donor class `Foo__AopDonor` (`Go\Proxy\DonorClassGenerator`, index `{cacheDir}/_zengine.cache`). Method execution join points only; property access, introductions, initialization join points, function interception and prebuilt caches stay with the stream driver and are refused with `Go\Aop\Exception\UnsupportedJoinpointException`. Requires `ext-ffi`, the z-engine branch of the running PHP minor and the opcache JIT off (on PHP 8.5 the driver switches the tracing JIT off at runtime, it miscompiles the framework). See `docs/zengine-driver.md`. * [Feature] **First-class callable advices** — the main way advices are now wired into woven code. Generated proxies declare each aspect-method advice as `Interceptor::before(The::aspect(MonitorAspect::class)->beforeMethodExecution(...))` — an eager first-class callable, since the interceptor list is only built while the intercepted method or hook is already executing (`LazyAdvisorAccessor` is removed). Compiled advisor cache files use the same facade in its lazy static-data form, e.g. `Interceptor::before(MonitorAspect::class, 'beforeMethodExecution')`, which returns a native PHP lazy proxy: interceptor construction, aspect resolution from the container and advice callable creation are all deferred until the advice is actually used, so cached advices whose pointcut never matches never instantiate their aspect. Advices registered in the container as plain closures (not aspect methods) are resolved through the new `The::advice('advisorId')` accessor, which unwraps `Advisor` and interceptor values down to the raw advice closure. * [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses. * [Feature] **Property interception with native PHP 8.4 property hooks** — `access(...)` pointcuts intercept reads and writes of public, protected and private instance properties, including constructor-promoted properties and properties declared in traits. The proxy re-declares every intercepted property with `get`/`set` hooks that dispatch through `FieldAccess` joinpoints (by reference for arrays, so indirect modification keeps working); asymmetric visibility (`private(set)`, `protected(set)`) and property attributes are preserved. Readonly, static and already-hooked properties are never intercepted, see `docs/php84-limitations.md`. diff --git a/README.md b/README.md index aa4334b9..6c6dadcc 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,22 @@ use Go\Core\AspectContainer; ### Optional configurations +#### Runtime weaving with z-engine (experimental) + +Instead of rewriting sources through the stream filter, the `zengine` driver rewires advised methods at runtime through +[lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): classes load natively, the original method body +stays callable under a private alias and the generated dispatcher is swapped in. Method execution join points only. + +```php +$applicationAspectKernel->init([ + 'driver' => 'zengine', + 'cacheDir' => __DIR__ . '/../var/cache/aop', + // ... +]); +``` + +Requires `ext-ffi`, the z-engine branch of your PHP minor and the opcache JIT off, see [docs/zengine-driver.md](docs/zengine-driver.md). + #### Weaving Doctrine entities Doctrine ORM (3.6+) maps a woven entity as is: the woven class keeps its name and all mapping diff --git a/composer.json b/composer.json index 1f3a511b..32df705a 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,9 @@ }, "suggest": { "doctrine/orm": "Maps woven entities through Go\\Bridge\\Doctrine\\WovenEntityClassLocator (ORM ^3.6).", - "symfony/console": "Enables the bin/aspect command-line tool (^7.4 || ^8.0)." + "symfony/console": "Enables the bin/aspect command-line tool (^7.4 || ^8.0).", + "lisachenko/z-engine": "Runtime method-table weaving through the zengine driver (branch of your PHP minor, ext-ffi required, see docs/zengine-driver.md)", + "ext-ffi": "Required by the zengine weaving driver" }, "autoload": { "psr-4": { @@ -98,9 +100,13 @@ ], "cs": "php-cs-fixer check --diff", "cs:fix": "php-cs-fixer fix", - "test": "phpunit", + "test": [ + "@putenv ZENGINE_AUTOBOOT=0", + "phpunit" + ], "test:performance": "phpunit --group performance", "test:performance:transformation": "@php -d opcache.jit=disable vendor/bin/phpunit --group transformation-performance", + "test:zengine": "@php -d ffi.enable=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit --group zengine", "regenerate:parse-table": [ "dissect Go/Aop/Pointcut/PointcutGrammar", "@php -r \"rename('src/Aop/Pointcut/parse_table.php', 'src/Aop/Pointcut/PointcutParseTable.php');\"" @@ -114,6 +120,7 @@ "test": "Run the PHPUnit test suite", "regenerate:parse-table": "Regenerate the pointcut parse table after a change of PointcutGrammar", "test:performance": "Run the joinpoint hot-path performance group", - "test:performance:transformation": "Run the source transformation performance group (JIT disabled)" + "test:performance:transformation": "Run the source transformation performance group (JIT disabled)", + "test:zengine": "Run the z-engine weaving driver group (needs lisachenko/z-engine and ext-ffi, JIT disabled)" } } diff --git a/docs/configuration.md b/docs/configuration.md index 02d155f1..b6e50dec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,10 +26,12 @@ bit throws `Go\Aop\Exception\InvalidConfigurationException`. | `features` | `int` | `0` | Bitmask of `Go\Aop\Features` flags, see below. | | `cacheFileMode` | `int` | `0770` minus the umask | Permissions of cache files. Must be between `0600` and `0777` and give the owner read and write access; directories get matching search bits. | | `containerClass` | `class-string` | `Go\Core\Container` | Aspect container implementation, must implement `Go\Core\AspectContainer`. | +| `driver` | `string` or `Go\Aop\WeavingDriver` | `'stream'` | Weaving driver: `stream` (source transformation through the stream filter, pure PHP) or `zengine` (runtime method-table weaving through lisachenko/z-engine and FFI, method execution only), see [docs/zengine-driver.md](zengine-driver.md). | ## Features -Combine the flags with `|`, e.g. `'features' => Features::INTERCEPT_FUNCTIONS | Features::PREBUILT_CACHE`. +Combine the flags with `|`, e.g. `'features' => Features::INTERCEPT_FUNCTIONS | Features::PREBUILT_CACHE`. The flags +configure source transformers, so they are only available with the `stream` driver; the `zengine` driver refuses them. | Flag | Effect | |-----------------------------|--------| diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md new file mode 100644 index 00000000..8a05b55b --- /dev/null +++ b/docs/zengine-driver.md @@ -0,0 +1,125 @@ +# The zengine weaving driver + +The framework weaves advised classes with one of two drivers, selected by the `driver` kernel option: + +| Driver | How advised classes are rewired | +|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `stream` (default) | Source transformation at load time: a composer autoloader wrapper redirects advised files through the `php://filter` stream filter, which converts the class to a trait and generates a proxy class into the cache directory. Pure PHP. | +| `zengine` | Runtime method-table weaving through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): classes load natively and, right after a class is loaded, the body of every advised method is swapped in place with a generated dispatcher, while the original body stays callable under a private alias. No source rewriting, no stream filter, no include interception. | + +Everything above the driver is shared: aspects, pointcuts, the advisor cache, the interceptor chain and the +join-point API. An aspect written for one driver runs unchanged on the other, within the limits listed below. + +## Requirements + +- `ext-ffi` with `ffi.enable=1` (or `preload` with a preload script booting z-engine). +- `lisachenko/z-engine`, the branch matching your PHP minor (`8.5` for PHP 8.5). The package is an optional + dependency of the framework, because every branch of it targets one PHP minor: + + ```bash + composer config repositories.zengine vcs https://github.com/lisachenko/z-engine + composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias + ``` + + Until the `redefine(..., preserveAs:)` API is merged, the branch above is the one the driver is built against. +- The opcache JIT **off** for the whole process: `opcache.jit=off` and `opcache.jit_buffer_size=0` in `php.ini`, the + FPM pool or `php -d`. z-engine hooks the executor internals the JIT bypasses. On PHP 8.5 the tracing JIT also + miscompiles code the framework runs (a known PHP bug, see `docs/php85-limitations.md`), so there the driver switches + the JIT off at runtime as a safety net and records that in its boot report; the process-level setting stays the + requirement, since code compiled before the kernel ran keeps its JIT state. + +A kernel configured for the `zengine` driver without z-engine, without FFI or with the JIT on throws +`Go\Aop\Exception\InvalidConfigurationException` from `init()` with the missing requirement in the message. + +## Configuration + +```php +ApplicationAspectKernel::getInstance()->init([ + 'driver' => 'zengine', // or Go\Aop\WeavingDriver::ZEngine + 'debug' => false, + 'appDir' => __DIR__ . '/..', + 'cacheDir' => __DIR__ . '/../var/cache/aop', + 'includePaths' => [__DIR__ . '/../src'], +]); +``` + +`includePaths`, `excludePaths`, `debug`, `cacheDir` and `cacheFileMode` mean the same as with the stream driver. +The `features` option must not contain `INTERCEPT_FUNCTIONS`, `INTERCEPT_INITIALIZATIONS`, `INTERCEPT_INCLUDES` or +`PREBUILT_CACHE`: those flags configure source transformers, and the engine driver never rewrites sources. + +## How a class is woven + +1. The kernel wraps composer's class loader with `Go\Instrument\ClassLoading\ZEngineComposerLoader`, which includes + the original file exactly as composer would (opcache caches it, magic constants and line numbers are untouched). +2. Right after the include, `Go\Instrument\ZEngine\ZEngineClassWeaver` matches the advisors against the class (from + the parsed source, so attributes, parameter defaults and the file's imports are seen as written) and generates + the **donor class** `Ns\Foo__AopDonor` next to the class in its namespace: an abstract class, never instantiated, + extending the same parent and implementing the same interfaces, that declares exactly the advised methods with + the dispatcher bodies a proxy class would get (`static $__joinPoint = InterceptorInjector::forMethod(...)`). + The donor file is written below `{cacheDir}/_zengine/`, mirroring the application tree, and recorded in the index + `{cacheDir}/_zengine.cache` together with the advised method names. +3. `Go\Instrument\ZEngine\MethodTableRewriter` hands the class and its donor to z-engine: + - a method the class declares itself keeps its body under the private alias `OriginalAlias` and gets the + dispatcher body swapped in place (`ReflectionMethod::redefine($donorMethod, preserveAs: ...)`); the dispatcher + calls `$this->OriginalAlias(...)` like a trait-based proxy; + - a method the class only inherits gets an own override adopting the dispatcher body + (`ReflectionClass::addMethod($method, $donorMethod)`), whose dispatcher reaches the original through + `parent::(...)`. + +A warm request finds a fresh record in the index (same freshness rule as the stream driver: source size and mtime, +tracked resources in debug mode), loads the donor file and rewires the class without loading the aspects or running +the matcher. + +Classes declared before the kernel was initialized (required directly, never autoloaded) are not seen by the loader. +Weave them on demand: + +```php +$weaver = $kernel->getContainer()->getService(Go\Instrument\ZEngine\ZEngineClassWeaver::class); +$result = $weaver->weave(App\Legacy::class); // Go\Instrument\ZEngine\WeaveResult: outcome and rewired methods +``` + +Weave before instances exist and before subclasses link: the engine mutation reaches neither objects created earlier +(under opcache) nor, for a class published in opcache shared memory, subclasses that were linked against the shared +entry (z-engine refuses that mutation, the driver reports it as `Go\Aop\Exception\UnsupportedJoinpointException`). + +## Supported join points + +The engine driver rewires **method bodies**: `execution(...)` pointcuts on instance and static methods, public, +protected and private, declared or inherited, in regular classes (final, abstract and readonly classes included). + +Everything else only the stream driver provides, since it needs rewritten sources: + +- property access (`access(...)` pointcuts), +- introductions (interfaces and traits), +- `initialization(...)` and `staticinitialization(...)` pointcuts, +- function interception (`Features::INTERCEPT_FUNCTIONS`), include interception, prebuilt caches, +- traits, enums and interfaces as weaving targets. + +An advisor matching an unsupported join point kind on a class makes the first load of that class throw +`UnsupportedJoinpointException` naming the advisor and the kind; the class is then declared and linked, but not +woven. Narrow the pointcut to method execution, or run that application with the stream driver. + +## Differences from the stream driver + +- The woven class is the original class: no `FooOriginalTrait`, no proxy class, `Go\Aop\Proxy` is not implemented + and `get_class()`/`instanceof` see no difference at all. +- Reflection of an advised method reports the donor file as the file of its body (`getFileName()`, + `getStartLine()`), since that is where the dispatcher was compiled; the class itself keeps its original file. +- A private or final method whose body is a single literal may have been inlined by the opcache optimizer into its + callers before the swap: those call sites keep the literal. +- `cache:warmup:aop` and `debug:weaving` work on the stream driver's cache and refuse a kernel configured for the + engine driver. Warm an application running the engine driver by loading its classes (a smoke request), the donor + index then serves every later request. + +## Boot report + +`Go\Instrument\ZEngine\ZEngineDriver::getBootReport($container)` returns what the boot did: PHP version, whether +opcache is active, and the JIT guard report (`jitWasActive`, `previousMode`, `disabledAtRuntime`). + +## Development + +`composer test:zengine` runs the `zengine` PHPUnit group (`tests/Functional/ZEngine/`), which is excluded from the +default run: it boots the engine in PHP subprocesses (both opcache legs) and compares the driver with the stream +driver on the same fixtures. The group self-skips where the engine cannot boot. `.github/workflows/zengine.yml` runs +it on PHP 8.5 with z-engine installed; the other workflows do not install z-engine (`phpstan/zengine-stubs.php` +declares the API the driver uses, for the analysis without the package). diff --git a/phpstan-baseline-strict.php b/phpstan-baseline-strict.php index 1a815e11..700c4792 100644 --- a/phpstan-baseline-strict.php +++ b/phpstan-baseline-strict.php @@ -295,6 +295,12 @@ 'count' => 1, 'path' => __DIR__ . '/src/Proxy/TraitProxyGenerator.php', ]; +$ignoreErrors[] = [ + 'message' => '#^Go\\\\Proxy\\\\DonorClassGenerator\\:\\:__construct\\(\\) does not call parent constructor from Go\\\\Proxy\\\\ClassProxyGenerator\\.$#', + 'identifier' => 'constructor.missingParentCall', + 'count' => 1, + 'path' => __DIR__ . '/src/Proxy/DonorClassGenerator.php', +]; $ignoreErrors[] = [ 'message' => '#^Go\\\\Proxy\\\\TraitProxyGenerator\\:\\:__construct\\(\\) does not call parent constructor from Go\\\\Proxy\\\\ClassProxyGenerator\\.$#', 'identifier' => 'constructor.missingParentCall', diff --git a/phpstan.neon b/phpstan.neon index d6a1ade0..0e5db1a6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,10 @@ parameters: - demos - src - tests + # lisachenko/z-engine is an optional dependency (installed only on the PHP minor its branch targets): the API the + # zengine weaving driver uses is declared in this file for the analysis of a host without the package + scanFiles: + - phpstan/zengine-stubs.php strictRules: # PHPUnit's assertions are static methods that are idiomatically called as $this->assert*() dynamicCallOnStaticMethod: false diff --git a/phpstan/zengine-stubs.php b/phpstan/zengine-stubs.php new file mode 100644 index 00000000..55c7a734 --- /dev/null +++ b/phpstan/zengine-stubs.php @@ -0,0 +1,54 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +/* + * Declarations of the lisachenko/z-engine API used by the zengine weaving driver (src/Instrument/ZEngine), for + * static analysis on a host where the package is not installed: it is an optional dependency, which only installs + * on the PHP minor its branch targets. PHPStan scans this file (`scanFiles` in phpstan.neon) and uses these + * declarations when the real classes are not autoloadable; they are never included at runtime. + * + * Keep the signatures in sync with the z-engine branch named in docs/zengine-driver.md. + */ + +namespace ZEngine { + final class Core + { + public static function isInitialized(): bool {} + + public static function init(): void {} + } +} + +namespace ZEngine\Reflection { + interface FunctionLikeInterface + { + public function getFunctionName(): ?string; + + public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $preserveAs = null): void; + } + + class ReflectionMethod extends \ReflectionMethod implements FunctionLikeInterface + { + public function getFunctionName(): ?string {} + + public function redefine(\Closure|FunctionLikeInterface $newBody, ?string $preserveAs = null): void {} + } + + class ReflectionClass extends \ReflectionClass + { + public static function fromClassTable(string $className): ?self {} + + public function getMethod(string $name): ReflectionMethod {} + + public function addMethod(string $methodName, \Closure|FunctionLikeInterface $method): ReflectionMethod {} + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e8e3e633..8c22b9ca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,12 @@ ratios from growing. --> transformation-performance + + zengine diff --git a/src/Aop/Exception/UnsupportedJoinpointException.php b/src/Aop/Exception/UnsupportedJoinpointException.php new file mode 100644 index 00000000..d653b14f --- /dev/null +++ b/src/Aop/Exception/UnsupportedJoinpointException.php @@ -0,0 +1,53 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop\Exception; + +/** + * Raised by the z-engine weaving driver when an advice matched a join point kind the driver + * cannot weave at runtime (property access, introductions, initialization join points, + * function interception) + * + * The exception escapes the autoloader at the first load of the offending class: the class + * is then declared and linked, but NOT woven. Narrow the pointcut, or use the stream driver + * for such aspects. + */ +final class UnsupportedJoinpointException extends WeavingException +{ + /** + * @param list $kinds Join-point kinds matched for the class (AspectContainer prefixes) + * @param list $advisorIds Advisor identifiers behind those join points + */ + public static function forClass(string $className, array $kinds, array $advisorIds): self + { + return new self(sprintf( + 'The zengine weaving driver cannot weave %s: the advisor(s) %s match join point kind(s) %s, ' + . 'which only the stream driver supports (runtime weaving rewires method bodies only). ' + . 'Narrow the pointcut(s) to method execution, or run this application with the stream driver.', + $className, + implode(', ', $advisorIds), + implode(', ', $kinds), + )); + } + + /** + * The class cannot be rewired because z-engine refused the engine mutation + */ + public static function engineRefusal(string $className, \Throwable $reason): self + { + return new self(sprintf( + 'The zengine weaving driver cannot weave %s: %s', + $className, + $reason->getMessage(), + ), 0, $reason); + } +} diff --git a/src/Aop/WeavingDriver.php b/src/Aop/WeavingDriver.php new file mode 100644 index 00000000..bd33f65e --- /dev/null +++ b/src/Aop/WeavingDriver.php @@ -0,0 +1,62 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop; + +use Go\Aop\Exception\InvalidConfigurationException; + +/** + * Weaving drivers selectable through the `driver` kernel option + * + * The driver decides HOW advised classes are rewired to the join-point runtime; everything + * else (aspects, pointcuts, advisor cache, the interceptor chain) is shared. + */ +enum WeavingDriver: string +{ + /** + * Source transformation at load time: a composer autoloader wrapper redirects advised + * files through the `php://filter` stream filter, which converts the class to a trait + * and generates a proxy class into the cache directory (the default, pure PHP) + */ + case Stream = 'stream'; + + /** + * Runtime method-table weaving through lisachenko/z-engine (PHP FFI): classes load + * natively, and right after a class is loaded the body of every advised method is + * swapped in place with a generated dispatcher while the original body stays callable + * under a private alias. No source rewriting, no stream filter, no include interception. + * Requires ext-ffi, the z-engine branch matching the PHP minor, and the opcache JIT off. + */ + case ZEngine = 'zengine'; + + /** + * Resolves the `driver` kernel option: the enum itself or its string value + * + * @throws InvalidConfigurationException For anything else + */ + public static function fromOption(mixed $option): self + { + if ($option instanceof self) { + return $option; + } + $driver = is_string($option) ? self::tryFrom($option) : null; + if ($driver === null) { + throw new InvalidConfigurationException(sprintf( + 'Option "driver" must be one of %s (or a Go\\Aop\\WeavingDriver case), got %s.', + implode(', ', array_map(static fn(self $case): string => '"' . $case->value . '"', self::cases())), + is_string($option) ? '"' . $option . '"' : get_debug_type($option), + )); + } + + return $driver; + } +} diff --git a/src/Core/AspectContainer.php b/src/Core/AspectContainer.php index 68fc4a66..3bd2bcf4 100644 --- a/src/Core/AspectContainer.php +++ b/src/Core/AspectContainer.php @@ -70,6 +70,12 @@ interface AspectContainer */ public const string ORIGINAL_TRAIT_FILE_SUFFIX = self::ORIGINAL_TRAIT_SUFFIX . '.php'; + /** + * Suffix of the donor class of the z-engine driver: the dispatcher bodies woven into `Foo` are compiled in + * the abstract class `Foo__AopDonor` (a name no PSR-4 class uses, next to the class in its namespace) + */ + public const string DONOR_CLASS_SUFFIX = '__AopDonor'; + /** * Returns a service from the container. * diff --git a/src/Core/AspectKernel.php b/src/Core/AspectKernel.php index 389ec22f..201b3d80 100644 --- a/src/Core/AspectKernel.php +++ b/src/Core/AspectKernel.php @@ -17,6 +17,7 @@ use Go\Aop\AspectException; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Features; +use Go\Aop\WeavingDriver; use Go\Core\Cache\CachedAspectLoader; use Go\Instrument\ClassLoading\AopComposerLoader; use Go\Instrument\ClassLoading\CachePathManager; @@ -26,6 +27,7 @@ use Go\Instrument\Transformer\FilterInjectorTransformer; use Go\Instrument\Transformer\MagicConstantTransformer; use Go\Instrument\Transformer\WeavingTransformer; +use Go\Instrument\ZEngine\ZEngineDriver; use ReflectionClass; /** @@ -39,7 +41,8 @@ * features: int, * includePaths: string[], * excludePaths: string[], - * containerClass: class-string + * containerClass: class-string, + * driver: WeavingDriver * } * @phpstan-type UserKernelOptions array{ * debug?: bool, @@ -49,7 +52,8 @@ * features?: int, * includePaths?: string[], * excludePaths?: string[], - * containerClass?: class-string + * containerClass?: class-string, + * driver?: WeavingDriver|string * } */ abstract class AspectKernel @@ -60,10 +64,19 @@ abstract class AspectKernel private const array RUNTIME_DEPENDENCIES = [ 'goaop/dissect', 'goaop/parser-reflection', + 'lisachenko/z-engine', 'nikic/php-parser', 'symfony/finder', ]; + /** + * Feature flags implemented by source transformers: meaningless for a driver that never rewrites sources + */ + private const int STREAM_ONLY_FEATURES = Features::INTERCEPT_FUNCTIONS + | Features::INTERCEPT_INITIALIZATIONS + | Features::INTERCEPT_INCLUDES + | Features::PREBUILT_CACHE; + /** * Kernel options * @@ -78,6 +91,7 @@ abstract class AspectKernel 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]; /** @@ -158,15 +172,21 @@ public function init(array $options = []): void // The class loader queries the cache index right below on every request, a lazy proxy would be pure overhead $container->add(CachePathManager::class, new CachePathManager($this)); - // The whole transformer pipeline (and the stream filter itself) is only needed on - // a cache miss, so every transformer is registered as a typical deferred container - // service and brought up by SourceTransformingLoader::ensureRegistered() from the - // miss path. Caching itself lives in SourceTransformingLoader, which serves cache - // hits before any transformer (or even the parser) is touched - the overridable - // hook below only registers the transformation chain. - $this->registerTransformerServices($container); - - AopComposerLoader::init($this->options, $container); + if ($this->options['driver'] === WeavingDriver::ZEngine) { + // Runtime weaving: classes load natively and are rewired through z-engine right + // after loading; no transformer chain, no stream filter, no include interception + ZEngineDriver::boot($this, $container); + } else { + // The whole transformer pipeline (and the stream filter itself) is only needed on + // a cache miss, so every transformer is registered as a typical deferred container + // service and brought up by SourceTransformingLoader::ensureRegistered() from the + // miss path. Caching itself lives in SourceTransformingLoader, which serves cache + // hits before any transformer (or even the parser) is touched - the overridable + // hook below only registers the transformation chain. + $this->registerTransformerServices($container); + + AopComposerLoader::init($this->options, $container); + } // In debug mode every lazily registered aspect's source file must be tracked as a // resource right away: the cache freshness checks consult it before any aspect @@ -244,6 +264,9 @@ public function getOptions(): array * features - integer Binary mask of features * includePaths - array Whitelist of directories where aspects should be applied. Empty for everywhere. * excludePaths - array Blacklist of directories or files where aspects shouldn't be applied. + * driver - Go\Aop\WeavingDriver (or its string value) The weaving driver: "stream" (source + * transformation at load time, the default) or "zengine" (runtime method-table + * weaving through lisachenko/z-engine, see docs/zengine-driver.md) * * @phpstan-return KernelOptions */ @@ -258,6 +281,7 @@ protected function getDefaultOptions(): array 'includePaths' => [], 'excludePaths' => [], 'containerClass' => static::$containerClass, + 'driver' => WeavingDriver::Stream, ]; } @@ -325,6 +349,14 @@ protected function normalizeOptions(array $options): array is_int($features) ? (string) $features : get_debug_type($features), )); } + $driver = WeavingDriver::fromOption($merged['driver'] ?? WeavingDriver::Stream); + if ($driver === WeavingDriver::ZEngine && ($features & self::STREAM_ONLY_FEATURES) !== 0) { + throw new InvalidConfigurationException( + 'The zengine weaving driver rewires loaded classes at runtime and never rewrites sources: ' + . 'the INTERCEPT_FUNCTIONS, INTERCEPT_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE ' + . 'features are only available with the stream driver.', + ); + } $rawIncludePaths = is_array($merged['includePaths'] ?? null) ? $merged['includePaths'] : []; $includePaths = array_values(array_filter($rawIncludePaths, is_string(...))); $debug = is_bool($merged['debug'] ?? null) ? $merged['debug'] : false; @@ -366,6 +398,7 @@ protected function normalizeOptions(array $options): array 'includePaths' => array_values(array_filter($resolvedIncludePaths, is_string(...))), 'excludePaths' => array_values(array_filter($resolvedExcludePaths, is_string(...))), 'containerClass' => $containerClass, + 'driver' => $driver, ]; } diff --git a/src/Instrument/AGENTS.md b/src/Instrument/AGENTS.md index 79d4e6d9..c2b7aa31 100644 --- a/src/Instrument/AGENTS.md +++ b/src/Instrument/AGENTS.md @@ -7,6 +7,14 @@ 4. Caching lives in SourceTransformingLoader::filter() — cache hit → cached content emitted as-is (no parsing, no transformers); miss → StreamMetaData + transformer chain → write cache 5. Freshness rule lives in one place: CachePathManager::queryFreshCacheState() (record filemtime/filesize, container isFreshSince(cachedAt); PREBUILT_CACHE trusts records), used by the filter and the debug autoloader +## zengine driver (src/Instrument/ZEngine/, `'driver' => 'zengine'`) +Runtime weaving through lisachenko/z-engine (FFI) in place of steps 2-4 above; AspectKernel::init() branches on the driver. ZEngineDriver::boot(): requires z-engine (class_exists Core), JitGuard::enforce() (JIT must be off; PHP 8.5: ini_set('opcache.jit','off') at runtime, reported), Core::init(), registers DonorCacheIndex / MethodTableRewriter / ZEngineClassWeaver as lazy services, ZEngineComposerLoader::init() (wraps composer's loader, unwrapping either driver's wrapper: ComposerLoaderDecorator). +- ZEngineComposerLoader::loadClass(): include the original file natively, then ZEngineClassWeaver::weaveLoadedClass() when the path filter allows (the subclass that triggered the autoload links against the already woven parent). +- ZEngineClassWeaver: once per class per request (failures remembered and rethrown); DonorCacheIndex::findFresh() → warm: include donor + rewire; miss: load aspects, AdviceMatcher on the parsed source (ReflectionFile, native fallback), refuse non-method kinds (UnsupportedJoinpointException::forClass), DonorClassGenerator → `{cacheDir}/_zengine//__AopDonor.php`, record. `weave($class)` is the public entry for classes declared before the kernel. +- MethodTableRewriter: own method → `$class->getMethod($m)->redefine($donorMethod, preserveAs: $m . 'OriginalAlias')`; inherited → `$class->addMethod($m, $donorMethod)`; EngineClass::fromClassTable() re-resolved per method (opcache copy-out repoints the bucket); z-engine refusals → UnsupportedJoinpointException::engineRefusal(). +- DonorCacheIndex: `{cacheDir}/_zengine.cache` (version, classes: source/filemtime/filesize/cachedAt/donor/donorClass/methods), freshness = CachePathManager::isFreshRecord() (the one shared rule), written via CachePathManager::writeIndexFile(), flushed at destruct. Separate from _transformation.cache on purpose (a record without cacheUri means "serve natively" to the stream driver). +- Stream-only: Features::INTERCEPT_* and PREBUILT_CACHE (refused in normalizeOptions), CacheWarmer/debug:weaving (refused), property/introduction/init join points. + ## Transformer chain (order matters) Applied per loaded file. Each returns TransformerResult: Transformed|Abstain|Aborted (skips the rest of the chain, reverts to the original source, recorded as untransformed). diff --git a/src/Instrument/ClassLoading/AopComposerLoader.php b/src/Instrument/ClassLoading/AopComposerLoader.php index 72260a15..43dc7e11 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -25,7 +25,7 @@ * * @phpstan-import-type KernelOptions from AspectKernel */ -final class AopComposerLoader +final class AopComposerLoader implements ComposerLoaderDecorator { /** * File enumerator @@ -100,7 +100,8 @@ public function __construct( * Initialize aspect autoloader and returns status whether initialization was successful or not * * Replaces original composer autoloader with wrapper. A loader wrapped by an earlier call - * is wrapped again around its original composer loader, with the given options and container. + * (of either weaving driver) is wrapped again around its original composer loader, with the + * given options and container. * * @phpstan-param KernelOptions $options Aspect kernel options */ @@ -113,7 +114,7 @@ public static function init(array $options, AspectContainer $container): bool $loaderToUnregister = $loader; if (is_array($loader)) { $originalLoader = $loader[0]; - if ($originalLoader instanceof self) { + if ($originalLoader instanceof ComposerLoaderDecorator) { $originalLoader = $originalLoader->getOriginalLoader(); } if ($originalLoader instanceof ClassLoader) { @@ -145,16 +146,16 @@ public function getOriginalLoader(): ClassLoader } /** - * Finds the original source file of a class through the composer loaders wrapped by AopComposerLoader::init(), - * without loading the class + * Finds the original source file of a class through the composer loaders wrapped by the kernel (by this + * loader or by the loader of the z-engine driver), without loading the class * * @return string|null Path given by composer, or null when no wrapped composer loader knows the class */ public static function findOriginalFile(string $class): ?string { foreach (spl_autoload_functions() as $loader) { - if (is_array($loader) && $loader[0] instanceof self) { - $file = $loader[0]->original->findFile($class); + if (is_array($loader) && $loader[0] instanceof ComposerLoaderDecorator) { + $file = $loader[0]->getOriginalLoader()->findFile($class); if ($file !== false) { return $file; } diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index 2aa781cd..26a2319c 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -300,23 +300,39 @@ public function getCachePathForResource(string $resource): ?string public function queryFreshCacheState(string $resource, AspectContainer $container): ?array { $cacheState = $this->queryCacheState($resource); + if ($cacheState === null) { + return null; + } + return $this->isFreshRecord($cacheState, $resource, $container) ? $cacheState : null; + } + + /** + * Decides whether a cache record is still valid for the source it was built from + * + * The freshness rule itself, shared by every cache index of the framework (the transformation metadata + * of the stream driver and the donor index of the z-engine driver), so staleness is decided in one place. + * + * @param array $record Cache record carrying `filemtime`, `filesize` and `cachedAt` + */ + public function isFreshRecord(array $record, string $resource, AspectContainer $container): bool + { // With a prebuilt cache (built at deploy time) an existing cache record is trusted as-is: no filemtime or // tracked-resource freshness checks - staleness is the deployer's responsibility - if ($cacheState === null || ($this->options['features'] & Features::PREBUILT_CACHE) !== 0) { - return $cacheState; + if (($this->options['features'] & Features::PREBUILT_CACHE) !== 0) { + return true; } // The record keeps the size and mtime of the source it was woven from: any difference, also an older // mtime restored by a deployment (rsync -t, checkout of an older revision), means the source changed. // Weaving depends on the tracked resources too (kernel and aspect files in debug mode) - $cachedAt = $cacheState['cachedAt'] ?? 0; - $isStale = ($cacheState['filemtime'] ?? null) !== filemtime($resource) - || ($cacheState['filesize'] ?? null) !== filesize($resource) - || (isset($cacheState['cacheUri']) && !is_string($cacheState['cacheUri'])) + $cachedAt = $record['cachedAt'] ?? 0; + $isStale = ($record['filemtime'] ?? null) !== filemtime($resource) + || ($record['filesize'] ?? null) !== filesize($resource) + || (isset($record['cacheUri']) && !is_string($record['cacheUri'])) || !$container->isFreshSince(is_int($cachedAt) ? $cachedAt : 0); - return $isStale ? null : $cacheState; + return !$isStale; } /** @@ -440,6 +456,23 @@ public function flushCacheState(bool $force = false): void } } + /** + * Writes an index file of another cache (the donor index of the z-engine driver) in the metadata format of + * this manager: a constant PHP return-array next to the transformation metadata, with portable paths + * + * @param string $relativeFileName File name below the cache directory, with a leading slash + * @param array $data + * + * @throws InvalidConfigurationException When caching is disabled (no cache directory) + */ + public function writeIndexFile(string $relativeFileName, array $data): void + { + if ($this->cacheDir === null) { + throw new InvalidConfigurationException('Cache index files need the `cacheDir` option to be configured'); + } + $this->writeCacheFile($relativeFileName, $data); + } + /** * Writes one cache file as a constant PHP return-array with portable paths * diff --git a/src/Instrument/ClassLoading/CacheWarmer.php b/src/Instrument/ClassLoading/CacheWarmer.php index ebc6e469..8a135331 100644 --- a/src/Instrument/ClassLoading/CacheWarmer.php +++ b/src/Instrument/ClassLoading/CacheWarmer.php @@ -14,6 +14,7 @@ use ErrorException; use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\WeavingDriver; use Go\Core\AspectKernel; use Go\Instrument\FileSystem\Enumerator; use Go\Instrument\Transformer\FilterInjectorTransformer; @@ -70,6 +71,14 @@ public function warmUp(): int if (empty($options['cacheDir'])) { throw new InvalidConfigurationException('Cache warmer require the `cacheDir` options to be configured'); } + if ($options['driver'] === WeavingDriver::ZEngine) { + throw new InvalidConfigurationException( + 'The cache warmer transforms sources through the stream filter, which the zengine weaving driver ' + . 'never uses: its donor classes are generated when a class is first loaded. Warm an application ' + . 'running the zengine driver by loading its classes (e.g. a smoke request), or warm the cache ' + . 'with a kernel configured for the stream driver.', + ); + } // The transformation pipeline is registered lazily; the filter URIs built below // bypass FilterInjectorTransformer::rewrite(), so bring it up explicitly diff --git a/src/Instrument/ClassLoading/ComposerLoaderDecorator.php b/src/Instrument/ClassLoading/ComposerLoaderDecorator.php new file mode 100644 index 00000000..1dc7dee5 --- /dev/null +++ b/src/Instrument/ClassLoading/ComposerLoaderDecorator.php @@ -0,0 +1,31 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ClassLoading; + +use Composer\Autoload\ClassLoader; + +/** + * A wrapper the kernel registers in place of composer's class loader + * + * Both weaving drivers decorate composer's loader (the stream driver to redirect advised files + * through the stream filter, the z-engine driver to weave a class right after it was loaded), + * so tools that look for a ClassLoader in spl_autoload_functions() unwrap either through this + * interface. + */ +interface ComposerLoaderDecorator +{ + /** + * Returns the wrapped composer class loader + */ + public function getOriginalLoader(): ClassLoader; +} diff --git a/src/Instrument/ClassLoading/ZEngineComposerLoader.php b/src/Instrument/ClassLoading/ZEngineComposerLoader.php new file mode 100644 index 00000000..e54cf3ac --- /dev/null +++ b/src/Instrument/ClassLoading/ZEngineComposerLoader.php @@ -0,0 +1,142 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ClassLoading; + +use Closure; +use Composer\Autoload\ClassLoader; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Instrument\FileSystem\Enumerator; +use Go\Instrument\PathResolver; +use Go\Instrument\ZEngine\ZEngineClassWeaver; + +/** + * Composer loader wrapper of the z-engine driver: loads the class natively, then weaves it + * + * The original file is included exactly as composer would (opcache caches it, magic constants + * and line numbers are untouched). The weaving happens INSIDE loadClass(), right after the + * include: a subclass whose linking triggered this autoload therefore links against the already + * woven parent, which is the order the engine mutation supports. + * + * @phpstan-import-type KernelOptions from AspectKernel + * + * @internal Framework service, not a public extension point + */ +final class ZEngineComposerLoader implements ComposerLoaderDecorator +{ + /** + * Includes a file in an isolated scope: neither $this nor the loader state leak into it + * + * @var (Closure(string): void)|null + */ + private static ?Closure $includeFile = null; + + /** + * File enumerator applying the includePaths/excludePaths of the kernel + */ + private readonly Enumerator $fileEnumerator; + + /** + * Lazy-initialized filter for allowed files + */ + private ?Closure $isAllowedFilter = null; + + /** + * @phpstan-param KernelOptions $options Kernel options + */ + public function __construct( + private readonly ClassLoader $original, + private readonly AspectContainer $container, + array $options, + ) { + // The framework itself and its runtime dependencies (z-engine included) are already part of excludePaths + $this->fileEnumerator = new Enumerator($options['appDir'], $options['includePaths'], $options['excludePaths']); + } + + /** + * Wraps every composer class loader registered with SPL (unwrapping an earlier wrapper of either driver) + * + * @phpstan-param KernelOptions $options Aspect kernel options + * + * @return bool Whether a composer loader was found and wrapped + */ + public static function init(array $options, AspectContainer $container): bool + { + $wasInitialized = false; + $loaders = spl_autoload_functions(); + + foreach ($loaders as &$loader) { + $loaderToUnregister = $loader; + if (is_array($loader)) { + $originalLoader = $loader[0]; + if ($originalLoader instanceof ComposerLoaderDecorator) { + $originalLoader = $originalLoader->getOriginalLoader(); + } + if ($originalLoader instanceof ClassLoader) { + $loader[0] = new self($originalLoader, $container, $options); + $wasInitialized = true; + } + } + spl_autoload_unregister($loaderToUnregister); + } + unset($loader); + + foreach ($loaders as $loader) { + spl_autoload_register($loader); + } + + return $wasInitialized; + } + + public function getOriginalLoader(): ClassLoader + { + return $this->original; + } + + /** + * Loads a class natively and weaves it when its file is within the weaving scope + * + * @return true|null True if loaded, null otherwise (the same contract as composer's loader) + */ + public function loadClass(string $class): ?true + { + $file = $this->original->findFile($class); + if ($file === false) { + return null; + } + + (self::$includeFile ??= static function (string $file): void { + include $file; + })($file); + + $resolved = PathResolver::realpath($file); + if (is_string($resolved)) { + $file = $resolved; + } + if (($this->isAllowedFilter ??= $this->fileEnumerator->getPathFilter())($file)) { + $this->container->getService(ZEngineClassWeaver::class)->weaveLoadedClass($class, $file); + } + + return true; + } + + /** + * Finds the original file of a class (composer's answer, never a filter stream) + * + * @return string|false + */ + public function findFile(string $class): string|false + { + return $this->original->findFile($class); + } +} diff --git a/src/Instrument/ZEngine/DonorCacheIndex.php b/src/Instrument/ZEngine/DonorCacheIndex.php new file mode 100644 index 00000000..d002422c --- /dev/null +++ b/src/Instrument/ZEngine/DonorCacheIndex.php @@ -0,0 +1,225 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\Instrument\PathResolver; +use Throwable; + +/** + * Index of the donor classes generated by the z-engine driver: `{cacheDir}/_zengine.cache` + * + * One record per loaded class name (what the loader has in hand), pointing at the generated + * donor file and listing the advised methods - or recording that nothing matched the class, so + * a warm request neither loads the aspects nor runs the matcher for it. Donor files live below + * `{cacheDir}/_zengine/`, mirroring the application tree like the stream driver's proxies. + * + * The index is deliberately separate from the stream driver's transformation metadata: a record + * without a cache URI is that driver's "serve natively, never weave" set in production, so + * writing engine records into it would poison a cache directory later used by the stream driver. + * Freshness is decided by the one shared rule, {@see CachePathManager::isFreshRecord()}. + * + * @phpstan-import-type KernelOptions from AspectKernel + * @phpstan-type DonorRecord array{donor: string|null, donorClass: string|null, methods: list} + * + * @internal Framework service, not a public extension point + */ +final class DonorCacheIndex +{ + /** + * Version of the index format, an index of another version is ignored and rebuilt + */ + public const int FORMAT_VERSION = 1; + + private const string INDEX_FILE_NAME = '/_zengine.cache'; + + private const string DONOR_DIRECTORY = '/_zengine'; + + private ?string $cacheDir; + + private string $appDir; + + /** + * Records read from the index file, keyed by class name + * + * @var array> + */ + private array $records = []; + + private bool $recordsLoaded = false; + + /** + * Records written during this request, keyed by class name + * + * @var array> + */ + private array $newRecords = []; + + /** + * @phpstan-param KernelOptions $options + */ + public function __construct(private readonly CachePathManager $cachePathManager, array $options) + { + $this->cacheDir = $options['cacheDir']; + $this->appDir = $options['appDir']; + } + + /** + * Returns the record of a class when it is still valid for its source file, null on a miss or a stale record + * + * @return DonorRecord|null + */ + public function findFresh(string $className, string $resource, AspectContainer $container): ?array + { + $record = $this->newRecords[$className] ?? $this->records()[$className] ?? null; + if ($record === null || !$this->cachePathManager->isFreshRecord($record, $resource, $container)) { + return null; + } + $donor = $record['donor'] ?? null; + $donorClass = $record['donorClass'] ?? null; + $methods = is_array($record['methods'] ?? null) ? array_values(array_filter($record['methods'], is_string(...))) : []; + if ($donor === null) { + return ['donor' => null, 'donorClass' => null, 'methods' => []]; + } + if (!is_string($donor) || !is_string($donorClass) || $donorClass === '' || !is_file($donor)) { + // A record pointing at a missing donor file is rebuilt like a miss + return null; + } + + return ['donor' => $donor, 'donorClass' => $donorClass, 'methods' => $methods]; + } + + /** + * Returns the file the donor class of a source file is written to + * + * `{cacheDir}/_zengine/{path relative to appDir}/{Short}__AopDonor.php`; sources outside the application + * root keep their absolute path below the donor directory, like the stream driver's proxies + */ + public function donorFileFor(string $resource, string $shortClassName): string + { + if ($this->cacheDir === null) { + throw new InvalidConfigurationException('The donor index needs the `cacheDir` option to be configured'); + } + $relativePath = ltrim(PathResolver::rebase($resource, $this->appDir, '') ?? $resource, '/\\'); + $directory = str_replace('\\', '/', dirname($relativePath)); + + return $this->cacheDir . self::DONOR_DIRECTORY . '/' + . ($directory === '.' ? '' : $directory . '/') + . $shortClassName . AspectContainer::DONOR_CLASS_SUFFIX . '.php'; + } + + /** + * Records that no advisor matched the class + */ + public function recordNoAdvices(string $className, string $resource): void + { + $this->newRecords[$className] = $this->baseRecord($resource) + [ + 'donor' => null, + 'donorClass' => null, + 'methods' => [], + ]; + } + + /** + * Records the donor generated for the class + * + * @param list $methods Advised method names + */ + public function recordDonor(string $className, string $resource, string $donorFile, string $donorClass, array $methods): void + { + $this->newRecords[$className] = $this->baseRecord($resource) + [ + 'donor' => $donorFile, + 'donorClass' => $donorClass, + 'methods' => $methods, + ]; + } + + /** + * Writes the records of this request into the index file (merged with the records already there) + */ + public function flush(): void + { + if ($this->newRecords === [] || $this->cacheDir === null || !is_writable($this->cacheDir)) { + return; + } + $allRecords = $this->newRecords + $this->records(); + $this->cachePathManager->writeIndexFile(self::INDEX_FILE_NAME, [ + 'version' => self::FORMAT_VERSION, + 'classes' => $allRecords, + ]); + $this->records = $allRecords; + $this->newRecords = []; + } + + /** + * Safety net: records of a request are persisted at the latest when the index is released + * + * Losing a write is recoverable (the next request weaves again), an exception escaping a + * destructor is not. + */ + public function __destruct() + { + try { + $this->flush(); + } catch (Throwable) { + // Deliberately swallowed, see above + } + } + + /** + * @return array + */ + private function baseRecord(string $resource): array + { + $fileTime = filemtime($resource); + $fileSize = filesize($resource); + + return [ + 'source' => PathResolver::rebase($resource, $this->appDir, '') ?? $resource, + 'filemtime' => $fileTime !== false ? $fileTime : 0, + 'filesize' => $fileSize !== false ? $fileSize : 0, + 'cachedAt' => time(), + ]; + } + + /** + * Records of the index file, loaded once per request + * + * @return array> + */ + private function records(): array + { + if ($this->recordsLoaded) { + return $this->records; + } + $this->recordsLoaded = true; + $indexFile = $this->cacheDir !== null ? $this->cacheDir . self::INDEX_FILE_NAME : null; + if ($indexFile === null || !is_file($indexFile)) { + return $this->records; + } + // Scope-isolated include: the file is a constant return array written atomically by the framework + $indexData = (static fn(string $file): mixed => include $file)($indexFile); + if (is_array($indexData) && ($indexData['version'] ?? null) === self::FORMAT_VERSION && is_array($indexData['classes'] ?? null)) { + foreach ($indexData['classes'] as $className => $record) { + if (is_string($className) && is_array($record)) { + $this->records[$className] = $record; + } + } + } + + return $this->records; + } +} diff --git a/src/Instrument/ZEngine/JitGuard.php b/src/Instrument/ZEngine/JitGuard.php new file mode 100644 index 00000000..5476865b --- /dev/null +++ b/src/Instrument/ZEngine/JitGuard.php @@ -0,0 +1,106 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Closure; +use Go\Aop\Exception\InvalidConfigurationException; + +/** + * Makes sure the opcache JIT is off before the engine driver touches the executor + * + * z-engine hooks the executor internals the JIT bypasses, so the JIT has to be off for the + * whole process (`opcache.jit=off` with `opcache.jit_buffer_size=0` in php.ini, the FPM pool + * or `php -d`). On PHP 8.5 the tracing JIT additionally miscompiles code the framework runs + * (a known PHP bug), so there the guard switches the JIT off at runtime as a safety net. + * That only covers code compiled AFTER the switch: Composer, z-engine's own classes and the + * application bootstrap were compiled before the kernel ran and keep their JIT hot counters, + * which is why the process-level setting stays the documented requirement and the report + * records what happened. + * + * Detection goes through `opcache_get_status()['jit']['on']`: the ini value is not usable + * (`-d opcache.jit=off` reads back as an empty string, `opcache.jit=tracing` with + * `opcache.jit_buffer_size=0` still reads "tracing" while the JIT is off). + * + * @phpstan-type JitReport array{jitWasActive: bool, previousMode: string|null, disabledAtRuntime: bool} + * + * @internal Framework service, not a public extension point + */ +final class JitGuard +{ + /** + * Static facade, never instantiated + * + * @codeCoverageIgnore + */ + private function __construct() {} + + /** + * Enforces the JIT policy for the running process + * + * @param int|null $phpVersionId PHP_VERSION_ID override (tests) + * @param (Closure(): (array|false))|null $statusReader opcache_get_status(false) replacement (tests) + * @param (Closure(string, string): (string|false))|null $iniWriter ini_set() replacement (tests) + * + * @return JitReport + * + * @throws InvalidConfigurationException When the JIT is on and may not be disabled here + */ + public static function enforce(?int $phpVersionId = null, ?Closure $statusReader = null, ?Closure $iniWriter = null): array + { + $phpVersionId ??= PHP_VERSION_ID; + $statusReader ??= static fn(): array|false => function_exists('opcache_get_status') ? opcache_get_status(false) : false; + $iniWriter ??= static fn(string $option, string $value): string|false => ini_set($option, $value); + + $report = ['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false]; + if (!self::isJitOn($statusReader())) { + return $report; + } + $report['jitWasActive'] = true; + + $isPhp85 = $phpVersionId >= 80500 && $phpVersionId < 80600; + if (!$isPhp85) { + throw new InvalidConfigurationException( + 'The zengine weaving driver requires the opcache JIT to be off: z-engine hooks the executor ' + . 'internals the JIT bypasses. Start PHP with opcache.jit=off and opcache.jit_buffer_size=0 ' + . '(php.ini, the FPM pool or `php -d`).', + ); + } + // PHP 8.5: the tracing JIT miscompiles code the framework runs (known PHP bug), so the + // driver switches it off for the rest of the request rather than refusing to run. + // "off" (never "disable", which locks the directive for the rest of the process) + $previousMode = $iniWriter('opcache.jit', 'off'); + $report['previousMode'] = is_string($previousMode) ? $previousMode : null; + if (self::isJitOn($statusReader())) { + throw new InvalidConfigurationException( + 'The zengine weaving driver could not switch the opcache JIT off at runtime (PHP 8.5 tracing ' + . 'JIT bug): start PHP with opcache.jit=off and opcache.jit_buffer_size=0 (php.ini, the FPM ' + . 'pool or `php -d`).', + ); + } + $report['disabledAtRuntime'] = true; + + return $report; + } + + /** + * @param array|false $status Result of opcache_get_status(false) + */ + private static function isJitOn(array|false $status): bool + { + if ($status === false || !isset($status['jit']) || !is_array($status['jit'])) { + return false; + } + + return ($status['jit']['on'] ?? false) === true; + } +} diff --git a/src/Instrument/ZEngine/MethodTableRewriter.php b/src/Instrument/ZEngine/MethodTableRewriter.php new file mode 100644 index 00000000..805ba8f6 --- /dev/null +++ b/src/Instrument/ZEngine/MethodTableRewriter.php @@ -0,0 +1,78 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Aop\Framework\AbstractMethodInvocation; +use ReflectionException; +use ReflectionMethod; +use ZEngine\Reflection\ReflectionClass as EngineClass; + +/** + * Rewires the advised methods of a loaded class to the dispatcher bodies of its donor class + * + * The whole engine contract is z-engine's public reflection API: + * - a method the class declares itself keeps its body under the private alias + * `OriginalAlias` and gets the dispatcher body swapped in place + * (`ReflectionMethod::redefine($donorMethod, preserveAs: ...)`): the published entry pointer, + * the constructor/magic slots, subclass buckets and warmed inline caches all stay valid, the + * dispatcher calls `$this->OriginalAlias(...)` / `self::...` like a trait-based proxy; + * - a method the class only inherits gets an own override adopting the dispatcher body + * (`ReflectionClass::addMethod($method, $donorMethod)`), whose dispatcher reaches the + * original through `parent::(...)`. + * + * An opcache-shared class is copied out of shared memory by z-engine on the first mutation; + * the class reflection is therefore re-resolved from the class table before every method. + * + * @internal Framework service, not a public extension point + */ +final class MethodTableRewriter +{ + /** + * @param class-string $className Loaded class to rewire + * @param class-string $donorClassName Loaded donor class declaring the dispatcher methods + * @param list $methods Advised method names (any case) + * + * @throws UnsupportedJoinpointException When the engine refuses a mutation (the class stays + * partially woven: the methods rewired before the refusal keep dispatching) + */ + public function rewire(string $className, string $donorClassName, array $methods): void + { + $donor = new EngineClass($donorClassName); + foreach ($methods as $methodName) { + // Re-resolved per method: the first mutation of an opcache-shared class repoints the + // class-table bucket at a writable per-process copy + $class = EngineClass::fromClassTable($className); + if ($class === null) { + throw UnsupportedJoinpointException::engineRefusal( + $className, + new ReflectionException("Class {$className} is not published in the engine class table"), + ); + } + $native = new ReflectionMethod($className, $methodName); + try { + $donorMethod = $donor->getMethod($methodName); + if ($native->getDeclaringClass()->getName() === $className) { + $class->getMethod($methodName)->redefine( + $donorMethod, + preserveAs: $methodName . AbstractMethodInvocation::TRAIT_ALIAS_SUFFIX, + ); + } else { + $class->addMethod($methodName, $donorMethod); + } + } catch (ReflectionException $refusal) { + throw UnsupportedJoinpointException::engineRefusal($className, $refusal); + } + } + } +} diff --git a/src/Instrument/ZEngine/WeaveOutcome.php b/src/Instrument/ZEngine/WeaveOutcome.php new file mode 100644 index 00000000..74dc26f0 --- /dev/null +++ b/src/Instrument/ZEngine/WeaveOutcome.php @@ -0,0 +1,31 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +/** + * What the z-engine driver did with a loaded class + */ +enum WeaveOutcome: string +{ + /** Advised methods were rewired through the engine */ + case Woven = 'woven'; + + /** No advisor matched the class, nothing to do */ + case NoAdvices = 'no-advices'; + + /** The class was woven by an earlier call in this request */ + case AlreadyWoven = 'already-woven'; + + /** The class is not weavable by this driver (see WeaveResult::$reason) */ + case Skipped = 'skipped'; +} diff --git a/src/Instrument/ZEngine/WeaveResult.php b/src/Instrument/ZEngine/WeaveResult.php new file mode 100644 index 00000000..d2699de0 --- /dev/null +++ b/src/Instrument/ZEngine/WeaveResult.php @@ -0,0 +1,58 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +/** + * Result of weaving one class through the z-engine driver + */ +final readonly class WeaveResult +{ + /** + * @param WeaveOutcome $outcome Outcome + * @param list $methods Names of the methods rewired through the engine (own methods + * keep their body under `OriginalAlias`, inherited ones + * got an override calling `parent::()`) + * @param string|null $reason Why the class was skipped, for WeaveOutcome::Skipped + */ + public function __construct( + public WeaveOutcome $outcome, + public array $methods = [], + public ?string $reason = null, + ) {} + + /** + * @param list $methods + */ + public static function woven(array $methods): self + { + return new self(WeaveOutcome::Woven, $methods); + } + + public static function noAdvices(): self + { + return new self(WeaveOutcome::NoAdvices); + } + + /** + * @param list $methods + */ + public static function alreadyWoven(array $methods): self + { + return new self(WeaveOutcome::AlreadyWoven, $methods); + } + + public static function skipped(string $reason): self + { + return new self(WeaveOutcome::Skipped, [], $reason); + } +} diff --git a/src/Instrument/ZEngine/ZEngineClassWeaver.php b/src/Instrument/ZEngine/ZEngineClassWeaver.php new file mode 100644 index 00000000..b7c08c66 --- /dev/null +++ b/src/Instrument/ZEngine/ZEngineClassWeaver.php @@ -0,0 +1,312 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Closure; +use Go\Aop\Advisor; +use Go\Aop\Aspect; +use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Aop\Exception\WeavingException; +use Go\Aop\Framework\AbstractJoinpoint; +use Go\Core\AdviceMatcherInterface; +use Go\Core\AspectContainer; +use Go\Core\AspectLoaderInterface; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\ParserReflection\ReflectionFile; +use Go\Proxy\ClassProxyGenerator; +use Go\Proxy\DonorClassGenerator; +use ReflectionClass; +use Throwable; + +/** + * Weaves a loaded class through the engine: matches advices, generates (or reuses) the donor + * class holding the dispatcher bodies and hands both to the method-table rewriter + * + * Per request every class is woven at most once; a failure is remembered so that a later call + * for the same class rethrows the same exception instead of reporting it as unadvised (the class + * is declared and linked by then, but NOT woven). + * + * Donor cache miss: the file of the loaded class is parsed with goaop/parser-reflection, so the + * advice matching and the donor generation see what the stream driver sees (attributes, parameter + * defaults, `self`/`parent` types and the file's imports verbatim); native reflection is the + * fallback for a class whose file cannot be parsed. A warm donor record skips both. + * + * @internal Framework service, not a public extension point + */ +final class ZEngineClassWeaver +{ + /** + * Join-point kinds the runtime rewiring supports: method bodies only + */ + private const array SUPPORTED_KINDS = [AspectContainer::METHOD_PREFIX, AspectContainer::STATIC_METHOD_PREFIX]; + + /** + * Results per class name of this request + * + * @var array + */ + private array $results = []; + + /** + * Failures per class name of this request, rethrown on a later call + * + * @var array + */ + private array $failures = []; + + /** + * Includes a donor file in an isolated scope + * + * @var (Closure(string): void)|null + */ + private static ?Closure $includeFile = null; + + public function __construct( + private readonly AspectContainer $container, + private readonly AdviceMatcherInterface $adviceMatcher, + private readonly AspectLoaderInterface $aspectLoader, + private readonly CachePathManager $cachePathManager, + private readonly DonorCacheIndex $donorCache, + private readonly MethodTableRewriter $rewriter, + ) {} + + /** + * Weaves a class that is already loaded (the public entry point for classes not loaded + * through composer: required files, classes declared before the kernel was initialized) + * + * Weave before instances exist and before subclasses link: the engine mutation reaches + * neither objects created earlier (under opcache) nor, for a shared-memory class, subclasses + * linked against the shared entry. + * + * @throws UnsupportedJoinpointException When an advice matched a join point kind this driver cannot weave + */ + public function weave(string $className): WeaveResult + { + if (isset($this->failures[$className])) { + throw $this->failures[$className]; + } + if (isset($this->results[$className])) { + $previous = $this->results[$className]; + + return $previous->outcome === WeaveOutcome::Woven ? WeaveResult::alreadyWoven($previous->methods) : $previous; + } + if (!class_exists($className, false)) { + return WeaveResult::skipped('the class is not loaded'); + } + $file = (new ReflectionClass($className))->getFileName(); + if ($file === false) { + return $this->remember($className, WeaveResult::skipped('the class has no source file (internal or runtime-generated)')); + } + + return $this->weaveLoadedClass($className, $file); + } + + /** + * Weaves the class the loader just included from $file + * + * @throws UnsupportedJoinpointException When an advice matched a join point kind this driver cannot weave + */ + public function weaveLoadedClass(string $className, string $file): WeaveResult + { + if (isset($this->failures[$className])) { + throw $this->failures[$className]; + } + if (isset($this->results[$className])) { + return $this->weave($className); + } + try { + return $this->remember($className, $this->doWeave($className, $file)); + } catch (Throwable $failure) { + $this->failures[$className] = $failure; + throw $failure; + } + } + + private function doWeave(string $className, string $file): WeaveResult + { + if (!class_exists($className, false)) { + return WeaveResult::skipped('the file did not declare the class'); + } + $native = new ReflectionClass($className); + if ($native->isInternal()) { + return WeaveResult::skipped('internal classes are never woven'); + } + if ($native->isInterface() || $native->isTrait() || $native->isEnum()) { + return WeaveResult::skipped('interfaces, traits and enums are not supported by the zengine driver'); + } + + $record = $this->donorCache->findFresh($className, $file, $this->container); + if ($record !== null) { + if ($record['donor'] === null || $record['donorClass'] === null) { + return WeaveResult::noAdvices(); + } + + return $this->apply($className, $record['donor'], $record['donorClass'], $record['methods']); + } + + // Cache miss: match the advices the way the stream driver does, from the parsed source + $unloadedAspects = $this->aspectLoader->getUnloadedAspects(); + foreach ($unloadedAspects as $unloadedAspect) { + $this->aspectLoader->loadAndRegister($unloadedAspect); + } + $advisors = $this->container->getServicesByInterface(Advisor::class); + + [$class, $imports, $isStrict] = $this->reflectSource($className, $file); + try { + $advices = $this->adviceMatcher->getAdvicesForClass($class, $advisors); + } catch (Throwable $matchingError) { + // Aspects used to be skipped before matching, so matching one must not fail the load + if ($this->isAspectSafe($class)) { + return WeaveResult::skipped('aspects are never woven'); + } + throw $matchingError; + } + if ($advices === []) { + $this->donorCache->recordNoAdvices($className, $file); + + return WeaveResult::noAdvices(); + } + // Aspects are never woven (checked after matching: it reflects every ancestor, #748) + if ($class->implementsInterface(Aspect::class)) { + return WeaveResult::skipped('aspects are never woven'); + } + $this->refuseUnsupportedKinds($className, $advices); + + $sortedAdvices = AbstractJoinpoint::flatAndSortAdvices($advices); + $methods = array_values(array_unique(array_merge( + array_keys($sortedAdvices[AspectContainer::METHOD_PREFIX] ?? []), + array_keys($sortedAdvices[AspectContainer::STATIC_METHOD_PREFIX] ?? []), + ))); + $methods = array_map(strval(...), $methods); + + $generator = new DonorClassGenerator($class, $sortedAdvices, $imports, ClassProxyGenerator::indexMethods($class)); + $donorCode = 'generate(); + $donorFile = $this->donorCache->donorFileFor($file, $class->getShortName()); + $donorClass = $generator->getDonorClassName(); + + $this->cachePathManager->getCacheFileWriter()->write($donorFile, $donorCode); + $this->donorCache->recordDonor($className, $file, $donorFile, $donorClass, $methods); + + return $this->apply($className, $donorFile, $donorClass, $methods); + } + + /** + * Loads the donor file and rewires the advised methods + * + * @param class-string $className + * @param list $methods + */ + private function apply(string $className, string $donorFile, string $donorClass, array $methods): WeaveResult + { + if (!class_exists($donorClass, false)) { + (self::$includeFile ??= static function (string $file): void { + include $file; + })($donorFile); + } + if (!class_exists($donorClass, false)) { + throw new WeavingException(sprintf( + 'The donor file %s of %s does not declare the donor class %s: clear the cache directory', + $donorFile, + $className, + $donorClass, + )); + } + $this->rewriter->rewire($className, $donorClass, $methods); + + return WeaveResult::woven($methods); + } + + /** + * Reflects the loaded class from its parsed source, falling back to native reflection + * + * @param class-string $className + * + * @return array{ReflectionClass, array, bool} Class reflection, imports of its + * namespace block, strict-types mode + */ + private function reflectSource(string $className, string $file): array + { + try { + $parsedFile = new ReflectionFile($file); + foreach ($parsedFile->getFileNamespaces() as $namespace) { + foreach ($namespace->getClasses() as $parsedClass) { + if (strcasecmp($parsedClass->getName(), $className) === 0) { + return [$parsedClass, $namespace->getNamespaceAliases(), $parsedFile->isStrictMode()]; + } + } + } + } catch (Throwable) { + // A source the parser cannot handle: native reflection below knows the loaded class anyway + } + $native = new ReflectionClass($className); + + return [$native, [], self::declaresStrictTypes($file)]; + } + + /** + * Whether the file opens with `declare(strict_types=1)`: the donor must mirror it, since the strict-types flag + * of the dispatcher bodies follows the donor file + */ + private static function declaresStrictTypes(string $file): bool + { + $source = @file_get_contents($file, false, null, 0, 4096); + + return is_string($source) && preg_match('/^<\?php\s*declare\s*\(\s*strict_types\s*=\s*1\s*\)/i', ltrim($source)) === 1; + } + + /** + * Refuses advices of the join-point kinds the runtime rewiring cannot provide + * + * @param array>> $advices Advices keyed by kind prefix, name and advisor id + */ + private function refuseUnsupportedKinds(string $className, array $advices): void + { + $unsupportedKinds = []; + $advisorIds = []; + foreach ($advices as $kind => $joinpoints) { + if (in_array($kind, self::SUPPORTED_KINDS, true)) { + continue; + } + $unsupportedKinds[] = $kind; + foreach ($joinpoints as $kindAdvices) { + foreach (array_keys($kindAdvices) as $advisorId) { + $advisorIds[] = $advisorId; + } + } + } + if ($unsupportedKinds !== []) { + throw UnsupportedJoinpointException::forClass($className, $unsupportedKinds, array_values(array_unique($advisorIds))); + } + } + + /** + * Checks whether the class is an aspect, a failure of the check counts as "not an aspect" + * + * @param ReflectionClass $class + */ + private function isAspectSafe(ReflectionClass $class): bool + { + try { + return $class->implementsInterface(Aspect::class); + } catch (Throwable) { + return false; + } + } + + private function remember(string $className, WeaveResult $result): WeaveResult + { + $this->results[$className] = $result; + + return $result; + } +} diff --git a/src/Instrument/ZEngine/ZEngineDriver.php b/src/Instrument/ZEngine/ZEngineDriver.php new file mode 100644 index 00000000..6eb6fbb9 --- /dev/null +++ b/src/Instrument/ZEngine/ZEngineDriver.php @@ -0,0 +1,112 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Core\AdviceMatcher; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Core\Cache\CachedAspectLoader; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\Instrument\ClassLoading\ZEngineComposerLoader; +use Throwable; +use ZEngine\Core; + +/** + * Boot sequence of the z-engine weaving driver, run by AspectKernel::init() in place of the + * transformer chain and the stream-filter autoloader + * + * Order matters: the JIT guard runs BEFORE z-engine is booted, so z-engine's own classes are + * compiled with the JIT already off when the engine was not auto-booted from composer's + * autoload files (`ZENGINE_AUTOBOOT=0`). + * + * @phpstan-import-type JitReport from JitGuard + * @phpstan-type BootReport array{driver: string, php: string, opcache: bool, jit: JitReport} + * + * @internal Framework service, not a public extension point + */ +final class ZEngineDriver +{ + /** + * Container key of the boot report (see getBootReport()) + */ + public const string BOOT_REPORT = 'kernel.zengine.boot'; + + /** + * Static facade, never instantiated + * + * @codeCoverageIgnore + */ + private function __construct() {} + + public static function boot(AspectKernel $kernel, AspectContainer $container): void + { + if (!class_exists(Core::class)) { + throw new InvalidConfigurationException( + 'The zengine weaving driver requires lisachenko/z-engine: install the z-engine branch ' + . 'matching the PHP minor version (composer require lisachenko/z-engine:8.5.x-dev on PHP 8.5) ' + . 'with ext-ffi enabled.', + ); + } + + $jitReport = JitGuard::enforce(); + + if (!Core::isInitialized()) { + try { + Core::init(); + } catch (Throwable $failure) { + throw new InvalidConfigurationException( + 'The zengine weaving driver cannot boot z-engine: ' . $failure->getMessage(), + 0, + $failure, + ); + } + } + + $container->add(self::BOOT_REPORT, [ + 'driver' => 'zengine', + 'php' => PHP_VERSION, + 'opcache' => function_exists('opcache_get_status') && opcache_get_status(false) !== false, + 'jit' => $jitReport, + ]); + + $container->addLazyService(DonorCacheIndex::class, fn(AspectContainer $container): DonorCacheIndex => new DonorCacheIndex( + $container->getService(CachePathManager::class), + $kernel->getOptions(), + )); + $container->addLazyService(MethodTableRewriter::class, fn(): MethodTableRewriter => new MethodTableRewriter()); + $container->addLazyService(ZEngineClassWeaver::class, fn(AspectContainer $container): ZEngineClassWeaver => new ZEngineClassWeaver( + $container, + $container->getService(AdviceMatcher::class), + $container->getService(CachedAspectLoader::class), + $container->getService(CachePathManager::class), + $container->getService(DonorCacheIndex::class), + $container->getService(MethodTableRewriter::class), + )); + + ZEngineComposerLoader::init($kernel->getOptions(), $container); + } + + /** + * What the boot did: PHP version, opcache state and the JIT guard report + * + * @return BootReport + */ + public static function getBootReport(AspectContainer $container): array + { + /** @var BootReport $report Shape of our own container value */ + $report = $container->getValue(self::BOOT_REPORT); + + return $report; + } +} diff --git a/src/Proxy/AGENTS.md b/src/Proxy/AGENTS.md index 72ca8376..4589ed77 100644 --- a/src/Proxy/AGENTS.md +++ b/src/Proxy/AGENTS.md @@ -7,6 +7,12 @@ - FunctionProxyGenerator — function wrappers - TraitProxyGenerator — trait proxies - EnumProxyGenerator — enum proxies (trait extraction + case re-declaration) +- DonorClassGenerator — zengine driver: abstract `Foo__AopDonor` (same namespace/imports/parent/interfaces, readonly + mirrored) declaring only the advised methods with proxy dispatcher bodies; overrides createJoinpointClassReference() + (`\Ns\Foo::class` literal, `self::class` would bind to the donor at compile time) and spells out `self`/`parent` + in signatures (TypeGenerator::resolveScopeKeywords(), `static` kept) because the engine compares the donor signature + with the swapped entry; original callable stays `$this->mOriginalAlias(...)` / `parent::m(...)` (scope follows the + swapped entry at run time) ## Imports in generated code (issue #668) - Generated code stays clean and readable: framework and aspect classes are referenced through short `use` aliases diff --git a/src/Proxy/ClassProxyGenerator.php b/src/Proxy/ClassProxyGenerator.php index 09ba7b02..dbf85770 100644 --- a/src/Proxy/ClassProxyGenerator.php +++ b/src/Proxy/ClassProxyGenerator.php @@ -406,7 +406,7 @@ protected function getJoinpointInvocationStatements(ReflectionMethod $method, ?R $this->imports->import(InterceptorInjector::class), $isStatic ? 'forStaticMethod' : 'forMethod', [ - new Arg(new ClassConstFetch(new Name('self'), 'class')), + new Arg($this->createJoinpointClassReference($originalClass)), new Arg(new String_($method->name)), new Arg((new InterceptorListGenerator($adviceNames, $this->imports))->getNode()), new Arg($this->createOriginalMethodCallable($method, $originalClass)), @@ -426,6 +426,20 @@ protected function getJoinpointInvocationStatements(ReflectionMethod $method, ?R ]; } + /** + * Builds the expression naming the class the joinpoint is created for + * + * A proxy class IS the woven class, so `self::class` names it. A generator whose method bodies + * are compiled inside another class (the z-engine donor) overrides this: PHP resolves + * `self::class` in a class method to that class at compile time. + * + * @param ReflectionClass|null $originalClass + */ + protected function createJoinpointClassReference(?ReflectionClass $originalClass): Expr + { + return new ClassConstFetch(new Name('self'), 'class'); + } + /** * Builds the first-class callable of the original method, which the joinpoint keeps * diff --git a/src/Proxy/DonorClassGenerator.php b/src/Proxy/DonorClassGenerator.php new file mode 100644 index 00000000..dd7496f9 --- /dev/null +++ b/src/Proxy/DonorClassGenerator.php @@ -0,0 +1,201 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Proxy; + +use Go\Aop\Exception\WeavingException; +use Go\Aop\Framework\GeneratedInterceptor; +use Go\Aop\Framework\Interceptor; +use Go\Aop\Framework\InterceptorInjector; +use Go\Aop\Framework\The; +use Go\Core\AspectContainer; +use Go\Proxy\Generator\ClassGenerator; +use Go\Proxy\Generator\ClassModifier; +use Go\Proxy\Generator\DocBlockGenerator; +use Go\Proxy\Generator\MethodGenerator; +use Go\Proxy\Generator\ProxyImports; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Name\FullyQualified; +use ReflectionClass; +use ReflectionMethod; + +/** + * Generates the donor class of the z-engine driver: the dispatcher bodies of the advised methods, compiled + * in a class of their own and adopted into the woven class by the engine + * + * The donor `Ns\Foo__AopDonor` of `Ns\Foo` is abstract (never instantiated), lives in the namespace of + * the woven class with the imports of its file, extends the same parent and implements the same + * interfaces, and declares exactly the advised methods with the signature, attributes and doc comment + * of the original: the engine copies the signature along with the body, and checks it against the + * entry it replaces. The bodies are the ones a proxy class would get, with two differences for code + * that is compiled outside the woven class: + * - the joinpoint names the woven class explicitly (`\Ns\Foo::class`), since `self::class` would be + * bound to the donor at compile time; + * - the `self` and `parent` keywords of parameter and return types are spelled out as the classes they + * stand for in the woven class, for the same reason (`static` keeps binding to the called class). + * The calls that reach the original body stay dynamic and follow the scope of the swapped entry: + * `$this->OriginalAlias(...)` for a method the class declares (the engine keeps the previous + * body under that private alias) and `parent::(...)` for an inherited one. + * + * @see \Go\Instrument\ZEngine\MethodTableRewriter The engine side of the contract + */ +final class DonorClassGenerator extends ClassProxyGenerator +{ + /** + * Fully qualified name of the generated donor class + */ + private readonly string $donorClassName; + + /** + * Fully qualified name of the woven class + */ + private readonly string $targetClassName; + + /** + * Fully qualified name of the parent of the woven class, null for a class without parent + */ + private readonly ?string $parentClassName; + + /** + * @param ReflectionClass $originalClass Woven class + * @param array>> $classAdviceNames Advices of the class, + * method kinds only + * @param array $originalImports Imports of the original + * file: class name => alias + * @param array|null $originalMethods Methods of the original + * class by name, see + * {@see self::indexMethods()} + * + * @throws WeavingException When the advices contain a join-point kind the donor cannot carry + */ + public function __construct( + ReflectionClass $originalClass, + array $classAdviceNames, + array $originalImports = [], + ?array $originalMethods = null, + ) { + $unsupportedKinds = array_diff( + array_keys($classAdviceNames), + [AspectContainer::METHOD_PREFIX, AspectContainer::STATIC_METHOD_PREFIX], + ); + if ($unsupportedKinds !== []) { + throw new WeavingException(sprintf( + 'A donor class carries method dispatchers only, the advices of %s contain the join-point kind(s) %s', + $originalClass->getName(), + implode(', ', array_map(strval(...), $unsupportedKinds)), + )); + } + + $this->adviceNames = $classAdviceNames; + $this->originalMethods = $originalMethods ?? self::indexMethods($originalClass); + $this->targetClassName = $originalClass->getName(); + $parentClass = $originalClass->getParentClass(); + $this->parentClassName = $parentClass !== false ? $parentClass->getName() : null; + + $namespace = $originalClass->getNamespaceName(); + $donorShortName = $originalClass->getShortName() . AspectContainer::DONOR_CLASS_SUFFIX; + $this->donorClassName = ($namespace !== '' ? $namespace . '\\' : '') . $donorShortName; + + $dynamicMethodAdvices = $classAdviceNames[AspectContainer::METHOD_PREFIX] ?? []; + $staticMethodAdvices = $classAdviceNames[AspectContainer::STATIC_METHOD_PREFIX] ?? []; + $interceptedMethods = array_map(strval(...), array_keys($dynamicMethodAdvices + $staticMethodAdvices)); + + // Register the imports up front, so that every generated reference below uses the final alias + $this->imports = ProxyImports::forClass($originalClass, $originalImports); + $this->imports->reserve($donorShortName); + $this->imports->import(InterceptorInjector::class); + $this->imports->import(Interceptor::class); + $this->imports->import(The::class); + foreach ($this->collectAspectClasses($classAdviceNames) as $aspectClass) { + $this->imports->import($aspectClass); + } + $this->importMethodInvocationTypes($originalClass, $interceptedMethods); + + $methodGenerators = []; + foreach ($this->interceptMethods($originalClass, $interceptedMethods) as $interceptedMethod) { + $methodGenerators[] = $this->resolveScopeKeywords($interceptedMethod->getGenerator()); + } + + // Never instantiated; a readonly parent (hence a readonly woven class) requires a readonly child + $modifiers = [ClassModifier::Abstract]; + if ($originalClass->isReadOnly()) { + $modifiers[] = ClassModifier::Readonly; + } + $interfaces = array_map(static fn(string $interface): string => '\\' . $interface, $originalClass->getInterfaceNames()); + + $classGenerator = new ClassGenerator( + $donorShortName, + $namespace !== '' ? $namespace : null, + $modifiers, + $this->parentClassName !== null ? '\\' . $this->parentClassName : null, + array_values(array_unique($interfaces)), + [], + $methodGenerators, + ); + $classGenerator->docBlock = DocBlockGenerator::fromDocComment( + '/**' . PHP_EOL + . ' * Dispatcher bodies woven into \\' . $this->targetClassName . ' by the zengine driver of Go! AOP' . PHP_EOL + . ' *' . PHP_EOL + . ' * @internal Generated code, never instantiated: the engine adopts the method bodies' . PHP_EOL + . ' */', + ); + foreach ($this->imports->getUses() as $className => $alias) { + $classGenerator->addUse($className, $alias); + } + + $this->generator = $classGenerator; + } + + /** + * Returns the fully qualified name of the generated donor class + */ + public function getDonorClassName(): string + { + return $this->donorClassName; + } + + public function generate(): string + { + return $this->generator->generate(); + } + + /** + * The joinpoint is created for the woven class, named explicitly: `self::class` in a donor method would + * be bound to the donor class when the donor file is compiled + * + * @param ReflectionClass|null $originalClass + */ + protected function createJoinpointClassReference(?ReflectionClass $originalClass): Expr + { + return new ClassConstFetch(new FullyQualified($this->targetClassName), 'class'); + } + + /** + * Spells out the `self` and `parent` keywords of the signature as the classes they name in the woven class + * + * The engine checks the donor signature against the one of the replaced entry (with the keywords resolved + * against the woven class), and a keyword compiled in the donor would name the donor's own hierarchy. + */ + private function resolveScopeKeywords(MethodGenerator $method): MethodGenerator + { + $method->returnType = $method->returnType?->resolveScopeKeywords($this->targetClassName, $this->parentClassName); + $parameters = []; + foreach ($method->getParameters() as $parameter) { + $type = $parameter->getType()?->resolveScopeKeywords($this->targetClassName, $this->parentClassName); + $parameters[] = $parameter->withType($type); + } + $method->setParameters($parameters); + + return $method; + } +} diff --git a/src/Proxy/Generator/MethodGenerator.php b/src/Proxy/Generator/MethodGenerator.php index 90251e53..d3d61fbc 100644 --- a/src/Proxy/Generator/MethodGenerator.php +++ b/src/Proxy/Generator/MethodGenerator.php @@ -168,6 +168,24 @@ public function addParameter(ParameterGenerator $parameter): void $this->parameters[] = $parameter; } + /** + * @return list + */ + public function getParameters(): array + { + return array_values($this->parameters); + } + + /** + * Replaces the parameter list + * + * @param list $parameters + */ + public function setParameters(array $parameters): void + { + $this->parameters = $parameters; + } + /** * Returns the underlying AST ClassMethod node, ready for injection into a class. */ diff --git a/src/Proxy/Generator/ParameterGenerator.php b/src/Proxy/Generator/ParameterGenerator.php index 36b1a21a..e5033131 100644 --- a/src/Proxy/Generator/ParameterGenerator.php +++ b/src/Proxy/Generator/ParameterGenerator.php @@ -115,6 +115,17 @@ public static function fromReflection(ReflectionParameter $param): self return $generator; } + /** + * Returns a copy of the parameter declared with another type (attributes and default value kept) + */ + public function withType(?TypeGenerator $type): self + { + $parameter = new self($this->name, $type, $this->byRef, $this->variadic, $this->defaultValue); + $parameter->attributeGroups = $this->attributeGroups; + + return $parameter; + } + public function getName(): string { return $this->name; diff --git a/src/Proxy/Generator/TypeGenerator.php b/src/Proxy/Generator/TypeGenerator.php index 13bfdf8a..54376579 100644 --- a/src/Proxy/Generator/TypeGenerator.php +++ b/src/Proxy/Generator/TypeGenerator.php @@ -105,6 +105,20 @@ public function getNode(): Identifier|Name|NullableType|UnionType|IntersectionTy return $this->typeNode; } + /** + * Returns the type with its `self` and `parent` keywords replaced by the classes they name in $selfClass + * + * For a declaration compiled inside another class (the donor class of the z-engine driver), where the + * keywords would resolve to that class. `static` is kept: it binds to the called class at run time. + * + * @param string $selfClass Class `self` stands for + * @param string|null $parentClass Class `parent` stands for, null when $selfClass has no parent + */ + public function resolveScopeKeywords(string $selfClass, ?string $parentClass): self + { + return new self(self::resolveScopeKeywordsIn($this->typeNode, $selfClass, $parentClass)); + } + /** * Generates the PHP type string. */ @@ -235,6 +249,57 @@ public static function renderTypeForPhpDoc(?ReflectionType $type): string return 'mixed'; } + /** + * Rebuilds a type node with `self` and `parent` replaced by fully qualified class names + */ + private static function resolveScopeKeywordsIn( + Identifier|Name|NullableType|UnionType|IntersectionType $node, + string $selfClass, + ?string $parentClass, + ): Identifier|Name|NullableType|UnionType|IntersectionType { + if ($node instanceof NullableType) { + $inner = self::resolveScopeKeywordsIn($node->type, $selfClass, $parentClass); + + return $inner instanceof Identifier || $inner instanceof Name ? new NullableType($inner) : $node; + } + if ($node instanceof UnionType) { + /** @var list $parts */ + $parts = []; + foreach ($node->types as $innerType) { + $innerNode = self::resolveScopeKeywordsIn($innerType, $selfClass, $parentClass); + if ($innerNode instanceof Identifier || $innerNode instanceof Name || $innerNode instanceof IntersectionType) { + $parts[] = $innerNode; + } + } + + return new UnionType($parts); + } + if ($node instanceof IntersectionType) { + /** @var list $parts */ + $parts = []; + foreach ($node->types as $innerType) { + $innerNode = self::resolveScopeKeywordsIn($innerType, $selfClass, $parentClass); + if ($innerNode instanceof Identifier || $innerNode instanceof Name) { + $parts[] = $innerNode; + } + } + + return new IntersectionType($parts); + } + if ($node instanceof Name && $node instanceof Name\FullyQualified) { + return $node; + } + $keyword = strtolower($node->toString()); + if ($keyword === 'self') { + return new Name\FullyQualified(ltrim($selfClass, '\\')); + } + if ($keyword === 'parent' && $parentClass !== null) { + return new Name\FullyQualified(ltrim($parentClass, '\\')); + } + + return $node; + } + private static function buildNodeFromReflection(ReflectionType $type): Identifier|Name|NullableType|UnionType|IntersectionType { if ($type instanceof ReflectionNamedType) { diff --git a/tests/AGENTS.md b/tests/AGENTS.md index a8404fd4..c149e889 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -30,6 +30,12 @@ run; `composer test:performance:transformation` (JIT disabled) before and after touching a SourceTransformer, a NodeRewriter or their wiring. CI compares head with base on such PRs (.github/workflows/transformation-performance.yml, `GO_AOP_TRANSFORMATION_REPORT=` writes the JSON report read by compare-transformation-performance.php) +- zengine group (tests/Functional/ZEngine/, `#[Group('zengine')]`): the z-engine weaving driver, excluded from the + default run; `composer test:zengine` (ffi on, JIT off). ZEngineFunctionalTestCase runs the probe + tests/Fixtures/project/bin/zengine-weaving.php in subprocesses (`GO_AOP_CONFIGURATION=zengine*`, both opcache legs, + `ZENGINE_AUTOBOOT=0`) and compares its JSON report with the stream driver on the same fixtures (`zengine_reference`); + self-skips when z-engine is not installed or cannot boot. Fixtures: Application/ZEngine*, Aspect/ZEngineAspect, + Kernel/ZEngineAspectKernel. Unit tests of the driver: tests/Instrument/ZEngine/, tests/Proxy/DonorClassGeneratorTest.php - If phpstan fails: fix errors before offering to commit ## PHPStan gate diff --git a/tests/Aop/WeavingDriverTest.php b/tests/Aop/WeavingDriverTest.php new file mode 100644 index 00000000..54795097 --- /dev/null +++ b/tests/Aop/WeavingDriverTest.php @@ -0,0 +1,59 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop; + +use Go\Aop\Exception\InvalidConfigurationException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class WeavingDriverTest extends TestCase +{ + /** + * @return iterable + */ + public static function provideOptions(): iterable + { + yield 'enum case' => [WeavingDriver::ZEngine, WeavingDriver::ZEngine]; + yield 'stream' => ['stream', WeavingDriver::Stream]; + yield 'zengine' => ['zengine', WeavingDriver::ZEngine]; + } + + #[DataProvider('provideOptions')] + public function testResolvesTheDriverOption(mixed $option, WeavingDriver $expected): void + { + $this->assertSame($expected, WeavingDriver::fromOption($option)); + } + + public function testRefusesAnUnknownDriverName(): void + { + try { + WeavingDriver::fromOption('ffi'); + self::fail('An unknown driver name must be refused'); + } catch (InvalidConfigurationException $exception) { + $this->assertSame( + 'Option "driver" must be one of "stream", "zengine" (or a Go\Aop\WeavingDriver case), got "ffi".', + $exception->getMessage(), + ); + } + } + + public function testRefusesAValueOfAnotherType(): void + { + try { + WeavingDriver::fromOption(1); + self::fail('A value of another type must be refused'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringEndsWith('got int.', $exception->getMessage()); + } + } +} diff --git a/tests/Core/AspectKernelTest.php b/tests/Core/AspectKernelTest.php index 86aebfe8..36270724 100644 --- a/tests/Core/AspectKernelTest.php +++ b/tests/Core/AspectKernelTest.php @@ -12,6 +12,7 @@ namespace Go\Core; +use Go\Aop\WeavingDriver; use Go\Aop\AspectException; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Features; @@ -231,7 +232,7 @@ public function testGetDefaultOptionsReturnsExpectedShapeAndContainerClass(): vo $default = $this->invokeProtectedArray($kernel, 'getDefaultOptions'); $this->assertSame( - ['debug', 'appDir', 'cacheDir', 'cacheFileMode', 'features', 'includePaths', 'excludePaths', 'containerClass'], + ['debug', 'appDir', 'cacheDir', 'cacheFileMode', 'features', 'includePaths', 'excludePaths', 'containerClass', 'driver'], array_keys($default), ); $this->assertFalse($default['debug']); @@ -240,6 +241,48 @@ public function testGetDefaultOptionsReturnsExpectedShapeAndContainerClass(): vo $this->assertSame([], $default['includePaths']); $this->assertSame([], $default['excludePaths']); $this->assertSame(Container::class, $default['containerClass']); + $this->assertSame(WeavingDriver::Stream, $default['driver']); + } + + public function testNormalizeOptionsResolvesTheDriverFromItsName(): void + { + $kernel = $this->makeKernel(); + + $normalized = $this->invokeProtectedArray($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 'zengine']]); + + $this->assertSame(WeavingDriver::ZEngine, $normalized['driver']); + $this->assertSame( + WeavingDriver::Stream, + $this->invokeProtectedArray($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir']])['driver'], + ); + } + + public function testNormalizeOptionsThrowsForAnUnknownDriver(): void + { + $kernel = $this->makeKernel(); + + try { + $this->invokeProtected($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 'ffi']]); + self::fail('An unknown driver must be refused'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringStartsWith('Option "driver" must be one of "stream", "zengine"', $exception->getMessage()); + } + } + + public function testNormalizeOptionsRefusesStreamOnlyFeaturesWithTheEngineDriver(): void + { + $kernel = $this->makeKernel(); + + try { + $this->invokeProtected($kernel, 'normalizeOptions', [[ + 'cacheDir' => '/some/cache/dir', + 'driver' => WeavingDriver::ZEngine, + 'features' => Features::INTERCEPT_FUNCTIONS, + ]]); + self::fail('Stream-only features must be refused with the engine driver'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('only available with the stream driver', $exception->getMessage()); + } } public function testNormalizeOptionsExcludesRuntimeDependenciesFromWeaving(): void diff --git a/tests/Core/Cache/CachedAspectLoaderTest.php b/tests/Core/Cache/CachedAspectLoaderTest.php index 1f34d2a6..23678a7d 100644 --- a/tests/Core/Cache/CachedAspectLoaderTest.php +++ b/tests/Core/Cache/CachedAspectLoaderTest.php @@ -18,6 +18,7 @@ use Go\Aop\Exception\NotCompilableException; use Go\Aop\Features; use Go\Aop\Pointcut\TruePointcut; +use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\AspectLoader; use Go\Core\AspectLoaderInterface; @@ -99,6 +100,7 @@ private function createLoader(int $features, bool $withCacheDir = true): CachedA 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); } diff --git a/tests/Core/ContainerTest.php b/tests/Core/ContainerTest.php index e5f04c26..08333286 100644 --- a/tests/Core/ContainerTest.php +++ b/tests/Core/ContainerTest.php @@ -26,6 +26,7 @@ use Go\Tests\TestProject\Aspect\EnumMethodAspect; use PHPUnit\Framework\TestCase; use stdClass; +use Go\Aop\WeavingDriver; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] class ContainerTest extends TestCase @@ -45,6 +46,7 @@ protected function setUp(): void 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); $this->container->add(AspectKernel::class, $mockKernel); $this->container->add('kernel.interceptFunctions', false); diff --git a/tests/Fixtures/project/bin/zengine-weaving.php b/tests/Fixtures/project/bin/zengine-weaving.php new file mode 100644 index 00000000..8b6c3635 --- /dev/null +++ b/tests/Fixtures/project/bin/zengine-weaving.php @@ -0,0 +1,126 @@ + php -d opcache.jit=off -d ffi.enable=1 zengine-weaving.php + * Prints one JSON report: what the kernel booted, which classes were woven, the results and the advice log + * of the scenario, or the exception that ended it. + */ + +include_once __DIR__ . '/../../../../vendor/autoload.php'; + +use Go\Core\AspectKernel; +use Go\Instrument\ZEngine\ZEngineClassWeaver; +use Go\Instrument\ZEngine\ZEngineDriver; +use Go\Tests\TestProject\Application\ZEngineChild; +use Go\Tests\TestProject\Application\ZEngineLate; +use Go\Tests\TestProject\Application\ZEngineParent; +use Go\Tests\TestProject\Application\ZEngineUnadvised; +use Go\Tests\TestProject\Application\ZEngineUnsupported; +use Go\Tests\TestProject\Aspect\ZEngineAspect; + +$configurationName = ($env = getenv('GO_AOP_CONFIGURATION')) ? $env : 'zengine'; +$scenario = $argv[1] ?? 'weave'; +$settings = require __DIR__ . '/../web/configuration.php'; +$configuration = $settings[$configurationName]; +$cacheDir = $configuration['cacheDir']; + +$report = [ + 'scenario' => $scenario, + 'configuration' => $configurationName, + 'php' => PHP_VERSION, + 'opcache' => function_exists('opcache_get_status') && opcache_get_status(false) !== false, + 'indexExisted' => is_file($cacheDir . '/_zengine.cache'), +]; + +$describeWeaving = static function (AspectKernel $kernel, string ...$classes) use (&$report): void { + if ($kernel->getOptions()['driver']->value !== 'zengine') { + return; + } + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + foreach ($classes as $class) { + $result = $weaver->weave($class); + $report['weave'][$class] = ['outcome' => $result->outcome->value, 'methods' => $result->methods, 'reason' => $result->reason]; + } +}; + +try { + if ($scenario === 'late') { + // Declared before the kernel exists, so never seen by the kernel's class loader + require __DIR__ . '/../src/Application/ZEngineLate.php'; + } + + /** @var AspectKernel $kernel */ + $kernel = $configuration['kernel']::getInstance(); + // The test-harness keys (kernel class, console and front-controller paths) are not kernel options + $kernel->init(array_diff_key($configuration, array_flip(['kernel', 'console', 'frontController']))); + $container = $kernel->getContainer(); + $report['driver'] = $kernel->getOptions()['driver']->value; + if ($report['driver'] === 'zengine') { + $report['boot'] = ZEngineDriver::getBootReport($container); + } + + switch ($scenario) { + case 'weave': + $parent = new ZEngineParent(); + $child = new ZEngineChild(); + $report['calls'] = [ + 'parent.greet' => $parent->greet('Ann'), + 'child.greet' => $child->greet('Bob'), + 'child.describe' => $child->describe()::class, + 'child.create' => ZEngineChild::create('Cid')::class, + 'child.callSecret' => $child->callSecret(), + 'child.withDefault' => $child->withDefault(), + 'child.withOther' => $child->withDefault(2, $child), + 'child.getCalls' => $child->getCalls(), + 'unadvised.ping' => (new ZEngineUnadvised())->ping(), + ]; + $report['reflection'] = [ + 'child.describe.class' => (new ReflectionMethod(ZEngineChild::class, 'describe'))->class, + 'child.greet.class' => (new ReflectionMethod(ZEngineChild::class, 'greet'))->class, + 'parent.greet.file' => basename((string) (new ReflectionMethod(ZEngineParent::class, 'greet'))->getFileName()), + 'child.alias' => method_exists($child, 'greetOriginalAlias'), + 'parent.alias' => method_exists($parent, 'greetOriginalAlias'), + ]; + $describeWeaving($kernel, ZEngineParent::class, ZEngineChild::class, ZEngineUnadvised::class); + break; + + case 'late': + $describeWeaving($kernel, ZEngineLate::class); + $late = new ZEngineLate(); + $report['calls'] = ['late.compute' => $late->compute(2, 3)]; + break; + + case 'unsupported': + $report['loaded'] = class_exists(ZEngineUnsupported::class); + break; + + default: + throw new InvalidArgumentException('Unknown scenario ' . $scenario); + } +} catch (Throwable $exception) { + $report['exception'] = [ + 'class' => $exception::class, + 'message' => $exception->getMessage(), + 'previous' => $exception->getPrevious() !== null ? $exception->getPrevious()::class : null, + ]; +} + +$report['log'] = ZEngineAspect::$log; +$report['donors'] = array_values(array_filter( + [ZEngineParent::class, ZEngineChild::class, ZEngineLate::class, ZEngineUnadvised::class], + static fn(string $class): bool => class_exists($class . '__AopDonor', false), +)); +$cacheFiles = []; +if (is_dir($cacheDir)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + $cacheFiles[] = str_replace('\\', '/', substr((string) $file, strlen($cacheDir) + 1)); + } + sort($cacheFiles); +} +$report['cache'] = $cacheFiles; + +echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/tests/Fixtures/project/src/Application/ZEngineChild.php b/tests/Fixtures/project/src/Application/ZEngineChild.php new file mode 100644 index 00000000..7a0fda87 --- /dev/null +++ b/tests/Fixtures/project/src/Application/ZEngineChild.php @@ -0,0 +1,32 @@ +calls++; + + return 'Hi, ' . $name . ' from child'; + } + + public function withDefault(int $times = self::TIMES, ?self $other = null): string + { + return str_repeat('x', $times) . ($other === null ? '' : ' with ' . $other::class); + } + + public function getCalls(): int + { + return $this->calls; + } +} diff --git a/tests/Fixtures/project/src/Application/ZEngineLate.php b/tests/Fixtures/project/src/Application/ZEngineLate.php new file mode 100644 index 00000000..578f4c4e --- /dev/null +++ b/tests/Fixtures/project/src/Application/ZEngineLate.php @@ -0,0 +1,16 @@ +greet($name); + + return $instance; + } + + public function callSecret(): string + { + return $this->secret(); + } + + protected function secret(): string + { + return 'secret of ' . static::class; + } +} diff --git a/tests/Fixtures/project/src/Application/ZEngineUnadvised.php b/tests/Fixtures/project/src/Application/ZEngineUnadvised.php new file mode 100644 index 00000000..046347b6 --- /dev/null +++ b/tests/Fixtures/project/src/Application/ZEngineUnadvised.php @@ -0,0 +1,15 @@ +counter; + } +} diff --git a/tests/Fixtures/project/src/Aspect/ZEngineAspect.php b/tests/Fixtures/project/src/Aspect/ZEngineAspect.php new file mode 100644 index 00000000..dde3c9df --- /dev/null +++ b/tests/Fixtures/project/src/Aspect/ZEngineAspect.php @@ -0,0 +1,45 @@ +:: on " + * + * @var list + */ + public static array $log = []; + + #[Pointcut\Before("execution(public Go\Tests\TestProject\Application\ZEngineParent->greet(*)) + || execution(public Go\Tests\TestProject\Application\ZEngineChild->*(*)) + || execution(public Go\Tests\TestProject\Application\ZEngineLate->*(*))")] + public function beforeMethod(MethodInvocation $invocation): void + { + $method = $invocation->getMethod(); + $target = $invocation->getThis(); + self::$log[] = sprintf( + 'before %s::%s on %s', + $method->class, + $method->name, + is_object($target) ? $target::class : (is_string($target) ? $target : get_debug_type($target)), + ); + } + + #[Pointcut\Around("execution(public Go\Tests\TestProject\Application\ZEngineChild->greet(*))")] + public function aroundGreet(MethodInvocation $invocation): string + { + $result = $invocation->proceed(); + self::$log[] = 'around greet got ' . get_debug_type($result); + + return strtoupper((string) $result); + } +} diff --git a/tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php b/tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php new file mode 100644 index 00000000..ba757a53 --- /dev/null +++ b/tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php @@ -0,0 +1,20 @@ +counter)")] + public function beforeCounterAccess(FieldAccess $access): void + { + echo 'counter accessed'; + } +} diff --git a/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php b/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php new file mode 100644 index 00000000..2016bbf1 --- /dev/null +++ b/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php @@ -0,0 +1,19 @@ +addLazyService(ZEngineAspect::class, fn(): ZEngineAspect => new ZEngineAspect()); + } +} diff --git a/tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php b/tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php new file mode 100644 index 00000000..6430cb19 --- /dev/null +++ b/tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php @@ -0,0 +1,21 @@ +addLazyService(ZEngineAspect::class, fn(): ZEngineAspect => new ZEngineAspect()); + $container->addLazyService(ZEngineUnsupportedAspect::class, fn(): ZEngineUnsupportedAspect => new ZEngineUnsupportedAspect()); + } +} diff --git a/tests/Fixtures/project/web/configuration.php b/tests/Fixtures/project/web/configuration.php index 042b6a27..530461b8 100644 --- a/tests/Fixtures/project/web/configuration.php +++ b/tests/Fixtures/project/web/configuration.php @@ -41,6 +41,61 @@ ], ], + // The z-engine weaving driver: classes load natively and are rewired through the engine (tests/Functional/ZEngine) + 'zengine' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\ZEngineAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => true, + 'driver' => 'zengine', + 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + + 'zengine_production' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\ZEngineAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => false, + 'driver' => 'zengine', + 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-production', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + + // The same aspects woven by the stream driver: the reference the z-engine driver's behaviour is compared with + 'zengine_reference' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\ZEngineAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => true, + 'driver' => 'stream', + 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-reference', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + + // An aspect with a property pointcut: the z-engine driver refuses the class it matches + 'zengine_unsupported' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\ZEngineUnsupportedAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => true, + 'driver' => 'zengine', + 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-unsupported', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + 'inconsistent_weaving' => [ 'kernel' => \Go\Tests\TestProject\Kernel\InconsistentlyWeavingAspectKernel::class, 'console' => __DIR__ . '/../bin/console', diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 5540250b..5ebe49c2 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -128,6 +128,7 @@ protected function execute( assert($phpExecutable !== false); $commandLine = [ $phpExecutable, + ...$this->getPhpOptions(), $this->configuration['console'], '--no-ansi', $command, @@ -165,6 +166,23 @@ protected function execute( return $process->getOutput(); } + /** + * Command-line options of the PHP subprocess running the console, `-d` ini overrides included + * + * On PHP 8.5 the tracing JIT is switched off: it miscompiles code the framework runs (a known PHP bug), + * so a php.ini enabling the JIT would make the whole functional suite fail. Override to add options. + * + * @return list + */ + protected function getPhpOptions(): array + { + if (PHP_VERSION_ID >= 80500 && PHP_VERSION_ID < 80600) { + return ['-d', 'opcache.jit=off', '-d', 'opcache.jit_buffer_size=0']; + } + + return []; + } + /** * Assert that class is woven. * diff --git a/tests/Functional/CachePortabilityTest.php b/tests/Functional/CachePortabilityTest.php index a0a3cf6e..dc8baea0 100644 --- a/tests/Functional/CachePortabilityTest.php +++ b/tests/Functional/CachePortabilityTest.php @@ -53,7 +53,7 @@ public function testMovedCacheIsServedAsPrebuiltCache(): void '\\' . EdgeCaseDemo::class, '\\' . EdgeCaseAspect::class, ); - $process = new Process([$phpExecutable, '-r', $script], null, ['GO_AOP_CONFIGURATION' => 'moved_cache']); + $process = new Process([$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => 'moved_cache']); $process->run(); $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); diff --git a/tests/Functional/ClassWeavingTest.php b/tests/Functional/ClassWeavingTest.php index 259ee7ab..4b8cf8df 100644 --- a/tests/Functional/ClassWeavingTest.php +++ b/tests/Functional/ClassWeavingTest.php @@ -181,7 +181,7 @@ public function testNewInInitializerPromotedPropertyWeaving(): void ); assert($phpExecutable !== false); $process = new Process( - [$phpExecutable, '-r', $script], + [$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); @@ -222,7 +222,7 @@ public function testEdgeCaseMethodsKeepTheirBehaviourWhenWoven(): void ); assert($phpExecutable !== false); $process = new Process( - [$phpExecutable, '-r', $script], + [$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); @@ -253,7 +253,7 @@ public function testArrayPropertyInterceptionAllowsIndirectModification(): void ); assert($phpExecutable !== false); $process = new Process( - [$phpExecutable, '-r', $script], + [$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); @@ -287,7 +287,7 @@ public function testNullablePropertyInterceptionHandlesNullValues(): void ); assert($phpExecutable !== false); $process = new Process( - [$phpExecutable, '-r', $script], + [$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); diff --git a/tests/Functional/DebugModeLoadingTest.php b/tests/Functional/DebugModeLoadingTest.php index 5398535e..381b1307 100644 --- a/tests/Functional/DebugModeLoadingTest.php +++ b/tests/Functional/DebugModeLoadingTest.php @@ -109,7 +109,7 @@ private function runScript(string ...$classNames): array $phpExecutable = (new PhpExecutableFinder())->find(); $this->assertIsString($phpExecutable); $process = new Process( - [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/debug-loading.php', ...$classNames], + [$phpExecutable, ...$this->getPhpOptions(), __DIR__ . '/../Fixtures/project/bin/debug-loading.php', ...$classNames], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); diff --git a/tests/Functional/DoctrineBridgeTest.php b/tests/Functional/DoctrineBridgeTest.php index 11c49ba5..1dacac0a 100644 --- a/tests/Functional/DoctrineBridgeTest.php +++ b/tests/Functional/DoctrineBridgeTest.php @@ -34,7 +34,7 @@ public function testDoctrineMapsWovenEntity(): void $phpExecutable = (new PhpExecutableFinder())->find(); $this->assertIsString($phpExecutable); $process = new Process( - [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/doctrine-metadata.php'], + [$phpExecutable, ...$this->getPhpOptions(), __DIR__ . '/../Fixtures/project/bin/doctrine-metadata.php'], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); diff --git a/tests/Functional/MagicConstantTest.php b/tests/Functional/MagicConstantTest.php index f8aa7cc1..772976ab 100644 --- a/tests/Functional/MagicConstantTest.php +++ b/tests/Functional/MagicConstantTest.php @@ -46,7 +46,7 @@ private function runScript(): array $phpExecutable = (new PhpExecutableFinder())->find(); $this->assertIsString($phpExecutable); $process = new Process( - [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/magic-constants.php'], + [$phpExecutable, ...$this->getPhpOptions(), __DIR__ . '/../Fixtures/project/bin/magic-constants.php'], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); diff --git a/tests/Functional/ProxyNameCollisionTest.php b/tests/Functional/ProxyNameCollisionTest.php index 9de04049..0b0179eb 100644 --- a/tests/Functional/ProxyNameCollisionTest.php +++ b/tests/Functional/ProxyNameCollisionTest.php @@ -46,7 +46,7 @@ public function testProxyWithCollidingShortNamesLoadsAndRunsBothAdvices(): void '\\' . ImportCollisionClass::class, ); $process = new Process( - [$phpExecutable, '-r', $script], + [$phpExecutable, ...$this->getPhpOptions(), '-r', $script], null, ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], ); diff --git a/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php b/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php new file mode 100644 index 00000000..62cf1a94 --- /dev/null +++ b/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php @@ -0,0 +1,246 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Functional\ZEngine; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Base of the z-engine driver tests: every scenario runs the probe tests/Fixtures/project/bin/zengine-weaving.php + * in a PHP subprocess with FFI enabled and the JIT off, and reads back its JSON report + * + * The group `zengine` (declared on every concrete test class, PHPUnit 13 does not inherit it) is excluded from the + * default run (`composer test:zengine` runs it) and self-skips on a host where the engine cannot boot: z-engine not + * installed, ext-ffi missing, or a z-engine branch of another PHP minor. + */ +abstract class ZEngineFunctionalTestCase extends TestCase +{ + protected const string PROJECT_DIR = __DIR__ . '/../../Fixtures/project'; + + protected const string PROBE = self::PROJECT_DIR . '/bin/zengine-weaving.php'; + + /** + * PHP options every subprocess runs with: FFI on, the JIT off (z-engine hooks the executor internals the JIT + * bypasses, and the PHP 8.5 tracing JIT miscompiles the framework), freshly written donor files cacheable at once + */ + protected const array PHP_OPTIONS = [ + '-d', 'ffi.enable=1', + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + '-d', 'opcache.file_update_protection=0', + '-d', 'opcache.enable=1', + ]; + + private static ?string $engineUnavailableReason = null; + + private static bool $engineChecked = false; + + protected function setUp(): void + { + $reason = self::engineUnavailableReason(); + if ($reason !== null) { + self::markTestSkipped($reason); + } + $this->clearCaches(); + } + + protected function tearDown(): void + { + $this->clearCaches(); + } + + /** + * Both opcache legs: the engine mutation differs for a class published in shared memory + * + * @return iterable + */ + public static function provideOpcacheLegs(): iterable + { + yield 'opcache off' => [false]; + yield 'opcache on' => [true]; + } + + /** + * Runs one probe scenario and returns its report + * + * @param string $configuration Configuration name in tests/Fixtures/project/web/configuration.php + * @param string $scenario Scenario of the probe + * @param bool $opcache Whether opcache is active in the subprocess + * @param list $extraOptions Further command-line options of the PHP subprocess + * + * @return array + */ + protected function runProbe(string $configuration, string $scenario, bool $opcache, array $extraOptions = []): array + { + $process = new Process( + [ + self::phpExecutable(), + ...self::PHP_OPTIONS, + '-d', 'opcache.enable_cli=' . ($opcache ? '1' : '0'), + ...$extraOptions, + self::PROBE, + $scenario, + ], + null, + ['GO_AOP_CONFIGURATION' => $configuration, 'ZENGINE_AUTOBOOT' => '0'], + ); + $process->run(); + self::assertTrue($process->isSuccessful(), sprintf( + "The probe failed (%s, %s):\n%s\n%s", + $configuration, + $scenario, + $process->getOutput(), + $process->getErrorOutput(), + )); + + $report = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($report); + if ($opcache && ($report['opcache'] ?? false) !== true) { + self::markTestSkipped('opcache is not available in the PHP subprocess, the opcache leg cannot run'); + } + + return $report; + } + + /** + * Returns a value of the report by its path of keys, failing on a missing key + * + * @param array $report + */ + protected static function at(array $report, string ...$path): mixed + { + $value = $report; + foreach ($path as $key) { + self::assertIsArray($value, 'Report path ' . implode('.', $path) . ' leads through a non-array'); + self::assertArrayHasKey($key, $value, 'Report path ' . implode('.', $path) . ' is missing'); + $value = $value[$key]; + } + + return $value; + } + + /** + * Returns a string value of the report by its path of keys + * + * @param array $report + */ + protected static function stringAt(array $report, string ...$path): string + { + $value = self::at($report, ...$path); + self::assertIsString($value, 'Report path ' . implode('.', $path) . ' is not a string'); + + return $value; + } + + /** + * Runs a console command of the fixture project with the engine configuration, returns exit code, stdout, stderr + * + * @param list $arguments + * + * @return array{int|null, string, string} + */ + protected function runConsole(string $configuration, string $command, array $arguments = []): array + { + $process = new Process( + [ + self::phpExecutable(), + ...self::PHP_OPTIONS, + '-d', 'opcache.enable_cli=0', + self::PROJECT_DIR . '/bin/console', + '--no-ansi', + $command, + self::PROJECT_DIR . '/web/index.php', + ...$arguments, + ], + null, + // A wide terminal: the console wraps exception messages to the terminal width, mid-word + ['GO_AOP_CONFIGURATION' => $configuration, 'ZENGINE_AUTOBOOT' => '0', 'COLUMNS' => '1000'], + ); + $process->run(); + + return [$process->getExitCode(), $process->getOutput(), $process->getErrorOutput()]; + } + + /** + * Cache directory of a fixture configuration + */ + protected static function cacheDir(string $configuration): string + { + /** @var array $configurations Shape of our own fixture file is trusted */ + $configurations = require self::PROJECT_DIR . '/web/configuration.php'; + + return $configurations[$configuration]['cacheDir']; + } + + /** + * Removes the cache directories of every z-engine configuration + */ + protected function clearCaches(): void + { + /** @var array $configurations Shape of our own fixture file is trusted */ + $configurations = require self::PROJECT_DIR . '/web/configuration.php'; + $filesystem = new Filesystem(); + foreach ($configurations as $name => $configuration) { + if (str_starts_with($name, 'zengine') && $filesystem->exists($configuration['cacheDir'])) { + $filesystem->remove($configuration['cacheDir']); + } + } + } + + /** + * Why the engine cannot run on this host, null when it can (checked once per process, in a subprocess booting + * z-engine exactly like the probe: ext-ffi, ffi.enable and the PHP minor of the installed z-engine branch) + */ + private static function engineUnavailableReason(): ?string + { + if (self::$engineChecked) { + return self::$engineUnavailableReason; + } + self::$engineChecked = true; + if (!class_exists(\ZEngine\Core::class)) { + return self::$engineUnavailableReason = 'lisachenko/z-engine is not installed: ' + . 'composer require --dev lisachenko/z-engine:dev-feature/redefine-preserve-alias (see docs/zengine-driver.md)'; + } + $process = new Process( + [ + self::phpExecutable(), + ...self::PHP_OPTIONS, + '-d', 'opcache.enable_cli=0', + '-r', + 'require $argv[1]; ZEngine\Core::init(); echo "booted";', + __DIR__ . '/../../../vendor/autoload.php', + ], + null, + ['ZENGINE_AUTOBOOT' => '0'], + ); + $process->run(); + if (!$process->isSuccessful() || !str_contains($process->getOutput(), 'booted')) { + $failure = trim($process->getErrorOutput() . "\n" . $process->getOutput()); + + return self::$engineUnavailableReason = 'z-engine cannot boot on this host (ext-ffi, ffi.enable=1 and the ' + . 'z-engine branch of this PHP minor are required): ' . strtok($failure, "\n"); + } + + return self::$engineUnavailableReason = null; + } + + private static function phpExecutable(): string + { + $phpExecutable = (new PhpExecutableFinder())->find(false); + self::assertNotFalse($phpExecutable, 'A PHP executable is needed to run the probe'); + + return $phpExecutable; + } +} diff --git a/tests/Functional/ZEngine/ZEngineWeavingTest.php b/tests/Functional/ZEngine/ZEngineWeavingTest.php new file mode 100644 index 00000000..172f2eab --- /dev/null +++ b/tests/Functional/ZEngine/ZEngineWeavingTest.php @@ -0,0 +1,190 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Functional\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Tests\TestProject\Application\ZEngineChild; +use Go\Tests\TestProject\Application\ZEngineLate; +use Go\Tests\TestProject\Application\ZEngineParent; +use Go\Tests\TestProject\Application\ZEngineUnadvised; +use Go\Tests\TestProject\Aspect\ZEngineUnsupportedAspect; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * The z-engine driver weaves the fixture project at runtime: same behaviour as the stream driver, donors cached + */ +#[Group('zengine')] +final class ZEngineWeavingTest extends ZEngineFunctionalTestCase +{ + /** + * Files the driver writes into the cache directory of the `weave` scenario + */ + private const array EXPECTED_CACHE_FILES = [ + '_zengine/src/Application/ZEngineChild__AopDonor.php', + '_zengine/src/Application/ZEngineParent__AopDonor.php', + 'src/Aspect/ZEngineAspect.cache.php', + ]; + + #[DataProvider('provideOpcacheLegs')] + public function testWeavesLikeTheStreamDriver(bool $opcache): void + { + $engine = $this->runProbe('zengine', 'weave', $opcache); + $stream = $this->runProbe('zengine_reference', 'weave', $opcache); + + self::assertArrayNotHasKey('exception', $engine, 'The engine driver must weave the scenario'); + self::assertArrayNotHasKey('exception', $stream, 'The stream driver must weave the scenario'); + self::assertSame('zengine', $engine['driver']); + self::assertSame('stream', $stream['driver']); + + // Advised calls return the same results and run the same advices in the same order + self::assertSame($stream['calls'], $engine['calls']); + self::assertSame($stream['log'], $engine['log']); + self::assertNotEmpty($engine['log']); + + // Reflection agrees too, except that a swapped body carries the file it was compiled in: the donor + $expectedReflection = $stream['reflection']; + self::assertIsArray($expectedReflection); + $expectedReflection['parent.greet.file'] = 'ZEngineParent__AopDonor.php'; + self::assertSame($expectedReflection, $engine['reflection']); + + // Own methods are swapped, inherited public methods get an override, the unadvised class is left alone + self::assertSame( + ['outcome' => 'already-woven', 'methods' => ['greet'], 'reason' => null], + self::at($engine, 'weave', ZEngineParent::class), + ); + self::assertSame( + ['outcome' => 'already-woven', 'methods' => ['greet', 'withDefault', 'getCalls', 'describe', 'callSecret'], 'reason' => null], + self::at($engine, 'weave', ZEngineChild::class), + ); + self::assertSame( + ['outcome' => 'no-advices', 'methods' => [], 'reason' => null], + self::at($engine, 'weave', ZEngineUnadvised::class), + ); + self::assertSame([ZEngineParent::class, ZEngineChild::class], $engine['donors']); + self::assertSame(self::EXPECTED_CACHE_FILES, $engine['cache']); + + // The subprocess runs with the JIT off, nothing for the guard to do + self::assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], self::at($engine, 'boot', 'jit')); + self::assertSame($opcache, self::at($engine, 'boot', 'opcache')); + } + + #[DataProvider('provideOpcacheLegs')] + public function testWarmRequestReusesTheDonorAndStaleSourceRegeneratesIt(bool $opcache): void + { + $donorFile = self::cacheDir('zengine') . '/_zengine/src/Application/ZEngineChild__AopDonor.php'; + $source = realpath(self::PROJECT_DIR . '/src/Application/ZEngineChild.php'); + self::assertNotFalse($source); + + $cold = $this->runProbe('zengine', 'weave', $opcache); + self::assertFalse($cold['indexExisted']); + self::assertFileExists($donorFile); + self::assertFileExists(self::cacheDir('zengine') . '/_zengine.cache', 'The donor index is flushed at the end of the request'); + + // A marked donor file proves that the warm request loads the cached donor instead of generating it again + file_put_contents($donorFile, PHP_EOL . '// marked by ' . self::class . PHP_EOL, FILE_APPEND); + $markedDonor = md5_file($donorFile); + + $warm = $this->runProbe('zengine', 'weave', $opcache); + self::assertTrue($warm['indexExisted']); + self::assertSame($cold['calls'], $warm['calls']); + self::assertSame($cold['log'], $warm['log']); + self::assertSame($markedDonor, md5_file($donorFile), 'A fresh record reuses its donor'); + + // Another mtime of the source (a deployment restoring an older file) makes the record stale + $sourceMtime = filemtime($source); + self::assertNotFalse($sourceMtime); + touch($source, $sourceMtime - 60); + try { + $stale = $this->runProbe('zengine', 'weave', $opcache); + } finally { + touch($source, $sourceMtime); + } + self::assertSame($cold['calls'], $stale['calls']); + self::assertNotSame($markedDonor, md5_file($donorFile), 'A stale record regenerates its donor'); + } + + #[DataProvider('provideOpcacheLegs')] + public function testProductionModeReusesTheIndex(bool $opcache): void + { + $cold = $this->runProbe('zengine_production', 'weave', $opcache); + $warm = $this->runProbe('zengine_production', 'weave', $opcache); + + self::assertArrayNotHasKey('exception', $cold); + self::assertFalse($cold['indexExisted']); + self::assertTrue($warm['indexExisted']); + self::assertSame($cold['calls'], $warm['calls']); + self::assertSame($cold['log'], $warm['log']); + self::assertSame([ZEngineParent::class, ZEngineChild::class], $warm['donors']); + } + + #[DataProvider('provideOpcacheLegs')] + public function testWeavesAClassDeclaredBeforeTheKernelOnDemand(bool $opcache): void + { + $report = $this->runProbe('zengine', 'late', $opcache); + + self::assertArrayNotHasKey('exception', $report); + self::assertSame( + ['outcome' => 'woven', 'methods' => ['compute'], 'reason' => null], + self::at($report, 'weave', ZEngineLate::class), + ); + self::assertSame(['late.compute' => 5], $report['calls']); + self::assertSame(['before ' . ZEngineLate::class . '::compute on ' . ZEngineLate::class], $report['log']); + self::assertSame([ZEngineLate::class], $report['donors']); + } + + public function testRefusesJoinpointKindsTheEngineCannotWeave(): void + { + $report = $this->runProbe('zengine_unsupported', 'unsupported', false); + + self::assertArrayHasKey('exception', $report); + self::assertSame(UnsupportedJoinpointException::class, self::at($report, 'exception', 'class')); + self::assertStringContainsString(ZEngineUnsupportedAspect::class . '->beforeCounterAccess', self::stringAt($report, 'exception', 'message')); + self::assertStringContainsString('join point kind(s) prop', self::stringAt($report, 'exception', 'message')); + self::assertSame([], $report['donors'], 'No donor is generated for a refused class'); + self::assertSame([], $report['log']); + } + + public function testCacheWarmupRefusesTheEngineDriver(): void + { + [$exitCode, $output, $errorOutput] = $this->runConsole('zengine', 'cache:warmup:aop'); + + self::assertSame(1, $exitCode, $output . $errorOutput); + self::assertStringContainsString('zengine weaving driver', $output . $errorOutput); + self::assertStringContainsString('stream filter', $output . $errorOutput); + } + + public function testJitGuardHandlesATracingJitAccordingToThePhpVersion(): void + { + $report = $this->runProbe('zengine', 'weave', true, ['-d', 'opcache.jit=tracing', '-d', 'opcache.jit_buffer_size=64M']); + + if (PHP_VERSION_ID >= 80500 && PHP_VERSION_ID < 80600) { + if (isset($report['exception'])) { + self::fail('The JIT must be switched off at runtime on PHP 8.5: ' . json_encode($report['exception'])); + } + if (self::at($report, 'boot', 'jit', 'jitWasActive') !== true) { + self::markTestSkipped('The JIT cannot be enabled in this PHP build, the guard has nothing to do'); + } + self::assertSame(['jitWasActive' => true, 'previousMode' => 'tracing', 'disabledAtRuntime' => true], self::at($report, 'boot', 'jit')); + self::assertSame('HI, BOB FROM CHILD', self::at($report, 'calls', 'child.greet')); + + return; + } + if (!isset($report['exception'])) { + self::markTestSkipped('The JIT cannot be enabled in this PHP build, the guard has nothing to do'); + } + self::assertSame(InvalidConfigurationException::class, self::at($report, 'exception', 'class')); + self::assertStringContainsString('opcache.jit=off', self::stringAt($report, 'exception', 'message')); + } +} diff --git a/tests/Instrument/ClassLoading/AopComposerLoaderTest.php b/tests/Instrument/ClassLoading/AopComposerLoaderTest.php index e9d44949..a0e3050d 100644 --- a/tests/Instrument/ClassLoading/AopComposerLoaderTest.php +++ b/tests/Instrument/ClassLoading/AopComposerLoaderTest.php @@ -13,6 +13,7 @@ namespace Go\Instrument\ClassLoading; use Composer\Autoload\ClassLoader; +use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\Container; use Go\Core\AspectKernel; @@ -57,6 +58,7 @@ public function testExcludedPathsAreNotWoven(): void 'includePaths' => [], 'excludePaths' => [$this->appDir . '/vendor/goaop/dissect'], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); $fileEnumerator = new ReflectionProperty(AopComposerLoader::class, 'fileEnumerator')->getValue($loader); @@ -262,6 +264,40 @@ private function findWrapperOf(object $originalLoader): ?AopComposerLoader return null; } + public function testInitRewrapsALoaderWrappedByTheEngineDriver(): void + { + $originalLoaders = spl_autoload_functions(); + $composerLoader = new ClassLoader(); + $engineLoader = new ZEngineComposerLoader($composerLoader, $this->createContainer(), $this->createOptions([])); + self::registerLoader([$engineLoader, 'loadClass']); + try { + $this->assertTrue(AopComposerLoader::init($this->createOptions([]), $this->createContainer())); + + $wrappers = []; + foreach (spl_autoload_functions() as $loader) { + if (is_array($loader) && $loader[0] instanceof ComposerLoaderDecorator && $loader[0]->getOriginalLoader() === $composerLoader) { + $wrappers[] = $loader[0]; + } + $this->assertFalse(is_array($loader) && $loader[0] === $engineLoader, 'The engine wrapper is replaced'); + } + $this->assertCount(1, $wrappers); + $this->assertInstanceOf(AopComposerLoader::class, $wrappers[0]); + } finally { + // init() wrapped composer's real loader too: restore the autoloaders of the test runner + foreach (spl_autoload_functions() as $loader) { + spl_autoload_unregister($loader); + } + foreach ($originalLoaders as $loader) { + spl_autoload_register($loader); + } + } + } + + private static function registerLoader(callable $loader): void + { + spl_autoload_register($loader); + } + /** * @param list $excludePaths */ @@ -275,7 +311,7 @@ private function createLoader(ClassLoader $composerLoader, array $excludePaths = * * @param list $excludePaths * - * @return array{debug: bool, appDir: string, cacheDir: string|null, cacheFileMode: int, features: int, includePaths: list, excludePaths: list, containerClass: class-string} + * @return array{debug: bool, appDir: string, cacheDir: string|null, cacheFileMode: int, features: int, includePaths: list, excludePaths: list, containerClass: class-string, driver: WeavingDriver} */ private function createOptions(array $excludePaths): array { @@ -288,6 +324,7 @@ private function createOptions(array $excludePaths): array 'includePaths' => [$this->appDir . '/src'], 'excludePaths' => $excludePaths, 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]; } diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php index 6c25e3db..d220a5c0 100644 --- a/tests/Instrument/ClassLoading/CachePathManagerTest.php +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -15,6 +15,7 @@ use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Exception\WeavingException; use Go\Aop\Features; +use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\AspectKernel; use Go\Core\Container; @@ -57,6 +58,7 @@ private function createManager(bool $prebuiltCache = false, ?string $cacheDir = 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); $kernel->method('hasFeature')->willReturn($prebuiltCache); @@ -308,4 +310,48 @@ private function createContainer(bool $isFresh): AspectContainer return $container; } + + public function testIsFreshRecordIsTheSharedFreshnessRule(): void + { + $resource = self::$appDir . '/Fresh.php'; + file_put_contents($resource, ' filemtime($resource), 'filesize' => filesize($resource), 'cachedAt' => time()]; + + $manager = $this->createManager(); + $this->assertTrue($manager->isFreshRecord($record, $resource, $this->createContainer(true))); + $this->assertFalse($manager->isFreshRecord($record, $resource, $this->createContainer(false)), 'A newer tracked resource'); + $this->assertFalse($manager->isFreshRecord(['filesize' => 0] + $record, $resource, $this->createContainer(true)), 'Another size'); + $this->assertFalse($manager->isFreshRecord(['filemtime' => 1] + $record, $resource, $this->createContainer(true)), 'Another mtime'); + $this->assertFalse($manager->isFreshRecord(['cacheUri' => 5] + $record, $resource, $this->createContainer(true)), 'A broken cache URI'); + // With a prebuilt cache a record is trusted as is + $this->assertTrue($this->createManager(true)->isFreshRecord(['filemtime' => 1] + $record, $resource, $this->createContainer(false))); + } + + public function testWriteIndexFileWritesALoadableIndexNextToTheTransformationMetadata(): void + { + $this->createManager()->writeIndexFile('/_other.cache', ['version' => 7, 'classes' => ['App\\Foo' => ['donor' => self::$cacheDir . '/donor.php']]]); + + $indexFile = self::$cacheDir . '/_other.cache'; + $this->assertFileExists($indexFile); + $this->assertSame( + ['version' => 7, 'classes' => ['App\\Foo' => ['donor' => self::$cacheDir . '/donor.php']]], + include $indexFile, + ); + $this->assertStringContainsString('__DIR__', (string) file_get_contents($indexFile), 'Cache paths are written portably'); + + $kernel = $this->createMock(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => false, + 'appDir' => self::$appDir, + 'cacheDir' => null, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, + ]); + $this->expectException(InvalidConfigurationException::class); + (new CachePathManager($kernel))->writeIndexFile('/_other.cache', []); + } } diff --git a/tests/Instrument/ClassLoading/CacheWarmerTest.php b/tests/Instrument/ClassLoading/CacheWarmerTest.php index 668d1ea6..7db76458 100644 --- a/tests/Instrument/ClassLoading/CacheWarmerTest.php +++ b/tests/Instrument/ClassLoading/CacheWarmerTest.php @@ -28,6 +28,7 @@ use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\StreamOutput; +use Go\Aop\WeavingDriver; // Separate processes: warming up registers the process-wide source transforming // stream filter, which must not leak into the rest of the PHPUnit process @@ -84,6 +85,7 @@ private function createKernel( 'includePaths' => $includePaths, 'excludePaths' => $excludePaths, 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); $container = $this->createMock(AspectContainer::class); diff --git a/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php b/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php index ad5bbd53..c059a509 100644 --- a/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php +++ b/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php @@ -25,6 +25,7 @@ use PhpToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Go\Aop\WeavingDriver; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] class SourceTransformingLoaderTest extends TestCase @@ -91,6 +92,7 @@ private function registerLoader(array $transformers, int $features = 0, ?string 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, + 'driver' => WeavingDriver::Stream, ]); $kernel->method('hasFeature')->willReturnCallback( static fn(int $featureToCheck): bool => ($features & $featureToCheck) !== 0, diff --git a/tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php b/tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php new file mode 100644 index 00000000..3d400b9f --- /dev/null +++ b/tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php @@ -0,0 +1,159 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ClassLoading; + +use Composer\Autoload\ClassLoader; +use Go\Aop\WeavingDriver; +use Go\Core\AspectContainer; +use Go\Core\Container; +use Go\PhpUnit\UsesTemporaryDirectory; +use PHPUnit\Framework\TestCase; + +/** + * The composer loader wrapper of the z-engine driver: native loading, weaving only within the woven paths + */ +final class ZEngineComposerLoaderTest extends TestCase +{ + use UsesTemporaryDirectory; + + private string $appDir; + + /** + * Autoloaders of the test runner, restored in tearDown() (init() wraps composer's real loader too) + * + * @var list + */ + private array $originalLoaders = []; + + protected function setUp(): void + { + $this->originalLoaders = spl_autoload_functions(); + // Real directory: the file enumerator resolves candidate files with realpath() + $this->appDir = self::createTemporaryDirectory('zengine-loader'); + mkdir($this->appDir . '/src'); + mkdir($this->appDir . '/lib'); + } + + protected function tearDown(): void + { + foreach (spl_autoload_functions() as $loader) { + spl_autoload_unregister($loader); + } + foreach ($this->originalLoaders as $loader) { + spl_autoload_register($loader); + } + self::removeTemporaryDirectory($this->appDir); + } + + public function testInitWrapsEveryComposerLoaderAndUnwrapsEarlierWrappers(): void + { + $composerLoader = new ClassLoader(); + $otherLoader = static function (string $class): void {}; + $this->register([$composerLoader, 'loadClass']); + // A loader wrapped by an earlier init() of either driver is wrapped again around composer's loader + $this->register([new ZEngineComposerLoader($composerLoader, $this->createContainer(), $this->createOptions()), 'loadClass']); + $this->register($otherLoader); + + $this->assertTrue(ZEngineComposerLoader::init($this->createOptions(), $this->createContainer())); + + $wrappers = []; + foreach (spl_autoload_functions() as $loader) { + if (is_array($loader) && $loader[0] instanceof ZEngineComposerLoader && $loader[0]->getOriginalLoader() === $composerLoader) { + $wrappers[] = $loader[0]; + } + $this->assertFalse(is_array($loader) && $loader[0] === $composerLoader, 'The bare composer loader is replaced'); + } + $this->assertCount(2, $wrappers, 'The bare loader and the earlier wrapper are both wrapped around the composer loader'); + $this->assertContains($otherLoader, spl_autoload_functions(), 'Other autoloaders stay registered'); + } + + public function testInitReportsWhenNoComposerLoaderIsRegistered(): void + { + // Composer's real loader is registered in the test runner: without it only a plain autoloader remains + foreach (spl_autoload_functions() as $loader) { + spl_autoload_unregister($loader); + } + $this->register(static function (string $class): void {}); + + $this->assertFalse(ZEngineComposerLoader::init($this->createOptions(), $this->createContainer())); + } + + public function testLoadsAClassOutsideTheWovenPathsNativelyWithoutWeaving(): void + { + $className = __NAMESPACE__ . '\\ZEngineLoaderProbe'; + $probeFile = $this->appDir . '/lib/ZEngineLoaderProbe.php'; + file_put_contents($probeFile, <<<'PHP' + addClassMap([$className => $probeFile]); + $container = $this->createMock(AspectContainer::class); + $container->expects($this->never())->method('getService'); + + $loader = new ZEngineComposerLoader($composerLoader, $container, $this->createOptions()); + + $this->assertTrue($loader->loadClass($className)); + $this->assertTrue(class_exists($className, false)); + $this->assertFalse(ZEngineLoaderProbe::$sawThis, 'The file is included in an isolated scope'); + $this->assertNull($loader->loadClass('Totally\\Unknown\\ZEngineClass')); + $this->assertSame($probeFile, $loader->findFile($className)); + $this->assertFalse($loader->findFile('Totally\\Unknown\\ZEngineClass')); + } + + public function testTheOriginalFileOfAClassIsFoundThroughTheWrapper(): void + { + $composerLoader = new ClassLoader(); + $composerLoader->addClassMap(['App\\Known' => $this->appDir . '/src/Known.php']); + $this->register([new ZEngineComposerLoader($composerLoader, $this->createContainer(), $this->createOptions()), 'loadClass']); + + $this->assertSame($this->appDir . '/src/Known.php', AopComposerLoader::findOriginalFile('App\\Known')); + $this->assertNull(AopComposerLoader::findOriginalFile('App\\Unknown')); + } + + private function register(callable $loader): void + { + spl_autoload_register($loader); + } + + /** + * Options weaving the application sources only + * + * @return array{debug: bool, appDir: string, cacheDir: string|null, cacheFileMode: int, features: int, includePaths: list, excludePaths: list, containerClass: class-string, driver: WeavingDriver} + */ + private function createOptions(): array + { + return [ + 'debug' => true, + 'appDir' => $this->appDir, + 'cacheDir' => null, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [$this->appDir . '/src'], + 'excludePaths' => [], + 'containerClass' => Container::class, + 'driver' => WeavingDriver::ZEngine, + ]; + } + + private function createContainer(): AspectContainer + { + return $this->createStub(AspectContainer::class); + } +} diff --git a/tests/Instrument/ZEngine/DonorCacheIndexTest.php b/tests/Instrument/ZEngine/DonorCacheIndexTest.php new file mode 100644 index 00000000..b5cb6fb9 --- /dev/null +++ b/tests/Instrument/ZEngine/DonorCacheIndexTest.php @@ -0,0 +1,181 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\WeavingDriver; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Core\Container; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\PhpUnit\UsesTemporaryDirectory; +use PHPUnit\Framework\TestCase; + +/** + * The donor index of the z-engine driver: records per class, freshness through the shared rule, persisted as PHP + */ +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +final class DonorCacheIndexTest extends TestCase +{ + use UsesTemporaryDirectory; + + private const string CLASS_NAME = 'App\Service'; + + private string $appDir; + + private string $cacheDir; + + private string $source; + + protected function setUp(): void + { + // Real directories: the index file is loaded with include, its paths are relative to __DIR__ + $this->appDir = self::createTemporaryDirectory('donor-app'); + $this->cacheDir = self::createTemporaryDirectory('donor-cache'); + $this->source = $this->appDir . '/src/Service.php'; + mkdir($this->appDir . '/src'); + file_put_contents($this->source, 'cacheDir); + self::removeTemporaryDirectory($this->appDir); + } + + public function testDonorFilesMirrorTheApplicationTreeBelowTheDonorDirectory(): void + { + $index = $this->createIndex(); + + $this->assertSame( + $this->cacheDir . '/_zengine/src/Service__AopDonor.php', + $index->donorFileFor($this->source, 'Service'), + ); + $this->assertSame( + $this->cacheDir . '/_zengine/Root__AopDonor.php', + $index->donorFileFor($this->appDir . '/Root.php', 'Root'), + ); + // A source outside the application root keeps its absolute path below the donor directory + $this->assertSame( + $this->cacheDir . '/_zengine/' . ltrim($this->cacheDir, '/') . '/Outside__AopDonor.php', + $index->donorFileFor($this->cacheDir . '/Outside.php', 'Outside'), + ); + } + + public function testRecordsSurviveAFlushAndAreServedWhileFresh(): void + { + $donorFile = $this->cacheDir . '/_zengine/src/Service__AopDonor.php'; + mkdir(dirname($donorFile), 0777, true); + file_put_contents($donorFile, 'createIndex(); + $this->assertNull($writer->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); + $writer->recordDonor(self::CLASS_NAME, $this->source, $donorFile, 'App\Service__AopDonor', ['run']); + $writer->recordNoAdvices('App\Plain', $this->source); + // Records of the request are served before the flush... + $this->assertSame( + ['donor' => $donorFile, 'donorClass' => 'App\Service__AopDonor', 'methods' => ['run']], + $writer->findFresh(self::CLASS_NAME, $this->source, $this->createContainer()), + ); + $writer->flush(); + $this->assertFileExists($this->cacheDir . '/_zengine.cache'); + + // ...and by another request through the index file + $reader = $this->createIndex(); + $this->assertSame( + ['donor' => $donorFile, 'donorClass' => 'App\Service__AopDonor', 'methods' => ['run']], + $reader->findFresh(self::CLASS_NAME, $this->source, $this->createContainer()), + ); + $this->assertSame( + ['donor' => null, 'donorClass' => null, 'methods' => []], + $reader->findFresh('App\Plain', $this->source, $this->createContainer()), + ); + $this->assertNull($reader->findFresh('App\Unknown', $this->source, $this->createContainer())); + } + + public function testAStaleOrOrphanedRecordIsAMiss(): void + { + $donorFile = $this->cacheDir . '/_zengine/src/Service__AopDonor.php'; + mkdir(dirname($donorFile), 0777, true); + file_put_contents($donorFile, 'createIndex(); + $writer->recordDonor(self::CLASS_NAME, $this->source, $donorFile, 'App\Service__AopDonor', ['run']); + $writer->flush(); + + // The container knows a resource newer than the record + $this->assertNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer(false))); + + // The source changed since the record was written + $mtime = filemtime($this->source); + $this->assertNotFalse($mtime); + touch($this->source, $mtime - 60); + $this->assertNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); + touch($this->source, $mtime); + $this->assertNotNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); + + // The donor file is gone + unlink($donorFile); + $this->assertNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); + } + + public function testAnIndexOfAnotherFormatVersionIsIgnored(): void + { + file_put_contents($this->cacheDir . '/_zengine.cache', ' 0, "classes" => ["App\\Service" => ["donor" => null]]];'); + + $this->assertNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); + } + + public function testWithoutCacheDirectoryNothingIsPersisted(): void + { + $index = $this->createIndex(null); + $index->recordNoAdvices(self::CLASS_NAME, $this->source); + $index->flush(); + + $this->assertSame( + ['donor' => null, 'donorClass' => null, 'methods' => []], + $index->findFresh(self::CLASS_NAME, $this->source, $this->createContainer()), + 'Records of the request are still served in memory', + ); + + $this->expectException(InvalidConfigurationException::class); + $index->donorFileFor($this->source, 'Service'); + } + + private function createIndex(?string $cacheDir = ''): DonorCacheIndex + { + $options = [ + 'debug' => true, + 'appDir' => $this->appDir, + 'cacheDir' => $cacheDir === '' ? $this->cacheDir : $cacheDir, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + 'driver' => WeavingDriver::ZEngine, + ]; + $kernel = $this->createMock(AspectKernel::class); + $kernel->method('getOptions')->willReturn($options); + $kernel->method('hasFeature')->willReturn(false); + + return new DonorCacheIndex(new CachePathManager($kernel), $options); + } + + private function createContainer(bool $fresh = true): AspectContainer + { + $container = $this->createMock(AspectContainer::class); + $container->method('isFreshSince')->willReturn($fresh); + + return $container; + } +} diff --git a/tests/Instrument/ZEngine/JitGuardTest.php b/tests/Instrument/ZEngine/JitGuardTest.php new file mode 100644 index 00000000..0f6f008d --- /dev/null +++ b/tests/Instrument/ZEngine/JitGuardTest.php @@ -0,0 +1,110 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use PHPUnit\Framework\TestCase; + +final class JitGuardTest extends TestCase +{ + private const int PHP_84 = 80400; + + private const int PHP_85 = 80511; + + /** + * @var list + */ + private array $iniWrites = []; + + public function testNothingToDoWhenTheJitIsOff(): void + { + $report = JitGuard::enforce(self::PHP_84, self::jitStatus(false), $this->iniWriter()); + + $this->assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], $report); + $this->assertSame([], $this->iniWrites); + } + + public function testNothingToDoWithoutOpcache(): void + { + $report = JitGuard::enforce(self::PHP_85, static fn(): false => false, $this->iniWriter()); + + $this->assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], $report); + $this->assertSame([], $this->iniWrites); + } + + public function testAnActiveJitIsRefusedOutsidePhp85(): void + { + try { + JitGuard::enforce(self::PHP_84, self::jitStatus(true), $this->iniWriter()); + self::fail('An active JIT must be refused outside PHP 8.5'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('opcache.jit=off and opcache.jit_buffer_size=0', $exception->getMessage()); + } + $this->assertSame([], $this->iniWrites, 'The guard never touches the ini outside PHP 8.5'); + } + + public function testTheTracingJitIsSwitchedOffAtRuntimeOnPhp85(): void + { + $readings = [true, false]; + $reader = static function () use (&$readings): array { + return ['jit' => ['on' => array_shift($readings), 'kind' => 'tracing']]; + }; + + $report = JitGuard::enforce(self::PHP_85, $reader, $this->iniWriter('tracing')); + + $this->assertSame(['jitWasActive' => true, 'previousMode' => 'tracing', 'disabledAtRuntime' => true], $report); + $this->assertSame([['opcache.jit', 'off']], $this->iniWrites); + } + + public function testAJitThatStaysOnAfterTheSwitchIsRefusedOnPhp85(): void + { + try { + JitGuard::enforce(self::PHP_85, self::jitStatus(true), $this->iniWriter('tracing')); + self::fail('A JIT that stays on must be refused'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('could not switch the opcache JIT off at runtime', $exception->getMessage()); + } + $this->assertSame([['opcache.jit', 'off']], $this->iniWrites); + } + + public function testDefaultsDescribeTheRunningProcess(): void + { + $status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; + $jitOn = is_array($status) && is_array($status['jit'] ?? null) && ($status['jit']['on'] ?? false) === true; + if ($jitOn) { + self::markTestSkipped('The JIT is active in the test runner: the guard would change the process'); + } + + $this->assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], JitGuard::enforce()); + } + + /** + * @return \Closure(): array + */ + private static function jitStatus(bool $jitOn): \Closure + { + return static fn(): array => ['opcache_enabled' => true, 'jit' => ['enabled' => true, 'on' => $jitOn, 'kind' => 'tracing']]; + } + + /** + * @return \Closure(string, string): (string|false) + */ + private function iniWriter(string|false $previous = false): \Closure + { + return function (string $option, string $value) use ($previous): string|false { + $this->iniWrites[] = [$option, $value]; + + return $previous; + }; + } +} diff --git a/tests/Performance/TransformationPerformanceTest.php b/tests/Performance/TransformationPerformanceTest.php index 4a50732b..ce95dd18 100644 --- a/tests/Performance/TransformationPerformanceTest.php +++ b/tests/Performance/TransformationPerformanceTest.php @@ -37,6 +37,7 @@ use RecursiveIteratorIterator; use ReflectionClass; use SplFileInfo; +use Go\Aop\WeavingDriver; /** * Performance of the source transformation, which runs on every cache miss of a loaded file @@ -326,6 +327,7 @@ private function createKernel(): AspectKernel 'includePaths' => [], 'excludePaths' => [], 'containerClass' => AspectContainer::class, + 'driver' => WeavingDriver::Stream, ]); return $kernel; diff --git a/tests/Proxy/DonorClassGeneratorTest.php b/tests/Proxy/DonorClassGeneratorTest.php new file mode 100644 index 00000000..b7b988ee --- /dev/null +++ b/tests/Proxy/DonorClassGeneratorTest.php @@ -0,0 +1,157 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Proxy; + +use Go\Aop\Exception\WeavingException; +use Go\Aop\Framework\BeforeInterceptor; +use Go\Aop\Framework\GeneratedInterceptor; +use Go\Core\AspectContainer; +use Go\ParserReflection\ReflectionFile; +use Go\PhpUnit\AssertsCompilablePhp; +use Go\Tests\TestProject\Application\ZEngineChild; +use Go\Tests\TestProject\Application\ZEngineParent; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +/** + * The donor class of the z-engine driver: dispatcher bodies compiled outside the woven class + */ +final class DonorClassGeneratorTest extends TestCase +{ + use AssertsCompilablePhp; + + private const string FIXTURES = __DIR__ . '/../Fixtures/project/src/Application/'; + + /** + * Both reflection flavours the weaver hands to the generator: the parsed source (the usual case) and native + * reflection (fallback for a source the parser cannot handle) + * + * @return iterable + */ + public static function provideReflectionFlavours(): iterable + { + yield 'parsed source' => [true]; + yield 'native reflection' => [false]; + } + + #[DataProvider('provideReflectionFlavours')] + public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): void + { + [$class, $imports] = self::reflect(ZEngineChild::class, $parsed); + $generator = new DonorClassGenerator($class, [ + AspectContainer::METHOD_PREFIX => [ + 'greet' => [self::testAdvice()], + 'withDefault' => [self::testAdvice()], + 'describe' => [self::testAdvice()], + ], + AspectContainer::STATIC_METHOD_PREFIX => [ + 'create' => [self::testAdvice()], + ], + ], $imports, ClassProxyGenerator::indexMethods($class)); + $code = "generate(); + + self::assertPhpCompiles($code); + self::assertSame(ZEngineChild::class . AspectContainer::DONOR_CLASS_SUFFIX, $generator->getDonorClassName()); + self::assertStringContainsString('namespace Go\Tests\TestProject\Application;', $code); + // Never instantiated, same parent as the woven class (parent:: must resolve), no final modifier + self::assertStringContainsString( + 'abstract class ZEngineChild__AopDonor extends \Go\Tests\TestProject\Application\ZEngineParent', + $code, + ); + self::assertStringNotContainsString('final', $code); + // The joinpoint is created for the woven class, named explicitly (self::class would be the donor) + self::assertStringContainsString('\Go\Tests\TestProject\Application\ZEngineChild::class,', $code); + self::assertStringNotContainsString('self::class', $code); + // Own methods reach their previous body under the alias, inherited ones through the parent + self::assertStringContainsString('$this->greetOriginalAlias(...)', $code); + self::assertStringContainsString('$this->withDefaultOriginalAlias(...)', $code); + self::assertStringContainsString('parent::describe(...)', $code); + self::assertStringContainsString('parent::create(...)', $code); + self::assertStringContainsString("InterceptorInjector::forStaticMethod(", $code); + // self in the signature is spelled out, static keeps binding to the called class + self::assertStringContainsString('?\Go\Tests\TestProject\Application\ZEngineChild $other = null): string', $code); + if ($parsed) { + // The parsed source keeps a constant default as written, native reflection only knows its value + self::assertStringContainsString('public function withDefault(int $times = self::TIMES, ', $code); + } + // An inherited method keeps the class its declaration resolves to (the parsed source names the declaring + // class like the stream driver's proxies do, native reflection the woven class), never the donor + self::assertMatchesRegularExpression( + '~public function describe\(\): \\\\Go\\\\Tests\\\\TestProject\\\\Application\\\\ZEngine(Parent|Child)\n~', + $code, + ); + self::assertStringContainsString('public static function create(string $name): static', $code); + self::assertStringNotContainsString(': self', $code); + self::assertStringNotContainsString('?self', $code); + } + + #[DataProvider('provideReflectionFlavours')] + public function testOwnStaticMethodsDispatchThroughTheStaticAlias(bool $parsed): void + { + [$class, $imports] = self::reflect(ZEngineParent::class, $parsed); + $generator = new DonorClassGenerator($class, [ + AspectContainer::STATIC_METHOD_PREFIX => ['create' => [self::testAdvice()]], + AspectContainer::METHOD_PREFIX => ['describe' => [self::testAdvice()]], + ], $imports, ClassProxyGenerator::indexMethods($class)); + $code = "generate(); + + self::assertPhpCompiles($code); + self::assertStringContainsString('abstract class ZEngineParent__AopDonor' . "\n", $code); + self::assertStringContainsString('self::createOriginalAlias(...)', $code); + self::assertStringContainsString('static::class', $code); + self::assertStringContainsString('public function describe(): \Go\Tests\TestProject\Application\ZEngineParent', $code); + } + + public function testRefusesJoinpointKindsADonorCannotCarry(): void + { + [$class, $imports] = self::reflect(ZEngineChild::class, true); + + try { + new DonorClassGenerator($class, [ + AspectContainer::METHOD_PREFIX => ['greet' => [self::testAdvice()]], + AspectContainer::PROPERTY_PREFIX => ['calls' => [self::testAdvice()]], + AspectContainer::INIT_PREFIX => ['root' => [self::testAdvice()]], + ], $imports); + self::fail('Non-method join points must be refused'); + } catch (WeavingException $exception) { + $this->assertStringContainsString('contain the join-point kind(s) prop, init', $exception->getMessage()); + } + } + + /** + * @param class-string $className + * + * @return array{ReflectionClass, array} + */ + private static function reflect(string $className, bool $parsed): array + { + if (!$parsed) { + return [new ReflectionClass($className), []]; + } + $file = new ReflectionFile(self::FIXTURES . (new ReflectionClass($className))->getShortName() . '.php'); + foreach ($file->getFileNamespaces() as $namespace) { + foreach ($namespace->getClasses() as $class) { + if ($class->getName() === $className) { + return [$class, $namespace->getNamespaceAliases()]; + } + } + } + self::fail('Fixture class not found: ' . $className); + } + + private static function testAdvice(): GeneratedInterceptor + { + return GeneratedInterceptor::fromAdvice('test', new BeforeInterceptor(static function (): void {})); + } +} diff --git a/tests/Proxy/Generator/MethodGeneratorTest.php b/tests/Proxy/Generator/MethodGeneratorTest.php index 1d41228b..762c7068 100644 --- a/tests/Proxy/Generator/MethodGeneratorTest.php +++ b/tests/Proxy/Generator/MethodGeneratorTest.php @@ -247,4 +247,20 @@ public function testFromReflectionNoAttributeWhenNone(): void $output = $gen->generate(); $this->assertStringNotContainsString('#[', $output); } + + public function testParametersCanBeReadAndReplaced(): void + { + $generator = new MethodGenerator('swap'); + $this->assertSame([], $generator->getParameters()); + + $first = new ParameterGenerator('first', TypeGenerator::fromTypeString('int')); + $second = new ParameterGenerator('second'); + $generator->addParameter($first); + $generator->addParameter($second); + $this->assertSame([$first, $second], $generator->getParameters()); + + $generator->setParameters([$second->withType(TypeGenerator::fromTypeString('string'))]); + $this->assertCount(1, $generator->getParameters()); + $this->assertSame('public function swap(string $second)' . "\n" . '{' . "\n" . '}', $generator->generate()); + } } diff --git a/tests/Proxy/Generator/ParameterGeneratorTest.php b/tests/Proxy/Generator/ParameterGeneratorTest.php index 17e5cb8e..7045ee33 100644 --- a/tests/Proxy/Generator/ParameterGeneratorTest.php +++ b/tests/Proxy/Generator/ParameterGeneratorTest.php @@ -202,4 +202,19 @@ private function findFunctionNode(array $stmts, string $name): ?Function_ } return null; } + + public function testWithTypeKeepsEverythingButTheType(): void + { + $function = new ReflectionFunction(static function (int &$count = 5, string ...$names): void {}); + $generator = ParameterGenerator::fromReflection($function->getParameters()[0]); + + $retyped = $generator->withType(TypeGenerator::fromTypeString('?int')); + + $this->assertSame('?int &$count = 5', $retyped->generate()); + $this->assertSame('int &$count = 5', $generator->generate(), 'The original parameter is untouched'); + $this->assertSame('&$count = 5', $generator->withType(null)->generate()); + + $variadic = ParameterGenerator::fromReflection($function->getParameters()[1])->withType(TypeGenerator::fromTypeString('\\Stringable')); + $this->assertSame('\\Stringable ...$names', $variadic->generate()); + } } diff --git a/tests/Proxy/Generator/TypeGeneratorTest.php b/tests/Proxy/Generator/TypeGeneratorTest.php index 12c7694a..cf02713a 100644 --- a/tests/Proxy/Generator/TypeGeneratorTest.php +++ b/tests/Proxy/Generator/TypeGeneratorTest.php @@ -225,4 +225,34 @@ public function testFromReflectionIntersectionType(): void $this->assertStringContainsString('Iterator', $output); $this->assertStringContainsString('&', $output); } + + /** + * @return iterable + */ + public static function provideScopeKeywordTypes(): iterable + { + yield 'self' => ['self', '\\App\\Child']; + yield 'nullable self' => ['?self', '?\\App\\Child']; + yield 'parent' => ['parent', '\\App\\Base']; + yield 'union' => ['self|int|null', '\\App\\Child|int|null']; + yield 'intersection' => ['self&\\Countable', '\\App\\Child&\\Countable']; + yield 'dnf' => ['(self&\\Countable)|parent', '(\\App\\Child&\\Countable)|\\App\\Base']; + yield 'static stays' => ['static', 'static']; + yield 'builtin stays' => ['?int', '?int']; + yield 'class stays' => ['\\App\\Other', '\\App\\Other']; + } + + #[DataProvider('provideScopeKeywordTypes')] + public function testResolveScopeKeywordsSpellsOutSelfAndParent(string $declared, string $expected): void + { + $resolved = TypeGenerator::fromTypeString($declared)->resolveScopeKeywords('App\\Child', 'App\\Base'); + + $this->assertSame($expected, $resolved->generate()); + $this->assertSame($declared, TypeGenerator::fromTypeString($declared)->generate(), 'The original generator is untouched'); + } + + public function testResolveScopeKeywordsKeepsParentWithoutAParentClass(): void + { + $this->assertSame('?parent', TypeGenerator::fromTypeString('?parent')->resolveScopeKeywords('App\\Child', null)->generate()); + } } From d868ea4a8c15beea9a0a9a1d9e66776fd64c762c Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:32:19 +0000 Subject: [PATCH 02/10] ci(zengine): run the z-engine driver on PHP 8.4 and 8.5 with the z-engine 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/zengine.yml | 20 ++++++++++--------- AGENTS.md | 2 +- docs/zengine-driver.md | 14 +++++++------ .../ZEngine/ZEngineFunctionalTestCase.php | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml index 9f158290..c3b1ac54 100644 --- a/.github/workflows/zengine.yml +++ b/.github/workflows/zengine.yml @@ -14,12 +14,9 @@ on: - master # The zengine driver weaves loaded classes through lisachenko/z-engine (PHP FFI). The package is an optional -# dependency: its branches target one PHP minor each, so it is installed here only, on that minor, and the -# `zengine` PHPUnit group (excluded from the default run) exercises the driver in subprocesses with the JIT off. -env: - PHP_MINOR: "8.5" - ZENGINE_BRANCH: "dev-feature/redefine-preserve-alias" - +# dependency: its branches target one PHP minor each, so it is installed here only, with the branch of the +# matrix leg's minor, and the `zengine` PHPUnit group (excluded from the default run) exercises the driver in +# subprocesses with the JIT off. jobs: zengine: name: "z-engine driver (PHP ${{ matrix.php-version }}, opcache legs)" @@ -28,8 +25,13 @@ jobs: strategy: fail-fast: false matrix: - php-version: - - "8.5" + include: + - php-version: "8.4" + zengine-branch: "dev-feature/redefine-preserve-alias-8.4" + - php-version: "8.5" + zengine-branch: "dev-feature/redefine-preserve-alias" + env: + ZENGINE_BRANCH: ${{ matrix.zengine-branch }} steps: - name: "Checkout" uses: actions/checkout@v7 @@ -56,7 +58,7 @@ jobs: path: | ~/.composer/cache vendor - key: "composer-zengine-${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}-${{ env.ZENGINE_BRANCH }}" + key: "composer-zengine-${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}-${{ matrix.zengine-branch }}" restore-keys: "composer-zengine-${{ runner.os }}-php-${{ matrix.php-version }}-" - name: "Install dependencies with lisachenko/z-engine" diff --git a/AGENTS.md b/AGENTS.md index dc27b5d1..db63b8e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,5 +46,5 @@ Intercepts PHP class loading pipeline: source stream filter transforms source - New classes are `final` unless they are meant to be extended. - No anonymous classes in `src/`: extract a named `final` class (`@internal` for machinery); anonymous classes are for tests and mocks. - Commit messages and PR titles must follow Conventional Commits (`type(scope): summary`, e.g. `fix(proxy): ...`). -- lisachenko/z-engine is an optional dependency (its branches target one PHP minor each): never add it to require/require-dev; `phpstan/zengine-stubs.php` (scanFiles) declares the API the zengine driver uses, keep it in sync; the `zengine` PHPUnit group runs with `composer test:zengine` (JIT off, ffi on) and self-skips without the engine; CI installs z-engine only in .github/workflows/zengine.yml. +- lisachenko/z-engine is an optional dependency (its branches target one PHP minor each): never add it to require/require-dev; `phpstan/zengine-stubs.php` (scanFiles) declares the API the zengine driver uses, keep it in sync; the `zengine` PHPUnit group runs with `composer test:zengine` (JIT off, ffi on) and self-skips without the engine; CI installs z-engine only in .github/workflows/zengine.yml (one leg per PHP minor, each with the z-engine branch of that minor). - Generated code (proxies, woven traits, advisor caches) must stay clean and readable: reference classes through short `use` aliases, never replace them with fully-qualified names. Name collisions are detected and resolved by adjusting the imports automatically (`Go\Proxy\Generator\ProxyImports`, see `src/Proxy/AGENTS.md`). diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index 8a05b55b..390aeb31 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -13,15 +13,17 @@ join-point API. An aspect written for one driver runs unchanged on the other, wi ## Requirements - `ext-ffi` with `ffi.enable=1` (or `preload` with a preload script booting z-engine). -- `lisachenko/z-engine`, the branch matching your PHP minor (`8.5` for PHP 8.5). The package is an optional - dependency of the framework, because every branch of it targets one PHP minor: +- `lisachenko/z-engine`, the branch matching your PHP minor (`8.4` for PHP 8.4, `8.5` for PHP 8.5). The + package is an optional dependency of the framework, because every branch of it targets one PHP minor: ```bash composer config repositories.zengine vcs https://github.com/lisachenko/z-engine - composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias + composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 # PHP 8.4 + composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias # PHP 8.5 ``` - Until the `redefine(..., preserveAs:)` API is merged, the branch above is the one the driver is built against. + Until the `redefine(..., preserveAs:)` API is merged into the version branches, the branches above are the + ones the driver is built against. - The opcache JIT **off** for the whole process: `opcache.jit=off` and `opcache.jit_buffer_size=0` in `php.ini`, the FPM pool or `php -d`. z-engine hooks the executor internals the JIT bypasses. On PHP 8.5 the tracing JIT also miscompiles code the framework runs (a known PHP bug, see `docs/php85-limitations.md`), so there the driver switches @@ -121,5 +123,5 @@ opcache is active, and the JIT guard report (`jitWasActive`, `previousMode`, `di `composer test:zengine` runs the `zengine` PHPUnit group (`tests/Functional/ZEngine/`), which is excluded from the default run: it boots the engine in PHP subprocesses (both opcache legs) and compares the driver with the stream driver on the same fixtures. The group self-skips where the engine cannot boot. `.github/workflows/zengine.yml` runs -it on PHP 8.5 with z-engine installed; the other workflows do not install z-engine (`phpstan/zengine-stubs.php` -declares the API the driver uses, for the analysis without the package). +it on PHP 8.4 and 8.5 with the z-engine branch of each minor installed; the other workflows do not install z-engine +(`phpstan/zengine-stubs.php` declares the API the driver uses, for the analysis without the package). diff --git a/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php b/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php index 62cf1a94..45f2d38e 100644 --- a/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php +++ b/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php @@ -211,7 +211,7 @@ private static function engineUnavailableReason(): ?string self::$engineChecked = true; if (!class_exists(\ZEngine\Core::class)) { return self::$engineUnavailableReason = 'lisachenko/z-engine is not installed: ' - . 'composer require --dev lisachenko/z-engine:dev-feature/redefine-preserve-alias (see docs/zengine-driver.md)'; + . 'composer require --dev lisachenko/z-engine: (see docs/zengine-driver.md)'; } $process = new Process( [ From 81b8cd6efd14d4f02c23ebddd56b547262382165 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:42:13 +0000 Subject: [PATCH 03/10] fix(zengine): never weave sources outside the application root 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- src/Instrument/ZEngine/DonorCacheIndex.php | 22 +++++++++++++++---- src/Instrument/ZEngine/ZEngineClassWeaver.php | 3 +++ .../ZEngine/DonorCacheIndexTest.php | 11 +++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Instrument/ZEngine/DonorCacheIndex.php b/src/Instrument/ZEngine/DonorCacheIndex.php index d002422c..dd7ca7c0 100644 --- a/src/Instrument/ZEngine/DonorCacheIndex.php +++ b/src/Instrument/ZEngine/DonorCacheIndex.php @@ -13,6 +13,7 @@ namespace Go\Instrument\ZEngine; use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Exception\WeavingException; use Go\Core\AspectContainer; use Go\Core\AspectKernel; use Go\Instrument\ClassLoading\CachePathManager; @@ -103,17 +104,30 @@ public function findFresh(string $className, string $resource, AspectContainer $ } /** - * Returns the file the donor class of a source file is written to + * Whether a source file lies below the application root: only those are woven (like with the stream driver, + * whose cache mirrors the application tree), sources outside it are never touched + */ + public function isApplicationSource(string $resource): bool + { + return PathResolver::isBelow($resource, $this->appDir); + } + + /** + * Returns the file the donor class of a source file is written to: + * `{cacheDir}/_zengine/{path relative to appDir}/{Short}__AopDonor.php` * - * `{cacheDir}/_zengine/{path relative to appDir}/{Short}__AopDonor.php`; sources outside the application - * root keep their absolute path below the donor directory, like the stream driver's proxies + * @throws WeavingException For a source outside the application root, see {@see isApplicationSource()} */ public function donorFileFor(string $resource, string $shortClassName): string { if ($this->cacheDir === null) { throw new InvalidConfigurationException('The donor index needs the `cacheDir` option to be configured'); } - $relativePath = ltrim(PathResolver::rebase($resource, $this->appDir, '') ?? $resource, '/\\'); + $relativePath = PathResolver::rebase($resource, $this->appDir, ''); + if ($relativePath === null) { + throw new WeavingException(sprintf('The source %s lies outside the application root %s and is never woven', $resource, $this->appDir)); + } + $relativePath = ltrim($relativePath, '/\\'); $directory = str_replace('\\', '/', dirname($relativePath)); return $this->cacheDir . self::DONOR_DIRECTORY . '/' diff --git a/src/Instrument/ZEngine/ZEngineClassWeaver.php b/src/Instrument/ZEngine/ZEngineClassWeaver.php index b7c08c66..fd7a445a 100644 --- a/src/Instrument/ZEngine/ZEngineClassWeaver.php +++ b/src/Instrument/ZEngine/ZEngineClassWeaver.php @@ -144,6 +144,9 @@ private function doWeave(string $className, string $file): WeaveResult if ($native->isInterface() || $native->isTrait() || $native->isEnum()) { return WeaveResult::skipped('interfaces, traits and enums are not supported by the zengine driver'); } + if (!$this->donorCache->isApplicationSource($file)) { + return WeaveResult::skipped('sources outside the application root are never woven'); + } $record = $this->donorCache->findFresh($className, $file, $this->container); if ($record !== null) { diff --git a/tests/Instrument/ZEngine/DonorCacheIndexTest.php b/tests/Instrument/ZEngine/DonorCacheIndexTest.php index b5cb6fb9..d8327cfa 100644 --- a/tests/Instrument/ZEngine/DonorCacheIndexTest.php +++ b/tests/Instrument/ZEngine/DonorCacheIndexTest.php @@ -13,6 +13,7 @@ namespace Go\Instrument\ZEngine; use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Exception\WeavingException; use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\AspectKernel; @@ -65,11 +66,11 @@ public function testDonorFilesMirrorTheApplicationTreeBelowTheDonorDirectory(): $this->cacheDir . '/_zengine/Root__AopDonor.php', $index->donorFileFor($this->appDir . '/Root.php', 'Root'), ); - // A source outside the application root keeps its absolute path below the donor directory - $this->assertSame( - $this->cacheDir . '/_zengine/' . ltrim($this->cacheDir, '/') . '/Outside__AopDonor.php', - $index->donorFileFor($this->cacheDir . '/Outside.php', 'Outside'), - ); + // A source outside the application root is never woven, so it has no donor file + $this->assertTrue($index->isApplicationSource($this->source)); + $this->assertFalse($index->isApplicationSource($this->cacheDir . '/Outside.php')); + $this->expectException(WeavingException::class); + $index->donorFileFor($this->cacheDir . '/Outside.php', 'Outside'); } public function testRecordsSurviveAFlushAndAreServedWhileFresh(): void From a9a0bcc3b47c1375c4d7522c1fcc9260f54f5ba8 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:58:41 +0000 Subject: [PATCH 04/10] test(zengine): cover the driver in-process and run the group in the coverage 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/phpunit.yml | 17 +- docs/zengine-driver.md | 17 +- src/Instrument/ZEngine/JitGuard.php | 13 +- tests/AGENTS.md | 5 +- .../UnsupportedJoinpointExceptionTest.php | 38 ++++ tests/Functional/BaseFunctionalTestCase.php | 3 +- .../Functional/ZEngine/ZEngineWeavingTest.php | 3 +- .../ClassLoading/CacheWarmerTest.php | 26 +++ .../ZEngineClassWeaverInProcessTest.php | 191 ++++++++++++++++++ .../InProcess/ZEngineDriverInProcessTest.php | 58 ++++++ .../InProcess/ZEngineInProcessTestCase.php | 151 ++++++++++++++ tests/Instrument/ZEngine/JitGuardTest.php | 8 + tests/Stubs/ZEngine/InProcessAspect.php | 34 ++++ tests/Stubs/ZEngine/InProcessKernel.php | 20 ++ tests/Stubs/ZEngine/LateTarget.php | 16 ++ tests/Stubs/ZEngine/UnadvisedTarget.php | 13 ++ tests/Stubs/ZEngine/UnsupportedAspect.php | 18 ++ tests/Stubs/ZEngine/UnsupportedTarget.php | 10 + tests/Stubs/ZEngine/WarmTarget.php | 16 ++ tests/Stubs/ZEngine/WovenChild.php | 13 ++ tests/Stubs/ZEngine/WovenParent.php | 18 ++ tests/Stubs/ZEngine/WovenTarget.php | 22 ++ 22 files changed, 696 insertions(+), 14 deletions(-) create mode 100644 tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php create mode 100644 tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php create mode 100644 tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php create mode 100644 tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php create mode 100644 tests/Stubs/ZEngine/InProcessAspect.php create mode 100644 tests/Stubs/ZEngine/InProcessKernel.php create mode 100644 tests/Stubs/ZEngine/LateTarget.php create mode 100644 tests/Stubs/ZEngine/UnadvisedTarget.php create mode 100644 tests/Stubs/ZEngine/UnsupportedAspect.php create mode 100644 tests/Stubs/ZEngine/UnsupportedTarget.php create mode 100644 tests/Stubs/ZEngine/WarmTarget.php create mode 100644 tests/Stubs/ZEngine/WovenChild.php create mode 100644 tests/Stubs/ZEngine/WovenParent.php create mode 100644 tests/Stubs/ZEngine/WovenTarget.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 5d24d477..50549714 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -99,7 +99,10 @@ jobs: with: coverage: "xdebug" php-version: "8.4" - ini-values: memory_limit=-1 + # ext-ffi for lisachenko/z-engine, so the zengine driver is covered too: FFI on, the JIT off (it rewrites + # the executor internals z-engine hooks into), opcache inactive in the runner + extensions: ffi, opcache + ini-values: memory_limit=-1, ffi.enable=1, opcache.enable=1, opcache.enable_cli=0, opcache.jit=off, opcache.jit_buffer_size=0 tools: composer:v2 - name: "Cache dependencies" @@ -111,11 +114,17 @@ jobs: key: "composer-${{ runner.os }}-php-8.4-highest-${{ hashFiles('composer.json') }}" restore-keys: "composer-${{ runner.os }}-php-8.4-highest-" - - name: "Install highest dependencies" - run: "composer update --no-interaction --no-progress" + - name: "Install highest dependencies with lisachenko/z-engine" + # The optional engine of the zengine driver, from the branch targeting PHP 8.4 (see docs/zengine-driver.md) + run: | + composer config repositories.zengine vcs https://github.com/lisachenko/z-engine + composer require --dev --no-update lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 + composer update --no-interaction --no-progress - name: "Tests with coverage" - run: "vendor/bin/phpunit --coverage-clover build/logs/clover.xml" + # The zengine group (excluded from the default run) is included here: its in-process tests weave stub + # classes through the engine inside the runner, which is what covers the driver + run: "vendor/bin/phpunit --exclude-group performance --exclude-group transformation-performance --coverage-clover build/logs/clover.xml" - name: "Upload coverage to Codecov" uses: "codecov/codecov-action@v7" diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index 390aeb31..b3fdae25 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -120,8 +120,15 @@ opcache is active, and the JIT guard report (`jitWasActive`, `previousMode`, `di ## Development -`composer test:zengine` runs the `zengine` PHPUnit group (`tests/Functional/ZEngine/`), which is excluded from the -default run: it boots the engine in PHP subprocesses (both opcache legs) and compares the driver with the stream -driver on the same fixtures. The group self-skips where the engine cannot boot. `.github/workflows/zengine.yml` runs -it on PHP 8.4 and 8.5 with the z-engine branch of each minor installed; the other workflows do not install z-engine -(`phpstan/zengine-stubs.php` declares the API the driver uses, for the analysis without the package). +`composer test:zengine` runs the `zengine` PHPUnit group, which is excluded from the default run and self-skips +where the engine cannot boot: + +- `tests/Functional/ZEngine/` boots the engine in PHP subprocesses (both opcache legs) and compares the driver with + the stream driver on the same fixtures; +- `tests/Instrument/ZEngine/InProcess/` boots z-engine inside the test runner and weaves the stubs of + `tests/Stubs/ZEngine` for real, which is what gives the driver code coverage (the coverage job of + `.github/workflows/phpunit.yml` installs z-engine and includes the group). + +`.github/workflows/zengine.yml` runs the group on PHP 8.4 and 8.5 with the z-engine branch of each minor installed; +the other workflows do not install z-engine (`phpstan/zengine-stubs.php` declares the API the driver uses, for the +analysis without the package). diff --git a/src/Instrument/ZEngine/JitGuard.php b/src/Instrument/ZEngine/JitGuard.php index 5476865b..db55935c 100644 --- a/src/Instrument/ZEngine/JitGuard.php +++ b/src/Instrument/ZEngine/JitGuard.php @@ -67,8 +67,7 @@ public static function enforce(?int $phpVersionId = null, ?Closure $statusReader } $report['jitWasActive'] = true; - $isPhp85 = $phpVersionId >= 80500 && $phpVersionId < 80600; - if (!$isPhp85) { + if (!self::hasTracingJitBug($phpVersionId)) { throw new InvalidConfigurationException( 'The zengine weaving driver requires the opcache JIT to be off: z-engine hooks the executor ' . 'internals the JIT bypasses. Start PHP with opcache.jit=off and opcache.jit_buffer_size=0 ' @@ -92,6 +91,16 @@ public static function enforce(?int $phpVersionId = null, ?Closure $statusReader return $report; } + /** + * Whether a PHP version has the tracing-JIT bug the guard works around at runtime (PHP 8.5) + * + * @param int $phpVersionId PHP_VERSION_ID of the version to check, the running one by default + */ + public static function hasTracingJitBug(int $phpVersionId = PHP_VERSION_ID): bool + { + return $phpVersionId >= 80500 && $phpVersionId < 80600; + } + /** * @param array|false $status Result of opcache_get_status(false) */ diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c149e889..88cecefb 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -35,7 +35,10 @@ tests/Fixtures/project/bin/zengine-weaving.php in subprocesses (`GO_AOP_CONFIGURATION=zengine*`, both opcache legs, `ZENGINE_AUTOBOOT=0`) and compares its JSON report with the stream driver on the same fixtures (`zengine_reference`); self-skips when z-engine is not installed or cannot boot. Fixtures: Application/ZEngine*, Aspect/ZEngineAspect, - Kernel/ZEngineAspectKernel. Unit tests of the driver: tests/Instrument/ZEngine/, tests/Proxy/DonorClassGeneratorTest.php + Kernel/ZEngineAspectKernel. Unit tests of the driver: tests/Instrument/ZEngine/, tests/Proxy/DonorClassGeneratorTest.php. + In-process tests (tests/Instrument/ZEngine/InProcess/, same group): boot z-engine INSIDE the runner and weave the stubs of + tests/Stubs/ZEngine for real (kernel created without the singleton, autoloaders restored, one stub class per test since a + woven class stays woven) - the only way the driver gets coverage; the coverage CI job installs z-engine and runs the group - If phpstan fails: fix errors before offering to commit ## PHPStan gate diff --git a/tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php b/tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php new file mode 100644 index 00000000..d1b9748d --- /dev/null +++ b/tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php @@ -0,0 +1,38 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop\Exception; + +use PHPUnit\Framework\TestCase; +use ReflectionException; + +final class UnsupportedJoinpointExceptionTest extends TestCase +{ + public function testNamesTheAdvisorsAndKindsTheDriverCannotWeave(): void + { + $exception = UnsupportedJoinpointException::forClass('App\Entity', ['prop', 'init'], ['advisor.A->first', 'advisor.B->second']); + + $this->assertStringContainsString('cannot weave App\Entity', $exception->getMessage()); + $this->assertStringContainsString('advisor.A->first, advisor.B->second', $exception->getMessage()); + $this->assertStringContainsString('join point kind(s) prop, init', $exception->getMessage()); + $this->assertStringContainsString('stream driver', $exception->getMessage()); + } + + public function testWrapsAnEngineRefusal(): void + { + $reason = new ReflectionException('Class App\Entity is not published in the engine class table'); + $exception = UnsupportedJoinpointException::engineRefusal('App\Entity', $reason); + + $this->assertSame($reason, $exception->getPrevious()); + $this->assertSame('The zengine weaving driver cannot weave App\Entity: ' . $reason->getMessage(), $exception->getMessage()); + } +} diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 5ebe49c2..65273dc9 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -24,6 +24,7 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; +use Go\Instrument\ZEngine\JitGuard; /** * Base class for functional tests. @@ -176,7 +177,7 @@ protected function execute( */ protected function getPhpOptions(): array { - if (PHP_VERSION_ID >= 80500 && PHP_VERSION_ID < 80600) { + if (JitGuard::hasTracingJitBug()) { return ['-d', 'opcache.jit=off', '-d', 'opcache.jit_buffer_size=0']; } diff --git a/tests/Functional/ZEngine/ZEngineWeavingTest.php b/tests/Functional/ZEngine/ZEngineWeavingTest.php index 172f2eab..9638d024 100644 --- a/tests/Functional/ZEngine/ZEngineWeavingTest.php +++ b/tests/Functional/ZEngine/ZEngineWeavingTest.php @@ -21,6 +21,7 @@ use Go\Tests\TestProject\Aspect\ZEngineUnsupportedAspect; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Go\Instrument\ZEngine\JitGuard; /** * The z-engine driver weaves the fixture project at runtime: same behaviour as the stream driver, donors cached @@ -169,7 +170,7 @@ public function testJitGuardHandlesATracingJitAccordingToThePhpVersion(): void { $report = $this->runProbe('zengine', 'weave', true, ['-d', 'opcache.jit=tracing', '-d', 'opcache.jit_buffer_size=64M']); - if (PHP_VERSION_ID >= 80500 && PHP_VERSION_ID < 80600) { + if (JitGuard::hasTracingJitBug()) { if (isset($report['exception'])) { self::fail('The JIT must be switched off at runtime on PHP 8.5: ' . json_encode($report['exception'])); } diff --git a/tests/Instrument/ClassLoading/CacheWarmerTest.php b/tests/Instrument/ClassLoading/CacheWarmerTest.php index 7db76458..f9fc5d17 100644 --- a/tests/Instrument/ClassLoading/CacheWarmerTest.php +++ b/tests/Instrument/ClassLoading/CacheWarmerTest.php @@ -29,6 +29,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\StreamOutput; use Go\Aop\WeavingDriver; +use Go\Aop\Exception\InvalidConfigurationException; // Separate processes: warming up registers the process-wide source transforming // stream filter, which must not leak into the rest of the PHPUnit process @@ -59,6 +60,31 @@ protected function tearDown(): void self::removeTemporaryDirectory($this->appDir); } + public function testRefusesAKernelOfTheEngineDriver(): void + { + $kernel = $this->createMock(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => true, + 'appDir' => $this->appDir, + 'cacheDir' => $this->cacheDir, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + 'driver' => WeavingDriver::ZEngine, + ]); + $kernel->expects($this->never())->method('getContainer'); + + try { + (new CacheWarmer($kernel))->warmUp(); + self::fail('The engine driver has nothing to warm up through the stream filter'); + } catch (InvalidConfigurationException $refusal) { + $this->assertStringContainsString('zengine weaving driver', $refusal->getMessage()); + $this->assertStringContainsString('stream filter', $refusal->getMessage()); + } + } + /** * Creates a kernel mock wired to a container mock, mirroring what the cache * warmer receives from a real aspect kernel diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php new file mode 100644 index 00000000..b1b46a92 --- /dev/null +++ b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php @@ -0,0 +1,191 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine\InProcess; + +use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Aop\Framework\BeforeInterceptor; +use Go\Aop\Framework\GeneratedInterceptor; +use Go\Core\AspectContainer; +use Go\Core\Cache\CachedAspectLoader; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\Instrument\ZEngine\DonorCacheIndex; +use Go\Instrument\ZEngine\WeaveOutcome; +use Go\Instrument\ZEngine\ZEngineClassWeaver; +use Go\ParserReflection\ReflectionFile; +use Go\Proxy\ClassProxyGenerator; +use Go\Proxy\DonorClassGenerator; +use Go\Stubs\ZEngine\InProcessAspect; +use Go\Stubs\ZEngine\LateTarget; +use Go\Stubs\ZEngine\UnadvisedTarget; +use Go\Stubs\ZEngine\UnsupportedAspect; +use Go\Stubs\ZEngine\UnsupportedTarget; +use Go\Stubs\ZEngine\WarmTarget; +use Go\Stubs\ZEngine\WovenChild; +use Go\Stubs\ZEngine\WovenParent; +use Go\Stubs\ZEngine\WovenTarget; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; +use ReflectionMethod; + +/** + * Classes are woven through the engine when they are loaded, and on demand + */ +#[Group('zengine')] +final class ZEngineClassWeaverInProcessTest extends ZEngineInProcessTestCase +{ + public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $this->assertFalse(class_exists(WovenTarget::class, false), 'The stub must not be loaded by an earlier test'); + + $target = new WovenTarget(); + + $this->assertSame('HELLO ANN', $target->greet('Ann'), 'The around advice ran'); + $this->assertSame(1, $target->getCalls(), 'The original body ran under its alias'); + $this->assertSame([WovenTarget::class . '::greet', WovenTarget::class . '::getCalls'], InProcessAspect::$log); + $this->assertTrue(new ReflectionClass($target)->hasMethod('greetOriginalAlias'), 'The previous body is kept under the alias'); + + $result = $weaver->weave(WovenTarget::class); + $this->assertSame(WeaveOutcome::AlreadyWoven, $result->outcome); + $this->assertSame(['greet', 'getCalls'], $result->methods); + + $this->assertSame('pong', (new UnadvisedTarget())->ping()); + $this->assertSame(WeaveOutcome::NoAdvices, $weaver->weave(UnadvisedTarget::class)->outcome); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weave('Go\Stubs\ZEngine\NeverLoaded')->outcome); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\ArrayObject::class)->outcome); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\Countable::class)->outcome); + $this->assertSame(WeaveOutcome::NoAdvices, $weaver->weave(self::class)->outcome, 'Within the application root, matched against the advisors'); + + // A class whose source lies outside the application root is never woven + $outsideFile = $this->cacheDir . '/OutsideTarget.php'; + file_put_contents($outsideFile, "weave('Go\\Stubs\\ZEngine\\OutsideTarget'); + $this->assertSame(WeaveOutcome::Skipped, $outside->outcome); + $this->assertSame('sources outside the application root are never woven', $outside->reason); + } + + public function testInheritedMethodsGetAnOverrideReachingTheParent(): void + { + $this->bootKernel(); + $this->assertFalse(class_exists(WovenChild::class, false)); + + $child = WovenChild::create(); + + $this->assertSame('parent of ' . WovenChild::class, $child->describe()); + $this->assertSame('own with ' . WovenChild::class, $child->own($child)); + $this->assertSame( + [WovenChild::class . '::create', WovenChild::class . '::describe', WovenChild::class . '::own'], + InProcessAspect::$log, + ); + // The override is published into the child, the parent's own method is untouched + $this->assertSame(WovenChild::class, new ReflectionMethod(WovenChild::class, 'describe')->class); + $this->assertFalse(new ReflectionClass(WovenParent::class)->hasMethod('describeOriginalAlias')); + $this->assertTrue(new ReflectionClass(WovenChild::class)->hasMethod('ownOriginalAlias')); + } + + public function testAClassMatchedByAnUnsupportedJoinpointKindIsRefusedOnLoad(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + + try { + class_exists(UnsupportedTarget::class); + self::fail('The property pointcut must be refused'); + } catch (UnsupportedJoinpointException $refusal) { + $this->assertStringContainsString(UnsupportedAspect::class . '->beforeCounterAccess', $refusal->getMessage()); + $this->assertStringContainsString('join point kind(s) ' . AspectContainer::PROPERTY_PREFIX, $refusal->getMessage()); + } + $this->assertTrue(class_exists(UnsupportedTarget::class, false), 'The class is declared, but not woven'); + + // The failure is remembered: a later weave of the class fails the same way + $this->expectException(UnsupportedJoinpointException::class); + $weaver->weave(UnsupportedTarget::class); + } + + public function testAClassDeclaredBeforeTheKernelIsWovenOnDemand(): void + { + require_once self::STUBS_DIR . '/LateTarget.php'; + $this->assertTrue(class_exists(LateTarget::class, false)); + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + + $result = $weaver->weave(LateTarget::class); + + $this->assertSame(WeaveOutcome::Woven, $result->outcome); + $this->assertSame(['compute'], $result->methods); + $this->assertSame(5, (new LateTarget())->compute(2, 3)); + $this->assertSame([LateTarget::class . '::compute'], InProcessAspect::$log); + $this->assertSame(WeaveOutcome::AlreadyWoven, $weaver->weave(LateTarget::class)->outcome); + } + + public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void + { + // A warm cache: the donor and its record, as a previous request would have left them + $source = realpath(self::STUBS_DIR . '/WarmTarget.php'); + $this->assertNotFalse($source); + $parsedFile = new ReflectionFile($source); + $class = null; + $imports = []; + foreach ($parsedFile->getFileNamespaces() as $namespace) { + foreach ($namespace->getClasses() as $parsedClass) { + $class = $parsedClass; + $imports = $namespace->getNamespaceAliases(); + } + } + $this->assertNotNull($class); + $generator = new DonorClassGenerator($class, [ + AspectContainer::METHOD_PREFIX => ['answer' => [self::testInterceptor()]], + ], $imports, ClassProxyGenerator::indexMethods($class)); + $kernel = $this->bootKernel(); + $container = $kernel->getContainer(); + $index = $container->getService(DonorCacheIndex::class); + $donorFile = $index->donorFileFor($source, 'WarmTarget'); + $container->getService(CachePathManager::class)->getCacheFileWriter()->write($donorFile, "generate()); + $index->recordDonor(WarmTarget::class, $source, $donorFile, $generator->getDonorClassName(), ['answer']); + $index->flush(); + + // A weaver of a new request reads the index and applies the record: no aspect gets loaded for it + $weaver = new ZEngineClassWeaver( + $container, + $container->getService(\Go\Core\AdviceMatcher::class), + $container->getService(CachedAspectLoader::class), + $container->getService(CachePathManager::class), + new DonorCacheIndex($container->getService(CachePathManager::class), $kernel->getOptions()), + $container->getService(\Go\Instrument\ZEngine\MethodTableRewriter::class), + ); + $unloadedBefore = $container->getService(CachedAspectLoader::class)->getUnloadedAspects(); + $this->assertNotEmpty($unloadedBefore); + require_once $source; + $result = $weaver->weaveLoadedClass(WarmTarget::class, $source); + + $this->assertSame(WeaveOutcome::Woven, $result->outcome); + $this->assertSame(['answer'], $result->methods); + $this->assertSame(42, (new WarmTarget())->answer()); + $this->assertSame([WarmTarget::class . '::answer'], InProcessAspect::$log); + $this->assertSame($unloadedBefore, $container->getService(CachedAspectLoader::class)->getUnloadedAspects(), 'The aspects stayed unloaded'); + } + + /** + * The descriptor of the aspect-method advice, as the aspect loader produces it: the donor then resolves the + * aspect through the container (The::aspect()), without the aspect loader + */ + private static function testInterceptor(): GeneratedInterceptor + { + return GeneratedInterceptor::fromAdvice( + 'advisor.' . InProcessAspect::class . '->beforeMethod', + new BeforeInterceptor(new InProcessAspect()->beforeMethod(...)), + ); + } +} diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php b/tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php new file mode 100644 index 00000000..6b91a76e --- /dev/null +++ b/tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php @@ -0,0 +1,58 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine\InProcess; + +use Go\Aop\WeavingDriver; +use Go\Instrument\ClassLoading\ZEngineComposerLoader; +use Go\Instrument\ZEngine\DonorCacheIndex; +use Go\Instrument\ZEngine\MethodTableRewriter; +use Go\Instrument\ZEngine\ZEngineClassWeaver; +use Go\Instrument\ZEngine\ZEngineDriver; +use PHPUnit\Framework\Attributes\Group; + +/** + * The boot of the zengine driver inside the test runner + */ +#[Group('zengine')] +final class ZEngineDriverInProcessTest extends ZEngineInProcessTestCase +{ + public function testBootRegistersTheDriverAndReportsWhatItDid(): void + { + $kernel = $this->bootKernel(); + $container = $kernel->getContainer(); + + $report = ZEngineDriver::getBootReport($container); + $this->assertSame('zengine', $report['driver']); + $this->assertSame(PHP_VERSION, $report['php']); + $this->assertSame(function_exists('opcache_get_status') && opcache_get_status(false) !== false, $report['opcache']); + // The tests skip when the JIT is active, so the guard had nothing to do + $this->assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], $report['jit']); + + foreach ([ZEngineClassWeaver::class, DonorCacheIndex::class, MethodTableRewriter::class] as $service) { + $this->assertTrue($container->has($service), $service . ' is registered by the boot'); + } + $this->assertSame($container->getService(ZEngineClassWeaver::class), $container->getService(ZEngineClassWeaver::class), 'One weaver per container'); + + $wrappers = 0; + foreach (spl_autoload_functions() as $loader) { + if (is_array($loader) && $loader[0] instanceof ZEngineComposerLoader) { + $wrappers++; + } + } + $this->assertGreaterThan(0, $wrappers, "Composer's loader is wrapped by the engine loader"); + + // A second init() of the same kernel is a no-op + $kernel->init(['driver' => 'stream', 'cacheDir' => $this->cacheDir]); + $this->assertSame(WeavingDriver::ZEngine, $kernel->getOptions()['driver']); + } +} diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php new file mode 100644 index 00000000..be5e3841 --- /dev/null +++ b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php @@ -0,0 +1,151 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine\InProcess; + +use Go\Aop\WeavingDriver; +use Go\Core\AspectKernel; +use Go\Instrument\ZEngine\DonorCacheIndex; +use Go\PhpUnit\UsesTemporaryDirectory; +use Go\Stubs\ZEngine\InProcessAspect; +use Go\Stubs\ZEngine\InProcessKernel; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionProperty; +use Throwable; +use ZEngine\Core; + +/** + * Base of the tests running the z-engine driver INSIDE the test runner: the engine is booted in this process and + * the stub classes of tests/Stubs/ZEngine are woven for real, which is what gives the driver code coverage + * + * Every test boots its own kernel against a temporary cache directory. The kernel is created without the + * per-process singleton (other tests of the process may hold a kernel of another class there) and installed as + * the singleton only for the duration of the test, since woven code resolves its aspects through + * AspectKernel::getInstance(); the previous singleton and the autoloaders of the test runner (the kernel wraps + * composer's loader) are restored afterwards. A woven stub class stays woven for the rest of the process, so every + * test uses stub classes of its own. The tests self-skip where the engine cannot run in this process: z-engine + * not installed, FFI disabled, the opcache JIT active, or a z-engine branch of another PHP minor. + */ +abstract class ZEngineInProcessTestCase extends TestCase +{ + use UsesTemporaryDirectory; + + protected const string STUBS_DIR = __DIR__ . '/../../../Stubs/ZEngine'; + + private static ?string $engineUnavailableReason = null; + + private static bool $engineChecked = false; + + /** + * Autoloaders of the test runner, restored in tearDown() + * + * @var list + */ + private array $originalLoaders = []; + + protected string $cacheDir; + + protected ?InProcessKernel $kernel = null; + + /** + * Kernel singleton of the process before the test, restored in tearDown() + */ + private ?AspectKernel $previousSingleton = null; + + protected function setUp(): void + { + $reason = self::engineUnavailableReason(); + if ($reason !== null) { + self::markTestSkipped($reason); + } + $this->originalLoaders = spl_autoload_functions(); + $this->cacheDir = self::createTemporaryDirectory('zengine-in-process'); + InProcessAspect::$log = []; + } + + protected function tearDown(): void + { + foreach (spl_autoload_functions() as $loader) { + spl_autoload_unregister($loader); + } + foreach ($this->originalLoaders as $loader) { + spl_autoload_register($loader); + } + if ($this->kernel !== null) { + $this->kernel->getContainer()->getService(DonorCacheIndex::class)->flush(); + self::singletonProperty()->setValue(null, $this->previousSingleton); + $this->kernel = null; + } + self::removeTemporaryDirectory($this->cacheDir); + } + + /** + * Boots a kernel for the zengine driver weaving the stub classes of tests/Stubs/ZEngine, and makes it the + * kernel singleton of the process until tearDown() + */ + protected function bootKernel(): InProcessKernel + { + $appDir = realpath(__DIR__ . '/../../../..'); + self::assertNotFalse($appDir); + $stubsDir = realpath(self::STUBS_DIR); + self::assertNotFalse($stubsDir); + + $kernel = new ReflectionClass(InProcessKernel::class)->newInstanceWithoutConstructor(); + $kernel->init([ + 'driver' => WeavingDriver::ZEngine, + 'debug' => true, + 'appDir' => $appDir, + 'cacheDir' => $this->cacheDir, + 'includePaths' => [$stubsDir], + ]); + $singleton = self::singletonProperty(); + $previous = $singleton->getValue(); + $this->previousSingleton = $previous instanceof AspectKernel ? $previous : null; + $singleton->setValue(null, $kernel); + + return $this->kernel = $kernel; + } + + private static function singletonProperty(): ReflectionProperty + { + return new ReflectionProperty(AspectKernel::class, 'instance'); + } + + /** + * Why the engine cannot run in this process, null when it can (checked once per process, booting z-engine here) + */ + private static function engineUnavailableReason(): ?string + { + if (self::$engineChecked) { + return self::$engineUnavailableReason; + } + self::$engineChecked = true; + if (!class_exists(Core::class)) { + return self::$engineUnavailableReason = 'lisachenko/z-engine is not installed: ' + . 'composer require --dev lisachenko/z-engine: (see docs/zengine-driver.md)'; + } + $status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; + if (is_array($status) && is_array($status['jit'] ?? null) && ($status['jit']['on'] ?? false) === true) { + return self::$engineUnavailableReason = 'The opcache JIT is active in the test runner: z-engine needs it off, ' + . 'start PHP with opcache.jit=off and opcache.jit_buffer_size=0'; + } + try { + Core::init(); + } catch (Throwable $failure) { + return self::$engineUnavailableReason = 'z-engine cannot boot in this process (ext-ffi, ffi.enable=1 and the ' + . 'z-engine branch of this PHP minor are required): ' . $failure->getMessage(); + } + + return self::$engineUnavailableReason = null; + } +} diff --git a/tests/Instrument/ZEngine/JitGuardTest.php b/tests/Instrument/ZEngine/JitGuardTest.php index 0f6f008d..5d2141d8 100644 --- a/tests/Instrument/ZEngine/JitGuardTest.php +++ b/tests/Instrument/ZEngine/JitGuardTest.php @@ -77,6 +77,14 @@ public function testAJitThatStaysOnAfterTheSwitchIsRefusedOnPhp85(): void $this->assertSame([['opcache.jit', 'off']], $this->iniWrites); } + public function testKnowsWhichVersionsHaveTheTracingJitBug(): void + { + $this->assertFalse(JitGuard::hasTracingJitBug(self::PHP_84)); + $this->assertTrue(JitGuard::hasTracingJitBug(80500)); + $this->assertTrue(JitGuard::hasTracingJitBug(self::PHP_85)); + $this->assertFalse(JitGuard::hasTracingJitBug(80600)); + } + public function testDefaultsDescribeTheRunningProcess(): void { $status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; diff --git a/tests/Stubs/ZEngine/InProcessAspect.php b/tests/Stubs/ZEngine/InProcessAspect.php new file mode 100644 index 00000000..13e56155 --- /dev/null +++ b/tests/Stubs/ZEngine/InProcessAspect.php @@ -0,0 +1,34 @@ + + */ + public static array $log = []; + + #[Pointcut\Before("execution(public Go\Stubs\ZEngine\WovenTarget->*(*)) || execution(public Go\Stubs\ZEngine\WovenChild->*(*)) + || execution(public Go\Stubs\ZEngine\WovenChild::*(*)) + || execution(public Go\Stubs\ZEngine\LateTarget->*(*)) || execution(public Go\Stubs\ZEngine\WarmTarget->*(*))")] + public function beforeMethod(MethodInvocation $invocation): void + { + self::$log[] = $invocation->getMethod()->class . '::' . $invocation->getMethod()->name; + } + + #[Pointcut\Around("execution(public Go\Stubs\ZEngine\WovenTarget->greet(*))")] + public function aroundGreet(MethodInvocation $invocation): string + { + return strtoupper((string) $invocation->proceed()); + } +} diff --git a/tests/Stubs/ZEngine/InProcessKernel.php b/tests/Stubs/ZEngine/InProcessKernel.php new file mode 100644 index 00000000..cf18d652 --- /dev/null +++ b/tests/Stubs/ZEngine/InProcessKernel.php @@ -0,0 +1,20 @@ +addLazyService(InProcessAspect::class, static fn(): InProcessAspect => new InProcessAspect()); + $container->addLazyService(UnsupportedAspect::class, static fn(): UnsupportedAspect => new UnsupportedAspect()); + } +} diff --git a/tests/Stubs/ZEngine/LateTarget.php b/tests/Stubs/ZEngine/LateTarget.php new file mode 100644 index 00000000..471f04ef --- /dev/null +++ b/tests/Stubs/ZEngine/LateTarget.php @@ -0,0 +1,16 @@ +counter)")] + public function beforeCounterAccess(FieldAccess $access): void {} +} diff --git a/tests/Stubs/ZEngine/UnsupportedTarget.php b/tests/Stubs/ZEngine/UnsupportedTarget.php new file mode 100644 index 00000000..16beaa64 --- /dev/null +++ b/tests/Stubs/ZEngine/UnsupportedTarget.php @@ -0,0 +1,10 @@ +calls++; + + return 'hello ' . $name; + } + + public function getCalls(): int + { + return $this->calls; + } +} From cb315ed373d89a37f094bd7e5c615cb138aa5b3f Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 02:17:44 +0000 Subject: [PATCH 05/10] test(zengine): cover the driver's refusal and fallback paths 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- src/Instrument/ZEngine/ZEngineDriver.php | 42 +++-- .../MethodTableRewriterInProcessTest.php | 60 +++++++ .../ZEngineClassWeaverInProcessTest.php | 153 ++++++++++++++++-- .../InProcess/ZEngineInProcessTestCase.php | 31 ++-- .../Instrument/ZEngine/ZEngineDriverTest.php | 66 ++++++++ tests/Proxy/DonorClassGeneratorTest.php | 14 ++ tests/Stubs/ZEngine/FallbackTarget.php | 16 ++ tests/Stubs/ZEngine/InProcessAspect.php | 3 +- tests/Stubs/ZEngine/InProcessKernel.php | 2 + tests/Stubs/ZEngine/MatcherErrorAdvisor.php | 61 +++++++ tests/Stubs/ZEngine/MatcherErrorAspect.php | 18 +++ tests/Stubs/ZEngine/MatcherErrorTarget.php | 16 ++ tests/Stubs/ZEngine/ReadonlyPoint.php | 18 +++ tests/Stubs/ZEngine/RewriterDonor.php | 13 ++ tests/Stubs/ZEngine/RewriterTarget.php | 13 ++ tests/Stubs/ZEngine/SelfMatchingAspect.php | 23 +++ tests/Stubs/ZEngine/broken-source.txt | 3 + 17 files changed, 516 insertions(+), 36 deletions(-) create mode 100644 tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php create mode 100644 tests/Instrument/ZEngine/ZEngineDriverTest.php create mode 100644 tests/Stubs/ZEngine/FallbackTarget.php create mode 100644 tests/Stubs/ZEngine/MatcherErrorAdvisor.php create mode 100644 tests/Stubs/ZEngine/MatcherErrorAspect.php create mode 100644 tests/Stubs/ZEngine/MatcherErrorTarget.php create mode 100644 tests/Stubs/ZEngine/ReadonlyPoint.php create mode 100644 tests/Stubs/ZEngine/RewriterDonor.php create mode 100644 tests/Stubs/ZEngine/RewriterTarget.php create mode 100644 tests/Stubs/ZEngine/SelfMatchingAspect.php create mode 100644 tests/Stubs/ZEngine/broken-source.txt diff --git a/src/Instrument/ZEngine/ZEngineDriver.php b/src/Instrument/ZEngine/ZEngineDriver.php index 6eb6fbb9..86e64b82 100644 --- a/src/Instrument/ZEngine/ZEngineDriver.php +++ b/src/Instrument/ZEngine/ZEngineDriver.php @@ -12,6 +12,7 @@ namespace Go\Instrument\ZEngine; +use Closure; use Go\Aop\Exception\InvalidConfigurationException; use Go\Core\AdviceMatcher; use Go\Core\AspectContainer; @@ -49,9 +50,26 @@ final class ZEngineDriver */ private function __construct() {} - public static function boot(AspectKernel $kernel, AspectContainer $container): void - { - if (!class_exists(Core::class)) { + /** + * @param (Closure(): bool)|null $isEngineInstalled Whether z-engine is installed, class_exists(Core::class) by default (tests) + * @param (Closure(): void)|null $bootEngine Boots z-engine unless it is initialized, Core::init() by default (tests) + * + * @throws InvalidConfigurationException When z-engine is missing, cannot boot, or the JIT is on where it may not be switched off + */ + public static function boot( + AspectKernel $kernel, + AspectContainer $container, + ?Closure $isEngineInstalled = null, + ?Closure $bootEngine = null, + ): void { + $isEngineInstalled ??= static fn(): bool => class_exists(Core::class); + $bootEngine ??= static function (): void { + if (!Core::isInitialized()) { + Core::init(); + } + }; + + if (!$isEngineInstalled()) { throw new InvalidConfigurationException( 'The zengine weaving driver requires lisachenko/z-engine: install the z-engine branch ' . 'matching the PHP minor version (composer require lisachenko/z-engine:8.5.x-dev on PHP 8.5) ' @@ -61,16 +79,14 @@ public static function boot(AspectKernel $kernel, AspectContainer $container): v $jitReport = JitGuard::enforce(); - if (!Core::isInitialized()) { - try { - Core::init(); - } catch (Throwable $failure) { - throw new InvalidConfigurationException( - 'The zengine weaving driver cannot boot z-engine: ' . $failure->getMessage(), - 0, - $failure, - ); - } + try { + $bootEngine(); + } catch (Throwable $failure) { + throw new InvalidConfigurationException( + 'The zengine weaving driver cannot boot z-engine: ' . $failure->getMessage(), + 0, + $failure, + ); } $container->add(self::BOOT_REPORT, [ diff --git a/tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php b/tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php new file mode 100644 index 00000000..95acd08b --- /dev/null +++ b/tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php @@ -0,0 +1,60 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine\InProcess; + +use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Instrument\ZEngine\MethodTableRewriter; +use Go\Stubs\ZEngine\RewriterDonor; +use Go\Stubs\ZEngine\RewriterTarget; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; +use ReflectionException; + +/** + * The engine refusals the rewriter translates for the driver + */ +#[Group('zengine')] +final class MethodTableRewriterInProcessTest extends ZEngineInProcessTestCase +{ + public function testAClassMissingFromTheEngineClassTableIsRefused(): void + { + $this->bootKernel(); + $this->assertTrue(class_exists(RewriterDonor::class)); + + try { + // @phpstan-ignore argument.type (a class name the engine does not know is what the test needs) + new MethodTableRewriter()->rewire('Go\Stubs\ZEngine\NeverDeclared', RewriterDonor::class, ['run']); + self::fail('A class the engine does not know cannot be rewired'); + } catch (UnsupportedJoinpointException $refusal) { + $this->assertStringContainsString('Go\Stubs\ZEngine\NeverDeclared', $refusal->getMessage()); + $this->assertStringContainsString('not published in the engine class table', $refusal->getMessage()); + $this->assertInstanceOf(ReflectionException::class, $refusal->getPrevious()); + } + } + + public function testADonorWithoutTheMethodIsRefusedAndTheClassStaysUntouched(): void + { + $this->bootKernel(); + $target = new RewriterTarget(); + + try { + new MethodTableRewriter()->rewire(RewriterTarget::class, RewriterDonor::class, ['run']); + self::fail('A donor without the dispatcher method cannot be adopted'); + } catch (UnsupportedJoinpointException $refusal) { + $this->assertStringContainsString(RewriterTarget::class, $refusal->getMessage()); + $this->assertInstanceOf(ReflectionException::class, $refusal->getPrevious()); + } + $this->assertSame('ran', $target->run()); + $this->assertFalse(new ReflectionClass($target)->hasMethod('runOriginalAlias')); + } +} diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php index b1b46a92..6f83c25b 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php +++ b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php @@ -13,19 +13,29 @@ namespace Go\Instrument\ZEngine\InProcess; use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Aop\Exception\WeavingException; use Go\Aop\Framework\BeforeInterceptor; +use Go\Aop\WeavingDriver; use Go\Aop\Framework\GeneratedInterceptor; +use Go\Core\AdviceMatcher; use Go\Core\AspectContainer; use Go\Core\Cache\CachedAspectLoader; use Go\Instrument\ClassLoading\CachePathManager; use Go\Instrument\ZEngine\DonorCacheIndex; +use Go\Instrument\ZEngine\MethodTableRewriter; use Go\Instrument\ZEngine\WeaveOutcome; use Go\Instrument\ZEngine\ZEngineClassWeaver; use Go\ParserReflection\ReflectionFile; use Go\Proxy\ClassProxyGenerator; use Go\Proxy\DonorClassGenerator; use Go\Stubs\ZEngine\InProcessAspect; +use Go\Stubs\ZEngine\FallbackTarget; +use Go\Stubs\ZEngine\InProcessKernel; use Go\Stubs\ZEngine\LateTarget; +use Go\Stubs\ZEngine\MatcherErrorAdvisor; +use Go\Stubs\ZEngine\MatcherErrorAspect; +use Go\Stubs\ZEngine\MatcherErrorTarget; +use Go\Stubs\ZEngine\SelfMatchingAspect; use Go\Stubs\ZEngine\UnadvisedTarget; use Go\Stubs\ZEngine\UnsupportedAspect; use Go\Stubs\ZEngine\UnsupportedTarget; @@ -36,6 +46,7 @@ use PHPUnit\Framework\Attributes\Group; use ReflectionClass; use ReflectionMethod; +use RuntimeException; /** * Classes are woven through the engine when they are loaded, and on demand @@ -59,12 +70,23 @@ public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void $result = $weaver->weave(WovenTarget::class); $this->assertSame(WeaveOutcome::AlreadyWoven, $result->outcome); $this->assertSame(['greet', 'getCalls'], $result->methods); + $file = new ReflectionClass(WovenTarget::class)->getFileName(); + $this->assertNotFalse($file); + $this->assertSame(WeaveOutcome::AlreadyWoven, $weaver->weaveLoadedClass(WovenTarget::class, $file)->outcome); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weaveLoadedClass(\ArrayObject::class, $file)->outcome, 'Internal, whatever file the loader names'); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weaveLoadedClass(\Countable::class, $file)->outcome, 'An interface'); + $enum = $weaver->weaveLoadedClass(WeavingDriver::class, $file); + $this->assertSame(WeaveOutcome::Skipped, $enum->outcome); + $this->assertSame('interfaces, traits and enums are not supported by the zengine driver', $enum->reason); $this->assertSame('pong', (new UnadvisedTarget())->ping()); $this->assertSame(WeaveOutcome::NoAdvices, $weaver->weave(UnadvisedTarget::class)->outcome); $this->assertSame(WeaveOutcome::Skipped, $weaver->weave('Go\Stubs\ZEngine\NeverLoaded')->outcome); - $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\ArrayObject::class)->outcome); + $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\ArrayObject::class)->outcome, 'The result of the load above'); $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\Countable::class)->outcome); + $internal = $weaver->weave(\ArrayIterator::class); + $this->assertSame(WeaveOutcome::Skipped, $internal->outcome); + $this->assertSame('the class has no source file (internal or runtime-generated)', $internal->reason); $this->assertSame(WeaveOutcome::NoAdvices, $weaver->weave(self::class)->outcome, 'Within the application root, matched against the advisors'); // A class whose source lies outside the application root is never woven @@ -114,6 +136,109 @@ class_exists(UnsupportedTarget::class); $weaver->weave(UnsupportedTarget::class); } + public function testRecordsOfAPreviousRequestDecideWithoutTheAspects(): void + { + $kernel = $this->bootKernel(); + $container = $kernel->getContainer(); + $index = $container->getService(DonorCacheIndex::class); + $source = realpath(self::STUBS_DIR . '/UnadvisedTarget.php'); + $this->assertNotFalse($source); + + // A file the loader included that did not declare the class it was asked for + $weaver = $container->getService(ZEngineClassWeaver::class); + $result = $weaver->weaveLoadedClass('Go\\Stubs\\ZEngine\\NotDeclaredAnywhere', $source); + $this->assertSame(WeaveOutcome::Skipped, $result->outcome); + $this->assertSame('the file did not declare the class', $result->reason); + + // A "no advices" record is served without the matcher + $index->recordNoAdvices(UnadvisedTarget::class, $source); + $index->flush(); + $warmWeaver = $this->weaverWithFreshIndex($kernel); + require_once $source; + $this->assertSame(WeaveOutcome::NoAdvices, $warmWeaver->weaveLoadedClass(UnadvisedTarget::class, $source)->outcome); + + // A donor record whose file does not declare the donor class is a broken cache + $donorFile = $index->donorFileFor($source, 'UnadvisedTarget'); + $container->getService(CachePathManager::class)->getCacheFileWriter()->write($donorFile, "recordDonor(UnadvisedTarget::class, $source, $donorFile, UnadvisedTarget::class . '__AopDonor', ['ping']); + $index->flush(); + $brokenWeaver = $this->weaverWithFreshIndex($kernel); + try { + $brokenWeaver->weaveLoadedClass(UnadvisedTarget::class, $source); + self::fail('A donor file without the donor class must be reported'); + } catch (WeavingException $failure) { + $this->assertStringContainsString('does not declare the donor class', $failure->getMessage()); + } + // The failure is remembered for the class, whichever entry point asks again + try { + $brokenWeaver->weaveLoadedClass(UnadvisedTarget::class, $source); + self::fail('The remembered failure must be rethrown'); + } catch (WeavingException $rethrown) { + $this->assertStringContainsString('does not declare the donor class', $rethrown->getMessage()); + } + $this->expectException(WeavingException::class); + $brokenWeaver->weave(UnadvisedTarget::class); + } + + public function testAnAspectIsNeverWovenEvenWhenItsOwnPointcutMatchesIt(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + + $this->assertSame('helper', new SelfMatchingAspect()->helper()); + $result = $weaver->weave(SelfMatchingAspect::class); + + $this->assertSame(WeaveOutcome::Skipped, $result->outcome); + $this->assertSame('aspects are never woven', $result->reason); + } + + public function testAPointcutFailingOnAnOrdinaryClassFailsItsLoad(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + + try { + class_exists(MatcherErrorTarget::class); + self::fail('The matching failure must fail the load'); + } catch (RuntimeException $failure) { + $this->assertSame(MatcherErrorAdvisor::FAILURE . ' ' . MatcherErrorTarget::class, $failure->getMessage()); + } + $this->assertTrue(class_exists(MatcherErrorTarget::class, false), 'The class is declared, but not woven'); + $this->assertSame('ran', new MatcherErrorTarget()->run()); + + // The failure is remembered for the class + $this->expectException(RuntimeException::class); + $weaver->weave(MatcherErrorTarget::class); + } + + public function testAPointcutFailingOnAnAspectDoesNotFailItsLoad(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + + $this->assertSame('helper', new MatcherErrorAspect()->helper()); + $result = $weaver->weave(MatcherErrorAspect::class); + + $this->assertSame(WeaveOutcome::Skipped, $result->outcome); + $this->assertSame('aspects are never woven', $result->reason); + } + + public function testASourceTheParserCannotReadFallsBackToNativeReflection(): void + { + $kernel = $this->bootKernel(); + $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + require_once self::STUBS_DIR . '/FallbackTarget.php'; + $brokenSource = realpath(self::STUBS_DIR . '/broken-source.txt'); + $this->assertNotFalse($brokenSource); + + $result = $weaver->weaveLoadedClass(FallbackTarget::class, $brokenSource); + + $this->assertSame(WeaveOutcome::Woven, $result->outcome); + $this->assertSame(['twice'], $result->methods); + $this->assertSame(6, new FallbackTarget()->twice(3)); + $this->assertSame([FallbackTarget::class . '::twice'], InProcessAspect::$log); + } + public function testAClassDeclaredBeforeTheKernelIsWovenOnDemand(): void { require_once self::STUBS_DIR . '/LateTarget.php'; @@ -157,14 +282,7 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void $index->flush(); // A weaver of a new request reads the index and applies the record: no aspect gets loaded for it - $weaver = new ZEngineClassWeaver( - $container, - $container->getService(\Go\Core\AdviceMatcher::class), - $container->getService(CachedAspectLoader::class), - $container->getService(CachePathManager::class), - new DonorCacheIndex($container->getService(CachePathManager::class), $kernel->getOptions()), - $container->getService(\Go\Instrument\ZEngine\MethodTableRewriter::class), - ); + $weaver = $this->weaverWithFreshIndex($kernel); $unloadedBefore = $container->getService(CachedAspectLoader::class)->getUnloadedAspects(); $this->assertNotEmpty($unloadedBefore); require_once $source; @@ -177,6 +295,23 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void $this->assertSame($unloadedBefore, $container->getService(CachedAspectLoader::class)->getUnloadedAspects(), 'The aspects stayed unloaded'); } + /** + * A weaver as the next request would build it: its own index instance, reading the flushed index file + */ + private function weaverWithFreshIndex(InProcessKernel $kernel): ZEngineClassWeaver + { + $container = $kernel->getContainer(); + + return new ZEngineClassWeaver( + $container, + $container->getService(AdviceMatcher::class), + $container->getService(CachedAspectLoader::class), + $container->getService(CachePathManager::class), + new DonorCacheIndex($container->getService(CachePathManager::class), $kernel->getOptions()), + $container->getService(MethodTableRewriter::class), + ); + } + /** * The descriptor of the aspect-method advice, as the aspect loader produces it: the donor then resolves the * aspect through the container (The::aspect()), without the aspect loader diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php index be5e3841..430af2f7 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php +++ b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php @@ -12,6 +12,7 @@ namespace Go\Instrument\ZEngine\InProcess; +use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\WeavingDriver; use Go\Core\AspectKernel; use Go\Instrument\ZEngine\DonorCacheIndex; @@ -21,7 +22,6 @@ use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionProperty; -use Throwable; use ZEngine\Core; /** @@ -101,13 +101,19 @@ protected function bootKernel(): InProcessKernel self::assertNotFalse($stubsDir); $kernel = new ReflectionClass(InProcessKernel::class)->newInstanceWithoutConstructor(); - $kernel->init([ - 'driver' => WeavingDriver::ZEngine, - 'debug' => true, - 'appDir' => $appDir, - 'cacheDir' => $this->cacheDir, - 'includePaths' => [$stubsDir], - ]); + try { + // The first boot of the process initializes the engine (ZEngineDriver::boot() calls Core::init()) + $kernel->init([ + 'driver' => WeavingDriver::ZEngine, + 'debug' => true, + 'appDir' => $appDir, + 'cacheDir' => $this->cacheDir, + 'includePaths' => [$stubsDir], + ]); + } catch (InvalidConfigurationException $failure) { + self::$engineUnavailableReason = 'The zengine driver cannot boot in this process: ' . $failure->getMessage(); + self::markTestSkipped(self::$engineUnavailableReason); + } $singleton = self::singletonProperty(); $previous = $singleton->getValue(); $this->previousSingleton = $previous instanceof AspectKernel ? $previous : null; @@ -139,12 +145,11 @@ private static function engineUnavailableReason(): ?string return self::$engineUnavailableReason = 'The opcache JIT is active in the test runner: z-engine needs it off, ' . 'start PHP with opcache.jit=off and opcache.jit_buffer_size=0'; } - try { - Core::init(); - } catch (Throwable $failure) { - return self::$engineUnavailableReason = 'z-engine cannot boot in this process (ext-ffi, ffi.enable=1 and the ' - . 'z-engine branch of this PHP minor are required): ' . $failure->getMessage(); + if (!extension_loaded('FFI') || !in_array(strtolower((string) ini_get('ffi.enable')), ['1', 'true', 'on'], true)) { + return self::$engineUnavailableReason = 'z-engine needs ext-ffi with ffi.enable=1 in the test runner'; } + // Whether this z-engine branch targets the running PHP minor is only known by booting: the first + // bootKernel() does that through the driver and skips the rest of the process on a failure return self::$engineUnavailableReason = null; } diff --git a/tests/Instrument/ZEngine/ZEngineDriverTest.php b/tests/Instrument/ZEngine/ZEngineDriverTest.php new file mode 100644 index 00000000..57813e55 --- /dev/null +++ b/tests/Instrument/ZEngine/ZEngineDriverTest.php @@ -0,0 +1,66 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ZEngine; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * The boot refusals of the driver: without the engine, or with an engine that cannot start (the successful boot + * is covered in-process, see InProcess/ZEngineDriverInProcessTest) + */ +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +final class ZEngineDriverTest extends TestCase +{ + public function testRefusesToBootWithoutZEngine(): void + { + $container = $this->createMock(AspectContainer::class); + $container->expects($this->never())->method('add'); + + try { + ZEngineDriver::boot($this->createStub(AspectKernel::class), $container, static fn(): bool => false); + self::fail('A missing engine must be refused'); + } catch (InvalidConfigurationException $refusal) { + $this->assertStringContainsString('requires lisachenko/z-engine', $refusal->getMessage()); + } + } + + public function testWrapsAnEngineThatCannotBoot(): void + { + $status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; + if (is_array($status) && is_array($status['jit'] ?? null) && ($status['jit']['on'] ?? false) === true) { + self::markTestSkipped('The opcache JIT is active in the test runner: the guard runs before the engine boot'); + } + $container = $this->createMock(AspectContainer::class); + $container->expects($this->never())->method('add'); + $reason = new RuntimeException('FFI is not enabled'); + + try { + ZEngineDriver::boot( + $this->createStub(AspectKernel::class), + $container, + static fn(): bool => true, + static function () use ($reason): void { + throw $reason; + }, + ); + self::fail('An engine that cannot boot must be refused'); + } catch (InvalidConfigurationException $refusal) { + $this->assertSame('The zengine weaving driver cannot boot z-engine: FFI is not enabled', $refusal->getMessage()); + $this->assertSame($reason, $refusal->getPrevious()); + } + } +} diff --git a/tests/Proxy/DonorClassGeneratorTest.php b/tests/Proxy/DonorClassGeneratorTest.php index b7b988ee..478aeb6a 100644 --- a/tests/Proxy/DonorClassGeneratorTest.php +++ b/tests/Proxy/DonorClassGeneratorTest.php @@ -18,6 +18,7 @@ use Go\Core\AspectContainer; use Go\ParserReflection\ReflectionFile; use Go\PhpUnit\AssertsCompilablePhp; +use Go\Stubs\ZEngine\ReadonlyPoint; use Go\Tests\TestProject\Application\ZEngineChild; use Go\Tests\TestProject\Application\ZEngineParent; use PHPUnit\Framework\Attributes\DataProvider; @@ -113,6 +114,19 @@ public function testOwnStaticMethodsDispatchThroughTheStaticAlias(bool $parsed): self::assertStringContainsString('public function describe(): \Go\Tests\TestProject\Application\ZEngineParent', $code); } + public function testAReadonlyClassGetsAReadonlyDonor(): void + { + $class = new ReflectionClass(ReadonlyPoint::class); + $generator = new DonorClassGenerator($class, [ + AspectContainer::METHOD_PREFIX => ['translate' => [self::testAdvice()]], + ]); + $code = "generate(); + + self::assertPhpCompiles($code); + self::assertStringContainsString('abstract readonly class ReadonlyPoint__AopDonor', $code); + self::assertStringContainsString('public function translate(int $dx, int $dy): \\Go\\Stubs\\ZEngine\\ReadonlyPoint', $code); + } + public function testRefusesJoinpointKindsADonorCannotCarry(): void { [$class, $imports] = self::reflect(ZEngineChild::class, true); diff --git a/tests/Stubs/ZEngine/FallbackTarget.php b/tests/Stubs/ZEngine/FallbackTarget.php new file mode 100644 index 00000000..dfd77f84 --- /dev/null +++ b/tests/Stubs/ZEngine/FallbackTarget.php @@ -0,0 +1,16 @@ +*(*)) || execution(public Go\Stubs\ZEngine\WovenChild->*(*)) || execution(public Go\Stubs\ZEngine\WovenChild::*(*)) - || execution(public Go\Stubs\ZEngine\LateTarget->*(*)) || execution(public Go\Stubs\ZEngine\WarmTarget->*(*))")] + || execution(public Go\Stubs\ZEngine\LateTarget->*(*)) || execution(public Go\Stubs\ZEngine\WarmTarget->*(*)) + || execution(public Go\Stubs\ZEngine\FallbackTarget->*(*))")] public function beforeMethod(MethodInvocation $invocation): void { self::$log[] = $invocation->getMethod()->class . '::' . $invocation->getMethod()->name; diff --git a/tests/Stubs/ZEngine/InProcessKernel.php b/tests/Stubs/ZEngine/InProcessKernel.php index cf18d652..9322e83b 100644 --- a/tests/Stubs/ZEngine/InProcessKernel.php +++ b/tests/Stubs/ZEngine/InProcessKernel.php @@ -16,5 +16,7 @@ protected function configureAop(AspectContainer $container): void { $container->addLazyService(InProcessAspect::class, static fn(): InProcessAspect => new InProcessAspect()); $container->addLazyService(UnsupportedAspect::class, static fn(): UnsupportedAspect => new UnsupportedAspect()); + $container->addLazyService(SelfMatchingAspect::class, static fn(): SelfMatchingAspect => new SelfMatchingAspect()); + $container->addLazyService(MatcherErrorAdvisor::class, static fn(): MatcherErrorAdvisor => new MatcherErrorAdvisor()); } } diff --git a/tests/Stubs/ZEngine/MatcherErrorAdvisor.php b/tests/Stubs/ZEngine/MatcherErrorAdvisor.php new file mode 100644 index 00000000..2d84a983 --- /dev/null +++ b/tests/Stubs/ZEngine/MatcherErrorAdvisor.php @@ -0,0 +1,61 @@ +getName(), [MatcherErrorTarget::class, MatcherErrorAspect::class], true) + ) { + throw new RuntimeException(self::FAILURE . ' ' . $context->getName()); + } + + return false; + } + + public function compileToPhp(): Expr + { + return new New_(new FullyQualified(self::class)); + } +} diff --git a/tests/Stubs/ZEngine/MatcherErrorAspect.php b/tests/Stubs/ZEngine/MatcherErrorAspect.php new file mode 100644 index 00000000..20441dd7 --- /dev/null +++ b/tests/Stubs/ZEngine/MatcherErrorAspect.php @@ -0,0 +1,18 @@ +x + $dx, $this->y + $dy); + } +} diff --git a/tests/Stubs/ZEngine/RewriterDonor.php b/tests/Stubs/ZEngine/RewriterDonor.php new file mode 100644 index 00000000..cfb8ba43 --- /dev/null +++ b/tests/Stubs/ZEngine/RewriterDonor.php @@ -0,0 +1,13 @@ +*(*))")] + public function beforeOwnMethod(MethodInvocation $invocation): void {} + + public function helper(): string + { + return 'helper'; + } +} diff --git a/tests/Stubs/ZEngine/broken-source.txt b/tests/Stubs/ZEngine/broken-source.txt new file mode 100644 index 00000000..79074f9f --- /dev/null +++ b/tests/Stubs/ZEngine/broken-source.txt @@ -0,0 +1,3 @@ + Date: Sat, 10 Oct 2026 16:11:35 +0000 Subject: [PATCH 06/10] ci(zengine): install the 8.5 branch of z-engine now that the feature 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/zengine.yml | 2 +- docs/zengine-driver.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml index c3b1ac54..d4decaf3 100644 --- a/.github/workflows/zengine.yml +++ b/.github/workflows/zengine.yml @@ -29,7 +29,7 @@ jobs: - php-version: "8.4" zengine-branch: "dev-feature/redefine-preserve-alias-8.4" - php-version: "8.5" - zengine-branch: "dev-feature/redefine-preserve-alias" + zengine-branch: "dev-8.5" env: ZENGINE_BRANCH: ${{ matrix.zengine-branch }} steps: diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index b3fdae25..1e129408 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -19,11 +19,11 @@ join-point API. An aspect written for one driver runs unchanged on the other, wi ```bash composer config repositories.zengine vcs https://github.com/lisachenko/z-engine composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 # PHP 8.4 - composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias # PHP 8.5 + composer require lisachenko/z-engine:dev-8.5 # PHP 8.5 ``` - Until the `redefine(..., preserveAs:)` API is merged into the version branches, the branches above are the - ones the driver is built against. + The `redefine(..., preserveAs:)` API the driver is built against is merged into `8.5`; until it reaches the + `8.4` branch as well, the PHP 8.4 install pins the feature branch above. - The opcache JIT **off** for the whole process: `opcache.jit=off` and `opcache.jit_buffer_size=0` in `php.ini`, the FPM pool or `php -d`. z-engine hooks the executor internals the JIT bypasses. On PHP 8.5 the tracing JIT also miscompiles code the framework runs (a known PHP bug, see `docs/php85-limitations.md`), so there the driver switches From 6e147077aa238e7eb81ba1af395bccb5181002c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:13:13 +0000 Subject: [PATCH 07/10] ci(zengine): require the 8.5 branch of z-engine by its version alias 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/zengine.yml | 2 +- docs/zengine-driver.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml index d4decaf3..9ee56b79 100644 --- a/.github/workflows/zengine.yml +++ b/.github/workflows/zengine.yml @@ -29,7 +29,7 @@ jobs: - php-version: "8.4" zengine-branch: "dev-feature/redefine-preserve-alias-8.4" - php-version: "8.5" - zengine-branch: "dev-8.5" + zengine-branch: "8.5.x-dev" env: ZENGINE_BRANCH: ${{ matrix.zengine-branch }} steps: diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index 1e129408..96c6e151 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -19,7 +19,7 @@ join-point API. An aspect written for one driver runs unchanged on the other, wi ```bash composer config repositories.zengine vcs https://github.com/lisachenko/z-engine composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 # PHP 8.4 - composer require lisachenko/z-engine:dev-8.5 # PHP 8.5 + composer require lisachenko/z-engine:8.5.x-dev # PHP 8.5 ``` The `redefine(..., preserveAs:)` API the driver is built against is merged into `8.5`; until it reaches the From d8785fc924b0ed7ba167a05499c1aefefb480bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:21:48 +0000 Subject: [PATCH 08/10] ci(zengine): install the 8.4 branch of z-engine now that the feature 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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/phpunit.yml | 2 +- .github/workflows/zengine.yml | 2 +- docs/zengine-driver.md | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 50549714..d89655f9 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -118,7 +118,7 @@ jobs: # The optional engine of the zengine driver, from the branch targeting PHP 8.4 (see docs/zengine-driver.md) run: | composer config repositories.zengine vcs https://github.com/lisachenko/z-engine - composer require --dev --no-update lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 + composer require --dev --no-update lisachenko/z-engine:8.4.x-dev composer update --no-interaction --no-progress - name: "Tests with coverage" diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml index 9ee56b79..e83c5e64 100644 --- a/.github/workflows/zengine.yml +++ b/.github/workflows/zengine.yml @@ -27,7 +27,7 @@ jobs: matrix: include: - php-version: "8.4" - zengine-branch: "dev-feature/redefine-preserve-alias-8.4" + zengine-branch: "8.4.x-dev" - php-version: "8.5" zengine-branch: "8.5.x-dev" env: diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index 96c6e151..354f8f4d 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -18,12 +18,11 @@ join-point API. An aspect written for one driver runs unchanged on the other, wi ```bash composer config repositories.zengine vcs https://github.com/lisachenko/z-engine - composer require lisachenko/z-engine:dev-feature/redefine-preserve-alias-8.4 # PHP 8.4 + composer require lisachenko/z-engine:8.4.x-dev # PHP 8.4 composer require lisachenko/z-engine:8.5.x-dev # PHP 8.5 ``` - The `redefine(..., preserveAs:)` API the driver is built against is merged into `8.5`; until it reaches the - `8.4` branch as well, the PHP 8.4 install pins the feature branch above. + The `redefine(..., preserveAs:)` API the driver is built against is merged into both version branches. - The opcache JIT **off** for the whole process: `opcache.jit=off` and `opcache.jit_buffer_size=0` in `php.ini`, the FPM pool or `php -d`. z-engine hooks the executor internals the JIT bypasses. On PHP 8.5 the tracing JIT also miscompiles code the framework runs (a known PHP bug, see `docs/php85-limitations.md`), so there the driver switches From cf4903730751aeec237cbee86379ac125fb64e9d Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 20:53:20 +0000 Subject: [PATCH 09/10] feat(zengine): intercept functions and mark woven classes as Go\Aop\Proxy 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/.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 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- demos/autoload_aspect.php | 5 +- docs/zengine-driver.md | 35 ++++- phpstan/zengine-stubs.php | 2 + src/Core/AspectKernel.php | 7 +- src/Instrument/AGENTS.md | 5 +- src/Instrument/ZEngine/DonorCacheIndex.php | 103 ++++++++++++++- .../ZEngine/MethodTableRewriter.php | 17 ++- src/Instrument/ZEngine/ZEngineClassWeaver.php | 125 +++++++++++++++++- src/Instrument/ZEngine/ZEngineDriver.php | 1 + tests/Core/AspectKernelTest.php | 15 ++- .../Fixtures/project/bin/zengine-weaving.php | 14 ++ .../src/Application/ZEngineFunctions.php | 27 ++++ .../project/src/Aspect/ZEngineAspect.php | 9 ++ tests/Fixtures/project/web/configuration.php | 3 + .../Functional/ZEngine/ZEngineWeavingTest.php | 42 +++++- .../ZEngineClassWeaverInProcessTest.php | 46 ++++++- .../InProcess/ZEngineInProcessTestCase.php | 2 + tests/Stubs/ZEngine/FunctionTarget.php | 29 ++++ tests/Stubs/ZEngine/InProcessAspect.php | 12 ++ 19 files changed, 473 insertions(+), 26 deletions(-) create mode 100644 tests/Fixtures/project/src/Application/ZEngineFunctions.php create mode 100644 tests/Stubs/ZEngine/FunctionTarget.php diff --git a/demos/autoload_aspect.php b/demos/autoload_aspect.php index 5b91183e..654c2278 100644 --- a/demos/autoload_aspect.php +++ b/demos/autoload_aspect.php @@ -15,11 +15,14 @@ include __DIR__ . '/../vendor/autoload.php'; -// Initialize demo aspect container +// Initialize demo aspect container. GO_AOP_DRIVER=zengine runs the demos through the z-engine weaving driver +// (needs lisachenko/z-engine, ext-ffi and the opcache JIT off, see docs/zengine-driver.md) +$driver = getenv('GO_AOP_DRIVER'); AwesomeAspectKernel::getInstance()->init([ 'debug' => true, 'appDir' => __DIR__, 'cacheDir' => __DIR__ . '/cache', + 'driver' => is_string($driver) && $driver !== '' ? $driver : 'stream', 'features' => Features::INTERCEPT_FUNCTIONS, ]); diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md index 354f8f4d..49415333 100644 --- a/docs/zengine-driver.md +++ b/docs/zengine-driver.md @@ -44,9 +44,10 @@ ApplicationAspectKernel::getInstance()->init([ ]); ``` -`includePaths`, `excludePaths`, `debug`, `cacheDir` and `cacheFileMode` mean the same as with the stream driver. -The `features` option must not contain `INTERCEPT_FUNCTIONS`, `INTERCEPT_INITIALIZATIONS`, `INTERCEPT_INCLUDES` or -`PREBUILT_CACHE`: those flags configure source transformers, and the engine driver never rewrites sources. +`includePaths`, `excludePaths`, `debug`, `cacheDir`, `cacheFileMode` and `Features::INTERCEPT_FUNCTIONS` mean the +same as with the stream driver. The `features` option must not contain `INTERCEPT_INITIALIZATIONS`, +`INTERCEPT_INCLUDES` or `PREBUILT_CACHE`: those flags configure source transformers, and the engine driver never +rewrites sources. ## How a class is woven @@ -67,10 +68,29 @@ The `features` option must not contain `INTERCEPT_FUNCTIONS`, `INTERCEPT_INITIAL (`ReflectionClass::addMethod($method, $donorMethod)`), whose dispatcher reaches the original through `parent::(...)`. +4. The rewired class is marked with the `Go\Aop\Proxy` interface (`ReflectionClass::addInterfaces()`), like a + generated proxy class: `instanceof Proxy` tells a woven object apart under both drivers. + A warm request finds a fresh record in the index (same freshness rule as the stream driver: source size and mtime, tracked resources in debug mode), loads the donor file and rewires the class without loading the aspects or running the matcher. +### Function interception + +With `Features::INTERCEPT_FUNCTIONS` the driver serves `execution(Ns\function(*))` pointcuts exactly the way the +stream driver does, and without touching the engine: an advised internal function called **unqualified** from a +namespace is shadowed by a namespaced proxy function of the same name (`Ns\array_sum()` dispatching to the advices +and reaching `\array_sum()`), which PHP resolves such a call to before the global function. The proxies of a +namespace are generated into `{cacheDir}/_zengine/_functions/{Ns/Path}.php`, recorded in the donor index with the +function names, and included right after the file declaring the first loaded class of that namespace - before any +code of the namespace runs. A warm request includes the recorded file without loading the aspects. + +Two consequences of that mechanism: a fully-qualified call (`\array_sum()`) is never intercepted, like with the +stream driver; and a call site that already ran before the proxies were declared (a class loaded before the +kernel) keeps the global function it resolved, since PHP caches the resolution per call site. Functions called +from the global namespace cannot be intercepted by either driver: a proxy there would redeclare the internal +function. + Classes declared before the kernel was initialized (required directly, never autoloaded) are not seen by the loader. Weave them on demand: @@ -86,14 +106,15 @@ entry (z-engine refuses that mutation, the driver reports it as `Go\Aop\Exceptio ## Supported join points The engine driver rewires **method bodies**: `execution(...)` pointcuts on instance and static methods, public, -protected and private, declared or inherited, in regular classes (final, abstract and readonly classes included). +protected and private, declared or inherited, in regular classes (final, abstract and readonly classes included), +and serves **function execution** pointcuts through namespaced proxies (`Features::INTERCEPT_FUNCTIONS`, see above). Everything else only the stream driver provides, since it needs rewritten sources: - property access (`access(...)` pointcuts), - introductions (interfaces and traits), - `initialization(...)` and `staticinitialization(...)` pointcuts, -- function interception (`Features::INTERCEPT_FUNCTIONS`), include interception, prebuilt caches, +- include interception, prebuilt caches, - traits, enums and interfaces as weaving targets. An advisor matching an unsupported join point kind on a class makes the first load of that class throw @@ -102,8 +123,8 @@ woven. Narrow the pointcut to method execution, or run that application with the ## Differences from the stream driver -- The woven class is the original class: no `FooOriginalTrait`, no proxy class, `Go\Aop\Proxy` is not implemented - and `get_class()`/`instanceof` see no difference at all. +- The woven class is the original class: no `FooOriginalTrait`, no proxy class, `get_class()` sees no difference. + It does implement `Go\Aop\Proxy` (added at runtime), so `instanceof Proxy` works like with a generated proxy. - Reflection of an advised method reports the donor file as the file of its body (`getFileName()`, `getStartLine()`), since that is where the dispatcher was compiled; the class itself keeps its original file. - A private or final method whose body is a single literal may have been inlined by the opcache optimizer into its diff --git a/phpstan/zengine-stubs.php b/phpstan/zengine-stubs.php index 55c7a734..3be61c44 100644 --- a/phpstan/zengine-stubs.php +++ b/phpstan/zengine-stubs.php @@ -50,5 +50,7 @@ public static function fromClassTable(string $className): ?self {} public function getMethod(string $name): ReflectionMethod {} public function addMethod(string $methodName, \Closure|FunctionLikeInterface $method): ReflectionMethod {} + + public function addInterfaces(string ...$interfaceNames): void {} } } diff --git a/src/Core/AspectKernel.php b/src/Core/AspectKernel.php index 201b3d80..26091ace 100644 --- a/src/Core/AspectKernel.php +++ b/src/Core/AspectKernel.php @@ -72,8 +72,7 @@ abstract class AspectKernel /** * Feature flags implemented by source transformers: meaningless for a driver that never rewrites sources */ - private const int STREAM_ONLY_FEATURES = Features::INTERCEPT_FUNCTIONS - | Features::INTERCEPT_INITIALIZATIONS + private const int STREAM_ONLY_FEATURES = Features::INTERCEPT_INITIALIZATIONS | Features::INTERCEPT_INCLUDES | Features::PREBUILT_CACHE; @@ -353,8 +352,8 @@ protected function normalizeOptions(array $options): array if ($driver === WeavingDriver::ZEngine && ($features & self::STREAM_ONLY_FEATURES) !== 0) { throw new InvalidConfigurationException( 'The zengine weaving driver rewires loaded classes at runtime and never rewrites sources: ' - . 'the INTERCEPT_FUNCTIONS, INTERCEPT_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE ' - . 'features are only available with the stream driver.', + . 'the INTERCEPT_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE features are only ' + . 'available with the stream driver.', ); } $rawIncludePaths = is_array($merged['includePaths'] ?? null) ? $merged['includePaths'] : []; diff --git a/src/Instrument/AGENTS.md b/src/Instrument/AGENTS.md index c2b7aa31..c8c32b78 100644 --- a/src/Instrument/AGENTS.md +++ b/src/Instrument/AGENTS.md @@ -11,9 +11,10 @@ Runtime weaving through lisachenko/z-engine (FFI) in place of steps 2-4 above; AspectKernel::init() branches on the driver. ZEngineDriver::boot(): requires z-engine (class_exists Core), JitGuard::enforce() (JIT must be off; PHP 8.5: ini_set('opcache.jit','off') at runtime, reported), Core::init(), registers DonorCacheIndex / MethodTableRewriter / ZEngineClassWeaver as lazy services, ZEngineComposerLoader::init() (wraps composer's loader, unwrapping either driver's wrapper: ComposerLoaderDecorator). - ZEngineComposerLoader::loadClass(): include the original file natively, then ZEngineClassWeaver::weaveLoadedClass() when the path filter allows (the subclass that triggered the autoload links against the already woven parent). - ZEngineClassWeaver: once per class per request (failures remembered and rethrown); DonorCacheIndex::findFresh() → warm: include donor + rewire; miss: load aspects, AdviceMatcher on the parsed source (ReflectionFile, native fallback), refuse non-method kinds (UnsupportedJoinpointException::forClass), DonorClassGenerator → `{cacheDir}/_zengine//__AopDonor.php`, record. `weave($class)` is the public entry for classes declared before the kernel. -- MethodTableRewriter: own method → `$class->getMethod($m)->redefine($donorMethod, preserveAs: $m . 'OriginalAlias')`; inherited → `$class->addMethod($m, $donorMethod)`; EngineClass::fromClassTable() re-resolved per method (opcache copy-out repoints the bucket); z-engine refusals → UnsupportedJoinpointException::engineRefusal(). +- MethodTableRewriter: own method → `$class->getMethod($m)->redefine($donorMethod, preserveAs: $m . 'OriginalAlias')`; inherited → `$class->addMethod($m, $donorMethod)`; EngineClass::fromClassTable() re-resolved per method (opcache copy-out repoints the bucket); afterwards `addInterfaces(Go\Aop\Proxy)` marks the class like a generated proxy; z-engine refusals → UnsupportedJoinpointException::engineRefusal(). +- Functions (INTERCEPT_FUNCTIONS): ZEngineClassWeaver::weaveFunctions(ns, file), called before doWeave() for the namespace of every loaded class: the stream driver's FunctionProxyGenerator output written to `{cacheDir}/_zengine/_functions/.php`, recorded per namespace in the index (`functions`: file + function names, fresh by tracked resources only), included right after the class file (PHP resolves an unqualified call to the namespaced function first); never included twice in a process (function_exists guard), never for the global namespace. - DonorCacheIndex: `{cacheDir}/_zengine.cache` (version, classes: source/filemtime/filesize/cachedAt/donor/donorClass/methods), freshness = CachePathManager::isFreshRecord() (the one shared rule), written via CachePathManager::writeIndexFile(), flushed at destruct. Separate from _transformation.cache on purpose (a record without cacheUri means "serve natively" to the stream driver). -- Stream-only: Features::INTERCEPT_* and PREBUILT_CACHE (refused in normalizeOptions), CacheWarmer/debug:weaving (refused), property/introduction/init join points. +- Stream-only: Features::INTERCEPT_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE (refused in normalizeOptions), CacheWarmer/debug:weaving (refused), property/introduction/init join points. ## Transformer chain (order matters) Applied per loaded file. Each returns TransformerResult: Transformed|Abstain|Aborted (skips the rest of the chain, reverts to the original source, recorded as untransformed). diff --git a/src/Instrument/ZEngine/DonorCacheIndex.php b/src/Instrument/ZEngine/DonorCacheIndex.php index dd7ca7c0..1b9c81b4 100644 --- a/src/Instrument/ZEngine/DonorCacheIndex.php +++ b/src/Instrument/ZEngine/DonorCacheIndex.php @@ -33,8 +33,14 @@ * writing engine records into it would poison a cache directory later used by the stream driver. * Freshness is decided by the one shared rule, {@see CachePathManager::isFreshRecord()}. * + * The function proxies of a namespace (`Features::INTERCEPT_FUNCTIONS`) are recorded per namespace name under + * `functions`: the proxy file generated below `{cacheDir}/_zengine/_functions/` and the names of the functions it + * declares, or that no advisor matches a function called from the namespace. Those records have no source file: + * they go stale with the tracked resources only (the internal function table never changes within a PHP build). + * * @phpstan-import-type KernelOptions from AspectKernel * @phpstan-type DonorRecord array{donor: string|null, donorClass: string|null, methods: list} + * @phpstan-type FunctionsRecord array{file: string|null, functions: list} * * @internal Framework service, not a public extension point */ @@ -43,12 +49,14 @@ final class DonorCacheIndex /** * Version of the index format, an index of another version is ignored and rebuilt */ - public const int FORMAT_VERSION = 1; + public const int FORMAT_VERSION = 2; private const string INDEX_FILE_NAME = '/_zengine.cache'; private const string DONOR_DIRECTORY = '/_zengine'; + private const string FUNCTIONS_DIRECTORY = '/_zengine/_functions'; + private ?string $cacheDir; private string $appDir; @@ -69,6 +77,20 @@ final class DonorCacheIndex */ private array $newRecords = []; + /** + * Function-proxy records read from the index file, keyed by namespace name + * + * @var array> + */ + private array $functionRecords = []; + + /** + * Function-proxy records written during this request, keyed by namespace name + * + * @var array> + */ + private array $newFunctionRecords = []; + /** * @phpstan-param KernelOptions $options */ @@ -103,6 +125,62 @@ public function findFresh(string $className, string $resource, AspectContainer $ return ['donor' => $donor, 'donorClass' => $donorClass, 'methods' => $methods]; } + /** + * Returns the function-proxy record of a namespace when it is still valid, null on a miss or a stale record + * + * @return FunctionsRecord|null + */ + public function findFreshFunctions(string $namespace, AspectContainer $container): ?array + { + $this->records(); + $record = $this->newFunctionRecords[$namespace] ?? $this->functionRecords[$namespace] ?? null; + if ($record === null) { + return null; + } + $cachedAt = $record['cachedAt'] ?? 0; + if (!$container->isFreshSince(is_int($cachedAt) ? $cachedAt : 0)) { + return null; + } + $file = $record['file'] ?? null; + $functions = is_array($record['functions'] ?? null) ? array_values(array_filter($record['functions'], is_string(...))) : []; + if ($file === null) { + return ['file' => null, 'functions' => []]; + } + if (!is_string($file) || !is_file($file)) { + // A record pointing at a missing proxy file is rebuilt like a miss + return null; + } + + return ['file' => $file, 'functions' => $functions]; + } + + /** + * Returns the file the function proxies of a namespace are written to: + * `{cacheDir}/_zengine/_functions/{Namespace/Path}.php` + */ + public function functionsFileFor(string $namespace): string + { + if ($this->cacheDir === null) { + throw new InvalidConfigurationException('The donor index needs the `cacheDir` option to be configured'); + } + + return $this->cacheDir . self::FUNCTIONS_DIRECTORY . '/' . str_replace('\\', '/', trim($namespace, '\\')) . '.php'; + } + + /** + * Records the function proxies generated for a namespace, or that no advisor matches a function called from it + * + * @param list $functions Names of the proxied functions (without the namespace) + */ + public function recordFunctions(string $namespace, ?string $file, array $functions): void + { + $this->newFunctionRecords[$namespace] = [ + 'file' => $file, + 'functions' => $functions, + 'cachedAt' => time(), + ]; + } + /** * Whether a source file lies below the application root: only those are woven (like with the stream driver, * whose cache mirrors the application tree), sources outside it are never touched @@ -166,16 +244,20 @@ public function recordDonor(string $className, string $resource, string $donorFi */ public function flush(): void { - if ($this->newRecords === [] || $this->cacheDir === null || !is_writable($this->cacheDir)) { + if (($this->newRecords === [] && $this->newFunctionRecords === []) || $this->cacheDir === null || !is_writable($this->cacheDir)) { return; } - $allRecords = $this->newRecords + $this->records(); + $allRecords = $this->newRecords + $this->records(); + $allFunctionRecords = $this->newFunctionRecords + $this->functionRecords; $this->cachePathManager->writeIndexFile(self::INDEX_FILE_NAME, [ - 'version' => self::FORMAT_VERSION, - 'classes' => $allRecords, + 'version' => self::FORMAT_VERSION, + 'classes' => $allRecords, + 'functions' => $allFunctionRecords, ]); - $this->records = $allRecords; - $this->newRecords = []; + $this->records = $allRecords; + $this->newRecords = []; + $this->functionRecords = $allFunctionRecords; + $this->newFunctionRecords = []; } /** @@ -232,6 +314,13 @@ private function records(): array $this->records[$className] = $record; } } + if (is_array($indexData['functions'] ?? null)) { + foreach ($indexData['functions'] as $namespace => $record) { + if (is_string($namespace) && is_array($record)) { + $this->functionRecords[$namespace] = $record; + } + } + } } return $this->records; diff --git a/src/Instrument/ZEngine/MethodTableRewriter.php b/src/Instrument/ZEngine/MethodTableRewriter.php index 805ba8f6..bf01337f 100644 --- a/src/Instrument/ZEngine/MethodTableRewriter.php +++ b/src/Instrument/ZEngine/MethodTableRewriter.php @@ -14,6 +14,7 @@ use Go\Aop\Exception\UnsupportedJoinpointException; use Go\Aop\Framework\AbstractMethodInvocation; +use Go\Aop\Proxy; use ReflectionException; use ReflectionMethod; use ZEngine\Reflection\ReflectionClass as EngineClass; @@ -29,7 +30,11 @@ * dispatcher calls `$this->OriginalAlias(...)` / `self::...` like a trait-based proxy; * - a method the class only inherits gets an own override adopting the dispatcher body * (`ReflectionClass::addMethod($method, $donorMethod)`), whose dispatcher reaches the - * original through `parent::(...)`. + * original through `parent::(...)`; + * - the rewired class is marked with the `Go\Aop\Proxy` interface + * (`ReflectionClass::addInterfaces()`), like a generated proxy class: code asking + * `instanceof Proxy` to tell a woven object (the Doctrine bridge, fluent-interface guards) + * gets the same answer from both drivers. * * An opcache-shared class is copied out of shared memory by z-engine on the first mutation; * the class reflection is therefore re-resolved from the class table before every method. @@ -74,5 +79,15 @@ public function rewire(string $className, string $donorClassName, array $methods throw UnsupportedJoinpointException::engineRefusal($className, $refusal); } } + + // Re-resolved once more: the first mutation above may have copied the class out of shared memory + $class = EngineClass::fromClassTable($className); + if ($class !== null && !in_array(Proxy::class, $class->getInterfaceNames(), true)) { + try { + $class->addInterfaces(Proxy::class); + } catch (ReflectionException $refusal) { + throw UnsupportedJoinpointException::engineRefusal($className, $refusal); + } + } } } diff --git a/src/Instrument/ZEngine/ZEngineClassWeaver.php b/src/Instrument/ZEngine/ZEngineClassWeaver.php index fd7a445a..c2e77cdf 100644 --- a/src/Instrument/ZEngine/ZEngineClassWeaver.php +++ b/src/Instrument/ZEngine/ZEngineClassWeaver.php @@ -23,8 +23,10 @@ use Go\Core\AspectLoaderInterface; use Go\Instrument\ClassLoading\CachePathManager; use Go\ParserReflection\ReflectionFile; +use Go\ParserReflection\ReflectionFileNamespace; use Go\Proxy\ClassProxyGenerator; use Go\Proxy\DonorClassGenerator; +use Go\Proxy\FunctionProxyGenerator; use ReflectionClass; use Throwable; @@ -41,6 +43,12 @@ * defaults, `self`/`parent` types and the file's imports verbatim); native reflection is the * fallback for a class whose file cannot be parsed. A warm donor record skips both. * + * Function interception (`Features::INTERCEPT_FUNCTIONS`) needs no engine mutation at all: the + * stream driver's function proxies - namespaced functions shadowing the advised internal ones, + * which PHP resolves an unqualified call to before the global function - are generated per + * namespace and included right after the file declaring a class of that namespace, before any + * of its code runs ({@see weaveFunctions()}). + * * @internal Framework service, not a public extension point */ final class ZEngineClassWeaver @@ -65,12 +73,22 @@ final class ZEngineClassWeaver private array $failures = []; /** - * Includes a donor file in an isolated scope + * Function-proxy files declared in this request, keyed by namespace name (null: nothing proxied there) + * + * @var array + */ + private array $functionNamespaces = []; + + /** + * Includes a donor or function-proxy file in an isolated scope * * @var (Closure(string): void)|null */ private static ?Closure $includeFile = null; + /** + * @param bool $interceptFunctions Whether `Features::INTERCEPT_FUNCTIONS` is enabled (the kernel's `kernel.interceptFunctions`) + */ public function __construct( private readonly AspectContainer $container, private readonly AdviceMatcherInterface $adviceMatcher, @@ -78,6 +96,7 @@ public function __construct( private readonly CachePathManager $cachePathManager, private readonly DonorCacheIndex $donorCache, private readonly MethodTableRewriter $rewriter, + private readonly bool $interceptFunctions = false, ) {} /** @@ -125,6 +144,10 @@ public function weaveLoadedClass(string $className, string $file): WeaveResult return $this->weave($className); } try { + // The function proxies of the namespace come first: a class of it is loaded, so code of + // that namespace is about to run + $this->weaveFunctions(self::namespaceOf($className), $file); + return $this->remember($className, $this->doWeave($className, $file)); } catch (Throwable $failure) { $this->failures[$className] = $failure; @@ -132,6 +155,106 @@ public function weaveLoadedClass(string $className, string $file): WeaveResult } } + /** + * Declares the function proxies of a namespace, once per request + * + * With `Features::INTERCEPT_FUNCTIONS` an advised internal function called unqualified from a + * namespace is shadowed by a namespaced proxy function of the same name, generated the way the + * stream driver generates them (the proxy dispatches to the advices and reaches the original + * through its fully-qualified name). PHP resolves an unqualified call to the namespaced function + * first, so the proxies only have to be declared before the calling code runs - which is what + * including them right after the file declaring a class of the namespace guarantees. A call site + * that already ran keeps the global function it resolved (PHP caches the resolution per call + * site), like a class loaded before the kernel keeps its original methods. The global namespace + * cannot be served: a proxy there would redeclare the internal function. + * + * The proxy file is generated below `{cacheDir}/_zengine/_functions/` on the first request and + * recorded in the donor index with the function names; a warm request includes it without + * loading the aspects. The functions of a namespace already declared in this process (another + * kernel of a test runner) are never included again. + * + * @param string $namespace Namespace to declare the proxies for + * @param string $file A source file declaring that namespace block, parsed for the matching + * + * @return string|null The proxy file declared for the namespace, null when nothing is proxied there + */ + public function weaveFunctions(string $namespace, string $file): ?string + { + $namespace = trim($namespace, '\\'); + if (!$this->interceptFunctions || $namespace === '') { + return null; + } + if (array_key_exists($namespace, $this->functionNamespaces)) { + return $this->functionNamespaces[$namespace]; + } + $this->functionNamespaces[$namespace] = null; + if (!$this->donorCache->isApplicationSource($file)) { + return null; + } + + $record = $this->donorCache->findFreshFunctions($namespace, $this->container) + ?? $this->generateFunctionProxies($namespace, $file); + if ($record['file'] === null) { + return null; + } + foreach ($record['functions'] as $function) { + if (function_exists($namespace . '\\' . $function)) { + // Declared by an earlier kernel of this process: a function cannot be declared twice + return $this->functionNamespaces[$namespace] = $record['file']; + } + } + (self::$includeFile ??= static function (string $file): void { + include $file; + })($record['file']); + + return $this->functionNamespaces[$namespace] = $record['file']; + } + + /** + * Matches the function advisors against the namespace and writes its proxy file (recorded either way) + * + * @return array{file: string|null, functions: list} + */ + private function generateFunctionProxies(string $namespace, string $file): array + { + try { + $fileNamespace = new ReflectionFileNamespace($file, $namespace); + } catch (Throwable) { + // The file declares no such namespace block (a class name spelled in another case than its + // declaration, a source the parser cannot read): nothing to match against, and not worth a record + return ['file' => null, 'functions' => []]; + } + foreach ($this->aspectLoader->getUnloadedAspects() as $unloadedAspect) { + $this->aspectLoader->loadAndRegister($unloadedAspect); + } + $advisors = $this->container->getServicesByInterface(Advisor::class); + $advices = $this->adviceMatcher->getAdvicesForFunctions($fileNamespace, $advisors); + if ($advices === []) { + $this->donorCache->recordFunctions($namespace, null, []); + + return ['file' => null, 'functions' => []]; + } + $sortedAdvices = AbstractJoinpoint::flatAndSortAdvices($advices); + $functions = array_map(strval(...), array_keys($sortedAdvices[AspectContainer::FUNCTION_PREFIX] ?? [])); + $proxyFile = $this->donorCache->functionsFileFor($namespace); + $generator = new FunctionProxyGenerator($fileNamespace, $sortedAdvices); + + $this->cachePathManager->getCacheFileWriter()->write($proxyFile, $generator->generate()); + $this->donorCache->recordFunctions($namespace, $proxyFile, $functions); + + return ['file' => $proxyFile, 'functions' => $functions]; + } + + /** + * Namespace of a class name, '' for the global namespace + */ + private static function namespaceOf(string $className): string + { + $separator = strrpos($className, '\\'); + + return $separator === false ? '' : substr($className, 0, $separator); + } + private function doWeave(string $className, string $file): WeaveResult { if (!class_exists($className, false)) { diff --git a/src/Instrument/ZEngine/ZEngineDriver.php b/src/Instrument/ZEngine/ZEngineDriver.php index 86e64b82..82584bac 100644 --- a/src/Instrument/ZEngine/ZEngineDriver.php +++ b/src/Instrument/ZEngine/ZEngineDriver.php @@ -108,6 +108,7 @@ public static function boot( $container->getService(CachePathManager::class), $container->getService(DonorCacheIndex::class), $container->getService(MethodTableRewriter::class), + (bool) $container->getValue('kernel.interceptFunctions'), )); ZEngineComposerLoader::init($kernel->getOptions(), $container); diff --git a/tests/Core/AspectKernelTest.php b/tests/Core/AspectKernelTest.php index 36270724..4a01bcf6 100644 --- a/tests/Core/AspectKernelTest.php +++ b/tests/Core/AspectKernelTest.php @@ -277,7 +277,7 @@ public function testNormalizeOptionsRefusesStreamOnlyFeaturesWithTheEngineDriver $this->invokeProtected($kernel, 'normalizeOptions', [[ 'cacheDir' => '/some/cache/dir', 'driver' => WeavingDriver::ZEngine, - 'features' => Features::INTERCEPT_FUNCTIONS, + 'features' => Features::INTERCEPT_INITIALIZATIONS, ]]); self::fail('Stream-only features must be refused with the engine driver'); } catch (InvalidConfigurationException $exception) { @@ -285,6 +285,19 @@ public function testNormalizeOptionsRefusesStreamOnlyFeaturesWithTheEngineDriver } } + public function testNormalizeOptionsAcceptsFunctionInterceptionWithTheEngineDriver(): void + { + $kernel = $this->makeKernel(); + + $normalized = $this->invokeProtectedArray($kernel, 'normalizeOptions', [[ + 'cacheDir' => '/some/cache/dir', + 'driver' => WeavingDriver::ZEngine, + 'features' => Features::INTERCEPT_FUNCTIONS, + ]]); + + $this->assertSame(Features::INTERCEPT_FUNCTIONS, $normalized['features']); + } + public function testNormalizeOptionsExcludesRuntimeDependenciesFromWeaving(): void { $kernel = $this->makeKernel(); diff --git a/tests/Fixtures/project/bin/zengine-weaving.php b/tests/Fixtures/project/bin/zengine-weaving.php index 8b6c3635..818eeb9f 100644 --- a/tests/Fixtures/project/bin/zengine-weaving.php +++ b/tests/Fixtures/project/bin/zengine-weaving.php @@ -11,10 +11,12 @@ include_once __DIR__ . '/../../../../vendor/autoload.php'; +use Go\Aop\Proxy; use Go\Core\AspectKernel; use Go\Instrument\ZEngine\ZEngineClassWeaver; use Go\Instrument\ZEngine\ZEngineDriver; use Go\Tests\TestProject\Application\ZEngineChild; +use Go\Tests\TestProject\Application\ZEngineFunctions; use Go\Tests\TestProject\Application\ZEngineLate; use Go\Tests\TestProject\Application\ZEngineParent; use Go\Tests\TestProject\Application\ZEngineUnadvised; @@ -83,6 +85,8 @@ 'parent.greet.file' => basename((string) (new ReflectionMethod(ZEngineParent::class, 'greet'))->getFileName()), 'child.alias' => method_exists($child, 'greetOriginalAlias'), 'parent.alias' => method_exists($parent, 'greetOriginalAlias'), + 'child.proxy' => $child instanceof Proxy, + 'unadvised.proxy' => new ZEngineUnadvised() instanceof Proxy, ]; $describeWeaving($kernel, ZEngineParent::class, ZEngineChild::class, ZEngineUnadvised::class); break; @@ -97,6 +101,16 @@ $report['loaded'] = class_exists(ZEngineUnsupported::class); break; + case 'functions': + $functions = new ZEngineFunctions(); + $report['calls'] = [ + 'functions.total' => $functions->total([1, 2, 3]), + 'functions.totalQualified' => $functions->totalQualified([4, 5]), + 'functions.proxied' => function_exists('Go\\Tests\\TestProject\\Application\\array_sum'), + ]; + $describeWeaving($kernel, ZEngineFunctions::class); + break; + default: throw new InvalidArgumentException('Unknown scenario ' . $scenario); } diff --git a/tests/Fixtures/project/src/Application/ZEngineFunctions.php b/tests/Fixtures/project/src/Application/ZEngineFunctions.php new file mode 100644 index 00000000..c4cf2765 --- /dev/null +++ b/tests/Fixtures/project/src/Application/ZEngineFunctions.php @@ -0,0 +1,27 @@ + $numbers + */ + public function total(array $numbers): int + { + return (int) array_sum($numbers); + } + + /** + * @param list $numbers + */ + public function totalQualified(array $numbers): int + { + return (int) \array_sum($numbers); + } +} diff --git a/tests/Fixtures/project/src/Aspect/ZEngineAspect.php b/tests/Fixtures/project/src/Aspect/ZEngineAspect.php index dde3c9df..72bdafbf 100644 --- a/tests/Fixtures/project/src/Aspect/ZEngineAspect.php +++ b/tests/Fixtures/project/src/Aspect/ZEngineAspect.php @@ -4,6 +4,7 @@ namespace Go\Tests\TestProject\Aspect; use Go\Aop\Aspect; +use Go\Aop\Intercept\FunctionInvocation; use Go\Aop\Intercept\MethodInvocation; use Go\Lang\Attribute as Pointcut; @@ -42,4 +43,12 @@ public function aroundGreet(MethodInvocation $invocation): string return strtoupper((string) $result); } + + #[Pointcut\Around("execution(Go\Tests\TestProject\Application\array_sum(*))")] + public function aroundArraySum(FunctionInvocation $invocation): mixed + { + self::$log[] = 'around array_sum with ' . json_encode($invocation->getArguments()); + + return $invocation->proceed(); + } } diff --git a/tests/Fixtures/project/web/configuration.php b/tests/Fixtures/project/web/configuration.php index 530461b8..348440c8 100644 --- a/tests/Fixtures/project/web/configuration.php +++ b/tests/Fixtures/project/web/configuration.php @@ -50,6 +50,7 @@ 'debug' => true, 'driver' => 'zengine', 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine', + 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, 'includePaths' => [ __DIR__ . '/../src/' ], @@ -63,6 +64,7 @@ 'debug' => false, 'driver' => 'zengine', 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-production', + 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, 'includePaths' => [ __DIR__ . '/../src/' ], @@ -77,6 +79,7 @@ 'debug' => true, 'driver' => 'stream', 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-reference', + 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, 'includePaths' => [ __DIR__ . '/../src/' ], diff --git a/tests/Functional/ZEngine/ZEngineWeavingTest.php b/tests/Functional/ZEngine/ZEngineWeavingTest.php index 9638d024..d7dfcbe1 100644 --- a/tests/Functional/ZEngine/ZEngineWeavingTest.php +++ b/tests/Functional/ZEngine/ZEngineWeavingTest.php @@ -15,6 +15,7 @@ use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Exception\UnsupportedJoinpointException; use Go\Tests\TestProject\Application\ZEngineChild; +use Go\Tests\TestProject\Application\ZEngineFunctions; use Go\Tests\TestProject\Application\ZEngineLate; use Go\Tests\TestProject\Application\ZEngineParent; use Go\Tests\TestProject\Application\ZEngineUnadvised; @@ -33,6 +34,7 @@ final class ZEngineWeavingTest extends ZEngineFunctionalTestCase * Files the driver writes into the cache directory of the `weave` scenario */ private const array EXPECTED_CACHE_FILES = [ + '_zengine/_functions/Go/Tests/TestProject/Application.php', '_zengine/src/Application/ZEngineChild__AopDonor.php', '_zengine/src/Application/ZEngineParent__AopDonor.php', 'src/Aspect/ZEngineAspect.cache.php', @@ -54,11 +56,14 @@ public function testWeavesLikeTheStreamDriver(bool $opcache): void self::assertSame($stream['log'], $engine['log']); self::assertNotEmpty($engine['log']); - // Reflection agrees too, except that a swapped body carries the file it was compiled in: the donor + // Reflection agrees too, except that a swapped body carries the file it was compiled in: the donor. + // The woven class is marked as a Go\Aop\Proxy like a generated proxy class, an unadvised one is not $expectedReflection = $stream['reflection']; self::assertIsArray($expectedReflection); $expectedReflection['parent.greet.file'] = 'ZEngineParent__AopDonor.php'; self::assertSame($expectedReflection, $engine['reflection']); + self::assertTrue(self::at($engine, 'reflection', 'child.proxy')); + self::assertFalse(self::at($engine, 'reflection', 'unadvised.proxy')); // Own methods are swapped, inherited public methods get an override, the unadvised class is left alone self::assertSame( @@ -81,6 +86,41 @@ public function testWeavesLikeTheStreamDriver(bool $opcache): void self::assertSame($opcache, self::at($engine, 'boot', 'opcache')); } + /** + * Function interception: the namespaced proxies of the stream driver, declared right after a class of the + * namespace is loaded; a warm request includes the recorded proxy file without the aspects + */ + #[DataProvider('provideOpcacheLegs')] + public function testInterceptsFunctionsLikeTheStreamDriver(bool $opcache): void + { + $engine = $this->runProbe('zengine', 'functions', $opcache); + $stream = $this->runProbe('zengine_reference', 'functions', $opcache); + + self::assertArrayNotHasKey('exception', $engine, 'The engine driver must run the scenario'); + self::assertArrayNotHasKey('exception', $stream, 'The stream driver must run the scenario'); + self::assertSame($stream['calls'], $engine['calls']); + self::assertSame($stream['log'], $engine['log']); + self::assertSame(['around array_sum with [[1,2,3]]'], $engine['log'], 'Only the unqualified call is intercepted'); + self::assertTrue(self::at($engine, 'calls', 'functions.proxied')); + self::assertSame( + ['outcome' => 'no-advices', 'methods' => [], 'reason' => null], + self::at($engine, 'weave', ZEngineFunctions::class), + 'No method advice matches the class: the function proxies need no donor', + ); + self::assertSame([], $engine['donors']); + + $proxyFile = self::cacheDir('zengine') . '/_zengine/_functions/Go/Tests/TestProject/Application.php'; + self::assertFileExists($proxyFile); + file_put_contents($proxyFile, PHP_EOL . '// marked by ' . self::class . PHP_EOL, FILE_APPEND); + $markedProxy = md5_file($proxyFile); + + $warm = $this->runProbe('zengine', 'functions', $opcache); + self::assertTrue($warm['indexExisted']); + self::assertSame($engine['calls'], $warm['calls']); + self::assertSame($engine['log'], $warm['log']); + self::assertSame($markedProxy, md5_file($proxyFile), 'A fresh record reuses its proxy file'); + } + #[DataProvider('provideOpcacheLegs')] public function testWarmRequestReusesTheDonorAndStaleSourceRegeneratesIt(bool $opcache): void { diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php index 6f83c25b..2cc12006 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php +++ b/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php @@ -15,6 +15,7 @@ use Go\Aop\Exception\UnsupportedJoinpointException; use Go\Aop\Exception\WeavingException; use Go\Aop\Framework\BeforeInterceptor; +use Go\Aop\Proxy; use Go\Aop\WeavingDriver; use Go\Aop\Framework\GeneratedInterceptor; use Go\Core\AdviceMatcher; @@ -30,6 +31,7 @@ use Go\Proxy\DonorClassGenerator; use Go\Stubs\ZEngine\InProcessAspect; use Go\Stubs\ZEngine\FallbackTarget; +use Go\Stubs\ZEngine\FunctionTarget; use Go\Stubs\ZEngine\InProcessKernel; use Go\Stubs\ZEngine\LateTarget; use Go\Stubs\ZEngine\MatcherErrorAdvisor; @@ -66,6 +68,7 @@ public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void $this->assertSame(1, $target->getCalls(), 'The original body ran under its alias'); $this->assertSame([WovenTarget::class . '::greet', WovenTarget::class . '::getCalls'], InProcessAspect::$log); $this->assertTrue(new ReflectionClass($target)->hasMethod('greetOriginalAlias'), 'The previous body is kept under the alias'); + $this->assertContains(Proxy::class, class_implements($target), 'A woven class is marked like a generated proxy class'); $result = $weaver->weave(WovenTarget::class); $this->assertSame(WeaveOutcome::AlreadyWoven, $result->outcome); @@ -81,6 +84,7 @@ public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void $this->assertSame('pong', (new UnadvisedTarget())->ping()); $this->assertSame(WeaveOutcome::NoAdvices, $weaver->weave(UnadvisedTarget::class)->outcome); + $this->assertNotContains(Proxy::class, class_implements(new UnadvisedTarget()), 'An unadvised class is not marked'); $this->assertSame(WeaveOutcome::Skipped, $weaver->weave('Go\Stubs\ZEngine\NeverLoaded')->outcome); $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\ArrayObject::class)->outcome, 'The result of the load above'); $this->assertSame(WeaveOutcome::Skipped, $weaver->weave(\Countable::class)->outcome); @@ -279,9 +283,11 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void $donorFile = $index->donorFileFor($source, 'WarmTarget'); $container->getService(CachePathManager::class)->getCacheFileWriter()->write($donorFile, "generate()); $index->recordDonor(WarmTarget::class, $source, $donorFile, $generator->getDonorClassName(), ['answer']); + // ... including the function-proxy record of the namespace, written by the same request + $index->recordFunctions('Go\\Stubs\\ZEngine', null, []); $index->flush(); - // A weaver of a new request reads the index and applies the record: no aspect gets loaded for it + // A weaver of a new request reads the index and applies the records: no aspect gets loaded for it $weaver = $this->weaverWithFreshIndex($kernel); $unloadedBefore = $container->getService(CachedAspectLoader::class)->getUnloadedAspects(); $this->assertNotEmpty($unloadedBefore); @@ -295,6 +301,43 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void $this->assertSame($unloadedBefore, $container->getService(CachedAspectLoader::class)->getUnloadedAspects(), 'The aspects stayed unloaded'); } + /** + * With INTERCEPT_FUNCTIONS the advised internal functions called from the stubs' namespace are shadowed by + * proxies declared right after the first class of the namespace is loaded; the proxies are declared once per + * process and the warm index serves them without the aspects + */ + public function testFunctionsOfANamespaceAreProxiedWhenAClassOfItIsLoaded(): void + { + $kernel = $this->bootKernel(); + $container = $kernel->getContainer(); + $weaver = $container->getService(ZEngineClassWeaver::class); + $index = $container->getService(DonorCacheIndex::class); + $this->assertFalse(class_exists(FunctionTarget::class, false), 'The stub must not be loaded by an earlier test'); + + $target = new FunctionTarget(); + + // The proxy was declared by the autoload of the class (or by an earlier kernel of this process) + $this->assertTrue(function_exists('Go\\Stubs\\ZEngine\\str_rot13'), 'The namespaced proxy shadows the internal function'); + $this->assertSame('uryyb', $target->encode('hello'), 'The proxy reaches the original function'); + $this->assertSame(['around str_rot13 with ["hello"]'], InProcessAspect::$log); + $this->assertSame('uryyb', $target->encodeQualified('hello'), 'A qualified call reaches the internal function directly'); + $this->assertSame(['around str_rot13 with ["hello"]'], InProcessAspect::$log, 'The qualified call bypassed the proxy'); + + $proxyFile = $index->functionsFileFor('Go\\Stubs\\ZEngine'); + $this->assertSame($proxyFile, $weaver->weaveFunctions('Go\\Stubs\\ZEngine', self::STUBS_DIR . '/FunctionTarget.php')); + $this->assertFileExists($proxyFile); + $this->assertStringContainsString('function str_rot13(', (string) file_get_contents($proxyFile)); + $this->assertNull($weaver->weaveFunctions('', self::STUBS_DIR . '/FunctionTarget.php'), 'The global namespace cannot be proxied'); + $this->assertNull($weaver->weaveFunctions('Go\\Stubs\\Unrelated', self::STUBS_DIR . '/FunctionTarget.php'), 'A namespace the file does not declare'); + $this->assertSame($proxyFile, $weaver->weaveFunctions('Go\\Stubs\\ZEngine', $this->cacheDir . '/Outside.php'), 'Memoized per namespace and request'); + + // A warm request reads the record: the proxy file is reused, the functions stay declared + $index->flush(); + $warm = $this->weaverWithFreshIndex($kernel); + $this->assertSame($proxyFile, $warm->weaveFunctions('Go\\Stubs\\ZEngine', self::STUBS_DIR . '/FunctionTarget.php')); + $this->assertSame('uryyb', $target->encode('hello')); + } + /** * A weaver as the next request would build it: its own index instance, reading the flushed index file */ @@ -309,6 +352,7 @@ private function weaverWithFreshIndex(InProcessKernel $kernel): ZEngineClassWeav $container->getService(CachePathManager::class), new DonorCacheIndex($container->getService(CachePathManager::class), $kernel->getOptions()), $container->getService(MethodTableRewriter::class), + (bool) $container->getValue('kernel.interceptFunctions'), ); } diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php index 430af2f7..010c5f13 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php +++ b/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php @@ -13,6 +13,7 @@ namespace Go\Instrument\ZEngine\InProcess; use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Features; use Go\Aop\WeavingDriver; use Go\Core\AspectKernel; use Go\Instrument\ZEngine\DonorCacheIndex; @@ -109,6 +110,7 @@ protected function bootKernel(): InProcessKernel 'appDir' => $appDir, 'cacheDir' => $this->cacheDir, 'includePaths' => [$stubsDir], + 'features' => Features::INTERCEPT_FUNCTIONS, ]); } catch (InvalidConfigurationException $failure) { self::$engineUnavailableReason = 'The zengine driver cannot boot in this process: ' . $failure->getMessage(); diff --git a/tests/Stubs/ZEngine/FunctionTarget.php b/tests/Stubs/ZEngine/FunctionTarget.php new file mode 100644 index 00000000..f885f62a --- /dev/null +++ b/tests/Stubs/ZEngine/FunctionTarget.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Stubs\ZEngine; + +/** + * Calls an advised internal function: unqualified (served by the namespaced proxy) and qualified (never intercepted) + */ +final class FunctionTarget +{ + public function encode(string $text): string + { + return str_rot13($text); + } + + public function encodeQualified(string $text): string + { + return \str_rot13($text); + } +} diff --git a/tests/Stubs/ZEngine/InProcessAspect.php b/tests/Stubs/ZEngine/InProcessAspect.php index 505709b8..543ec2e5 100644 --- a/tests/Stubs/ZEngine/InProcessAspect.php +++ b/tests/Stubs/ZEngine/InProcessAspect.php @@ -5,6 +5,7 @@ namespace Go\Stubs\ZEngine; use Go\Aop\Aspect; +use Go\Aop\Intercept\FunctionInvocation; use Go\Aop\Intercept\MethodInvocation; use Go\Lang\Attribute as Pointcut; @@ -32,4 +33,15 @@ public function aroundGreet(MethodInvocation $invocation): string { return strtoupper((string) $invocation->proceed()); } + + /** + * Function interception: an internal function called unqualified from the stubs' namespace + */ + #[Pointcut\Around("execution(Go\Stubs\ZEngine\str_rot13(*))")] + public function aroundRot13(FunctionInvocation $invocation): mixed + { + self::$log[] = 'around str_rot13 with ' . json_encode($invocation->getArguments()); + + return $invocation->proceed(); + } } From a4e7792aeac959e2d431bee1dca68f9b9ab40924 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 20:56:09 +0000 Subject: [PATCH 10/10] docs(changelog): function interception and the Proxy marker of the zengine driver Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d6639f..aa46dfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide. * [BC BREAK] **Dependencies** — `ext-tokenizer`, `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0 and `symfony/finder` ^7.4 || ^8.0. `nikic/php-parser` 5 comes through parser-reflection and is no longer required directly. `symfony/console` ^7.4 || ^8.0 is needed for the `bin/aspect` tool. **Added** -* [Feature] **zengine weaving driver (experimental)** — the new `driver` kernel option selects how advised classes are rewired: `stream` (the default, source transformation through the stream filter) or `zengine`, which weaves at runtime through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI). With the engine driver classes load natively and, right after a class is loaded, the body of every advised method is swapped in place with a generated dispatcher while the original body stays callable under the private `OriginalAlias` (inherited methods get an engine-published override): no source rewriting, no stream filter, no proxy class, no include interception. Dispatcher bodies are compiled in a cached donor class `Foo__AopDonor` (`Go\Proxy\DonorClassGenerator`, index `{cacheDir}/_zengine.cache`). Method execution join points only; property access, introductions, initialization join points, function interception and prebuilt caches stay with the stream driver and are refused with `Go\Aop\Exception\UnsupportedJoinpointException`. Requires `ext-ffi`, the z-engine branch of the running PHP minor and the opcache JIT off (on PHP 8.5 the driver switches the tracing JIT off at runtime, it miscompiles the framework). See `docs/zengine-driver.md`. +* [Feature] **zengine weaving driver (experimental)** — the new `driver` kernel option selects how advised classes are rewired: `stream` (the default, source transformation through the stream filter) or `zengine`, which weaves at runtime through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI). With the engine driver classes load natively and, right after a class is loaded, the body of every advised method is swapped in place with a generated dispatcher while the original body stays callable under the private `OriginalAlias` (inherited methods get an engine-published override): no source rewriting, no stream filter, no proxy class, no include interception. Dispatcher bodies are compiled in a cached donor class `Foo__AopDonor` (`Go\Proxy\DonorClassGenerator`, index `{cacheDir}/_zengine.cache`). Method execution join points, plus function interception (`Features::INTERCEPT_FUNCTIONS`) through the same namespaced function proxies the stream driver generates; a woven class is marked with `Go\Aop\Proxy`. Property access, introductions, initialization join points and prebuilt caches stay with the stream driver and are refused with `Go\Aop\Exception\UnsupportedJoinpointException`. Requires `ext-ffi`, the z-engine branch of the running PHP minor and the opcache JIT off (on PHP 8.5 the driver switches the tracing JIT off at runtime, it miscompiles the framework). See `docs/zengine-driver.md`. * [Feature] **First-class callable advices** — the main way advices are now wired into woven code. Generated proxies declare each aspect-method advice as `Interceptor::before(The::aspect(MonitorAspect::class)->beforeMethodExecution(...))` — an eager first-class callable, since the interceptor list is only built while the intercepted method or hook is already executing (`LazyAdvisorAccessor` is removed). Compiled advisor cache files use the same facade in its lazy static-data form, e.g. `Interceptor::before(MonitorAspect::class, 'beforeMethodExecution')`, which returns a native PHP lazy proxy: interceptor construction, aspect resolution from the container and advice callable creation are all deferred until the advice is actually used, so cached advices whose pointcut never matches never instantiate their aspect. Advices registered in the container as plain closures (not aspect methods) are resolved through the new `The::advice('advisorId')` accessor, which unwraps `Advisor` and interceptor values down to the raw advice closure. * [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses. * [Feature] **Property interception with native PHP 8.4 property hooks** — `access(...)` pointcuts intercept reads and writes of public, protected and private instance properties, including constructor-promoted properties and properties declared in traits. The proxy re-declares every intercepted property with `get`/`set` hooks that dispatch through `FieldAccess` joinpoints (by reference for arrays, so indirect modification keeps working); asymmetric visibility (`private(set)`, `protected(set)`) and property attributes are preserved. Readonly, static and already-hooked properties are never intercepted, see `docs/php84-limitations.md`.