From e1a51e0538bd19ef2756f4f5903981e908fceac5 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 10 Oct 2026 01:08:42 +0000 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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`. From 86d7fbb734233951069216f18b1471d395d50413 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:32:55 +0000 Subject: [PATCH 11/19] refactor(core)!: weave through a WeavingDriver plugin and move the z-engine driver into packages/ffi-driver The `driver` kernel option names the class of a `Go\Driver\WeavingDriver`: the kernel instantiates it, registers it in the container and boots it once; a driver running the transformer chain implements `Go\Driver\SourceTransformingDriver`, for which alone the kernel registers the transformer services and the cache warmer builds a cache. The stream filter driver stays the built-in default (`Go\Driver\StreamFilter\StreamFilterDriver`), `SourceTransformingLoader::transformSource()` is the shared entry point of the source-transforming drivers. The z-engine driver becomes the ffi driver of its own package (`packages/ffi-driver`, `Go\Driver\Ffi`): the donor is a trait named `OriginalTrait` (the suffix of the trait codegen; `self::class` and `parent::` then bind at run time, so the joinpoint reference hook leaves the core), functions are proxied from the registered pointcuts through `AdvisorIndex` (no feature flag), the JIT guard switches an active JIT off on any PHP version, exceptions use named constructors of the package's own classes. The package has its own tests, composer.json and phpstan config; the root `composer test` excludes the `ffi-driver` testsuite, `composer test:ffi` / `composer analyze:ffi` run it. CI: the path-triggered `ffi-driver.yml` installs `lisachenko/z-engine` plainly (no branches, no vcs repositories), the coverage job no longer touches z-engine. Docs per driver live in `docs/driver/`, the option is documented as a class name, the demo is back to master's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/ffi-driver.yml | 85 +++++++++ .github/workflows/phpunit.yml | 17 +- .github/workflows/zengine.yml | 81 -------- .php-cs-fixer.dist.php | 1 + AGENTS.md | 10 +- CHANGELOG.md | 2 +- README.md | 19 +- codecov.yml | 6 + composer.json | 22 ++- docs/configuration.md | 5 +- docs/driver/README.md | 67 +++++++ docs/driver/ffi.md | 180 ++++++++++++++++++ docs/driver/stream-filter.md | 61 ++++++ docs/driver/stream-wrapper.md | 51 +++++ docs/zengine-driver.md | 154 --------------- packages/ffi-driver/AGENTS.md | 56 ++++++ packages/ffi-driver/README.md | 31 +++ packages/ffi-driver/composer.json | 43 +++++ packages/ffi-driver/phpstan-baseline.php | 13 ++ packages/ffi-driver/phpstan.neon | 15 ++ .../ffi-driver/src/ClassWeaver.php | 74 ++++--- .../ffi-driver/src}/DonorCacheIndex.php | 36 ++-- .../ffi-driver/src/DonorTraitGenerator.php | 112 ++++------- .../EngineConfigurationException.php | 55 ++++++ .../src/Exception/FfiWeavingException.php | 48 +++++ .../UnsupportedJoinpointException.php | 50 +++++ .../ffi-driver/src/FfiComposerLoader.php | 12 +- packages/ffi-driver/src/FfiDriver.php | 148 ++++++++++++++ packages/ffi-driver/src/JitGuard.php | 88 +++++++++ .../ffi-driver/src}/MethodTableRewriter.php | 10 +- .../ffi-driver/src}/WeaveOutcome.php | 4 +- .../ffi-driver/src}/WeaveResult.php | 4 +- .../ffi-driver/tests}/DonorCacheIndexTest.php | 31 ++- .../tests/DonorTraitGeneratorTest.php | 68 +++---- .../UnsupportedJoinpointExceptionTest.php | 6 +- .../tests/FfiComposerLoaderTest.php | 42 ++-- packages/ffi-driver/tests/FfiDriverTest.php | 98 ++++++++++ .../Functional/FfiFunctionalTestCase.php | 26 +-- .../tests/Functional/FfiWeavingTest.php | 124 ++++++------ .../InProcess/ClassWeaverInProcessTest.php | 118 ++++++------ .../InProcess/FfiDriverInProcessTest.php | 35 ++-- .../tests/InProcess/InProcessTestCase.php | 34 ++-- .../MethodTableRewriterInProcessTest.php | 18 +- .../ffi-driver/tests}/JitGuardTest.php | 44 ++--- .../tests/Stubs}/FallbackTarget.php | 2 +- .../tests/Stubs}/FunctionTarget.php | 2 +- .../tests/Stubs}/InProcessAspect.php | 16 +- .../tests/Stubs}/InProcessKernel.php | 4 +- .../ffi-driver/tests/Stubs}/LateTarget.php | 4 +- .../tests/Stubs}/MatcherErrorAdvisor.php | 2 +- .../tests/Stubs}/MatcherErrorAspect.php | 2 +- .../tests/Stubs}/MatcherErrorTarget.php | 2 +- .../ffi-driver/tests/Stubs}/ReadonlyPoint.php | 2 +- .../ffi-driver/tests/Stubs}/RewriterDonor.php | 2 +- .../tests/Stubs}/RewriterTarget.php | 2 +- .../tests/Stubs}/SelfMatchingAspect.php | 4 +- .../tests/Stubs}/UnadvisedTarget.php | 2 +- .../tests/Stubs}/UnsupportedAspect.php | 6 +- .../tests/Stubs}/UnsupportedTarget.php | 2 +- .../ffi-driver/tests/Stubs}/WarmTarget.php | 2 +- .../ffi-driver/tests/Stubs}/WovenChild.php | 2 +- .../ffi-driver/tests/Stubs}/WovenParent.php | 2 +- .../ffi-driver/tests/Stubs}/WovenTarget.php | 2 +- .../ffi-driver/tests/Stubs}/broken-source.txt | 0 phpstan-baseline-strict.php | 6 - phpstan.neon | 6 +- phpstan/zengine-stubs.php | 56 ------ phpunit.xml.dist | 21 +- .../InvalidConfigurationException.php | 33 ++++ .../UnsupportedJoinpointException.php | 53 ------ src/Aop/Pointcut/PointcutParseTable.php | 5 +- src/Aop/WeavingDriver.php | 62 ------ src/Core/AspectContainer.php | 6 - src/Core/AspectKernel.php | 62 +++--- src/Driver/SourceTransformingDriver.php | 24 +++ .../StreamFilter/StreamFilterDriver.php | 33 ++++ src/Driver/WeavingDriver.php | 46 +++++ src/Instrument/AGENTS.md | 14 +- .../ClassLoading/AopComposerLoader.php | 4 +- .../ClassLoading/CachePathManager.php | 6 +- src/Instrument/ClassLoading/CacheWarmer.php | 12 +- .../ClassLoading/ComposerLoaderDecorator.php | 8 +- .../ClassLoading/SourceTransformingLoader.php | 66 ++++--- src/Instrument/ZEngine/JitGuard.php | 115 ----------- src/Instrument/ZEngine/ZEngineDriver.php | 129 ------------- src/Proxy/AGENTS.md | 10 +- src/Proxy/ClassProxyGenerator.php | 16 +- src/Proxy/Generator/TypeGenerator.php | 4 +- tests/AGENTS.md | 20 +- tests/Aop/Exception/ExceptionMessagesTest.php | 29 +++ tests/Aop/WeavingDriverTest.php | 59 ------ tests/Core/AspectKernelTest.php | 56 +++--- tests/Core/Cache/CachedAspectLoaderTest.php | 4 +- tests/Core/ContainerTest.php | 4 +- ...zengine-weaving.php => driver-weaving.php} | 73 +++---- .../{ZEngineChild.php => FfiChild.php} | 4 +- ...{ZEngineFunctions.php => FfiFunctions.php} | 4 +- .../{ZEngineLate.php => FfiLate.php} | 4 +- .../{ZEngineParent.php => FfiParent.php} | 4 +- ...{ZEngineUnadvised.php => FfiUnadvised.php} | 4 +- ...gineUnsupported.php => FfiUnsupported.php} | 4 +- .../{ZEngineAspect.php => FfiAspect.php} | 12 +- ...tedAspect.php => FfiUnsupportedAspect.php} | 6 +- .../project/src/Kernel/FfiAspectKernel.php | 19 ++ .../src/Kernel/FfiUnsupportedAspectKernel.php | 21 ++ .../src/Kernel/ZEngineAspectKernel.php | 19 -- .../Kernel/ZEngineUnsupportedAspectKernel.php | 21 -- tests/Fixtures/project/web/configuration.php | 83 +++++--- tests/Functional/BaseFunctionalTestCase.php | 7 +- .../ClassLoading/AopComposerLoaderTest.php | 32 +++- .../ClassLoading/CachePathManagerTest.php | 6 +- .../ClassLoading/CacheWarmerTest.php | 15 +- .../SourceTransformingLoaderTest.php | 4 +- .../Instrument/ZEngine/ZEngineDriverTest.php | 66 ------- .../TransformationPerformanceTest.php | 4 +- tests/Stubs/Driver/RewiringDriver.php | 22 +++ 116 files changed, 2071 insertions(+), 1598 deletions(-) create mode 100644 .github/workflows/ffi-driver.yml delete mode 100644 .github/workflows/zengine.yml create mode 100644 codecov.yml create mode 100644 docs/driver/README.md create mode 100644 docs/driver/ffi.md create mode 100644 docs/driver/stream-filter.md create mode 100644 docs/driver/stream-wrapper.md delete mode 100644 docs/zengine-driver.md create mode 100644 packages/ffi-driver/AGENTS.md create mode 100644 packages/ffi-driver/README.md create mode 100644 packages/ffi-driver/composer.json create mode 100644 packages/ffi-driver/phpstan-baseline.php create mode 100644 packages/ffi-driver/phpstan.neon rename src/Instrument/ZEngine/ZEngineClassWeaver.php => packages/ffi-driver/src/ClassWeaver.php (88%) rename {src/Instrument/ZEngine => packages/ffi-driver/src}/DonorCacheIndex.php (88%) rename src/Proxy/DonorClassGenerator.php => packages/ffi-driver/src/DonorTraitGenerator.php (53%) create mode 100644 packages/ffi-driver/src/Exception/EngineConfigurationException.php create mode 100644 packages/ffi-driver/src/Exception/FfiWeavingException.php create mode 100644 packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php rename src/Instrument/ClassLoading/ZEngineComposerLoader.php => packages/ffi-driver/src/FfiComposerLoader.php (91%) create mode 100644 packages/ffi-driver/src/FfiDriver.php create mode 100644 packages/ffi-driver/src/JitGuard.php rename {src/Instrument/ZEngine => packages/ffi-driver/src}/MethodTableRewriter.php (94%) rename {src/Instrument/ZEngine => packages/ffi-driver/src}/WeaveOutcome.php (88%) rename {src/Instrument/ZEngine => packages/ffi-driver/src}/WeaveResult.php (94%) rename {tests/Instrument/ZEngine => packages/ffi-driver/tests}/DonorCacheIndexTest.php (87%) rename tests/Proxy/DonorClassGeneratorTest.php => packages/ffi-driver/tests/DonorTraitGeneratorTest.php (71%) rename {tests/Aop => packages/ffi-driver/tests}/Exception/UnsupportedJoinpointExceptionTest.php (82%) rename tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php => packages/ffi-driver/tests/FfiComposerLoaderTest.php (75%) create mode 100644 packages/ffi-driver/tests/FfiDriverTest.php rename tests/Functional/ZEngine/ZEngineFunctionalTestCase.php => packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php (86%) rename tests/Functional/ZEngine/ZEngineWeavingTest.php => packages/ffi-driver/tests/Functional/FfiWeavingTest.php (61%) rename tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php => packages/ffi-driver/tests/InProcess/ClassWeaverInProcessTest.php (82%) rename tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php => packages/ffi-driver/tests/InProcess/FfiDriverInProcessTest.php (54%) rename tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php => packages/ffi-driver/tests/InProcess/InProcessTestCase.php (82%) rename {tests/Instrument/ZEngine => packages/ffi-driver/tests}/InProcess/MethodTableRewriterInProcessTest.php (77%) rename {tests/Instrument/ZEngine => packages/ffi-driver/tests}/JitGuardTest.php (64%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/FallbackTarget.php (88%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/FunctionTarget.php (95%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/InProcessAspect.php (59%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/InProcessKernel.php (83%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/LateTarget.php (76%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/MatcherErrorAdvisor.php (98%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/MatcherErrorAspect.php (90%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/MatcherErrorTarget.php (87%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/ReadonlyPoint.php (92%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/RewriterDonor.php (86%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/RewriterTarget.php (81%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/SelfMatchingAspect.php (78%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/UnadvisedTarget.php (81%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/UnsupportedAspect.php (56%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/UnsupportedTarget.php (75%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/WarmTarget.php (88%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/WovenChild.php (87%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/WovenParent.php (88%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/WovenTarget.php (90%) rename {tests/Stubs/ZEngine => packages/ffi-driver/tests/Stubs}/broken-source.txt (100%) delete mode 100644 phpstan/zengine-stubs.php delete mode 100644 src/Aop/Exception/UnsupportedJoinpointException.php delete mode 100644 src/Aop/WeavingDriver.php create mode 100644 src/Driver/SourceTransformingDriver.php create mode 100644 src/Driver/StreamFilter/StreamFilterDriver.php create mode 100644 src/Driver/WeavingDriver.php delete mode 100644 src/Instrument/ZEngine/JitGuard.php delete mode 100644 src/Instrument/ZEngine/ZEngineDriver.php delete mode 100644 tests/Aop/WeavingDriverTest.php rename tests/Fixtures/project/bin/{zengine-weaving.php => driver-weaving.php} (65%) rename tests/Fixtures/project/src/Application/{ZEngineChild.php => FfiChild.php} (79%) rename tests/Fixtures/project/src/Application/{ZEngineFunctions.php => FfiFunctions.php} (77%) rename tests/Fixtures/project/src/Application/{ZEngineLate.php => FfiLate.php} (82%) rename tests/Fixtures/project/src/Application/{ZEngineParent.php => FfiParent.php} (81%) rename tests/Fixtures/project/src/Application/{ZEngineUnadvised.php => FfiUnadvised.php} (54%) rename tests/Fixtures/project/src/Application/{ZEngineUnsupported.php => FfiUnsupported.php} (64%) rename tests/Fixtures/project/src/Aspect/{ZEngineAspect.php => FfiAspect.php} (79%) rename tests/Fixtures/project/src/Aspect/{ZEngineUnsupportedAspect.php => FfiUnsupportedAspect.php} (67%) create mode 100644 tests/Fixtures/project/src/Kernel/FfiAspectKernel.php create mode 100644 tests/Fixtures/project/src/Kernel/FfiUnsupportedAspectKernel.php delete mode 100644 tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php delete mode 100644 tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php delete mode 100644 tests/Instrument/ZEngine/ZEngineDriverTest.php create mode 100644 tests/Stubs/Driver/RewiringDriver.php diff --git a/.github/workflows/ffi-driver.yml b/.github/workflows/ffi-driver.yml new file mode 100644 index 00000000..04660371 --- /dev/null +++ b/.github/workflows/ffi-driver.yml @@ -0,0 +1,85 @@ +name: "ffi weaving driver" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/ffi-driver/**" + - "src/Driver/**" + - ".github/workflows/ffi-driver.yml" + - "composer.json" + push: + branches: + - master + +jobs: + ffi: + name: "ffi driver (PHP ${{ matrix.php-version }})" + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - php-version: "8.4" + coverage: "xdebug" + - php-version: "8.5" + coverage: "none" + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: shivammathur/setup-php@v2 + with: + coverage: "${{ matrix.coverage }}" + php-version: "${{ matrix.php-version }}" + extensions: ffi, opcache + ini-values: memory_limit=-1, ffi.enable=1, opcache.enable=1 + tools: composer:v2 + + - name: "Cache dependencies" + uses: actions/cache@v6 + with: + path: | + ~/.composer/cache + vendor + key: "composer-ffi-${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}" + restore-keys: "composer-ffi-${{ runner.os }}-php-${{ matrix.php-version }}-" + + - name: "Install dependencies" + run: "composer update --no-interaction --no-progress" + + # Optional engine of the driver: packagist resolves the release matching the PHP minor + - name: "Install lisachenko/z-engine" + run: "composer require --dev --no-interaction --no-progress lisachenko/z-engine" + + - name: "Static analysis of the driver package" + run: "composer analyze:ffi" + + - name: "ffi driver tests with coverage" + if: ${{ matrix.coverage == 'xdebug' }} + run: "composer test:ffi -- --coverage-clover build/logs/clover.xml" + + - name: "ffi driver tests" + if: ${{ matrix.coverage != 'xdebug' }} + run: "composer test:ffi" + + - name: "Default test suite with z-engine installed" + run: "composer test" + + - name: "Upload coverage to Codecov" + if: ${{ matrix.coverage == 'xdebug' }} + uses: codecov/codecov-action@v7 + with: + files: build/logs/clover.xml + fail_ci_if_error: false + slug: goaop/framework + token: ${{ secrets.CODECOV_TOKEN }} + flags: ffi-driver diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 72410e95..cd4ea052 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -99,10 +99,8 @@ jobs: with: coverage: "xdebug" php-version: "8.4" - # 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 + ini-values: memory_limit=-1, ffi.enable=1, opcache.enable=1 tools: composer:v2 - name: "Cache dependencies" @@ -114,17 +112,11 @@ 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 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:8.4.x-dev - composer update --no-interaction --no-progress + - name: "Install highest dependencies" + run: "composer update --no-interaction --no-progress" - name: "Tests with coverage" - # 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" + run: "vendor/bin/phpunit --exclude-group performance --exclude-group transformation-performance --exclude-testsuite ffi-driver --coverage-clover build/logs/clover.xml" - name: "Upload coverage to Codecov" uses: "codecov/codecov-action@v7" @@ -133,6 +125,7 @@ jobs: fail_ci_if_error: false slug: goaop/framework token: ${{ secrets.CODECOV_TOKEN }} + flags: framework demos: name: "Demos smoke test" diff --git a/.github/workflows/zengine.yml b/.github/workflows/zengine.yml deleted file mode 100644 index e83c5e64..00000000 --- a/.github/workflows/zengine.yml +++ /dev/null @@ -1,81 +0,0 @@ -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, 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)" - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - include: - - php-version: "8.4" - zengine-branch: "8.4.x-dev" - - php-version: "8.5" - zengine-branch: "8.5.x-dev" - env: - ZENGINE_BRANCH: ${{ matrix.zengine-branch }} - 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') }}-${{ matrix.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/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 71edf4ae..2d2a1cb0 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -11,6 +11,7 @@ __DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/demos', + __DIR__ . '/packages', ]) // Same "source shape is the test subject" exclusions as phpstan.neon: // weaving-input fixtures/stubs and their generated woven/proxy snapshots diff --git a/AGENTS.md b/AGENTS.md index 1871cba0..decc3433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,21 +22,23 @@ AOP via source transformation at load time (stream filter, no PECL, no eval). ## Architecture overview Intercepts PHP class loading pipeline: source stream filter transforms source → injects interception hooks → caches result. -- Init: AspectKernel::init() → stream filter → transformers → configureAop() +- Init: AspectKernel::init() → transformer services (source-transforming drivers only) → driver boot (stream filter by default) → 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 +- Weaving drivers (`driver` kernel option = class-string, validated with class_exists/is_a): AspectKernel::init() instantiates it, `$container->add(WeavingDriver::class, $driver)`, registers the transformer services only for a Go\Driver\SourceTransformingDriver, then `$driver->boot($kernel, $container)`. Built-in default Go\Driver\StreamFilter\StreamFilterDriver (AopComposerLoader + php://filter); other drivers are packages in `packages/` (goaop/stream-wrapper-driver → Go\Driver\StreamWrapper\StreamWrapperDriver, goaop/ffi-driver → Go\Driver\Ffi\FfiDriver). CacheWarmer refuses a driver that is no SourceTransformingDriver. docs/driver/ ## Directory → AGENTS.md map | Directory | Sub-AGENTS.md | Covers | |-------------------|----------------------------|---------------------------------------------------------------| -| `src/Instrument/` | `src/Instrument/AGENTS.md` | Init flow, transformers, trait engine, line numbers, Override, zengine driver | +| `src/Instrument/` | `src/Instrument/AGENTS.md` | Init flow, transformers, trait engine, line numbers, Override, weaving drivers | | `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/Aop/Exception/` | `src/Aop/Exception/AGENTS.md` | Exception groups, static constructors | | `src/Core/` | `src/Core/AGENTS.md` | Container, aspect loading, advice matching, bridge | | `src/Core/Cache/` | `src/Core/Cache/AGENTS.md` | Compiled advisor cache: compiler, printer, writer, loader | | `tests/` | `tests/AGENTS.md` | Test conventions, fixtures, PHPUnit, PHPStan | +| `packages/stream-wrapper-driver/` | `packages/stream-wrapper-driver/AGENTS.md` | goaop/stream-wrapper-driver: `file://` stream wrapper driver | +| `packages/ffi-driver/` | `packages/ffi-driver/AGENTS.md` | goaop/ffi-driver: z-engine (FFI) runtime weaving driver, donor traits, JIT guard | ## Rules - Skip explanations unless asked. Show changes, not commentary. @@ -48,5 +50,5 @@ Intercepts PHP class loading pipeline: source stream filter transforms source - Throwing or adding an exception → read `src/Aop/Exception/AGENTS.md` first. - 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 (one leg per PHP minor, each with the z-engine branch of that minor). +- `packages/*` are separate composer packages (own composer.json and AGENTS.md), mapped by the root autoload-dev; never move their code into src/. The ffi package needs lisachenko/z-engine: never in the root require/require-dev, installed only by .github/workflows/ffi-driver.yml (`composer require --dev lisachenko/z-engine`, no vcs repo, no branch); its tests run with `composer test:ffi` (ffi on, JIT off, self-skip without the engine) and its analysis with `composer analyze:ffi`; the default `composer test` excludes the `ffi-driver` testsuite. - 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 0fa95166..afc9ada0 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, `psr/container` ^1.1 || ^2.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, 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] **Weaving drivers** — the new `driver` kernel option names the class that decides how advised classes are woven (`class-string`); aspects, pointcuts, the advisor cache and the join-point runtime are shared by every driver. `AspectKernel::init()` instantiates the driver, registers it in the container under `Go\Driver\WeavingDriver` and boots it once; a driver weaving through the transformer chain implements the marker `Go\Driver\SourceTransformingDriver`, for which alone the kernel registers the transformer services and the cache warmer (`cache:warmup:aop`, `debug:weaving`) builds the cache ahead of time (any other driver is refused with `InvalidConfigurationException`). The built-in default is `Go\Driver\StreamFilter\StreamFilterDriver` (source transformation at load time through the `php://filter` stream filter behind Composer's loader). Two more drivers live in packages of their own under `packages/`: `goaop/stream-wrapper-driver` (`Go\Driver\StreamWrapper\StreamWrapperDriver`, a `file://` stream wrapper serving every included file of the weaving scope transformed through `SourceTransformingLoader::transformSource()`, so includes are always intercepted and opcache caches the woven sources) and the experimental `goaop/ffi-driver` (`Go\Driver\Ffi\FfiDriver`, runtime method-table weaving through [lisachenko/z-engine](https://github.com/lisachenko/z-engine): classes load natively, advised method bodies are swapped in place with the dispatchers of a cached donor trait while the original body stays callable under `OriginalAlias`, method execution and function join points only, everything else refused with `Go\Driver\Ffi\Exception\UnsupportedJoinpointException`; requires `lisachenko/z-engine` with `ext-ffi` and the opcache JIT off). See `docs/driver/`. * [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 95661283..d49b53bd 100644 --- a/README.md +++ b/README.md @@ -343,21 +343,26 @@ use Go\Core\AspectContainer; ### Optional configurations -#### Runtime weaving with z-engine (experimental) +#### Weaving drivers -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. +The `driver` kernel option names the class that decides how advised classes are woven; everything else (aspects, +pointcuts, the advisor cache, the join-point API) is shared: ```php $applicationAspectKernel->init([ - 'driver' => 'zengine', + 'driver' => Go\Driver\StreamFilter\StreamFilterDriver::class, // the default, may be omitted '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). +| Driver | Class | +|--------|-------| +| [Stream filter](docs/driver/stream-filter.md) (default, built in) | `Go\Driver\StreamFilter\StreamFilterDriver` — source transformation at load time through a `php://filter` stream filter behind Composer's loader | +| [Stream wrapper](docs/driver/stream-wrapper.md) (`composer require goaop/stream-wrapper-driver`) | `Go\Driver\StreamWrapper\StreamWrapperDriver` — source transformation of every included file through a `file://` stream wrapper; includes are always intercepted, opcache caches the woven sources | +| [ffi](docs/driver/ffi.md) (`composer require goaop/ffi-driver`, experimental) | `Go\Driver\Ffi\FfiDriver` — runtime rewiring of loaded classes through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution join points only; needs `ext-ffi` and the opcache JIT off | + +See [docs/driver/](docs/driver/README.md) for the driver interface and how to write one. #### Weaving Doctrine entities @@ -401,6 +406,8 @@ Documentation - [Configuration and deployment](docs/configuration.md): kernel options, `Features` flags, the `bin/aspect` console commands, the production deployment recipe and custom transformers or node rewriters +- [Weaving drivers](docs/driver/README.md): the [stream filter](docs/driver/stream-filter.md) (default), + [stream wrapper](docs/driver/stream-wrapper.md) and [ffi](docs/driver/ffi.md) drivers, the driver interface - [Pointcut syntax](docs/pointcuts.md): every pointcut type, wildcards, modifiers, return types and operators - [Advices](docs/advices.md): advice types, joinpoints, property interception, introductions, function and object-creation interception diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..2d76692f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +flag_management: + default_rules: + carryforward: true + individual_flags: + - name: framework + - name: ffi-driver diff --git a/composer.json b/composer.json index 86513e15..e1a0e88a 100644 --- a/composer.json +++ b/composer.json @@ -54,9 +54,7 @@ }, "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).", - "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" + "symfony/console": "Enables the bin/aspect command-line tool (^7.4 || ^8.0)." }, "autoload": { "psr-4": { @@ -67,7 +65,15 @@ "psr-4": { "Demo\\": "demos/Demo/", "Go\\": "tests/", - "Go\\Tests\\TestProject\\": "tests/Fixtures/project/src/" + "Go\\Tests\\TestProject\\": "tests/Fixtures/project/src/", + "Go\\Driver\\Ffi\\": [ + "packages/ffi-driver/src/", + "packages/ffi-driver/tests/" + ], + "Go\\Driver\\StreamWrapper\\": [ + "packages/stream-wrapper-driver/src/", + "packages/stream-wrapper-driver/tests/" + ] }, "files": [ "tests/functions.php", @@ -94,6 +100,7 @@ }, "scripts": { "analyze": "phpstan analyze --memory-limit=512M", + "analyze:ffi": "phpstan analyze -c packages/ffi-driver/phpstan.neon --memory-limit=512M", "check": [ "@cs", "@analyze", @@ -103,11 +110,11 @@ "cs:fix": "php-cs-fixer fix", "test": [ "@putenv ZENGINE_AUTOBOOT=0", - "phpunit" + "phpunit --exclude-testsuite ffi-driver" ], "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", + "test:ffi": "@php -d ffi.enable=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit --testsuite ffi-driver", "regenerate:parse-table": [ "dissect Go/Aop/Pointcut/PointcutGrammar", "@php -r \"rename('src/Aop/Pointcut/parse_table.php', 'src/Aop/Pointcut/PointcutParseTable.php');\"" @@ -115,6 +122,7 @@ }, "scripts-descriptions": { "analyze": "Run PHPStan static analysis (level 10)", + "analyze:ffi": "Run PHPStan on the ffi driver package (needs lisachenko/z-engine installed)", "check": "Run coding-standards check, static analysis and the test suite", "cs": "Check coding standards (PER-CS, php-cs-fixer) without changing files", "cs:fix": "Fix coding-standards violations (PER-CS, php-cs-fixer)", @@ -122,6 +130,6 @@ "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:zengine": "Run the z-engine weaving driver group (needs lisachenko/z-engine and ext-ffi, JIT disabled)" + "test:ffi": "Run the ffi driver package tests (needs lisachenko/z-engine and ext-ffi, JIT disabled)" } } diff --git a/docs/configuration.md b/docs/configuration.md index 98f9f2f8..9f57d6c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,8 +13,8 @@ ApplicationAspectKernel::getInstance()->init([ ]); ``` -Only the options below are accepted. An unknown key, an unknown `containerClass` or an unknown feature -bit throws `Go\Aop\Exception\InvalidConfigurationException`. +Only the options below are accepted. An unknown key, an unknown `containerClass` or `driver` class or an unknown +feature bit throws `Go\Aop\Exception\InvalidConfigurationException`. | Option | Type | Default | Purpose | |------------------|------------|-----------------------------|---------| @@ -26,6 +26,7 @@ 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` (a PSR-11 `ContainerInterface`). | +| `driver` | `class-string` | `Go\Driver\StreamFilter\StreamFilterDriver::class` | Class of the weaving driver, which decides how advised classes are woven: the built-in stream filter driver, `Go\Driver\StreamWrapper\StreamWrapperDriver` (`goaop/stream-wrapper-driver`) or `Go\Driver\Ffi\FfiDriver` (`goaop/ffi-driver`, experimental). See [docs/driver/](driver/README.md). | ## Features diff --git a/docs/driver/README.md b/docs/driver/README.md new file mode 100644 index 00000000..4e29ba6d --- /dev/null +++ b/docs/driver/README.md @@ -0,0 +1,67 @@ +# Weaving drivers + +A weaving driver decides *how* the classes of an application get their advices woven in. Everything else is shared: +aspects, pointcuts, the advice matcher, the advisor cache and the join-point runtime. The `driver` kernel option names +the class of the driver (see [configuration](../configuration.md)): + +```php +ApplicationAspectKernel::getInstance()->init([ + 'driver' => Go\Driver\StreamFilter\StreamFilterDriver::class, // the default + 'cacheDir' => __DIR__ . '/../var/cache/aop', + 'includePaths' => [__DIR__ . '/../src'], +]); +``` + +| Driver | Package | Weaves by | +|--------|---------|-----------| +| [`Go\Driver\StreamFilter\StreamFilterDriver`](stream-filter.md) (default) | `goaop/framework` (built in) | Source transformation of advised files at load time through a `php://filter` stream filter behind composer's loader. Pure PHP. | +| [`Go\Driver\StreamWrapper\StreamWrapperDriver`](stream-wrapper.md) | `goaop/stream-wrapper-driver` | Source transformation of every included file within the weaving scope through a `file://` stream wrapper; includes are always intercepted, opcache caches the woven sources. Pure PHP. | +| [`Go\Driver\Ffi\FfiDriver`](ffi.md) (experimental) | `goaop/ffi-driver` | Runtime rewiring of the methods of a loaded class through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution join points only. | + +An option value that is not an existing class, or a class that does not implement `Go\Driver\WeavingDriver`, throws +`Go\Aop\Exception\InvalidConfigurationException` from `init()`. + +## The interface + +```php +namespace Go\Driver; + +interface WeavingDriver +{ + public function boot(AspectKernel $kernel, AspectContainer $container): void; +} + +interface SourceTransformingDriver extends WeavingDriver {} +``` + +`AspectKernel::init()` instantiates the configured class without arguments, registers the instance in the container +under `Go\Driver\WeavingDriver` and calls `boot()` once: the kernel options are normalized, the framework services are +registered, no aspect is loaded yet and `configureAop()` runs right after. In `boot()` the driver registers the +services it needs and takes over class loading, e.g. by wrapping composer's loader (implement +`Go\Instrument\ClassLoading\ComposerLoaderDecorator`, so tools unwrap it and a later kernel wraps the original loader +again), registering a stream filter or wrapper, or hooking the engine. + +A driver that weaves by running sources through the transformer chain +(`AspectKernel::registerTransformerServices()`) implements the marker `SourceTransformingDriver`: + +- the kernel registers the transformer services for such a driver only, +- the one entry point `Go\Instrument\ClassLoading\SourceTransformingLoader::transformSource($uri, $source, $stream)` + returns the source the way the application must see it (cache hit, transformer chain on a miss, untouched source + without a usable cache directory), +- the cache warmer (`cache:warmup:aop`, `debug:weaving`) can build its cache ahead of time; for any other driver it + throws `InvalidConfigurationException` (the warmer requires a source-transforming driver). + +## Writing a driver + +1. Create a package with a `final class ... implements WeavingDriver` (or `SourceTransformingDriver` when the + transformer chain does the weaving) and a parameterless constructor. +2. In `boot()`, read the normalized options from `$kernel->getOptions()`, refuse what the driver cannot honour with an + `InvalidConfigurationException` (the ffi driver refuses `Features::INTERCEPT_INCLUDES`), register the driver's + services with `$container->addLazyService()` and hook into class loading. +3. Keep the driver's own cache files below `cacheDir`, separate from the stream filter driver's + `_transformation.cache` (its records mean something to that driver), and decide freshness with + `CachePathManager::isFreshRecord()`, the one shared rule. +4. Mark woven classes with `Go\Aop\Proxy` where that is possible, so `instanceof Proxy` answers alike under every + driver. + +The two drivers in `packages/` of the framework repository are the reference implementations. diff --git a/docs/driver/ffi.md b/docs/driver/ffi.md new file mode 100644 index 00000000..b5ebe778 --- /dev/null +++ b/docs/driver/ffi.md @@ -0,0 +1,180 @@ +# The ffi weaving driver (experimental) + +Package `goaop/ffi-driver`, class `Go\Driver\Ffi\FfiDriver`. + +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 or +wrapper, no proxy class, no include interception. + +Everything above the driver is shared with the [other drivers](README.md): 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`. Every release of it targets one PHP minor, and composer picks the release matching the PHP + running the install: + + ```bash + composer require goaop/ffi-driver + composer require lisachenko/z-engine + ``` + + A project installed on several PHP minors constrains the engine accordingly, e.g. `"lisachenko/z-engine": "~8.4.0|~8.5.0"`. +- 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. A JIT found active is switched off + at runtime as a safety net and reported in the [boot report](#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 driver without z-engine, without FFI or with a JIT that cannot be switched off throws +`Go\Driver\Ffi\Exception\EngineConfigurationException` (a `Go\Aop\Exception\InvalidConfigurationException`) from +`init()` with the missing requirement in the message. + +## Configuration + +```php +use Go\Driver\Ffi\FfiDriver; + +ApplicationAspectKernel::getInstance()->init([ + 'driver' => FfiDriver::class, + '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 filter +driver; the installed z-engine package joins the excluded paths. The `features` option must not contain +`Features::INTERCEPT_INCLUDES`: includes are rewritten by a source transformer, and this driver never rewrites +sources (`EngineConfigurationException` from `init()`). + +## How a class is woven + +1. The driver wraps composer's class loader with `Go\Driver\Ffi\FfiComposerLoader`, 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\Driver\Ffi\ClassWeaver` matches the advisors against the class (from the parsed + source, so attributes, parameter defaults and the file's imports are seen as written; native reflection is the + fallback for a file the parser cannot read) and generates the **donor trait** `Ns\FooOriginalTrait` of `Ns\Foo` + (`Go\Driver\Ffi\DonorTraitGenerator`): a trait in the namespace of the woven class, with the imports of its file, + declaring exactly the advised methods with the dispatcher bodies a proxy class would get + (`static $__joinPoint = InterceptorInjector::forMethod(...)`). The name carries the suffix of the stream filter + driver's trait codegen (`AspectContainer::ORIGINAL_TRAIT_SUFFIX`), but the trait is never used by a class: the + engine adopts its method bodies. Compiled in a trait, `self::class` and `parent::` resolve at run time against the + woven class, so only the `self` and `parent` keywords of parameter and return types are spelled out as the classes + they name (the engine compares the donor signature with the swapped entry). + The donor file is written to `{cacheDir}/_ffi//OriginalTrait.php` and recorded in + the index `{cacheDir}/_ffi.cache` together with the advised method names. +3. `Go\Driver\Ffi\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::(...)`. +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 every driver. + +A warm request finds a fresh record in the index (same freshness rule as the stream filter 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. A class no advisor matches is recorded as well, so a warm request never matches it again. + +The index is separate from the stream filter driver's `_transformation.cache` on purpose: a record without a cache +URI means "serve natively, never weave" to that driver, so engine records would poison a cache directory later used +by it. + +### Function interception + +An `execution(Ns\function(*))` pointcut needs no flag: the driver serves it exactly the way the stream filter 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}/_ffi/_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 +filter 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 any driver: a proxy there would redeclare the internal function. + +### Classes declared before the kernel + +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\Driver\Ffi\ClassWeaver::class); +$result = $weaver->weave(App\Legacy::class); // Go\Driver\Ffi\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 `UnsupportedJoinpointException`). + +## Supported join points + +The 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), and serves +**function execution** pointcuts through namespaced proxies (see above). + +Everything else only a source-transforming driver provides, since it needs rewritten sources: + +- property access (`access(...)` pointcuts), +- introductions (interfaces and traits), +- `initialization(...)` and `staticinitialization(...)` pointcuts, +- include interception (`Features::INTERCEPT_INCLUDES`), +- traits, enums and interfaces as weaving targets (skipped, never woven). + +An advisor matching an unsupported join point kind on a class makes the first load of that class throw +`Go\Driver\Ffi\Exception\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 filter +or the stream wrapper driver. + +The exceptions of the package extend the framework's: `EngineConfigurationException` is an +`InvalidConfigurationException`, `FfiWeavingException` (donor cannot be generated, written or loaded) is a +`Go\Aop\Exception\WeavingException`, and `UnsupportedJoinpointException` is an `FfiWeavingException`. + +## Differences from the source-transforming drivers + +- The woven class is the original class: 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 + callers before the swap: those call sites keep the literal. +- A call site that ran before the function proxies of its namespace were declared keeps the global function. +- `cache:warmup:aop` and `debug:weaving` transform sources through the transformer chain and refuse a kernel + configured for this driver (`InvalidConfigurationException`: the cache warmer requires a + `Go\Driver\SourceTransformingDriver`). Warm an application running this driver by loading its classes (a smoke + request), the donor index then serves every later request. + +## Boot report + +`Go\Driver\Ffi\FfiDriver::getBootReport($container)` (container key `kernel.ffi.boot`) returns what the boot did: +the driver class, PHP version, whether opcache is active, and the JIT guard report (`jitWasActive`, `previousMode`, +`disabledAtRuntime`). + +## Development + +The package lives in `packages/ffi-driver/` of the framework repository and is mapped by the root `autoload-dev`. +Its tests form the `ffi-driver` PHPUnit testsuite, excluded from `composer test`: + +```bash +composer require --dev lisachenko/z-engine # never part of the framework's own dependencies +composer test:ffi # ffi on, JIT off +composer analyze:ffi # phpstan with packages/ffi-driver/phpstan.neon +``` + +The tests self-skip where the engine cannot boot: + +- `packages/ffi-driver/tests/Functional/` runs the probe `tests/Fixtures/project/bin/driver-weaving.php` in PHP + subprocesses (both opcache legs) with the `ffi`, `ffi_production` and `ffi_unsupported` configurations of the + fixture project and compares the reports with the stream filter driver on the same fixtures (`ffi_reference`); +- `packages/ffi-driver/tests/InProcess/` boots z-engine inside the test runner and weaves the stubs of + `packages/ffi-driver/tests/Stubs` for real, which is what gives the driver code coverage. + +`.github/workflows/ffi-driver.yml` is the only workflow installing z-engine (`composer require --dev +lisachenko/z-engine`, composer resolves the release of the runner's PHP minor) and runs the suite on PHP 8.4 and 8.5. diff --git a/docs/driver/stream-filter.md b/docs/driver/stream-filter.md new file mode 100644 index 00000000..a15600b8 --- /dev/null +++ b/docs/driver/stream-filter.md @@ -0,0 +1,61 @@ +# The stream filter driver (default) + +Built into `goaop/framework`, class `Go\Driver\StreamFilter\StreamFilterDriver`, a `Go\Driver\SourceTransformingDriver`. +Pure PHP, no extension, no further package: it is what the kernel uses when the `driver` option is omitted. + +```php +ApplicationAspectKernel::getInstance()->init([ + 'driver' => Go\Driver\StreamFilter\StreamFilterDriver::class, // may be omitted + 'debug' => false, + 'appDir' => __DIR__ . '/..', + 'cacheDir' => __DIR__ . '/../var/cache/aop', + 'includePaths' => [__DIR__ . '/../src'], +]); +``` + +## Mechanism + +1. `boot()` wraps composer's class loader with `Go\Instrument\ClassLoading\AopComposerLoader` (a + `ComposerLoaderDecorator`). Only classes loaded through composer are seen; a file `require`d directly is loaded as + is unless includes are intercepted (`Features::INTERCEPT_INCLUDES`, see [configuration](../configuration.md)). +2. For a class whose file lies within the weaving scope (`appDir`, `includePaths`, `excludePaths`), the loader + redirects the include through the `php://filter/read=go.source.transforming.loader/resource=` stream filter + (`Go\Instrument\ClassLoading\SourceTransformingLoader`). In production a class recorded as woven is included from + its cache file through composer's class map, and a class recorded as untransformed from its original path, so both + are cached by opcache; a `php://filter` include is never cached by opcache, which is why it is reserved for cache + misses and debug mode. +3. The filter calls `SourceTransformingLoader::transformSource()`: a usable cache record serves the cached woven + source without parsing anything; a miss runs the transformer chain (`SyntaxTreeRewriter` with its rules, then + `WeavingTransformer`, then your own transformers) and persists the result. +4. `WeavingTransformer` converts the original class into the trait `FooOriginalTrait` (line numbers preserved, so + breakpoints keep working) and generates the proxy class `Foo` into the cache directory: it re-inherits the parent + and the interfaces, implements `Go\Aop\Proxy`, uses the trait with private `OriginalAlias` aliases and + dispatches every advised method through a per-method `static $__joinPoint`. + +## Cache layout + +Below `cacheDir`, mirroring the application tree relative to `appDir`: for `src/Foo.php` the woven source (the +trait) is cached as `src/FooOriginalTrait.php` and the proxy class next to it as `src/Foo.php` (included from the +woven source through `AOP_CACHE_DIR`), function proxies go to `_functions/`, plus the advisor cache and the index +files `_transformation.cache` (what was transformed, with size and mtime of the source) and `_include.cache` (the +include map). The freshness rule lives in `Go\Instrument\ClassLoading\CachePathManager`. The cache is built ahead of time by +`bin/aspect cache:warmup:aop` and trusted completely with `Features::PREBUILT_CACHE`; the kernel options, the +`Features` flags, the console commands and the deployment recipe are documented in [configuration.md](../configuration.md), +the generated code and the join points in [advices.md](../advices.md). + +## Supported join points + +Every join point of the framework: method execution (instance, static, private), function execution through +namespaced proxies, property access, introductions, `initialization(...)` and `staticinitialization(...)`, enum and +trait weaving, include interception with `Features::INTERCEPT_INCLUDES`. + +## Trade-offs + +- A cache miss costs a parse and the transformer chain; a warm production cache costs nothing beyond composer's class + map. +- The woven class is a generated proxy: `get_class()` returns the original name, but the original body lives in a + trait and reflection of the class points at the cache file (`__FILE__`/`__DIR__` are rewritten to the original + paths). +- Includes are only intercepted when `Features::INTERCEPT_INCLUDES` rewrites them in woven files; code loaded by + other means than composer and the rewritten includes is never woven. The [stream wrapper driver](stream-wrapper.md) + removes that limit. diff --git a/docs/driver/stream-wrapper.md b/docs/driver/stream-wrapper.md new file mode 100644 index 00000000..a51be896 --- /dev/null +++ b/docs/driver/stream-wrapper.md @@ -0,0 +1,51 @@ +# The stream wrapper driver + +Package `goaop/stream-wrapper-driver`, class `Go\Driver\StreamWrapper\StreamWrapperDriver`, a +`Go\Driver\SourceTransformingDriver`. Pure PHP, no extension. + +```bash +composer require goaop/stream-wrapper-driver +``` + +```php +use Go\Driver\StreamWrapper\StreamWrapperDriver; + +ApplicationAspectKernel::getInstance()->init([ + 'driver' => StreamWrapperDriver::class, + 'debug' => false, + 'appDir' => __DIR__ . '/..', + 'cacheDir' => __DIR__ . '/../var/cache/aop', + 'includePaths' => [__DIR__ . '/../src'], +]); +``` + +## Mechanism + +`boot()` replaces PHP's `file://` stream wrapper (`stream_wrapper_unregister('file')`, +`stream_wrapper_register('file', AopStreamWrapper::class)`). From then on every `include`/`require` of a file within +the weaving scope (`appDir`, `includePaths`, `excludePaths`), whoever issues it (composer's loader, a `require` in +legacy code, a template engine), is served transformed through the same entry point the stream filter driver uses, +`Go\Instrument\ClassLoading\SourceTransformingLoader::transformSource()`: a usable cache record serves the cached +woven source, a miss runs the transformer chain and persists the result. Files outside the scope and every other file +operation pass through to the real file system. No composer loader wrapper is installed. + +The woven source is served under the **original path**, so opcache caches it like any other file (an include through +`php://filter`, the stream filter driver's cache-miss path, is never cached by opcache). The cache layout below +`cacheDir` and the `_transformation.cache` index are those of the [stream filter driver](stream-filter.md), and +`bin/aspect cache:warmup:aop` builds the cache ahead of time for both. + +## Supported join points + +Every join point of the stream filter driver: method execution, functions, property access, introductions, +initialization join points, enums and traits. Includes are always intercepted: the wrapper sees every include, so +`Features::INTERCEPT_INCLUDES` is implied and refused in the `features` option (`InvalidConfigurationException` from +`init()`). + +## Trade-offs + +- Every include within the scope is woven, including files that composer never loads (legacy `require` trees), with + no rewriting of the include statements. +- The price: every file operation of the process (`fopen`, `file_get_contents`, `is_file`, `stat`, directory listings, + every include outside the scope too) passes through userland PHP while the wrapper is registered. Keep + `includePaths` narrow and measure. +- Code that registers its own `file://` wrapper, or that unregisters wrappers, conflicts with the driver. diff --git a/docs/zengine-driver.md b/docs/zengine-driver.md deleted file mode 100644 index 49415333..00000000 --- a/docs/zengine-driver.md +++ /dev/null @@ -1,154 +0,0 @@ -# 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.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: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 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 - 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`, `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 - -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::(...)`. - -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: - -```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), -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, -- 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, `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 - 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, 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/packages/ffi-driver/AGENTS.md b/packages/ffi-driver/AGENTS.md new file mode 100644 index 00000000..6bb1d1e1 --- /dev/null +++ b/packages/ffi-driver/AGENTS.md @@ -0,0 +1,56 @@ +# packages/ffi-driver — goaop/ffi-driver (Go\Driver\Ffi\) + +Weaving driver of Go! AOP: runtime method-table weaving through lisachenko/z-engine (ext-ffi), `'driver' => FfiDriver::class`. +Separate composer package (own composer.json: php ~8.4 || ~8.5, ext-ffi, goaop/framework ^4.0, lisachenko/z-engine), +mapped by the root autoload-dev (`Go\Driver\Ffi\` → src/ and tests/). Docs: docs/driver/ffi.md. + +## Commands (repo root) +- `composer require --dev lisachenko/z-engine` — packagist resolves the release of the running PHP minor; never a vcs repo, never a branch, never in the root composer.json +- `composer test:ffi` — `php -d ffi.enable=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit --testsuite ffi-driver`; excluded from `composer test`; self-skips without the engine +- `composer analyze:ffi` — phpstan with packages/ffi-driver/phpstan.neon (level 10, needs z-engine installed; baseline packages/ffi-driver/phpstan-baseline.php; tests/Stubs excluded) +- CI: .github/workflows/ffi-driver.yml only (PHP 8.4 + 8.5, installs z-engine, analyze:ffi, test:ffi, then `composer test` with the engine present) + +## Boot (FfiDriver::boot(), WeavingDriver, NOT a SourceTransformingDriver → no transformer chain) +1. refuses Features::INTERCEPT_INCLUDES (EngineConfigurationException::includeInterceptionNotSupported) +2. requires z-engine (class_exists ZEngine\Core) → EngineConfigurationException::engineNotInstalled +3. JitGuard::enforce() BEFORE Core::init() (z-engine's classes must compile with the JIT off): detection via `opcache_get_status()['jit']['on']` (ini value unusable); active → `ini_set('opcache.jit', 'off')` (never 'disable', it locks the directive), still on → jitStillOn; report {jitWasActive, previousMode, disabledAtRuntime}. Process-level `opcache.jit=off` + `opcache.jit_buffer_size=0` stays the documented requirement (code compiled before the kernel keeps its JIT state) +4. Core::init() unless initialized (failure → engineBootFailed) +5. boot report in the container under `kernel.ffi.boot` (FfiDriver::BOOT_REPORT; getBootReport($container)): driver, php, opcache, jit +6. installed z-engine package joins excludePaths; lazy services DonorCacheIndex, MethodTableRewriter, ClassWeaver +7. FfiComposerLoader::init(): wraps every composer loader in spl_autoload_functions() (unwrapping any ComposerLoaderDecorator), loadClass() includes the original file natively (opcache, magic constants, line numbers untouched) then ClassWeaver::weaveLoadedClass() when the Enumerator path filter allows — the subclass that triggered the autoload links against the already woven parent + +## ClassWeaver (@internal) +- once per class per request; failures remembered and rethrown on a later call (class declared + linked but NOT woven); `weave($class)` is the public entry for classes declared before the kernel (require'd directly), weave before instances exist and before subclasses link +- weaveLoadedClass(): weaveFunctions(namespace, file) first, then doWeave(): skip internal/interface/trait/enum/outside appDir; DonorCacheIndex::findFresh() → warm: include donor + rewire, no aspects loaded; miss: ReflectionFile parse (native fallback), AdviceMatcher::getAdvicesForClass(), aspects never woven (checked after matching, #748, matcher failure on an aspect swallowed), refuseUnsupportedKinds() (only METHOD_PREFIX/STATIC_METHOD_PREFIX → UnsupportedJoinpointException::forClass), DonorTraitGenerator → write donor + recordDonor() +- functions: no flag, `AdvisorIndex::hasPointcutKind(Pointcut::KIND_FUNCTION)`; fresh record decides first (a "none" record is written when no function pointcut exists, so warm requests never load aspects); FunctionProxyGenerator output → `{cacheDir}/_ffi/_functions/.php`, recorded per namespace (file + names, fresh by tracked resources only), included right after the file declaring a class of the namespace; never twice per process (function_exists guard), never for the global namespace, unqualified calls only, call sites that already ran keep the global function + +## DonorTraitGenerator (extends ClassProxyGenerator) +- donor = TRAIT `Ns\OriginalTrait` (AspectContainer::ORIGINAL_TRAIT_SUFFIX, same name as the stream driver's woven trait, but never used by a class: the engine adopts the bodies); same namespace + original imports (ProxyImports), declares only the advised methods with the proxy dispatcher bodies (`$this->mOriginalAlias(...)` / `self::mOriginalAlias(...)` own methods, `parent::m(...)` inherited) +- in a trait `self::class`/`parent::` bind at run time to the woven class → only `self`/`parent` KEYWORDS OF SIGNATURES are spelled out (TypeGenerator::resolveScopeKeywords(), ParameterGenerator::withType(), MethodGenerator::getParameters()/setParameters(); `static` kept) because the engine compares the donor signature with the swapped entry +- file: `{cacheDir}/_ffi//OriginalTrait.php`, `declare(strict_types=1)` mirrored from the source (dispatcher strictness follows the donor file) +- non-method kinds in the constructor → FfiWeavingException::unsupportedDonorKinds + +## MethodTableRewriter (@internal, z-engine public reflection API only) +- own method: `$class->getMethod($m)->redefine($donorMethod, preserveAs: $m . AbstractMethodInvocation::TRAIT_ALIAS_SUFFIX)` ('OriginalAlias', published entry pointer / magic slots / subclass buckets / inline caches stay valid) +- inherited method: `$class->addMethod($m, $donorMethod)` (override whose dispatcher reaches `parent::m()`) +- `EngineClass::fromClassTable()` re-resolved per method (first mutation copies an opcache-shared class out of SHM, repoints the bucket); afterwards `addInterfaces(Go\Aop\Proxy)` (instanceof Proxy answers like a generated proxy) +- ReflectionException from the engine → UnsupportedJoinpointException::engineRefusal (class partially woven: methods rewired before keep dispatching) + +## DonorCacheIndex (@internal) +- `{cacheDir}/_ffi.cache` (FORMAT_VERSION 2: version, classes{source, filemtime, filesize, cachedAt, donor, donorClass, methods}, functions{file, functions, cachedAt}); freshness = CachePathManager::isFreshRecord() (the one shared rule; functions: container isFreshSince only); written via CachePathManager::writeIndexFile(), flushed at destruct (exceptions swallowed) +- separate from _transformation.cache on purpose: a record without cacheUri means "serve natively" to the stream filter driver +- donorFileFor()/functionsFileFor() need cacheDir (InvalidConfigurationException::cacheIndexWithoutCacheDir); isApplicationSource() = below appDir + +## Exceptions (src/Exception/) +- EngineConfigurationException extends Go\Aop\Exception\InvalidConfigurationException: engineNotInstalled, engineBootFailed, jitStillOn, includeInterceptionNotSupported +- FfiWeavingException extends Go\Aop\Exception\WeavingException: unsupportedDonorKinds, sourceOutsideApplicationRoot, donorNotDeclared +- UnsupportedJoinpointException extends FfiWeavingException: forClass, engineRefusal + +## Not supported (source-transforming drivers only) +property access, introductions, initialization/staticinitialization, Features::INTERCEPT_INCLUDES, traits/enums/interfaces as targets, CacheWarmer / cache:warmup:aop / debug:weaving (refused by the framework: no SourceTransformingDriver). Known deviations: reflection of an advised method reports the donor file; a single-literal private/final body may be inlined by the optimizer before the swap; a call site run before the function proxies were declared keeps the global function. + +## Tests (tests/, suite `ffi-driver`, `#[Group('ffi')]`) +- unit: DonorCacheIndexTest, DonorTraitGeneratorTest, FfiComposerLoaderTest, FfiDriverTest (closure seams of the constructor), JitGuardTest, Exception/ +- InProcess/ (InProcessTestCase): boots z-engine INSIDE the runner, kernel created without the singleton (installed for the test, restored after), autoloaders restored, weaves tests/Stubs for real — one stub class per test (a woven class stays woven); the only coverage of the driver +- Functional/ (FfiFunctionalTestCase): subprocesses of tests/Fixtures/project/bin/driver-weaving.php (`GO_AOP_CONFIGURATION` = `ffi`, `ffi_production`, `ffi_unsupported`; `ffi_reference` = same fixtures through StreamFilterDriver, reports compared; both opcache legs; `ZENGINE_AUTOBOOT=0`); fixtures Application/Ffi*, Aspect/FfiAspect, Kernel/FfiAspectKernel + FfiUnsupportedAspectKernel +- tests/Stubs: weaving inputs (excluded from phpstan) diff --git a/packages/ffi-driver/README.md b/packages/ffi-driver/README.md new file mode 100644 index 00000000..a07e62f1 --- /dev/null +++ b/packages/ffi-driver/README.md @@ -0,0 +1,31 @@ +# goaop/ffi-driver + +Experimental weaving driver of the [Go! AOP framework](https://github.com/goaop/framework) that rewires loaded +classes at runtime through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI) instead of +transforming their sources: classes load natively, the body of every advised method is swapped in place with a +generated dispatcher and the original body stays callable under a private alias. + +## Installation + +```bash +composer require goaop/ffi-driver +composer require lisachenko/z-engine +``` + +Requires `ext-ffi` (`ffi.enable=1`) and the opcache JIT off (`opcache.jit=off`, `opcache.jit_buffer_size=0`). + +## Usage + +```php +use Go\Driver\Ffi\FfiDriver; + +ApplicationAspectKernel::getInstance()->init([ + 'driver' => FfiDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aop', + 'includePaths' => [__DIR__ . '/../src'], +]); +``` + +Method execution and function join points are supported; see +[docs/driver/ffi.md](https://github.com/goaop/framework/blob/master/docs/driver/ffi.md) for the mechanism, the +limits and the development setup. diff --git a/packages/ffi-driver/composer.json b/packages/ffi-driver/composer.json new file mode 100644 index 00000000..3a674515 --- /dev/null +++ b/packages/ffi-driver/composer.json @@ -0,0 +1,43 @@ +{ + "name": "goaop/ffi-driver", + "description": "Weaving driver of the Go! AOP framework that rewires loaded classes at runtime through lisachenko/z-engine (PHP FFI).", + "license": "MIT", + "type": "library", + "keywords": [ + "aop", + "php", + "aspect", + "ffi", + "z-engine" + ], + "authors": [ + { + "name": "Lisachenko Alexander", + "homepage": "https://github.com/lisachenko" + } + ], + "require": { + "php": "~8.4.0 || ~8.5.0", + "ext-ffi": "*", + "goaop/framework": "^4.0", + "lisachenko/z-engine": "~8.4.3 || ~8.5.1" + }, + "autoload": { + "psr-4": { + "Go\\Driver\\Ffi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Go\\Driver\\Ffi\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + } +} diff --git a/packages/ffi-driver/phpstan-baseline.php b/packages/ffi-driver/phpstan-baseline.php new file mode 100644 index 00000000..d14c7f5b --- /dev/null +++ b/packages/ffi-driver/phpstan-baseline.php @@ -0,0 +1,13 @@ + '#^Go\\\\Driver\\\\Ffi\\\\DonorTraitGenerator\\:\\:__construct\\(\\) does not call parent constructor from Go\\\\Proxy\\\\ClassProxyGenerator\\.$#', + 'identifier' => 'constructor.missingParentCall', + 'count' => 1, + 'path' => __DIR__ . '/src/DonorTraitGenerator.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/packages/ffi-driver/phpstan.neon b/packages/ffi-driver/phpstan.neon new file mode 100644 index 00000000..37b9b342 --- /dev/null +++ b/packages/ffi-driver/phpstan.neon @@ -0,0 +1,15 @@ +# Static analysis of the ffi driver package: needs lisachenko/z-engine installed (`composer analyze:ffi`) +includes: + - phpstan-baseline.php + +parameters: + level: 10 + paths: + - src + - tests + strictRules: + # PHPUnit's assertions are static methods that are idiomatically called as $this->assert*() + dynamicCallOnStaticMethod: false + excludePaths: + # Weaving-input stubs woven by the in-process tests: their source shape is the test subject + - tests/Stubs diff --git a/src/Instrument/ZEngine/ZEngineClassWeaver.php b/packages/ffi-driver/src/ClassWeaver.php similarity index 88% rename from src/Instrument/ZEngine/ZEngineClassWeaver.php rename to packages/ffi-driver/src/ClassWeaver.php index c2e77cdf..5322e56b 100644 --- a/src/Instrument/ZEngine/ZEngineClassWeaver.php +++ b/packages/ffi-driver/src/ClassWeaver.php @@ -10,29 +10,28 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; 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\Aop\Pointcut; use Go\Core\AdviceMatcherInterface; +use Go\Core\AdvisorIndex; use Go\Core\AspectContainer; -use Go\Core\AspectLoaderInterface; +use Go\Driver\Ffi\Exception\FfiWeavingException; +use Go\Driver\Ffi\Exception\UnsupportedJoinpointException; 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; /** * 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 + * trait 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 @@ -43,15 +42,15 @@ * 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 + * Function interception (a registered `execution(Ns\func(*))` pointcut) 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 + * @internal Driver service, not a public extension point */ -final class ZEngineClassWeaver +final class ClassWeaver { /** * Join-point kinds the runtime rewiring supports: method bodies only @@ -86,17 +85,13 @@ final class ZEngineClassWeaver */ 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, - private readonly AspectLoaderInterface $aspectLoader, + private readonly AdvisorIndex $advisorIndex, private readonly CachePathManager $cachePathManager, private readonly DonorCacheIndex $donorCache, private readonly MethodTableRewriter $rewriter, - private readonly bool $interceptFunctions = false, ) {} /** @@ -158,8 +153,8 @@ 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 + * While a function pointcut is registered (AdvisorIndex), 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 @@ -168,7 +163,7 @@ public function weaveLoadedClass(string $className, string $file): WeaveResult * 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 + * The proxy file is generated below `{cacheDir}/_ffi/_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. @@ -181,7 +176,7 @@ public function weaveLoadedClass(string $className, string $file): WeaveResult public function weaveFunctions(string $namespace, string $file): ?string { $namespace = trim($namespace, '\\'); - if (!$this->interceptFunctions || $namespace === '') { + if ($namespace === '') { return null; } if (array_key_exists($namespace, $this->functionNamespaces)) { @@ -192,8 +187,17 @@ public function weaveFunctions(string $namespace, string $file): ?string return null; } - $record = $this->donorCache->findFreshFunctions($namespace, $this->container) - ?? $this->generateFunctionProxies($namespace, $file); + // A fresh record decides without the aspects; only a miss asks the advisor index, which loads them + $record = $this->donorCache->findFreshFunctions($namespace, $this->container); + if ($record === null) { + if (!$this->advisorIndex->hasPointcutKind(Pointcut::KIND_FUNCTION)) { + // No function pointcut registered at all: recorded, so a warm request does not ask again + $this->donorCache->recordFunctions($namespace, null, []); + + return null; + } + $record = $this->generateFunctionProxies($namespace, $file); + } if ($record['file'] === null) { return null; } @@ -224,10 +228,7 @@ private function generateFunctionProxies(string $namespace, string $file): array // 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); + $advisors = $this->advisorIndex->getAdvisors(); $advices = $this->adviceMatcher->getAdvicesForFunctions($fileNamespace, $advisors); if ($advices === []) { $this->donorCache->recordFunctions($namespace, null, []); @@ -265,7 +266,7 @@ private function doWeave(string $className, string $file): WeaveResult 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'); + return WeaveResult::skipped('interfaces, traits and enums are not supported by the ffi driver'); } if (!$this->donorCache->isApplicationSource($file)) { return WeaveResult::skipped('sources outside the application root are never woven'); @@ -281,11 +282,7 @@ private function doWeave(string $className, string $file): WeaveResult } // 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); + $advisors = $this->advisorIndex->getAdvisors(); [$class, $imports, $isStrict] = $this->reflectSource($className, $file); try { @@ -315,10 +312,10 @@ private function doWeave(string $className, string $file): WeaveResult ))); $methods = array_map(strval(...), $methods); - $generator = new DonorClassGenerator($class, $sortedAdvices, $imports, ClassProxyGenerator::indexMethods($class)); + $generator = new DonorTraitGenerator($class, $sortedAdvices, $imports, ClassProxyGenerator::indexMethods($class)); $donorCode = 'generate(); $donorFile = $this->donorCache->donorFileFor($file, $class->getShortName()); - $donorClass = $generator->getDonorClassName(); + $donorClass = $generator->getDonorTraitName(); $this->cachePathManager->getCacheFileWriter()->write($donorFile, $donorCode); $this->donorCache->recordDonor($className, $file, $donorFile, $donorClass, $methods); @@ -334,18 +331,13 @@ private function doWeave(string $className, string $file): WeaveResult */ private function apply(string $className, string $donorFile, string $donorClass, array $methods): WeaveResult { - if (!class_exists($donorClass, false)) { + if (!trait_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, - )); + if (!trait_exists($donorClass, false)) { + throw FfiWeavingException::donorNotDeclared($donorFile, $className, $donorClass); } $this->rewriter->rewire($className, $donorClass, $methods); diff --git a/src/Instrument/ZEngine/DonorCacheIndex.php b/packages/ffi-driver/src/DonorCacheIndex.php similarity index 88% rename from src/Instrument/ZEngine/DonorCacheIndex.php rename to packages/ffi-driver/src/DonorCacheIndex.php index 1b9c81b4..fd62bb03 100644 --- a/src/Instrument/ZEngine/DonorCacheIndex.php +++ b/packages/ffi-driver/src/DonorCacheIndex.php @@ -10,31 +10,31 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; use Go\Aop\Exception\InvalidConfigurationException; -use Go\Aop\Exception\WeavingException; use Go\Core\AspectContainer; use Go\Core\AspectKernel; +use Go\Driver\Ffi\Exception\FfiWeavingException; 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` + * Index of the donor traits generated by the ffi driver: `{cacheDir}/_ffi.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. + * `{cacheDir}/_ffi/`, 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()}. * - * 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 + * The function proxies of a namespace (function pointcuts) are recorded per namespace name under + * `functions`: the proxy file generated below `{cacheDir}/_ffi/_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). * @@ -42,7 +42,7 @@ * @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 + * @internal Driver service, not a public extension point */ final class DonorCacheIndex { @@ -51,11 +51,11 @@ final class DonorCacheIndex */ public const int FORMAT_VERSION = 2; - private const string INDEX_FILE_NAME = '/_zengine.cache'; + private const string INDEX_FILE_NAME = '/_ffi.cache'; - private const string DONOR_DIRECTORY = '/_zengine'; + private const string DONOR_DIRECTORY = '/_ffi'; - private const string FUNCTIONS_DIRECTORY = '/_zengine/_functions'; + private const string FUNCTIONS_DIRECTORY = '/_ffi/_functions'; private ?string $cacheDir; @@ -156,12 +156,12 @@ public function findFreshFunctions(string $namespace, AspectContainer $container /** * Returns the file the function proxies of a namespace are written to: - * `{cacheDir}/_zengine/_functions/{Namespace/Path}.php` + * `{cacheDir}/_ffi/_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'); + throw InvalidConfigurationException::cacheIndexWithoutCacheDir(self::INDEX_FILE_NAME); } return $this->cacheDir . self::FUNCTIONS_DIRECTORY . '/' . str_replace('\\', '/', trim($namespace, '\\')) . '.php'; @@ -191,26 +191,26 @@ public function isApplicationSource(string $resource): bool } /** - * Returns the file the donor class of a source file is written to: - * `{cacheDir}/_zengine/{path relative to appDir}/{Short}__AopDonor.php` + * Returns the file the donor trait of a source file is written to: + * `{cacheDir}/_ffi/{path relative to appDir}/{Short}OriginalTrait.php` * - * @throws WeavingException For a source outside the application root, see {@see isApplicationSource()} + * @throws FfiWeavingException 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'); + throw InvalidConfigurationException::cacheIndexWithoutCacheDir(self::INDEX_FILE_NAME); } $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)); + throw FfiWeavingException::sourceOutsideApplicationRoot($resource, $this->appDir); } $relativePath = ltrim($relativePath, '/\\'); $directory = str_replace('\\', '/', dirname($relativePath)); return $this->cacheDir . self::DONOR_DIRECTORY . '/' . ($directory === '.' ? '' : $directory . '/') - . $shortClassName . AspectContainer::DONOR_CLASS_SUFFIX . '.php'; + . $shortClassName . AspectContainer::ORIGINAL_TRAIT_SUFFIX . '.php'; } /** diff --git a/src/Proxy/DonorClassGenerator.php b/packages/ffi-driver/src/DonorTraitGenerator.php similarity index 53% rename from src/Proxy/DonorClassGenerator.php rename to packages/ffi-driver/src/DonorTraitGenerator.php index dd7496f9..5a5aae5d 100644 --- a/src/Proxy/DonorClassGenerator.php +++ b/packages/ffi-driver/src/DonorTraitGenerator.php @@ -10,51 +10,45 @@ * with this source code in the file LICENSE. */ -namespace Go\Proxy; +namespace Go\Driver\Ffi; -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\Driver\Ffi\Exception\FfiWeavingException; +use Go\Proxy\ClassProxyGenerator; 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 Go\Proxy\Generator\TraitGenerator; 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 + * Generates the donor trait of the ffi driver: the dispatcher bodies of the advised methods, compiled in a trait + * 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. + * The donor `Ns\FooOriginalTrait` of `Ns\Foo` is a trait with the structure (and the suffix) of the stream + * driver's trait codegen: it lives in the namespace of the woven class with the imports of its file and declares + * exactly the advised methods with the signature, attributes and doc comment of the original, since 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 gets: `$this->OriginalAlias(...)` reaches the previous body of a method the class declares + * (the engine keeps it under that private alias), `parent::(...)` the body of an inherited one, and the + * joinpoint is created for `self::class`. Compiled in a trait, none of that is bound before run time, where it + * follows the scope of the swapped entry: the woven class. Only the `self` and `parent` keywords of parameter and + * return types are spelled out as the classes they name in the woven class: the engine resolves the keywords of a + * signature against the declaring type when it compares the signatures (`static` keeps binding to the called class). * - * @see \Go\Instrument\ZEngine\MethodTableRewriter The engine side of the contract + * @see MethodTableRewriter The engine side of the contract */ -final class DonorClassGenerator extends ClassProxyGenerator +final class DonorTraitGenerator extends ClassProxyGenerator { /** - * Fully qualified name of the generated donor class + * Fully qualified name of the generated donor trait */ - private readonly string $donorClassName; + private readonly string $donorTraitName; /** * Fully qualified name of the woven class @@ -76,7 +70,7 @@ final class DonorClassGenerator extends ClassProxyGenerator * class by name, see * {@see self::indexMethods()} * - * @throws WeavingException When the advices contain a join-point kind the donor cannot carry + * @throws FfiWeavingException When the advices contain a join-point kind the donor cannot carry */ public function __construct( ReflectionClass $originalClass, @@ -89,11 +83,7 @@ public function __construct( [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)), - )); + throw FfiWeavingException::unsupportedDonorKinds($originalClass->getName(), array_values(array_map(strval(...), $unsupportedKinds))); } $this->adviceNames = $classAdviceNames; @@ -102,9 +92,9 @@ public function __construct( $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; + $namespace = $originalClass->getNamespaceName(); + $donorShortName = $originalClass->getShortName() . AspectContainer::ORIGINAL_TRAIT_SUFFIX; + $this->donorTraitName = ($namespace !== '' ? $namespace . '\\' : '') . $donorShortName; $dynamicMethodAdvices = $classAdviceNames[AspectContainer::METHOD_PREFIX] ?? []; $staticMethodAdvices = $classAdviceNames[AspectContainer::STATIC_METHOD_PREFIX] ?? []; @@ -126,42 +116,31 @@ public function __construct( $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( + $traitGenerator = new TraitGenerator( $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 - . ' */', + DocBlockGenerator::fromDocComment( + '/**' . PHP_EOL + . ' * Dispatcher bodies woven into \\' . $this->targetClassName . ' by the ffi driver of Go! AOP' . PHP_EOL + . ' *' . PHP_EOL + . ' * @internal Generated code, never used by a class: the engine adopts the method bodies' . PHP_EOL + . ' */', + ), ); foreach ($this->imports->getUses() as $className => $alias) { - $classGenerator->addUse($className, $alias); + $traitGenerator->addUse($className, $alias); } - $this->generator = $classGenerator; + $this->generator = $traitGenerator; } /** - * Returns the fully qualified name of the generated donor class + * Returns the fully qualified name of the generated donor trait */ - public function getDonorClassName(): string + public function getDonorTraitName(): string { - return $this->donorClassName; + return $this->donorTraitName; } public function generate(): string @@ -169,22 +148,11 @@ 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. + * The engine checks the donor signature against the one of the replaced entry with the keywords resolved + * against the declaring type, and in the donor they would name the trait. */ private function resolveScopeKeywords(MethodGenerator $method): MethodGenerator { diff --git a/packages/ffi-driver/src/Exception/EngineConfigurationException.php b/packages/ffi-driver/src/Exception/EngineConfigurationException.php new file mode 100644 index 00000000..ea488efe --- /dev/null +++ b/packages/ffi-driver/src/Exception/EngineConfigurationException.php @@ -0,0 +1,55 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi\Exception; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Features; +use Throwable; + +/** + * The host or the kernel options do not fit the ffi driver: z-engine missing or refusing to boot, the opcache + * JIT on, a feature flag the driver cannot honour + */ +final class EngineConfigurationException extends InvalidConfigurationException +{ + public static function engineNotInstalled(): self + { + return new self( + 'The ffi weaving driver requires lisachenko/z-engine with ext-ffi enabled: ' + . 'composer require lisachenko/z-engine (composer picks the release of the running PHP minor).', + ); + } + + public static function engineBootFailed(Throwable $previous): self + { + return new self('The ffi weaving driver cannot boot z-engine: ' . $previous->getMessage(), 0, $previous); + } + + public static function jitStillOn(): self + { + return new self( + 'The ffi weaving driver requires the opcache JIT to be off (z-engine hooks the executor internals the ' + . 'JIT bypasses) and could not switch it off at runtime: start PHP with opcache.jit=off and ' + . 'opcache.jit_buffer_size=0 (php.ini, the FPM pool or `php -d`).', + ); + } + + public static function includeInterceptionNotSupported(): self + { + return new self(sprintf( + 'The ffi weaving driver rewires loaded classes in the engine and never rewrites sources: ' + . 'Features::INTERCEPT_INCLUDES (%d) is only available with a source-transforming driver.', + Features::INTERCEPT_INCLUDES, + )); + } +} diff --git a/packages/ffi-driver/src/Exception/FfiWeavingException.php b/packages/ffi-driver/src/Exception/FfiWeavingException.php new file mode 100644 index 00000000..1c3f865e --- /dev/null +++ b/packages/ffi-driver/src/Exception/FfiWeavingException.php @@ -0,0 +1,48 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi\Exception; + +use Go\Aop\Exception\WeavingException; + +/** + * Weave-time failures of the ffi driver: the donor of a class cannot be generated, written or loaded + */ +class FfiWeavingException extends WeavingException +{ + /** + * @param list $kinds Join-point kinds (AspectContainer prefixes) the advices contain + */ + public static function unsupportedDonorKinds(string $className, array $kinds): self + { + return new self(sprintf( + 'A donor trait carries method dispatchers only, the advices of %s contain the join-point kind(s) %s', + $className, + implode(', ', $kinds), + )); + } + + public static function sourceOutsideApplicationRoot(string $resource, string $appDir): self + { + return new self(sprintf('The source %s lies outside the application root %s and is never woven', $resource, $appDir)); + } + + public static function donorNotDeclared(string $donorFile, string $className, string $donorTrait): self + { + return new self(sprintf( + 'The donor file %s of %s does not declare the donor trait %s: clear the cache directory', + $donorFile, + $className, + $donorTrait, + )); + } +} diff --git a/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php b/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php new file mode 100644 index 00000000..8a6d1e4f --- /dev/null +++ b/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php @@ -0,0 +1,50 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi\Exception; + +use Throwable; + +/** + * Raised by the ffi weaving driver when an advice matched a join point kind the driver cannot weave at + * runtime (property access, introductions, initialization join points), or when the engine refuses a mutation + * + * 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 a source-transforming driver for such aspects. + */ +final class UnsupportedJoinpointException extends FfiWeavingException +{ + /** + * @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 ffi weaving driver cannot weave %s: the advisor(s) %s match join point kind(s) %s, ' + . 'which only a source-transforming driver (the stream filter or the stream wrapper driver) supports: ' + . 'runtime weaving rewires method bodies only. Narrow the pointcut(s) to method execution, or run ' + . 'this application with such a 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 ffi weaving driver cannot weave %s: %s', $className, $reason->getMessage()), 0, $reason); + } +} diff --git a/src/Instrument/ClassLoading/ZEngineComposerLoader.php b/packages/ffi-driver/src/FfiComposerLoader.php similarity index 91% rename from src/Instrument/ClassLoading/ZEngineComposerLoader.php rename to packages/ffi-driver/src/FfiComposerLoader.php index e54cf3ac..3bc123f5 100644 --- a/src/Instrument/ClassLoading/ZEngineComposerLoader.php +++ b/packages/ffi-driver/src/FfiComposerLoader.php @@ -10,18 +10,18 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ClassLoading; +namespace Go\Driver\Ffi; use Closure; use Composer\Autoload\ClassLoader; use Go\Core\AspectContainer; use Go\Core\AspectKernel; use Go\Instrument\FileSystem\Enumerator; +use Go\Instrument\ClassLoading\ComposerLoaderDecorator; use Go\Instrument\PathResolver; -use Go\Instrument\ZEngine\ZEngineClassWeaver; /** - * Composer loader wrapper of the z-engine driver: loads the class natively, then weaves it + * Composer loader wrapper of the ffi 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 @@ -30,9 +30,9 @@ * * @phpstan-import-type KernelOptions from AspectKernel * - * @internal Framework service, not a public extension point + * @internal Driver service, not a public extension point */ -final class ZEngineComposerLoader implements ComposerLoaderDecorator +final class FfiComposerLoader implements ComposerLoaderDecorator { /** * Includes a file in an isolated scope: neither $this nor the loader state leak into it @@ -124,7 +124,7 @@ public function loadClass(string $class): ?true $file = $resolved; } if (($this->isAllowedFilter ??= $this->fileEnumerator->getPathFilter())($file)) { - $this->container->getService(ZEngineClassWeaver::class)->weaveLoadedClass($class, $file); + $this->container->getService(ClassWeaver::class)->weaveLoadedClass($class, $file); } return true; diff --git a/packages/ffi-driver/src/FfiDriver.php b/packages/ffi-driver/src/FfiDriver.php new file mode 100644 index 00000000..fdf28809 --- /dev/null +++ b/packages/ffi-driver/src/FfiDriver.php @@ -0,0 +1,148 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi; + +use Closure; +use Composer\InstalledVersions; +use Go\Aop\Features; +use Go\Core\AdviceMatcher; +use Go\Core\AdvisorIndex; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Driver\Ffi\Exception\EngineConfigurationException; +use Go\Driver\WeavingDriver; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\Instrument\PathResolver; +use Throwable; +use ZEngine\Core; + +/** + * The ffi weaving driver: runtime method-table weaving through lisachenko/z-engine (PHP FFI) + * + * Classes load natively. Right after a class is loaded, the body of every advised method is swapped + * in place with a generated dispatcher while the previous body stays callable under the private + * `OriginalAlias`, and an inherited advised method gets an engine-published override. No + * source rewriting, no stream filter or wrapper, no include interception: `'driver' => FfiDriver::class`. + * + * Boot 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: class-string, php: string, opcache: bool, jit: JitReport} + */ +final class FfiDriver implements WeavingDriver +{ + /** + * Container key of the boot report (see getBootReport()) + */ + public const string BOOT_REPORT = 'kernel.ffi.boot'; + + /** + * Composer name of the engine package, whose code is never woven + */ + private const string ENGINE_PACKAGE = 'lisachenko/z-engine'; + + /** + * @var Closure(): bool + */ + private readonly Closure $isEngineInstalled; + + /** + * @var Closure(): void + */ + private readonly Closure $bootEngine; + + /** + * The kernel creates the driver without arguments; the closures are seams for the tests + * + * @param (Closure(): bool)|null $isEngineInstalled Whether z-engine is installed, class_exists(Core::class) by default + * @param (Closure(): void)|null $bootEngine Boots z-engine unless it is initialized, Core::init() by default + */ + public function __construct(?Closure $isEngineInstalled = null, ?Closure $bootEngine = null) + { + $this->isEngineInstalled = $isEngineInstalled ?? static fn(): bool => class_exists(Core::class); + $this->bootEngine = $bootEngine ?? static function (): void { + if (!Core::isInitialized()) { + Core::init(); + } + }; + } + + /** + * @throws EngineConfigurationException When z-engine is missing or cannot boot, the JIT stays on, or the + * kernel options enable include interception + */ + public function boot(AspectKernel $kernel, AspectContainer $container): void + { + $options = $kernel->getOptions(); + // Includes are rewritten by a source transformer: there is no source transformation here + if (($options['features'] & Features::INTERCEPT_INCLUDES) !== 0) { + throw EngineConfigurationException::includeInterceptionNotSupported(); + } + if (!($this->isEngineInstalled)()) { + throw EngineConfigurationException::engineNotInstalled(); + } + + $jitReport = JitGuard::enforce(); + + try { + ($this->bootEngine)(); + } catch (Throwable $failure) { + throw EngineConfigurationException::engineBootFailed($failure); + } + + $container->add(self::BOOT_REPORT, [ + 'driver' => self::class, + 'php' => PHP_VERSION, + 'opcache' => function_exists('opcache_get_status') && opcache_get_status(false) !== false, + 'jit' => $jitReport, + ]); + + // The engine must never weave the code it runs on: the installed engine joins the excluded paths + $enginePath = InstalledVersions::isInstalled(self::ENGINE_PACKAGE) ? InstalledVersions::getInstallPath(self::ENGINE_PACKAGE) : null; + $enginePath = $enginePath !== null ? PathResolver::realpath($enginePath) : null; + if (is_string($enginePath)) { + $options['excludePaths'][] = $enginePath; + } + + $container->addLazyService(DonorCacheIndex::class, fn(AspectContainer $container): DonorCacheIndex => new DonorCacheIndex( + $container->getService(CachePathManager::class), + $options, + )); + $container->addLazyService(MethodTableRewriter::class, fn(): MethodTableRewriter => new MethodTableRewriter()); + $container->addLazyService(ClassWeaver::class, fn(AspectContainer $container): ClassWeaver => new ClassWeaver( + $container, + $container->getService(AdviceMatcher::class), + $container->getService(AdvisorIndex::class), + $container->getService(CachePathManager::class), + $container->getService(DonorCacheIndex::class), + $container->getService(MethodTableRewriter::class), + )); + + FfiComposerLoader::init($options, $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->get(self::BOOT_REPORT); + + return $report; + } +} diff --git a/packages/ffi-driver/src/JitGuard.php b/packages/ffi-driver/src/JitGuard.php new file mode 100644 index 00000000..b91e5a26 --- /dev/null +++ b/packages/ffi-driver/src/JitGuard.php @@ -0,0 +1,88 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi; + +use Closure; +use Go\Driver\Ffi\Exception\EngineConfigurationException; + +/** + * Makes sure the opcache JIT is off before the ffi 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`). + * A JIT found active is switched 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 Driver 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 (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 EngineConfigurationException When the JIT is on and could not be switched off at runtime + */ + public static function enforce(?Closure $statusReader = null, ?Closure $iniWriter = null): array + { + $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; + + // "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 EngineConfigurationException::jitStillOn(); + } + $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/packages/ffi-driver/src/MethodTableRewriter.php similarity index 94% rename from src/Instrument/ZEngine/MethodTableRewriter.php rename to packages/ffi-driver/src/MethodTableRewriter.php index bf01337f..061ede94 100644 --- a/src/Instrument/ZEngine/MethodTableRewriter.php +++ b/packages/ffi-driver/src/MethodTableRewriter.php @@ -10,9 +10,9 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; -use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Driver\Ffi\Exception\UnsupportedJoinpointException; use Go\Aop\Framework\AbstractMethodInvocation; use Go\Aop\Proxy; use ReflectionException; @@ -20,7 +20,7 @@ use ZEngine\Reflection\ReflectionClass as EngineClass; /** - * Rewires the advised methods of a loaded class to the dispatcher bodies of its donor class + * Rewires the advised methods of a loaded class to the dispatcher bodies of its donor trait * * The whole engine contract is z-engine's public reflection API: * - a method the class declares itself keeps its body under the private alias @@ -39,13 +39,13 @@ * 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 + * @internal Driver 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 class-string $donorClassName Loaded donor trait declaring the dispatcher methods * @param list $methods Advised method names (any case) * * @throws UnsupportedJoinpointException When the engine refuses a mutation (the class stays diff --git a/src/Instrument/ZEngine/WeaveOutcome.php b/packages/ffi-driver/src/WeaveOutcome.php similarity index 88% rename from src/Instrument/ZEngine/WeaveOutcome.php rename to packages/ffi-driver/src/WeaveOutcome.php index 74dc26f0..cf7e7d4d 100644 --- a/src/Instrument/ZEngine/WeaveOutcome.php +++ b/packages/ffi-driver/src/WeaveOutcome.php @@ -10,10 +10,10 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; /** - * What the z-engine driver did with a loaded class + * What the ffi driver did with a loaded class */ enum WeaveOutcome: string { diff --git a/src/Instrument/ZEngine/WeaveResult.php b/packages/ffi-driver/src/WeaveResult.php similarity index 94% rename from src/Instrument/ZEngine/WeaveResult.php rename to packages/ffi-driver/src/WeaveResult.php index d2699de0..9eb227a0 100644 --- a/src/Instrument/ZEngine/WeaveResult.php +++ b/packages/ffi-driver/src/WeaveResult.php @@ -10,10 +10,10 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; /** - * Result of weaving one class through the z-engine driver + * Result of weaving one class through the ffi driver */ final readonly class WeaveResult { diff --git a/tests/Instrument/ZEngine/DonorCacheIndexTest.php b/packages/ffi-driver/tests/DonorCacheIndexTest.php similarity index 87% rename from tests/Instrument/ZEngine/DonorCacheIndexTest.php rename to packages/ffi-driver/tests/DonorCacheIndexTest.php index d8327cfa..e1a45ef3 100644 --- a/tests/Instrument/ZEngine/DonorCacheIndexTest.php +++ b/packages/ffi-driver/tests/DonorCacheIndexTest.php @@ -10,11 +10,10 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Exception\WeavingException; -use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\AspectKernel; use Go\Core\Container; @@ -23,7 +22,7 @@ use PHPUnit\Framework\TestCase; /** - * The donor index of the z-engine driver: records per class, freshness through the shared rule, persisted as PHP + * The donor index of the ffi driver: records per class, freshness through the shared rule, persisted as PHP */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] final class DonorCacheIndexTest extends TestCase @@ -59,11 +58,11 @@ public function testDonorFilesMirrorTheApplicationTreeBelowTheDonorDirectory(): $index = $this->createIndex(); $this->assertSame( - $this->cacheDir . '/_zengine/src/Service__AopDonor.php', + $this->cacheDir . '/_ffi/src/ServiceOriginalTrait.php', $index->donorFileFor($this->source, 'Service'), ); $this->assertSame( - $this->cacheDir . '/_zengine/Root__AopDonor.php', + $this->cacheDir . '/_ffi/RootOriginalTrait.php', $index->donorFileFor($this->appDir . '/Root.php', 'Root'), ); // A source outside the application root is never woven, so it has no donor file @@ -75,26 +74,26 @@ public function testDonorFilesMirrorTheApplicationTreeBelowTheDonorDirectory(): public function testRecordsSurviveAFlushAndAreServedWhileFresh(): void { - $donorFile = $this->cacheDir . '/_zengine/src/Service__AopDonor.php'; + $donorFile = $this->cacheDir . '/_ffi/src/ServiceOriginalTrait.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->recordDonor(self::CLASS_NAME, $this->source, $donorFile, 'App\ServiceOriginalTrait', ['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']], + ['donor' => $donorFile, 'donorClass' => 'App\ServiceOriginalTrait', 'methods' => ['run']], $writer->findFresh(self::CLASS_NAME, $this->source, $this->createContainer()), ); $writer->flush(); - $this->assertFileExists($this->cacheDir . '/_zengine.cache'); + $this->assertFileExists($this->cacheDir . '/_ffi.cache'); // ...and by another request through the index file $reader = $this->createIndex(); $this->assertSame( - ['donor' => $donorFile, 'donorClass' => 'App\Service__AopDonor', 'methods' => ['run']], + ['donor' => $donorFile, 'donorClass' => 'App\ServiceOriginalTrait', 'methods' => ['run']], $reader->findFresh(self::CLASS_NAME, $this->source, $this->createContainer()), ); $this->assertSame( @@ -106,11 +105,11 @@ public function testRecordsSurviveAFlushAndAreServedWhileFresh(): void public function testAStaleOrOrphanedRecordIsAMiss(): void { - $donorFile = $this->cacheDir . '/_zengine/src/Service__AopDonor.php'; + $donorFile = $this->cacheDir . '/_ffi/src/ServiceOriginalTrait.php'; mkdir(dirname($donorFile), 0777, true); - file_put_contents($donorFile, 'createIndex(); - $writer->recordDonor(self::CLASS_NAME, $this->source, $donorFile, 'App\Service__AopDonor', ['run']); + $writer->recordDonor(self::CLASS_NAME, $this->source, $donorFile, 'App\ServiceOriginalTrait', ['run']); $writer->flush(); // The container knows a resource newer than the record @@ -131,7 +130,7 @@ public function testAStaleOrOrphanedRecordIsAMiss(): void public function testAnIndexOfAnotherFormatVersionIsIgnored(): void { - file_put_contents($this->cacheDir . '/_zengine.cache', ' 0, "classes" => ["App\\Service" => ["donor" => null]]];'); + file_put_contents($this->cacheDir . '/_ffi.cache', ' 0, "classes" => ["App\\Service" => ["donor" => null]]];'); $this->assertNull($this->createIndex()->findFresh(self::CLASS_NAME, $this->source, $this->createContainer())); } @@ -163,7 +162,7 @@ private function createIndex(?string $cacheDir = ''): DonorCacheIndex 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::ZEngine, + 'driver' => FfiDriver::class, ]; $kernel = $this->createMock(AspectKernel::class); $kernel->method('getOptions')->willReturn($options); diff --git a/tests/Proxy/DonorClassGeneratorTest.php b/packages/ffi-driver/tests/DonorTraitGeneratorTest.php similarity index 71% rename from tests/Proxy/DonorClassGeneratorTest.php rename to packages/ffi-driver/tests/DonorTraitGeneratorTest.php index 478aeb6a..6cf4e7dc 100644 --- a/tests/Proxy/DonorClassGeneratorTest.php +++ b/packages/ffi-driver/tests/DonorTraitGeneratorTest.php @@ -10,29 +10,30 @@ * with this source code in the file LICENSE. */ -namespace Go\Proxy; +namespace Go\Driver\Ffi; -use Go\Aop\Exception\WeavingException; +use Go\Driver\Ffi\Exception\FfiWeavingException; use Go\Aop\Framework\BeforeInterceptor; use Go\Aop\Framework\GeneratedInterceptor; use Go\Core\AspectContainer; use Go\ParserReflection\ReflectionFile; +use Go\Driver\Ffi\Stubs\ReadonlyPoint; use Go\PhpUnit\AssertsCompilablePhp; -use Go\Stubs\ZEngine\ReadonlyPoint; -use Go\Tests\TestProject\Application\ZEngineChild; -use Go\Tests\TestProject\Application\ZEngineParent; +use Go\Proxy\ClassProxyGenerator; +use Go\Tests\TestProject\Application\FfiChild; +use Go\Tests\TestProject\Application\FfiParent; 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 + * The donor trait of the ffi driver: dispatcher bodies compiled outside the woven class */ -final class DonorClassGeneratorTest extends TestCase +final class DonorTraitGeneratorTest extends TestCase { use AssertsCompilablePhp; - private const string FIXTURES = __DIR__ . '/../Fixtures/project/src/Application/'; + private const string FIXTURES = __DIR__ . '/../../../tests/Fixtures/project/src/Application/'; /** * Both reflection flavours the weaver hands to the generator: the parsed source (the usual case) and native @@ -47,10 +48,10 @@ public static function provideReflectionFlavours(): iterable } #[DataProvider('provideReflectionFlavours')] - public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): void + public function testGeneratesADonorTraitNextToTheWovenClass(bool $parsed): void { - [$class, $imports] = self::reflect(ZEngineChild::class, $parsed); - $generator = new DonorClassGenerator($class, [ + [$class, $imports] = self::reflect(FfiChild::class, $parsed); + $generator = new DonorTraitGenerator($class, [ AspectContainer::METHOD_PREFIX => [ 'greet' => [self::testAdvice()], 'withDefault' => [self::testAdvice()], @@ -63,17 +64,15 @@ public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): v $code = "generate(); self::assertPhpCompiles($code); - self::assertSame(ZEngineChild::class . AspectContainer::DONOR_CLASS_SUFFIX, $generator->getDonorClassName()); + self::assertSame(FfiChild::class . AspectContainer::ORIGINAL_TRAIT_SUFFIX, $generator->getDonorTraitName()); 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); + // A trait under the suffix of the stream driver's trait codegen: nothing of it binds before run time + self::assertStringContainsString("trait FfiChildOriginalTrait\n", $code); + self::assertStringNotContainsString('class FfiChild', $code); + self::assertStringNotContainsString('extends', $code); + // The joinpoint is created for self::class, which a trait method resolves at run time (the woven class) + self::assertStringContainsString('self::class,', $code); + self::assertStringNotContainsString('\Go\Tests\TestProject\Application\FfiChild::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); @@ -81,7 +80,7 @@ public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): v 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); + self::assertStringContainsString('?\Go\Tests\TestProject\Application\FfiChild $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); @@ -89,7 +88,7 @@ public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): v // 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~', + '~public function describe\(\): \\\\Go\\\\Tests\\\\TestProject\\\\Application\\\\Ffi(Parent|Child)\n~', $code, ); self::assertStringContainsString('public static function create(string $name): static', $code); @@ -100,45 +99,46 @@ public function testGeneratesAnAbstractDonorNextToTheWovenClass(bool $parsed): v #[DataProvider('provideReflectionFlavours')] public function testOwnStaticMethodsDispatchThroughTheStaticAlias(bool $parsed): void { - [$class, $imports] = self::reflect(ZEngineParent::class, $parsed); - $generator = new DonorClassGenerator($class, [ + [$class, $imports] = self::reflect(FfiParent::class, $parsed); + $generator = new DonorTraitGenerator($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('trait FfiParentOriginalTrait' . "\n", $code); self::assertStringContainsString('self::createOriginalAlias(...)', $code); self::assertStringContainsString('static::class', $code); - self::assertStringContainsString('public function describe(): \Go\Tests\TestProject\Application\ZEngineParent', $code); + self::assertStringContainsString('public function describe(): \Go\Tests\TestProject\Application\FfiParent', $code); } - public function testAReadonlyClassGetsAReadonlyDonor(): void + public function testAReadonlyClassGetsAPlainDonorTrait(): void { $class = new ReflectionClass(ReadonlyPoint::class); - $generator = new DonorClassGenerator($class, [ + $generator = new DonorTraitGenerator($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); + self::assertStringContainsString("trait ReadonlyPointOriginalTrait\n", $code); + self::assertStringNotContainsString('readonly', $code); + self::assertStringContainsString('public function translate(int $dx, int $dy): \\Go\\Driver\\Ffi\\Stubs\\ReadonlyPoint', $code); } public function testRefusesJoinpointKindsADonorCannotCarry(): void { - [$class, $imports] = self::reflect(ZEngineChild::class, true); + [$class, $imports] = self::reflect(FfiChild::class, true); try { - new DonorClassGenerator($class, [ + new DonorTraitGenerator($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) { + } catch (FfiWeavingException $exception) { $this->assertStringContainsString('contain the join-point kind(s) prop, init', $exception->getMessage()); } } diff --git a/tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php b/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php similarity index 82% rename from tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php rename to packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php index d1b9748d..5d32e3a6 100644 --- a/tests/Aop/Exception/UnsupportedJoinpointExceptionTest.php +++ b/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php @@ -10,7 +10,7 @@ * with this source code in the file LICENSE. */ -namespace Go\Aop\Exception; +namespace Go\Driver\Ffi\Exception; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -24,7 +24,7 @@ public function testNamesTheAdvisorsAndKindsTheDriverCannotWeave(): void $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()); + $this->assertStringContainsString('source-transforming driver', $exception->getMessage()); } public function testWrapsAnEngineRefusal(): void @@ -33,6 +33,6 @@ public function testWrapsAnEngineRefusal(): void $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()); + $this->assertSame('The ffi weaving driver cannot weave App\Entity: ' . $reason->getMessage(), $exception->getMessage()); } } diff --git a/tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php b/packages/ffi-driver/tests/FfiComposerLoaderTest.php similarity index 75% rename from tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php rename to packages/ffi-driver/tests/FfiComposerLoaderTest.php index 3d400b9f..54d38e38 100644 --- a/tests/Instrument/ClassLoading/ZEngineComposerLoaderTest.php +++ b/packages/ffi-driver/tests/FfiComposerLoaderTest.php @@ -10,19 +10,19 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ClassLoading; +namespace Go\Driver\Ffi; use Composer\Autoload\ClassLoader; -use Go\Aop\WeavingDriver; use Go\Core\AspectContainer; use Go\Core\Container; +use Go\Instrument\ClassLoading\AopComposerLoader; 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 + * The composer loader wrapper of the ffi driver: native loading, weaving only within the woven paths */ -final class ZEngineComposerLoaderTest extends TestCase +final class FfiComposerLoaderTest extends TestCase { use UsesTemporaryDirectory; @@ -39,7 +39,7 @@ 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'); + $this->appDir = self::createTemporaryDirectory('ffi-loader'); mkdir($this->appDir . '/src'); mkdir($this->appDir . '/lib'); } @@ -61,14 +61,14 @@ public function testInitWrapsEveryComposerLoaderAndUnwrapsEarlierWrappers(): voi $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([new FfiComposerLoader($composerLoader, $this->createContainer(), $this->createOptions()), 'loadClass']); $this->register($otherLoader); - $this->assertTrue(ZEngineComposerLoader::init($this->createOptions(), $this->createContainer())); + $this->assertTrue(FfiComposerLoader::init($this->createOptions(), $this->createContainer())); $wrappers = []; foreach (spl_autoload_functions() as $loader) { - if (is_array($loader) && $loader[0] instanceof ZEngineComposerLoader && $loader[0]->getOriginalLoader() === $composerLoader) { + if (is_array($loader) && $loader[0] instanceof FfiComposerLoader && $loader[0]->getOriginalLoader() === $composerLoader) { $wrappers[] = $loader[0]; } $this->assertFalse(is_array($loader) && $loader[0] === $composerLoader, 'The bare composer loader is replaced'); @@ -85,43 +85,43 @@ public function testInitReportsWhenNoComposerLoaderIsRegistered(): void } $this->register(static function (string $class): void {}); - $this->assertFalse(ZEngineComposerLoader::init($this->createOptions(), $this->createContainer())); + $this->assertFalse(FfiComposerLoader::init($this->createOptions(), $this->createContainer())); } public function testLoadsAClassOutsideTheWovenPathsNativelyWithoutWeaving(): void { - $className = __NAMESPACE__ . '\\ZEngineLoaderProbe'; - $probeFile = $this->appDir . '/lib/ZEngineLoaderProbe.php'; + $className = __NAMESPACE__ . '\\FfiLoaderProbe'; + $probeFile = $this->appDir . '/lib/FfiLoaderProbe.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()); + $loader = new FfiComposerLoader($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->assertFalse(FfiLoaderProbe::$sawThis, 'The file is included in an isolated scope'); + $this->assertNull($loader->loadClass('Totally\\Unknown\\FfiClass')); $this->assertSame($probeFile, $loader->findFile($className)); - $this->assertFalse($loader->findFile('Totally\\Unknown\\ZEngineClass')); + $this->assertFalse($loader->findFile('Totally\\Unknown\\FfiClass')); } 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->register([new FfiComposerLoader($composerLoader, $this->createContainer(), $this->createOptions()), 'loadClass']); $this->assertSame($this->appDir . '/src/Known.php', AopComposerLoader::findOriginalFile('App\\Known')); $this->assertNull(AopComposerLoader::findOriginalFile('App\\Unknown')); @@ -135,7 +135,7 @@ private function register(callable $loader): void /** * 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} + * @return array{debug: bool, appDir: string, cacheDir: string|null, cacheFileMode: int, features: int, includePaths: list, excludePaths: list, containerClass: class-string, driver: class-string<\Go\Driver\WeavingDriver>} */ private function createOptions(): array { @@ -148,7 +148,7 @@ private function createOptions(): array 'includePaths' => [$this->appDir . '/src'], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::ZEngine, + 'driver' => FfiDriver::class, ]; } diff --git a/packages/ffi-driver/tests/FfiDriverTest.php b/packages/ffi-driver/tests/FfiDriverTest.php new file mode 100644 index 00000000..38a5c8ea --- /dev/null +++ b/packages/ffi-driver/tests/FfiDriverTest.php @@ -0,0 +1,98 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi; + +use Go\Aop\Features; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Core\Container; +use Go\Driver\Ffi\Exception\EngineConfigurationException; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * The boot refusals of the driver: without the engine, with an engine that cannot start, or with a feature the + * driver cannot honour (the successful boot is covered in-process, see InProcess/FfiDriverInProcessTest) + */ +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +final class FfiDriverTest extends TestCase +{ + public function testRefusesToBootWithoutZEngine(): void + { + $container = $this->createMock(AspectContainer::class); + $container->expects($this->never())->method('add'); + + try { + new FfiDriver(static fn(): bool => false)->boot($this->kernel(0), $container); + self::fail('A missing engine must be refused'); + } catch (EngineConfigurationException $refusal) { + $this->assertStringContainsString('requires lisachenko/z-engine', $refusal->getMessage()); + $this->assertStringContainsString('composer require lisachenko/z-engine', $refusal->getMessage()); + } + } + + public function testRefusesIncludeInterception(): void + { + $container = $this->createMock(AspectContainer::class); + $container->expects($this->never())->method('add'); + + try { + new FfiDriver(static fn(): bool => true)->boot($this->kernel(Features::INTERCEPT_INCLUDES), $container); + self::fail('Include interception is a source transformation the driver cannot provide'); + } catch (EngineConfigurationException $refusal) { + $this->assertStringContainsString('Features::INTERCEPT_INCLUDES', $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 { + new FfiDriver( + static fn(): bool => true, + static function () use ($reason): void { + throw $reason; + }, + )->boot($this->kernel(0), $container); + self::fail('An engine that cannot boot must be refused'); + } catch (EngineConfigurationException $refusal) { + $this->assertSame('The ffi weaving driver cannot boot z-engine: FFI is not enabled', $refusal->getMessage()); + $this->assertSame($reason, $refusal->getPrevious()); + } + } + + private function kernel(int $features): AspectKernel + { + $kernel = $this->createStub(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => true, + 'appDir' => __DIR__, + 'cacheDir' => __DIR__ . '/cache', + 'cacheFileMode' => 0770, + 'features' => $features, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + 'driver' => FfiDriver::class, + ]); + + return $kernel; + } +} diff --git a/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php b/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php similarity index 86% rename from tests/Functional/ZEngine/ZEngineFunctionalTestCase.php rename to packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php index 45f2d38e..2d39c946 100644 --- a/tests/Functional/ZEngine/ZEngineFunctionalTestCase.php +++ b/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php @@ -10,7 +10,7 @@ * with this source code in the file LICENSE. */ -namespace Go\Functional\ZEngine; +namespace Go\Driver\Ffi\Functional; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; @@ -18,22 +18,22 @@ use Symfony\Component\Process\Process; /** - * Base of the z-engine driver tests: every scenario runs the probe tests/Fixtures/project/bin/zengine-weaving.php + * Base of the ffi driver tests: every scenario runs the probe tests/Fixtures/project/bin/driver-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. + * The `ffi-driver` test suite is excluded from the default run (`composer test:ffi` runs it) and self-skips on a + * host where the engine cannot boot: z-engine not installed, ext-ffi missing, or a z-engine release of another + * PHP minor. */ -abstract class ZEngineFunctionalTestCase extends TestCase +abstract class FfiFunctionalTestCase extends TestCase { - protected const string PROJECT_DIR = __DIR__ . '/../../Fixtures/project'; + protected const string PROJECT_DIR = __DIR__ . '/../../../../tests/Fixtures/project'; - protected const string PROBE = self::PROJECT_DIR . '/bin/zengine-weaving.php'; + protected const string PROBE = self::PROJECT_DIR . '/bin/driver-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 + * bypasses), freshly written donor files cacheable at once */ protected const array PHP_OPTIONS = [ '-d', 'ffi.enable=1', @@ -193,7 +193,7 @@ protected function clearCaches(): void $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'])) { + if (str_starts_with($name, 'ffi') && $filesystem->exists($configuration['cacheDir'])) { $filesystem->remove($configuration['cacheDir']); } } @@ -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: (see docs/zengine-driver.md)'; + . 'composer require --dev lisachenko/z-engine (see docs/driver/ffi.md)'; } $process = new Process( [ @@ -220,7 +220,7 @@ private static function engineUnavailableReason(): ?string '-d', 'opcache.enable_cli=0', '-r', 'require $argv[1]; ZEngine\Core::init(); echo "booted";', - __DIR__ . '/../../../vendor/autoload.php', + __DIR__ . '/../../../../vendor/autoload.php', ], null, ['ZENGINE_AUTOBOOT' => '0'], @@ -230,7 +230,7 @@ private static function engineUnavailableReason(): ?string $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"); + . 'z-engine release of this PHP minor are required): ' . strtok($failure, "\n"); } return self::$engineUnavailableReason = null; diff --git a/tests/Functional/ZEngine/ZEngineWeavingTest.php b/packages/ffi-driver/tests/Functional/FfiWeavingTest.php similarity index 61% rename from tests/Functional/ZEngine/ZEngineWeavingTest.php rename to packages/ffi-driver/tests/Functional/FfiWeavingTest.php index d7dfcbe1..e54e3229 100644 --- a/tests/Functional/ZEngine/ZEngineWeavingTest.php +++ b/packages/ffi-driver/tests/Functional/FfiWeavingTest.php @@ -10,46 +10,46 @@ * 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\ZEngineFunctions; -use Go\Tests\TestProject\Application\ZEngineLate; -use Go\Tests\TestProject\Application\ZEngineParent; -use Go\Tests\TestProject\Application\ZEngineUnadvised; -use Go\Tests\TestProject\Aspect\ZEngineUnsupportedAspect; +namespace Go\Driver\Ffi\Functional; + +use Go\Driver\Ffi\Exception\UnsupportedJoinpointException; +use Go\Tests\TestProject\Application\FfiChild; +use Go\Tests\TestProject\Application\FfiFunctions; +use Go\Tests\TestProject\Application\FfiLate; +use Go\Tests\TestProject\Application\FfiParent; +use Go\Tests\TestProject\Application\FfiUnadvised; +use Go\Tests\TestProject\Aspect\FfiUnsupportedAspect; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Go\Instrument\ZEngine\JitGuard; +use Go\Driver\Ffi\FfiDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; /** - * The z-engine driver weaves the fixture project at runtime: same behaviour as the stream driver, donors cached + * The ffi driver weaves the fixture project at runtime: same behaviour as the stream driver, donors cached */ -#[Group('zengine')] -final class ZEngineWeavingTest extends ZEngineFunctionalTestCase +#[Group('ffi')] +final class FfiWeavingTest extends FfiFunctionalTestCase { /** * 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', + '_ffi/_functions/Go/Tests/TestProject/Application.php', + '_ffi/src/Application/FfiChildOriginalTrait.php', + '_ffi/src/Application/FfiParentOriginalTrait.php', + 'src/Aspect/FfiAspect.cache.php', ]; #[DataProvider('provideOpcacheLegs')] public function testWeavesLikeTheStreamDriver(bool $opcache): void { - $engine = $this->runProbe('zengine', 'weave', $opcache); - $stream = $this->runProbe('zengine_reference', 'weave', $opcache); + $engine = $this->runProbe('ffi', 'weave', $opcache); + $stream = $this->runProbe('ffi_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']); + self::assertSame(FfiDriver::class, $engine['driver']); + self::assertSame(StreamFilterDriver::class, $stream['driver']); // Advised calls return the same results and run the same advices in the same order self::assertSame($stream['calls'], $engine['calls']); @@ -60,7 +60,7 @@ public function testWeavesLikeTheStreamDriver(bool $opcache): void // 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'; + $expectedReflection['parent.greet.file'] = 'FfiParentOriginalTrait.php'; self::assertSame($expectedReflection, $engine['reflection']); self::assertTrue(self::at($engine, 'reflection', 'child.proxy')); self::assertFalse(self::at($engine, 'reflection', 'unadvised.proxy')); @@ -68,17 +68,17 @@ public function testWeavesLikeTheStreamDriver(bool $opcache): void // 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::at($engine, 'weave', FfiParent::class), ); self::assertSame( ['outcome' => 'already-woven', 'methods' => ['greet', 'withDefault', 'getCalls', 'describe', 'callSecret'], 'reason' => null], - self::at($engine, 'weave', ZEngineChild::class), + self::at($engine, 'weave', FfiChild::class), ); self::assertSame( ['outcome' => 'no-advices', 'methods' => [], 'reason' => null], - self::at($engine, 'weave', ZEngineUnadvised::class), + self::at($engine, 'weave', FfiUnadvised::class), ); - self::assertSame([ZEngineParent::class, ZEngineChild::class], $engine['donors']); + self::assertSame([FfiParent::class, FfiChild::class], $engine['donors']); self::assertSame(self::EXPECTED_CACHE_FILES, $engine['cache']); // The subprocess runs with the JIT off, nothing for the guard to do @@ -93,8 +93,8 @@ public function testWeavesLikeTheStreamDriver(bool $opcache): void #[DataProvider('provideOpcacheLegs')] public function testInterceptsFunctionsLikeTheStreamDriver(bool $opcache): void { - $engine = $this->runProbe('zengine', 'functions', $opcache); - $stream = $this->runProbe('zengine_reference', 'functions', $opcache); + $engine = $this->runProbe('ffi', 'functions', $opcache); + $stream = $this->runProbe('ffi_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'); @@ -104,17 +104,17 @@ public function testInterceptsFunctionsLikeTheStreamDriver(bool $opcache): void self::assertTrue(self::at($engine, 'calls', 'functions.proxied')); self::assertSame( ['outcome' => 'no-advices', 'methods' => [], 'reason' => null], - self::at($engine, 'weave', ZEngineFunctions::class), + self::at($engine, 'weave', FfiFunctions::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'; + $proxyFile = self::cacheDir('ffi') . '/_ffi/_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); + $warm = $this->runProbe('ffi', 'functions', $opcache); self::assertTrue($warm['indexExisted']); self::assertSame($engine['calls'], $warm['calls']); self::assertSame($engine['log'], $warm['log']); @@ -124,20 +124,20 @@ public function testInterceptsFunctionsLikeTheStreamDriver(bool $opcache): void #[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'); + $donorFile = self::cacheDir('ffi') . '/_ffi/src/Application/FfiChildOriginalTrait.php'; + $source = realpath(self::PROJECT_DIR . '/src/Application/FfiChild.php'); self::assertNotFalse($source); - $cold = $this->runProbe('zengine', 'weave', $opcache); + $cold = $this->runProbe('ffi', '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'); + self::assertFileExists(self::cacheDir('ffi') . '/_ffi.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); + $warm = $this->runProbe('ffi', 'weave', $opcache); self::assertTrue($warm['indexExisted']); self::assertSame($cold['calls'], $warm['calls']); self::assertSame($cold['log'], $warm['log']); @@ -148,7 +148,7 @@ public function testWarmRequestReusesTheDonorAndStaleSourceRegeneratesIt(bool $o self::assertNotFalse($sourceMtime); touch($source, $sourceMtime - 60); try { - $stale = $this->runProbe('zengine', 'weave', $opcache); + $stale = $this->runProbe('ffi', 'weave', $opcache); } finally { touch($source, $sourceMtime); } @@ -159,39 +159,39 @@ public function testWarmRequestReusesTheDonorAndStaleSourceRegeneratesIt(bool $o #[DataProvider('provideOpcacheLegs')] public function testProductionModeReusesTheIndex(bool $opcache): void { - $cold = $this->runProbe('zengine_production', 'weave', $opcache); - $warm = $this->runProbe('zengine_production', 'weave', $opcache); + $cold = $this->runProbe('ffi_production', 'weave', $opcache); + $warm = $this->runProbe('ffi_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']); + self::assertSame([FfiParent::class, FfiChild::class], $warm['donors']); } #[DataProvider('provideOpcacheLegs')] public function testWeavesAClassDeclaredBeforeTheKernelOnDemand(bool $opcache): void { - $report = $this->runProbe('zengine', 'late', $opcache); + $report = $this->runProbe('ffi', 'late', $opcache); self::assertArrayNotHasKey('exception', $report); self::assertSame( ['outcome' => 'woven', 'methods' => ['compute'], 'reason' => null], - self::at($report, 'weave', ZEngineLate::class), + self::at($report, 'weave', FfiLate::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']); + self::assertSame(['before ' . FfiLate::class . '::compute on ' . FfiLate::class], $report['log']); + self::assertSame([FfiLate::class], $report['donors']); } public function testRefusesJoinpointKindsTheEngineCannotWeave(): void { - $report = $this->runProbe('zengine_unsupported', 'unsupported', false); + $report = $this->runProbe('ffi_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(FfiUnsupportedAspect::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']); @@ -199,33 +199,23 @@ public function testRefusesJoinpointKindsTheEngineCannotWeave(): void public function testCacheWarmupRefusesTheEngineDriver(): void { - [$exitCode, $output, $errorOutput] = $this->runConsole('zengine', 'cache:warmup:aop'); + [$exitCode, $output, $errorOutput] = $this->runConsole('ffi', 'cache:warmup:aop'); self::assertSame(1, $exitCode, $output . $errorOutput); - self::assertStringContainsString('zengine weaving driver', $output . $errorOutput); - self::assertStringContainsString('stream filter', $output . $errorOutput); + self::assertStringContainsString(FfiDriver::class . ' never uses', $output . $errorOutput); } - public function testJitGuardHandlesATracingJitAccordingToThePhpVersion(): void + public function testJitGuardSwitchesAnActiveJitOffAtRuntime(): void { - $report = $this->runProbe('zengine', 'weave', true, ['-d', 'opcache.jit=tracing', '-d', 'opcache.jit_buffer_size=64M']); - - 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'])); - } - 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; + $report = $this->runProbe('ffi', 'weave', true, ['-d', 'opcache.jit=tracing', '-d', 'opcache.jit_buffer_size=64M']); + + if (isset($report['exception'])) { + self::fail('An active JIT must be switched off at runtime: ' . json_encode($report['exception'])); } - if (!isset($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(InvalidConfigurationException::class, self::at($report, 'exception', 'class')); - self::assertStringContainsString('opcache.jit=off', self::stringAt($report, 'exception', 'message')); + 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')); } } diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php b/packages/ffi-driver/tests/InProcess/ClassWeaverInProcessTest.php similarity index 82% rename from tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php rename to packages/ffi-driver/tests/InProcess/ClassWeaverInProcessTest.php index 2cc12006..19ae4029 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineClassWeaverInProcessTest.php +++ b/packages/ffi-driver/tests/InProcess/ClassWeaverInProcessTest.php @@ -10,41 +10,42 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine\InProcess; +namespace Go\Driver\Ffi\InProcess; -use Go\Aop\Exception\UnsupportedJoinpointException; +use Go\Aop\AdviceTypeEnum; +use Go\Driver\Ffi\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; +use Go\Core\AdvisorIndex; 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\Driver\Ffi\DonorCacheIndex; +use Go\Driver\Ffi\MethodTableRewriter; +use Go\Driver\Ffi\WeaveOutcome; +use Go\Driver\Ffi\ClassWeaver; 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\FunctionTarget; -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; -use Go\Stubs\ZEngine\WarmTarget; -use Go\Stubs\ZEngine\WovenChild; -use Go\Stubs\ZEngine\WovenParent; -use Go\Stubs\ZEngine\WovenTarget; +use Go\Driver\Ffi\DonorTraitGenerator; +use Go\Driver\Ffi\Stubs\InProcessAspect; +use Go\Driver\Ffi\Stubs\FallbackTarget; +use Go\Driver\Ffi\Stubs\FunctionTarget; +use Go\Driver\Ffi\Stubs\InProcessKernel; +use Go\Driver\Ffi\Stubs\LateTarget; +use Go\Driver\Ffi\Stubs\MatcherErrorAdvisor; +use Go\Driver\Ffi\Stubs\MatcherErrorAspect; +use Go\Driver\Ffi\Stubs\MatcherErrorTarget; +use Go\Driver\Ffi\Stubs\SelfMatchingAspect; +use Go\Driver\Ffi\Stubs\UnadvisedTarget; +use Go\Driver\Ffi\Stubs\UnsupportedAspect; +use Go\Driver\Ffi\Stubs\UnsupportedTarget; +use Go\Driver\Ffi\Stubs\WarmTarget; +use Go\Driver\Ffi\Stubs\WovenChild; +use Go\Driver\Ffi\Stubs\WovenParent; +use Go\Driver\Ffi\Stubs\WovenTarget; use PHPUnit\Framework\Attributes\Group; use ReflectionClass; use ReflectionMethod; @@ -53,13 +54,13 @@ /** * Classes are woven through the engine when they are loaded, and on demand */ -#[Group('zengine')] -final class ZEngineClassWeaverInProcessTest extends ZEngineInProcessTestCase +#[Group('ffi')] +final class ClassWeaverInProcessTest extends InProcessTestCase { public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); $this->assertFalse(class_exists(WovenTarget::class, false), 'The stub must not be loaded by an earlier test'); $target = new WovenTarget(); @@ -78,14 +79,14 @@ public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void $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); + $enum = $weaver->weaveLoadedClass(AdviceTypeEnum::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('interfaces, traits and enums are not supported by the ffi driver', $enum->reason); $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('Go\Driver\Ffi\Stubs\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); $internal = $weaver->weave(\ArrayIterator::class); @@ -95,9 +96,9 @@ public function testAnAdvisedClassIsWovenWhenItIsLoaded(): void // 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'); + $outside = $weaver->weave('Go\\Driver\\Ffi\\Stubs\\OutsideTarget'); $this->assertSame(WeaveOutcome::Skipped, $outside->outcome); $this->assertSame('sources outside the application root are never woven', $outside->reason); } @@ -124,7 +125,7 @@ public function testInheritedMethodsGetAnOverrideReachingTheParent(): void public function testAClassMatchedByAnUnsupportedJoinpointKindIsRefusedOnLoad(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); try { class_exists(UnsupportedTarget::class); @@ -149,8 +150,8 @@ public function testRecordsOfAPreviousRequestDecideWithoutTheAspects(): void $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); + $weaver = $container->getService(ClassWeaver::class); + $result = $weaver->weaveLoadedClass('Go\\Driver\\Ffi\\Stubs\\NotDeclaredAnywhere', $source); $this->assertSame(WeaveOutcome::Skipped, $result->outcome); $this->assertSame('the file did not declare the class', $result->reason); @@ -161,24 +162,24 @@ public function testRecordsOfAPreviousRequestDecideWithoutTheAspects(): void 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 + // A donor record whose file does not declare the donor trait 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->recordDonor(UnadvisedTarget::class, $source, $donorFile, UnadvisedTarget::class . 'OriginalTrait', ['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()); + $this->assertStringContainsString('does not declare the donor trait', $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->assertStringContainsString('does not declare the donor trait', $rethrown->getMessage()); } $this->expectException(WeavingException::class); $brokenWeaver->weave(UnadvisedTarget::class); @@ -187,7 +188,7 @@ public function testRecordsOfAPreviousRequestDecideWithoutTheAspects(): void public function testAnAspectIsNeverWovenEvenWhenItsOwnPointcutMatchesIt(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); $this->assertSame('helper', new SelfMatchingAspect()->helper()); $result = $weaver->weave(SelfMatchingAspect::class); @@ -199,7 +200,7 @@ public function testAnAspectIsNeverWovenEvenWhenItsOwnPointcutMatchesIt(): void public function testAPointcutFailingOnAnOrdinaryClassFailsItsLoad(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); try { class_exists(MatcherErrorTarget::class); @@ -218,7 +219,7 @@ class_exists(MatcherErrorTarget::class); public function testAPointcutFailingOnAnAspectDoesNotFailItsLoad(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); $this->assertSame('helper', new MatcherErrorAspect()->helper()); $result = $weaver->weave(MatcherErrorAspect::class); @@ -230,7 +231,7 @@ public function testAPointcutFailingOnAnAspectDoesNotFailItsLoad(): void public function testASourceTheParserCannotReadFallsBackToNativeReflection(): void { $kernel = $this->bootKernel(); - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); require_once self::STUBS_DIR . '/FallbackTarget.php'; $brokenSource = realpath(self::STUBS_DIR . '/broken-source.txt'); $this->assertNotFalse($brokenSource); @@ -248,7 +249,7 @@ 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); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); $result = $weaver->weave(LateTarget::class); @@ -274,7 +275,7 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void } } $this->assertNotNull($class); - $generator = new DonorClassGenerator($class, [ + $generator = new DonorTraitGenerator($class, [ AspectContainer::METHOD_PREFIX => ['answer' => [self::testInterceptor()]], ], $imports, ClassProxyGenerator::indexMethods($class)); $kernel = $this->bootKernel(); @@ -282,9 +283,9 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void $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->recordDonor(WarmTarget::class, $source, $donorFile, $generator->getDonorTraitName(), ['answer']); // ... including the function-proxy record of the namespace, written by the same request - $index->recordFunctions('Go\\Stubs\\ZEngine', null, []); + $index->recordFunctions('Go\\Driver\\Ffi\\Stubs', null, []); $index->flush(); // A weaver of a new request reads the index and applies the records: no aspect gets loaded for it @@ -302,7 +303,7 @@ public function testAFreshDonorRecordIsAppliedWithoutLoadingTheAspects(): void } /** - * With INTERCEPT_FUNCTIONS the advised internal functions called from the stubs' namespace are shadowed by + * 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 */ @@ -310,49 +311,48 @@ public function testFunctionsOfANamespaceAreProxiedWhenAClassOfItIsLoaded(): voi { $kernel = $this->bootKernel(); $container = $kernel->getContainer(); - $weaver = $container->getService(ZEngineClassWeaver::class); + $weaver = $container->getService(ClassWeaver::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->assertTrue(function_exists('Go\\Driver\\Ffi\\Stubs\\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')); + $proxyFile = $index->functionsFileFor('Go\\Driver\\Ffi\\Stubs'); + $this->assertSame($proxyFile, $weaver->weaveFunctions('Go\\Driver\\Ffi\\Stubs', 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'); + $this->assertNull($weaver->weaveFunctions('Go\\Driver\\Ffi\\Unrelated', self::STUBS_DIR . '/FunctionTarget.php'), 'A namespace the file does not declare'); + $this->assertSame($proxyFile, $weaver->weaveFunctions('Go\\Driver\\Ffi\\Stubs', $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($proxyFile, $warm->weaveFunctions('Go\\Driver\\Ffi\\Stubs', 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 */ - private function weaverWithFreshIndex(InProcessKernel $kernel): ZEngineClassWeaver + private function weaverWithFreshIndex(InProcessKernel $kernel): ClassWeaver { $container = $kernel->getContainer(); - return new ZEngineClassWeaver( + return new ClassWeaver( $container, $container->getService(AdviceMatcher::class), - $container->getService(CachedAspectLoader::class), + $container->getService(AdvisorIndex::class), $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/ZEngineDriverInProcessTest.php b/packages/ffi-driver/tests/InProcess/FfiDriverInProcessTest.php similarity index 54% rename from tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php rename to packages/ffi-driver/tests/InProcess/FfiDriverInProcessTest.php index 6b91a76e..77d9c591 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineDriverInProcessTest.php +++ b/packages/ffi-driver/tests/InProcess/FfiDriverInProcessTest.php @@ -10,49 +10,48 @@ * 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; +namespace Go\Driver\Ffi\InProcess; + +use Go\Driver\Ffi\FfiComposerLoader; +use Go\Driver\Ffi\DonorCacheIndex; +use Go\Driver\Ffi\MethodTableRewriter; +use Go\Driver\Ffi\ClassWeaver; +use Go\Driver\Ffi\FfiDriver; use PHPUnit\Framework\Attributes\Group; /** - * The boot of the zengine driver inside the test runner + * The boot of the ffi driver inside the test runner */ -#[Group('zengine')] -final class ZEngineDriverInProcessTest extends ZEngineInProcessTestCase +#[Group('ffi')] +final class FfiDriverInProcessTest extends InProcessTestCase { public function testBootRegistersTheDriverAndReportsWhatItDid(): void { $kernel = $this->bootKernel(); $container = $kernel->getContainer(); - $report = ZEngineDriver::getBootReport($container); - $this->assertSame('zengine', $report['driver']); + $report = FfiDriver::getBootReport($container); + $this->assertSame(FfiDriver::class, $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) { + foreach ([ClassWeaver::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'); + $this->assertSame($container->getService(ClassWeaver::class), $container->getService(ClassWeaver::class), 'One weaver per container'); $wrappers = 0; foreach (spl_autoload_functions() as $loader) { - if (is_array($loader) && $loader[0] instanceof ZEngineComposerLoader) { + if (is_array($loader) && $loader[0] instanceof FfiComposerLoader) { $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']); + $kernel->init(['driver' => \Go\Driver\StreamFilter\StreamFilterDriver::class, 'cacheDir' => $this->cacheDir]); + $this->assertSame(FfiDriver::class, $kernel->getOptions()['driver']); } } diff --git a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php b/packages/ffi-driver/tests/InProcess/InProcessTestCase.php similarity index 82% rename from tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php rename to packages/ffi-driver/tests/InProcess/InProcessTestCase.php index 010c5f13..40293a64 100644 --- a/tests/Instrument/ZEngine/InProcess/ZEngineInProcessTestCase.php +++ b/packages/ffi-driver/tests/InProcess/InProcessTestCase.php @@ -10,24 +10,23 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine\InProcess; +namespace Go\Driver\Ffi\InProcess; use Go\Aop\Exception\InvalidConfigurationException; -use Go\Aop\Features; -use Go\Aop\WeavingDriver; +use Go\Driver\Ffi\FfiDriver; use Go\Core\AspectKernel; -use Go\Instrument\ZEngine\DonorCacheIndex; +use Go\Driver\Ffi\DonorCacheIndex; use Go\PhpUnit\UsesTemporaryDirectory; -use Go\Stubs\ZEngine\InProcessAspect; -use Go\Stubs\ZEngine\InProcessKernel; +use Go\Driver\Ffi\Stubs\InProcessAspect; +use Go\Driver\Ffi\Stubs\InProcessKernel; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionProperty; 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 + * Base of the tests running the ffi driver INSIDE the test runner: the engine is booted in this process and + * the stub classes of tests/Stubs 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 @@ -35,13 +34,13 @@ * 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. + * not installed, FFI disabled, the opcache JIT active, or a z-engine release of another PHP minor. */ -abstract class ZEngineInProcessTestCase extends TestCase +abstract class InProcessTestCase extends TestCase { use UsesTemporaryDirectory; - protected const string STUBS_DIR = __DIR__ . '/../../../Stubs/ZEngine'; + protected const string STUBS_DIR = __DIR__ . '/../Stubs'; private static ?string $engineUnavailableReason = null; @@ -70,7 +69,7 @@ protected function setUp(): void self::markTestSkipped($reason); } $this->originalLoaders = spl_autoload_functions(); - $this->cacheDir = self::createTemporaryDirectory('zengine-in-process'); + $this->cacheDir = self::createTemporaryDirectory('ffi-in-process'); InProcessAspect::$log = []; } @@ -91,7 +90,7 @@ protected function tearDown(): void } /** - * Boots a kernel for the zengine driver weaving the stub classes of tests/Stubs/ZEngine, and makes it the + * Boots a kernel for the ffi driver weaving the stub classes of tests/Stubs, and makes it the * kernel singleton of the process until tearDown() */ protected function bootKernel(): InProcessKernel @@ -103,17 +102,16 @@ protected function bootKernel(): InProcessKernel $kernel = new ReflectionClass(InProcessKernel::class)->newInstanceWithoutConstructor(); try { - // The first boot of the process initializes the engine (ZEngineDriver::boot() calls Core::init()) + // The first boot of the process initializes the engine (FfiDriver::boot() calls Core::init()) $kernel->init([ - 'driver' => WeavingDriver::ZEngine, + 'driver' => FfiDriver::class, 'debug' => true, '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(); + self::$engineUnavailableReason = 'The ffi driver cannot boot in this process: ' . $failure->getMessage(); self::markTestSkipped(self::$engineUnavailableReason); } $singleton = self::singletonProperty(); @@ -140,7 +138,7 @@ private static function engineUnavailableReason(): ?string 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)'; + . 'composer require --dev lisachenko/z-engine (see docs/driver/ffi.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) { diff --git a/tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php b/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php similarity index 77% rename from tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php rename to packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php index 95acd08b..93a312f8 100644 --- a/tests/Instrument/ZEngine/InProcess/MethodTableRewriterInProcessTest.php +++ b/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php @@ -10,12 +10,12 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine\InProcess; +namespace Go\Driver\Ffi\InProcess; -use Go\Aop\Exception\UnsupportedJoinpointException; -use Go\Instrument\ZEngine\MethodTableRewriter; -use Go\Stubs\ZEngine\RewriterDonor; -use Go\Stubs\ZEngine\RewriterTarget; +use Go\Driver\Ffi\Exception\UnsupportedJoinpointException; +use Go\Driver\Ffi\MethodTableRewriter; +use Go\Driver\Ffi\Stubs\RewriterDonor; +use Go\Driver\Ffi\Stubs\RewriterTarget; use PHPUnit\Framework\Attributes\Group; use ReflectionClass; use ReflectionException; @@ -23,8 +23,8 @@ /** * The engine refusals the rewriter translates for the driver */ -#[Group('zengine')] -final class MethodTableRewriterInProcessTest extends ZEngineInProcessTestCase +#[Group('ffi')] +final class MethodTableRewriterInProcessTest extends InProcessTestCase { public function testAClassMissingFromTheEngineClassTableIsRefused(): void { @@ -33,10 +33,10 @@ public function testAClassMissingFromTheEngineClassTableIsRefused(): void 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']); + new MethodTableRewriter()->rewire('Go\Driver\Ffi\Stubs\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('Go\Driver\Ffi\Stubs\NeverDeclared', $refusal->getMessage()); $this->assertStringContainsString('not published in the engine class table', $refusal->getMessage()); $this->assertInstanceOf(ReflectionException::class, $refusal->getPrevious()); } diff --git a/tests/Instrument/ZEngine/JitGuardTest.php b/packages/ffi-driver/tests/JitGuardTest.php similarity index 64% rename from tests/Instrument/ZEngine/JitGuardTest.php rename to packages/ffi-driver/tests/JitGuardTest.php index 5d2141d8..a6627ddf 100644 --- a/tests/Instrument/ZEngine/JitGuardTest.php +++ b/packages/ffi-driver/tests/JitGuardTest.php @@ -10,17 +10,13 @@ * with this source code in the file LICENSE. */ -namespace Go\Instrument\ZEngine; +namespace Go\Driver\Ffi; -use Go\Aop\Exception\InvalidConfigurationException; +use Go\Driver\Ffi\Exception\EngineConfigurationException; use PHPUnit\Framework\TestCase; final class JitGuardTest extends TestCase { - private const int PHP_84 = 80400; - - private const int PHP_85 = 80511; - /** * @var list */ @@ -28,7 +24,7 @@ final class JitGuardTest extends TestCase public function testNothingToDoWhenTheJitIsOff(): void { - $report = JitGuard::enforce(self::PHP_84, self::jitStatus(false), $this->iniWriter()); + $report = JitGuard::enforce(self::jitStatus(false), $this->iniWriter()); $this->assertSame(['jitWasActive' => false, 'previousMode' => null, 'disabledAtRuntime' => false], $report); $this->assertSame([], $this->iniWrites); @@ -36,55 +32,37 @@ public function testNothingToDoWhenTheJitIsOff(): void public function testNothingToDoWithoutOpcache(): void { - $report = JitGuard::enforce(self::PHP_85, static fn(): false => false, $this->iniWriter()); + $report = JitGuard::enforce(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 + public function testAnActiveJitIsSwitchedOffAtRuntime(): 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')); + $report = JitGuard::enforce($reader, $this->iniWriter('tracing')); $this->assertSame(['jitWasActive' => true, 'previousMode' => 'tracing', 'disabledAtRuntime' => true], $report); $this->assertSame([['opcache.jit', 'off']], $this->iniWrites); } - public function testAJitThatStaysOnAfterTheSwitchIsRefusedOnPhp85(): void + public function testAJitThatStaysOnAfterTheSwitchIsRefused(): void { try { - JitGuard::enforce(self::PHP_85, self::jitStatus(true), $this->iniWriter('tracing')); + JitGuard::enforce(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()); + } catch (EngineConfigurationException $exception) { + $this->assertStringContainsString('could not switch it off at runtime', $exception->getMessage()); + $this->assertStringContainsString('opcache.jit=off and opcache.jit_buffer_size=0', $exception->getMessage()); } $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/FallbackTarget.php b/packages/ffi-driver/tests/Stubs/FallbackTarget.php similarity index 88% rename from tests/Stubs/ZEngine/FallbackTarget.php rename to packages/ffi-driver/tests/Stubs/FallbackTarget.php index dfd77f84..fb3cb487 100644 --- a/tests/Stubs/ZEngine/FallbackTarget.php +++ b/packages/ffi-driver/tests/Stubs/FallbackTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * Woven from native reflection: the weaver is handed a source file the parser cannot read diff --git a/tests/Stubs/ZEngine/FunctionTarget.php b/packages/ffi-driver/tests/Stubs/FunctionTarget.php similarity index 95% rename from tests/Stubs/ZEngine/FunctionTarget.php rename to packages/ffi-driver/tests/Stubs/FunctionTarget.php index f885f62a..837b78e7 100644 --- a/tests/Stubs/ZEngine/FunctionTarget.php +++ b/packages/ffi-driver/tests/Stubs/FunctionTarget.php @@ -10,7 +10,7 @@ * with this source code in the file LICENSE. */ -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * Calls an advised internal function: unqualified (served by the namespaced proxy) and qualified (never intercepted) diff --git a/tests/Stubs/ZEngine/InProcessAspect.php b/packages/ffi-driver/tests/Stubs/InProcessAspect.php similarity index 59% rename from tests/Stubs/ZEngine/InProcessAspect.php rename to packages/ffi-driver/tests/Stubs/InProcessAspect.php index 543ec2e5..721d9ad8 100644 --- a/tests/Stubs/ZEngine/InProcessAspect.php +++ b/packages/ffi-driver/tests/Stubs/InProcessAspect.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Aop\Aspect; use Go\Aop\Intercept\FunctionInvocation; @@ -10,7 +10,7 @@ use Go\Lang\Attribute as Pointcut; /** - * Method-execution advices for the in-process tests of the z-engine driver + * Method-execution advices for the in-process tests of the ffi driver */ final class InProcessAspect implements Aspect { @@ -19,16 +19,16 @@ final class InProcessAspect implements Aspect */ 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->*(*)) - || execution(public Go\Stubs\ZEngine\FallbackTarget->*(*))")] + #[Pointcut\Before("execution(public Go\Driver\Ffi\Stubs\WovenTarget->*(*)) || execution(public Go\Driver\Ffi\Stubs\WovenChild->*(*)) + || execution(public Go\Driver\Ffi\Stubs\WovenChild::*(*)) + || execution(public Go\Driver\Ffi\Stubs\LateTarget->*(*)) || execution(public Go\Driver\Ffi\Stubs\WarmTarget->*(*)) + || execution(public Go\Driver\Ffi\Stubs\FallbackTarget->*(*))")] public function beforeMethod(MethodInvocation $invocation): void { self::$log[] = $invocation->getMethod()->class . '::' . $invocation->getMethod()->name; } - #[Pointcut\Around("execution(public Go\Stubs\ZEngine\WovenTarget->greet(*))")] + #[Pointcut\Around("execution(public Go\Driver\Ffi\Stubs\WovenTarget->greet(*))")] public function aroundGreet(MethodInvocation $invocation): string { return strtoupper((string) $invocation->proceed()); @@ -37,7 +37,7 @@ public function aroundGreet(MethodInvocation $invocation): string /** * Function interception: an internal function called unqualified from the stubs' namespace */ - #[Pointcut\Around("execution(Go\Stubs\ZEngine\str_rot13(*))")] + #[Pointcut\Around("execution(Go\Driver\Ffi\Stubs\str_rot13(*))")] public function aroundRot13(FunctionInvocation $invocation): mixed { self::$log[] = 'around str_rot13 with ' . json_encode($invocation->getArguments()); diff --git a/tests/Stubs/ZEngine/InProcessKernel.php b/packages/ffi-driver/tests/Stubs/InProcessKernel.php similarity index 83% rename from tests/Stubs/ZEngine/InProcessKernel.php rename to packages/ffi-driver/tests/Stubs/InProcessKernel.php index 9322e83b..6a12ad0c 100644 --- a/tests/Stubs/ZEngine/InProcessKernel.php +++ b/packages/ffi-driver/tests/Stubs/InProcessKernel.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Core\AspectContainer; use Go\Core\AspectKernel; /** - * Kernel of the in-process z-engine driver tests: created without the singleton, see ZEngineInProcessTestCase + * Kernel of the in-process ffi driver tests: created without the singleton, see InProcessTestCase */ final class InProcessKernel extends AspectKernel { diff --git a/tests/Stubs/ZEngine/LateTarget.php b/packages/ffi-driver/tests/Stubs/LateTarget.php similarity index 76% rename from tests/Stubs/ZEngine/LateTarget.php rename to packages/ffi-driver/tests/Stubs/LateTarget.php index 471f04ef..edbeedba 100644 --- a/tests/Stubs/ZEngine/LateTarget.php +++ b/packages/ffi-driver/tests/Stubs/LateTarget.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** - * Loaded by a plain require before the kernel exists, woven on demand through ZEngineClassWeaver::weave() + * Loaded by a plain require before the kernel exists, woven on demand through ClassWeaver::weave() */ final class LateTarget { diff --git a/tests/Stubs/ZEngine/MatcherErrorAdvisor.php b/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php similarity index 98% rename from tests/Stubs/ZEngine/MatcherErrorAdvisor.php rename to packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php index 2d84a983..e9535b3c 100644 --- a/tests/Stubs/ZEngine/MatcherErrorAdvisor.php +++ b/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Aop\Advice; use Go\Aop\Framework\BeforeInterceptor; diff --git a/tests/Stubs/ZEngine/MatcherErrorAspect.php b/packages/ffi-driver/tests/Stubs/MatcherErrorAspect.php similarity index 90% rename from tests/Stubs/ZEngine/MatcherErrorAspect.php rename to packages/ffi-driver/tests/Stubs/MatcherErrorAspect.php index 20441dd7..388b95f7 100644 --- a/tests/Stubs/ZEngine/MatcherErrorAspect.php +++ b/packages/ffi-driver/tests/Stubs/MatcherErrorAspect.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Aop\Aspect; diff --git a/tests/Stubs/ZEngine/MatcherErrorTarget.php b/packages/ffi-driver/tests/Stubs/MatcherErrorTarget.php similarity index 87% rename from tests/Stubs/ZEngine/MatcherErrorTarget.php rename to packages/ffi-driver/tests/Stubs/MatcherErrorTarget.php index 83cb1522..7b269ea4 100644 --- a/tests/Stubs/ZEngine/MatcherErrorTarget.php +++ b/packages/ffi-driver/tests/Stubs/MatcherErrorTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * An ordinary class the MatcherErrorAdvisor pointcut fails on: its load fails diff --git a/tests/Stubs/ZEngine/ReadonlyPoint.php b/packages/ffi-driver/tests/Stubs/ReadonlyPoint.php similarity index 92% rename from tests/Stubs/ZEngine/ReadonlyPoint.php rename to packages/ffi-driver/tests/Stubs/ReadonlyPoint.php index a8c78a97..e4e1c29b 100644 --- a/tests/Stubs/ZEngine/ReadonlyPoint.php +++ b/packages/ffi-driver/tests/Stubs/ReadonlyPoint.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * A readonly class: its donor must be readonly too (a readonly parent requires a readonly child) diff --git a/tests/Stubs/ZEngine/RewriterDonor.php b/packages/ffi-driver/tests/Stubs/RewriterDonor.php similarity index 86% rename from tests/Stubs/ZEngine/RewriterDonor.php rename to packages/ffi-driver/tests/Stubs/RewriterDonor.php index cfb8ba43..7d76f095 100644 --- a/tests/Stubs/ZEngine/RewriterDonor.php +++ b/packages/ffi-driver/tests/Stubs/RewriterDonor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * A donor without the method the rewriter is asked for: the engine refuses the rewiring diff --git a/tests/Stubs/ZEngine/RewriterTarget.php b/packages/ffi-driver/tests/Stubs/RewriterTarget.php similarity index 81% rename from tests/Stubs/ZEngine/RewriterTarget.php rename to packages/ffi-driver/tests/Stubs/RewriterTarget.php index 9e79f36b..1d767f93 100644 --- a/tests/Stubs/ZEngine/RewriterTarget.php +++ b/packages/ffi-driver/tests/Stubs/RewriterTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; final class RewriterTarget { diff --git a/tests/Stubs/ZEngine/SelfMatchingAspect.php b/packages/ffi-driver/tests/Stubs/SelfMatchingAspect.php similarity index 78% rename from tests/Stubs/ZEngine/SelfMatchingAspect.php rename to packages/ffi-driver/tests/Stubs/SelfMatchingAspect.php index 1a8c5702..51b9e0df 100644 --- a/tests/Stubs/ZEngine/SelfMatchingAspect.php +++ b/packages/ffi-driver/tests/Stubs/SelfMatchingAspect.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Aop\Aspect; use Go\Aop\Intercept\MethodInvocation; @@ -13,7 +13,7 @@ */ final class SelfMatchingAspect implements Aspect { - #[Pointcut\Before("execution(public Go\Stubs\ZEngine\SelfMatchingAspect->*(*))")] + #[Pointcut\Before("execution(public Go\Driver\Ffi\Stubs\SelfMatchingAspect->*(*))")] public function beforeOwnMethod(MethodInvocation $invocation): void {} public function helper(): string diff --git a/tests/Stubs/ZEngine/UnadvisedTarget.php b/packages/ffi-driver/tests/Stubs/UnadvisedTarget.php similarity index 81% rename from tests/Stubs/ZEngine/UnadvisedTarget.php rename to packages/ffi-driver/tests/Stubs/UnadvisedTarget.php index 80f788eb..dd3a8748 100644 --- a/tests/Stubs/ZEngine/UnadvisedTarget.php +++ b/packages/ffi-driver/tests/Stubs/UnadvisedTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; final class UnadvisedTarget { diff --git a/tests/Stubs/ZEngine/UnsupportedAspect.php b/packages/ffi-driver/tests/Stubs/UnsupportedAspect.php similarity index 56% rename from tests/Stubs/ZEngine/UnsupportedAspect.php rename to packages/ffi-driver/tests/Stubs/UnsupportedAspect.php index 2065f215..877bc8bb 100644 --- a/tests/Stubs/ZEngine/UnsupportedAspect.php +++ b/packages/ffi-driver/tests/Stubs/UnsupportedAspect.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; use Go\Aop\Aspect; use Go\Aop\Intercept\FieldAccess; use Go\Lang\Attribute as Pointcut; /** - * A property-access advice: the z-engine driver refuses the class it matches + * A property-access advice: the ffi driver refuses the class it matches */ final class UnsupportedAspect implements Aspect { - #[Pointcut\Before("access(public Go\Stubs\ZEngine\UnsupportedTarget->counter)")] + #[Pointcut\Before("access(public Go\Driver\Ffi\Stubs\UnsupportedTarget->counter)")] public function beforeCounterAccess(FieldAccess $access): void {} } diff --git a/tests/Stubs/ZEngine/UnsupportedTarget.php b/packages/ffi-driver/tests/Stubs/UnsupportedTarget.php similarity index 75% rename from tests/Stubs/ZEngine/UnsupportedTarget.php rename to packages/ffi-driver/tests/Stubs/UnsupportedTarget.php index 16beaa64..bad1a544 100644 --- a/tests/Stubs/ZEngine/UnsupportedTarget.php +++ b/packages/ffi-driver/tests/Stubs/UnsupportedTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; final class UnsupportedTarget { diff --git a/tests/Stubs/ZEngine/WarmTarget.php b/packages/ffi-driver/tests/Stubs/WarmTarget.php similarity index 88% rename from tests/Stubs/ZEngine/WarmTarget.php rename to packages/ffi-driver/tests/Stubs/WarmTarget.php index db7ac920..a76dc8ef 100644 --- a/tests/Stubs/ZEngine/WarmTarget.php +++ b/packages/ffi-driver/tests/Stubs/WarmTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; /** * Woven from a donor record the test wrote before the kernel booted: the warm path of the weaver diff --git a/tests/Stubs/ZEngine/WovenChild.php b/packages/ffi-driver/tests/Stubs/WovenChild.php similarity index 87% rename from tests/Stubs/ZEngine/WovenChild.php rename to packages/ffi-driver/tests/Stubs/WovenChild.php index 17cbeffb..7b8bb360 100644 --- a/tests/Stubs/ZEngine/WovenChild.php +++ b/packages/ffi-driver/tests/Stubs/WovenChild.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; final class WovenChild extends WovenParent { diff --git a/tests/Stubs/ZEngine/WovenParent.php b/packages/ffi-driver/tests/Stubs/WovenParent.php similarity index 88% rename from tests/Stubs/ZEngine/WovenParent.php rename to packages/ffi-driver/tests/Stubs/WovenParent.php index 1b47504f..de50805f 100644 --- a/tests/Stubs/ZEngine/WovenParent.php +++ b/packages/ffi-driver/tests/Stubs/WovenParent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; class WovenParent { diff --git a/tests/Stubs/ZEngine/WovenTarget.php b/packages/ffi-driver/tests/Stubs/WovenTarget.php similarity index 90% rename from tests/Stubs/ZEngine/WovenTarget.php rename to packages/ffi-driver/tests/Stubs/WovenTarget.php index 8da31611..2ca006ce 100644 --- a/tests/Stubs/ZEngine/WovenTarget.php +++ b/packages/ffi-driver/tests/Stubs/WovenTarget.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Go\Stubs\ZEngine; +namespace Go\Driver\Ffi\Stubs; final class WovenTarget { diff --git a/tests/Stubs/ZEngine/broken-source.txt b/packages/ffi-driver/tests/Stubs/broken-source.txt similarity index 100% rename from tests/Stubs/ZEngine/broken-source.txt rename to packages/ffi-driver/tests/Stubs/broken-source.txt diff --git a/phpstan-baseline-strict.php b/phpstan-baseline-strict.php index 700c4792..1a815e11 100644 --- a/phpstan-baseline-strict.php +++ b/phpstan-baseline-strict.php @@ -295,12 +295,6 @@ '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 0e5db1a6..b7003d82 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,12 +9,10 @@ parameters: paths: - bin/aspect - demos + - packages/stream-wrapper-driver - 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 + # The ffi driver package needs lisachenko/z-engine for its analysis: `composer analyze:ffi` (packages/ffi-driver/phpstan.neon) 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 deleted file mode 100644 index 3be61c44..00000000 --- a/phpstan/zengine-stubs.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * 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 {} - - public function addInterfaces(string ...$interfaceNames): void {} - } -} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8c22b9ca..3c3e6536 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,9 +1,20 @@ - + ./tests/ + + + ./packages/stream-wrapper-driver/tests/ + + + + ./packages/ffi-driver/tests/ + @@ -21,17 +32,13 @@ ratios from growing. --> transformation-performance - - zengine ./src/ + ./packages/stream-wrapper-driver/src/ + ./packages/ffi-driver/src/ diff --git a/src/Aop/Exception/InvalidConfigurationException.php b/src/Aop/Exception/InvalidConfigurationException.php index f02fe7b4..3e97c718 100644 --- a/src/Aop/Exception/InvalidConfigurationException.php +++ b/src/Aop/Exception/InvalidConfigurationException.php @@ -13,6 +13,8 @@ namespace Go\Aop\Exception; use Go\Core\AspectContainer; +use Go\Driver\SourceTransformingDriver; +use Go\Driver\WeavingDriver; use InvalidArgumentException; /** @@ -88,6 +90,37 @@ public static function containerClassNotAspectContainer(string $containerClass): return new self(sprintf('Container class "%s" must extend %s.', $containerClass, AspectContainer::class)); } + public static function driverClassNotFound(mixed $driverClass): self + { + return new self(sprintf( + 'Option "driver" must name the class of a weaving driver (an implementation of %s), got %s.', + WeavingDriver::class, + is_string($driverClass) ? 'the unknown class "' . $driverClass . '"' : get_debug_type($driverClass), + )); + } + + public static function driverClassNotWeavingDriver(string $driverClass): self + { + return new self(sprintf('Weaving driver class "%s" must implement %s.', $driverClass, WeavingDriver::class)); + } + + public static function cacheWarmerRequiresSourceTransformingDriver(string $driverClass): self + { + return new self(sprintf( + 'The cache warmer transforms sources through the transformer chain, which the weaving driver %s never ' + . 'uses (it is no %s), so there is nothing to warm up ahead of time. Warm the application by loading ' + . 'its classes (e.g. a smoke request), or warm the cache with a kernel configured for a ' + . 'source-transforming driver.', + $driverClass, + SourceTransformingDriver::class, + )); + } + + public static function cacheIndexWithoutCacheDir(string $relativeFileName): self + { + return new self(sprintf('The cache index %s needs the `cacheDir` option to be configured.', $relativeFileName)); + } + public static function invalidLazyServiceId(string $id): self { return new self("Lazy service id must be a valid class name, \"{$id}\" given"); diff --git a/src/Aop/Exception/UnsupportedJoinpointException.php b/src/Aop/Exception/UnsupportedJoinpointException.php deleted file mode 100644 index d653b14f..00000000 --- a/src/Aop/Exception/UnsupportedJoinpointException.php +++ /dev/null @@ -1,53 +0,0 @@ - - * - * 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/Pointcut/PointcutParseTable.php b/src/Aop/Pointcut/PointcutParseTable.php index 46921d20..be9bb173 100644 --- a/src/Aop/Pointcut/PointcutParseTable.php +++ b/src/Aop/Pointcut/PointcutParseTable.php @@ -1 +1,4 @@ -[0=>['!'=>4,'('=>6,'access'=>19,'annotation'=>20,'execution'=>21,'within'=>22,'initialization'=>23,'staticinitialization'=>24,'matchInherited'=>25,'matchReturningByReference'=>26,'namePart'=>28,],1=>['||'=>29,'$eof'=>0,],2=>['&&'=>30,'$eof'=>-2,'||'=>-2,')'=>-2,],4=>['('=>6,'access'=>19,'annotation'=>20,'execution'=>21,'within'=>22,'initialization'=>23,'staticinitialization'=>24,'matchInherited'=>25,'matchReturningByReference'=>26,'namePart'=>28,],6=>['!'=>4,'('=>6,'access'=>19,'annotation'=>20,'execution'=>21,'within'=>22,'initialization'=>23,'staticinitialization'=>24,'matchInherited'=>25,'matchReturningByReference'=>26,'namePart'=>28,],19=>['('=>33,],20=>['access'=>34,'execution'=>35,'within'=>36,],21=>['('=>37,],22=>['('=>38,],23=>['('=>39,],24=>['('=>40,],25=>['('=>41,],26=>['('=>42,],27=>['->'=>43,'nsSeparator'=>44,],29=>['!'=>4,'('=>6,'access'=>19,'annotation'=>20,'execution'=>21,'within'=>22,'initialization'=>23,'staticinitialization'=>24,'matchInherited'=>25,'matchReturningByReference'=>26,'namePart'=>28,],30=>['!'=>4,'('=>6,'access'=>19,'annotation'=>20,'execution'=>21,'within'=>22,'initialization'=>23,'staticinitialization'=>24,'matchInherited'=>25,'matchReturningByReference'=>26,'namePart'=>28,],32=>[')'=>47,'||'=>29,],33=>['public'=>53,'protected'=>54,'private'=>55,'final'=>56,'readonly'=>57,'private(set)'=>58,'protected(set)'=>59,],34=>['('=>60,],35=>['('=>61,],36=>['('=>62,],37=>['**'=>67,'*'=>69,'namePart'=>70,'public'=>53,'protected'=>54,'private'=>55,'final'=>56,'readonly'=>57,'private(set)'=>58,'protected(set)'=>59,],38=>['**'=>67,'*'=>69,'namePart'=>70,],39=>['**'=>67,'*'=>69,'namePart'=>70,],40=>['**'=>67,'*'=>69,'namePart'=>70,],41=>[')'=>75,],42=>[')'=>76,],43=>['*'=>69,'namePart'=>70,],44=>['namePart'=>78,],45=>['&&'=>30,'$eof'=>-1,'||'=>-1,')'=>-1,],48=>[')'=>79,],50=>['**'=>67,'*'=>69,'namePart'=>70,],51=>['public'=>53,'protected'=>54,'private'=>55,'final'=>56,'readonly'=>57,'private(set)'=>58,'protected(set)'=>59,'**'=>-60,'*'=>-60,'namePart'=>-60,],52=>['|'=>82,'**'=>-62,'*'=>-62,'namePart'=>-62,'public'=>-62,'protected'=>-62,'private'=>-62,'final'=>-62,'readonly'=>-62,'private(set)'=>-62,'protected(set)'=>-62,],60=>['namePart'=>28,],61=>['namePart'=>28,],62=>['namePart'=>28,],63=>[')'=>86,],64=>[')'=>87,],65=>['('=>88,],66=>['nsSeparator'=>89,],68=>['*'=>90,'namePart'=>91,'|'=>92,'nsSeparator'=>-44,')'=>-44,'+'=>-44,'::'=>-44,'->'=>-44,],71=>[')'=>93,],72=>['+'=>94,'nsSeparator'=>95,')'=>-38,'::'=>-38,'->'=>-38,],73=>[')'=>96,],74=>[')'=>97,],77=>['*'=>90,'namePart'=>91,'|'=>92,'$eof'=>-31,'||'=>-31,'&&'=>-31,')'=>-31,],80=>['::'=>99,'->'=>100,],82=>['public'=>53,'protected'=>54,'private'=>55,'final'=>56,'readonly'=>57,'private(set)'=>58,'protected(set)'=>59,],83=>[')'=>102,'nsSeparator'=>44,],84=>[')'=>103,'nsSeparator'=>44,],85=>[')'=>104,'nsSeparator'=>44,],88=>['*'=>106,],89=>['**'=>108,'*'=>69,'namePart'=>70,],92=>['namePart'=>109,],95=>['**'=>108,'*'=>69,'namePart'=>70,],98=>['*'=>69,'namePart'=>70,],105=>[')'=>112,],107=>['('=>113,'*'=>90,'namePart'=>91,'|'=>92,'nsSeparator'=>-45,],110=>['*'=>90,'namePart'=>91,'|'=>92,')'=>-45,'+'=>-45,'nsSeparator'=>-45,'::'=>-45,'->'=>-45,],111=>['*'=>90,'namePart'=>91,'|'=>92,')'=>-37,'('=>-37,],112=>[':'=>114,')'=>-33,],113=>['*'=>106,],114=>['?'=>118,'namePart'=>28,],115=>[')'=>120,],116=>['|'=>121,')'=>-34,],117=>['&'=>122,')'=>-56,'|'=>-56,],118=>['namePart'=>28,],119=>['nsSeparator'=>44,')'=>-54,'|'=>-54,'&'=>-54,],120=>[':'=>124,')'=>-35,],121=>['namePart'=>28,],122=>['namePart'=>28,],123=>['nsSeparator'=>44,')'=>-58,'|'=>-58,],124=>['?'=>118,'namePart'=>28,],125=>['&'=>122,')'=>-57,'|'=>-57,],126=>['nsSeparator'=>44,')'=>-55,'|'=>-55,'&'=>-55,],127=>['|'=>121,')'=>-36,],3=>['$eof'=>-4,'||'=>-4,'&&'=>-4,')'=>-4,],5=>['$eof'=>-6,'||'=>-6,'&&'=>-6,')'=>-6,],7=>['$eof'=>-8,'||'=>-8,'&&'=>-8,')'=>-8,],8=>['$eof'=>-9,'||'=>-9,'&&'=>-9,')'=>-9,],9=>['$eof'=>-10,'||'=>-10,'&&'=>-10,')'=>-10,],10=>['$eof'=>-11,'||'=>-11,'&&'=>-11,')'=>-11,],11=>['$eof'=>-12,'||'=>-12,'&&'=>-12,')'=>-12,],12=>['$eof'=>-13,'||'=>-13,'&&'=>-13,')'=>-13,],13=>['$eof'=>-14,'||'=>-14,'&&'=>-14,')'=>-14,],14=>['$eof'=>-15,'||'=>-15,'&&'=>-15,')'=>-15,],15=>['$eof'=>-16,'||'=>-16,'&&'=>-16,')'=>-16,],16=>['$eof'=>-17,'||'=>-17,'&&'=>-17,')'=>-17,],17=>['$eof'=>-18,'||'=>-18,'&&'=>-18,')'=>-18,],18=>['$eof'=>-19,'||'=>-19,'&&'=>-19,')'=>-19,],28=>['->'=>-52,'nsSeparator'=>-52,')'=>-52,'|'=>-52,'&'=>-52,],31=>['$eof'=>-5,'||'=>-5,'&&'=>-5,')'=>-5,],46=>['$eof'=>-3,'||'=>-3,'&&'=>-3,')'=>-3,],47=>['$eof'=>-7,'||'=>-7,'&&'=>-7,')'=>-7,],49=>[')'=>-32,],53=>['**'=>-63,'*'=>-63,'namePart'=>-63,'public'=>-63,'protected'=>-63,'private'=>-63,'final'=>-63,'readonly'=>-63,'private(set)'=>-63,'protected(set)'=>-63,'|'=>-63,],54=>['**'=>-64,'*'=>-64,'namePart'=>-64,'public'=>-64,'protected'=>-64,'private'=>-64,'final'=>-64,'readonly'=>-64,'private(set)'=>-64,'protected(set)'=>-64,'|'=>-64,],55=>['**'=>-65,'*'=>-65,'namePart'=>-65,'public'=>-65,'protected'=>-65,'private'=>-65,'final'=>-65,'readonly'=>-65,'private(set)'=>-65,'protected(set)'=>-65,'|'=>-65,],56=>['**'=>-66,'*'=>-66,'namePart'=>-66,'public'=>-66,'protected'=>-66,'private'=>-66,'final'=>-66,'readonly'=>-66,'private(set)'=>-66,'protected(set)'=>-66,'|'=>-66,],57=>['**'=>-67,'*'=>-67,'namePart'=>-67,'public'=>-67,'protected'=>-67,'private'=>-67,'final'=>-67,'readonly'=>-67,'private(set)'=>-67,'protected(set)'=>-67,'|'=>-67,],58=>['**'=>-68,'*'=>-68,'namePart'=>-68,'public'=>-68,'protected'=>-68,'private'=>-68,'final'=>-68,'readonly'=>-68,'private(set)'=>-68,'protected(set)'=>-68,'|'=>-68,],59=>['**'=>-69,'*'=>-69,'namePart'=>-69,'public'=>-69,'protected'=>-69,'private'=>-69,'final'=>-69,'readonly'=>-69,'private(set)'=>-69,'protected(set)'=>-69,'|'=>-69,],67=>['nsSeparator'=>-43,')'=>-43,'+'=>-43,'::'=>-43,'->'=>-43,],69=>['$eof'=>-47,'||'=>-47,'&&'=>-47,')'=>-47,'('=>-47,'nsSeparator'=>-47,'*'=>-47,'namePart'=>-47,'|'=>-47,'+'=>-47,'::'=>-47,'->'=>-47,],70=>['$eof'=>-48,'||'=>-48,'&&'=>-48,')'=>-48,'('=>-48,'nsSeparator'=>-48,'*'=>-48,'namePart'=>-48,'|'=>-48,'+'=>-48,'::'=>-48,'->'=>-48,],75=>['$eof'=>-29,'||'=>-29,'&&'=>-29,')'=>-29,],76=>['$eof'=>-30,'||'=>-30,'&&'=>-30,')'=>-30,],78=>['->'=>-53,'nsSeparator'=>-53,')'=>-53,'|'=>-53,'&'=>-53,],79=>['$eof'=>-20,'||'=>-20,'&&'=>-20,')'=>-20,],81=>['**'=>-59,'*'=>-59,'namePart'=>-59,],86=>['$eof'=>-21,'||'=>-21,'&&'=>-21,')'=>-21,],87=>['$eof'=>-22,'||'=>-22,'&&'=>-22,')'=>-22,],90=>['$eof'=>-49,'||'=>-49,'&&'=>-49,')'=>-49,'('=>-49,'nsSeparator'=>-49,'*'=>-49,'namePart'=>-49,'|'=>-49,'+'=>-49,'::'=>-49,'->'=>-49,],91=>['$eof'=>-50,'||'=>-50,'&&'=>-50,')'=>-50,'('=>-50,'nsSeparator'=>-50,'*'=>-50,'namePart'=>-50,'|'=>-50,'+'=>-50,'::'=>-50,'->'=>-50,],93=>['$eof'=>-23,'||'=>-23,'&&'=>-23,')'=>-23,],94=>[')'=>-39,'::'=>-39,'->'=>-39,],96=>['$eof'=>-27,'||'=>-27,'&&'=>-27,')'=>-27,],97=>['$eof'=>-28,'||'=>-28,'&&'=>-28,')'=>-28,],99=>['*'=>-41,'namePart'=>-41,],100=>['*'=>-42,'namePart'=>-42,],101=>['**'=>-61,'*'=>-61,'namePart'=>-61,'public'=>-61,'protected'=>-61,'private'=>-61,'final'=>-61,'readonly'=>-61,'private(set)'=>-61,'protected(set)'=>-61,],102=>['$eof'=>-24,'||'=>-24,'&&'=>-24,')'=>-24,],103=>['$eof'=>-25,'||'=>-25,'&&'=>-25,')'=>-25,],104=>['$eof'=>-26,'||'=>-26,'&&'=>-26,')'=>-26,],106=>[')'=>-40,],108=>['nsSeparator'=>-46,')'=>-46,'+'=>-46,'::'=>-46,'->'=>-46,],109=>['$eof'=>-51,'||'=>-51,'&&'=>-51,')'=>-51,'('=>-51,'nsSeparator'=>-51,'*'=>-51,'namePart'=>-51,'|'=>-51,'+'=>-51,'::'=>-51,'->'=>-51,],],'goto'=>[0=>['pointcutExpression'=>1,'conjugatedExpression'=>2,'negatedExpression'=>3,'brakedExpression'=>5,'singlePointcut'=>7,'accessPointcut'=>8,'annotatedAccessPointcut'=>9,'executionPointcut'=>10,'annotatedExecutionPointcut'=>11,'withinPointcut'=>12,'annotatedWithinPointcut'=>13,'initializationPointcut'=>14,'staticInitializationPointcut'=>15,'matchInheritedPointcut'=>16,'matchReturningByReferencePointcut'=>17,'pointcutReference'=>18,'namespaceName'=>27,],4=>['brakedExpression'=>31,'singlePointcut'=>7,'accessPointcut'=>8,'annotatedAccessPointcut'=>9,'executionPointcut'=>10,'annotatedExecutionPointcut'=>11,'withinPointcut'=>12,'annotatedWithinPointcut'=>13,'initializationPointcut'=>14,'staticInitializationPointcut'=>15,'matchInheritedPointcut'=>16,'matchReturningByReferencePointcut'=>17,'pointcutReference'=>18,'namespaceName'=>27,],6=>['pointcutExpression'=>32,'conjugatedExpression'=>2,'negatedExpression'=>3,'brakedExpression'=>5,'singlePointcut'=>7,'accessPointcut'=>8,'annotatedAccessPointcut'=>9,'executionPointcut'=>10,'annotatedExecutionPointcut'=>11,'withinPointcut'=>12,'annotatedWithinPointcut'=>13,'initializationPointcut'=>14,'staticInitializationPointcut'=>15,'matchInheritedPointcut'=>16,'matchReturningByReferencePointcut'=>17,'pointcutReference'=>18,'namespaceName'=>27,],29=>['conjugatedExpression'=>45,'negatedExpression'=>3,'brakedExpression'=>5,'singlePointcut'=>7,'accessPointcut'=>8,'annotatedAccessPointcut'=>9,'executionPointcut'=>10,'annotatedExecutionPointcut'=>11,'withinPointcut'=>12,'annotatedWithinPointcut'=>13,'initializationPointcut'=>14,'staticInitializationPointcut'=>15,'matchInheritedPointcut'=>16,'matchReturningByReferencePointcut'=>17,'pointcutReference'=>18,'namespaceName'=>27,],30=>['negatedExpression'=>46,'brakedExpression'=>5,'singlePointcut'=>7,'accessPointcut'=>8,'annotatedAccessPointcut'=>9,'executionPointcut'=>10,'annotatedExecutionPointcut'=>11,'withinPointcut'=>12,'annotatedWithinPointcut'=>13,'initializationPointcut'=>14,'staticInitializationPointcut'=>15,'matchInheritedPointcut'=>16,'matchReturningByReferencePointcut'=>17,'pointcutReference'=>18,'namespaceName'=>27,],33=>['propertyAccessReference'=>48,'memberReference'=>49,'memberModifiers'=>50,'modifierGroup'=>51,'memberModifier'=>52,],37=>['methodExecutionReference'=>63,'functionExecutionReference'=>64,'memberReference'=>65,'namespacePattern'=>66,'memberModifiers'=>50,'namePatternPart'=>68,'modifierGroup'=>51,'memberModifier'=>52,],38=>['classFilter'=>71,'namespacePattern'=>72,'namePatternPart'=>68,],39=>['classFilter'=>73,'namespacePattern'=>72,'namePatternPart'=>68,],40=>['classFilter'=>74,'namespacePattern'=>72,'namePatternPart'=>68,],43=>['namePatternPart'=>77,],50=>['classFilter'=>80,'namespacePattern'=>72,'namePatternPart'=>68,],51=>['memberModifiers'=>81,'modifierGroup'=>51,'memberModifier'=>52,],60=>['namespaceName'=>83,],61=>['namespaceName'=>84,],62=>['namespaceName'=>85,],80=>['memberAccessType'=>98,],82=>['modifierGroup'=>101,'memberModifier'=>52,],88=>['argumentList'=>105,],89=>['namePatternPart'=>107,],95=>['namePatternPart'=>110,],98=>['namePatternPart'=>111,],113=>['argumentList'=>115,],114=>['returnTypePattern'=>116,'returnTypeMember'=>117,'namespaceName'=>119,],118=>['namespaceName'=>123,],121=>['returnTypeMember'=>125,'namespaceName'=>119,],122=>['namespaceName'=>126,],124=>['returnTypePattern'=>127,'returnTypeMember'=>117,'namespaceName'=>119,],]]; + [0 => ['!' => 4,'(' => 6,'access' => 19,'annotation' => 20,'execution' => 21,'within' => 22,'initialization' => 23,'staticinitialization' => 24,'matchInherited' => 25,'matchReturningByReference' => 26,'namePart' => 28,],1 => ['||' => 29,'$eof' => 0,],2 => ['&&' => 30,'$eof' => -2,'||' => -2,')' => -2,],4 => ['(' => 6,'access' => 19,'annotation' => 20,'execution' => 21,'within' => 22,'initialization' => 23,'staticinitialization' => 24,'matchInherited' => 25,'matchReturningByReference' => 26,'namePart' => 28,],6 => ['!' => 4,'(' => 6,'access' => 19,'annotation' => 20,'execution' => 21,'within' => 22,'initialization' => 23,'staticinitialization' => 24,'matchInherited' => 25,'matchReturningByReference' => 26,'namePart' => 28,],19 => ['(' => 33,],20 => ['access' => 34,'execution' => 35,'within' => 36,],21 => ['(' => 37,],22 => ['(' => 38,],23 => ['(' => 39,],24 => ['(' => 40,],25 => ['(' => 41,],26 => ['(' => 42,],27 => ['->' => 43,'nsSeparator' => 44,],29 => ['!' => 4,'(' => 6,'access' => 19,'annotation' => 20,'execution' => 21,'within' => 22,'initialization' => 23,'staticinitialization' => 24,'matchInherited' => 25,'matchReturningByReference' => 26,'namePart' => 28,],30 => ['!' => 4,'(' => 6,'access' => 19,'annotation' => 20,'execution' => 21,'within' => 22,'initialization' => 23,'staticinitialization' => 24,'matchInherited' => 25,'matchReturningByReference' => 26,'namePart' => 28,],32 => [')' => 47,'||' => 29,],33 => ['public' => 53,'protected' => 54,'private' => 55,'final' => 56,'readonly' => 57,'private(set)' => 58,'protected(set)' => 59,],34 => ['(' => 60,],35 => ['(' => 61,],36 => ['(' => 62,],37 => ['**' => 67,'*' => 69,'namePart' => 70,'public' => 53,'protected' => 54,'private' => 55,'final' => 56,'readonly' => 57,'private(set)' => 58,'protected(set)' => 59,],38 => ['**' => 67,'*' => 69,'namePart' => 70,],39 => ['**' => 67,'*' => 69,'namePart' => 70,],40 => ['**' => 67,'*' => 69,'namePart' => 70,],41 => [')' => 75,],42 => [')' => 76,],43 => ['*' => 69,'namePart' => 70,],44 => ['namePart' => 78,],45 => ['&&' => 30,'$eof' => -1,'||' => -1,')' => -1,],48 => [')' => 79,],50 => ['**' => 67,'*' => 69,'namePart' => 70,],51 => ['public' => 53,'protected' => 54,'private' => 55,'final' => 56,'readonly' => 57,'private(set)' => 58,'protected(set)' => 59,'**' => -60,'*' => -60,'namePart' => -60,],52 => ['|' => 82,'**' => -62,'*' => -62,'namePart' => -62,'public' => -62,'protected' => -62,'private' => -62,'final' => -62,'readonly' => -62,'private(set)' => -62,'protected(set)' => -62,],60 => ['namePart' => 28,],61 => ['namePart' => 28,],62 => ['namePart' => 28,],63 => [')' => 86,],64 => [')' => 87,],65 => ['(' => 88,],66 => ['nsSeparator' => 89,],68 => ['*' => 90,'namePart' => 91,'|' => 92,'nsSeparator' => -44,')' => -44,'+' => -44,'::' => -44,'->' => -44,],71 => [')' => 93,],72 => ['+' => 94,'nsSeparator' => 95,')' => -38,'::' => -38,'->' => -38,],73 => [')' => 96,],74 => [')' => 97,],77 => ['*' => 90,'namePart' => 91,'|' => 92,'$eof' => -31,'||' => -31,'&&' => -31,')' => -31,],80 => ['::' => 99,'->' => 100,],82 => ['public' => 53,'protected' => 54,'private' => 55,'final' => 56,'readonly' => 57,'private(set)' => 58,'protected(set)' => 59,],83 => [')' => 102,'nsSeparator' => 44,],84 => [')' => 103,'nsSeparator' => 44,],85 => [')' => 104,'nsSeparator' => 44,],88 => ['*' => 106,],89 => ['**' => 108,'*' => 69,'namePart' => 70,],92 => ['namePart' => 109,],95 => ['**' => 108,'*' => 69,'namePart' => 70,],98 => ['*' => 69,'namePart' => 70,],105 => [')' => 112,],107 => ['(' => 113,'*' => 90,'namePart' => 91,'|' => 92,'nsSeparator' => -45,],110 => ['*' => 90,'namePart' => 91,'|' => 92,')' => -45,'+' => -45,'nsSeparator' => -45,'::' => -45,'->' => -45,],111 => ['*' => 90,'namePart' => 91,'|' => 92,')' => -37,'(' => -37,],112 => [':' => 114,')' => -33,],113 => ['*' => 106,],114 => ['?' => 118,'namePart' => 28,],115 => [')' => 120,],116 => ['|' => 121,')' => -34,],117 => ['&' => 122,')' => -56,'|' => -56,],118 => ['namePart' => 28,],119 => ['nsSeparator' => 44,')' => -54,'|' => -54,'&' => -54,],120 => [':' => 124,')' => -35,],121 => ['namePart' => 28,],122 => ['namePart' => 28,],123 => ['nsSeparator' => 44,')' => -58,'|' => -58,],124 => ['?' => 118,'namePart' => 28,],125 => ['&' => 122,')' => -57,'|' => -57,],126 => ['nsSeparator' => 44,')' => -55,'|' => -55,'&' => -55,],127 => ['|' => 121,')' => -36,],3 => ['$eof' => -4,'||' => -4,'&&' => -4,')' => -4,],5 => ['$eof' => -6,'||' => -6,'&&' => -6,')' => -6,],7 => ['$eof' => -8,'||' => -8,'&&' => -8,')' => -8,],8 => ['$eof' => -9,'||' => -9,'&&' => -9,')' => -9,],9 => ['$eof' => -10,'||' => -10,'&&' => -10,')' => -10,],10 => ['$eof' => -11,'||' => -11,'&&' => -11,')' => -11,],11 => ['$eof' => -12,'||' => -12,'&&' => -12,')' => -12,],12 => ['$eof' => -13,'||' => -13,'&&' => -13,')' => -13,],13 => ['$eof' => -14,'||' => -14,'&&' => -14,')' => -14,],14 => ['$eof' => -15,'||' => -15,'&&' => -15,')' => -15,],15 => ['$eof' => -16,'||' => -16,'&&' => -16,')' => -16,],16 => ['$eof' => -17,'||' => -17,'&&' => -17,')' => -17,],17 => ['$eof' => -18,'||' => -18,'&&' => -18,')' => -18,],18 => ['$eof' => -19,'||' => -19,'&&' => -19,')' => -19,],28 => ['->' => -52,'nsSeparator' => -52,')' => -52,'|' => -52,'&' => -52,],31 => ['$eof' => -5,'||' => -5,'&&' => -5,')' => -5,],46 => ['$eof' => -3,'||' => -3,'&&' => -3,')' => -3,],47 => ['$eof' => -7,'||' => -7,'&&' => -7,')' => -7,],49 => [')' => -32,],53 => ['**' => -63,'*' => -63,'namePart' => -63,'public' => -63,'protected' => -63,'private' => -63,'final' => -63,'readonly' => -63,'private(set)' => -63,'protected(set)' => -63,'|' => -63,],54 => ['**' => -64,'*' => -64,'namePart' => -64,'public' => -64,'protected' => -64,'private' => -64,'final' => -64,'readonly' => -64,'private(set)' => -64,'protected(set)' => -64,'|' => -64,],55 => ['**' => -65,'*' => -65,'namePart' => -65,'public' => -65,'protected' => -65,'private' => -65,'final' => -65,'readonly' => -65,'private(set)' => -65,'protected(set)' => -65,'|' => -65,],56 => ['**' => -66,'*' => -66,'namePart' => -66,'public' => -66,'protected' => -66,'private' => -66,'final' => -66,'readonly' => -66,'private(set)' => -66,'protected(set)' => -66,'|' => -66,],57 => ['**' => -67,'*' => -67,'namePart' => -67,'public' => -67,'protected' => -67,'private' => -67,'final' => -67,'readonly' => -67,'private(set)' => -67,'protected(set)' => -67,'|' => -67,],58 => ['**' => -68,'*' => -68,'namePart' => -68,'public' => -68,'protected' => -68,'private' => -68,'final' => -68,'readonly' => -68,'private(set)' => -68,'protected(set)' => -68,'|' => -68,],59 => ['**' => -69,'*' => -69,'namePart' => -69,'public' => -69,'protected' => -69,'private' => -69,'final' => -69,'readonly' => -69,'private(set)' => -69,'protected(set)' => -69,'|' => -69,],67 => ['nsSeparator' => -43,')' => -43,'+' => -43,'::' => -43,'->' => -43,],69 => ['$eof' => -47,'||' => -47,'&&' => -47,')' => -47,'(' => -47,'nsSeparator' => -47,'*' => -47,'namePart' => -47,'|' => -47,'+' => -47,'::' => -47,'->' => -47,],70 => ['$eof' => -48,'||' => -48,'&&' => -48,')' => -48,'(' => -48,'nsSeparator' => -48,'*' => -48,'namePart' => -48,'|' => -48,'+' => -48,'::' => -48,'->' => -48,],75 => ['$eof' => -29,'||' => -29,'&&' => -29,')' => -29,],76 => ['$eof' => -30,'||' => -30,'&&' => -30,')' => -30,],78 => ['->' => -53,'nsSeparator' => -53,')' => -53,'|' => -53,'&' => -53,],79 => ['$eof' => -20,'||' => -20,'&&' => -20,')' => -20,],81 => ['**' => -59,'*' => -59,'namePart' => -59,],86 => ['$eof' => -21,'||' => -21,'&&' => -21,')' => -21,],87 => ['$eof' => -22,'||' => -22,'&&' => -22,')' => -22,],90 => ['$eof' => -49,'||' => -49,'&&' => -49,')' => -49,'(' => -49,'nsSeparator' => -49,'*' => -49,'namePart' => -49,'|' => -49,'+' => -49,'::' => -49,'->' => -49,],91 => ['$eof' => -50,'||' => -50,'&&' => -50,')' => -50,'(' => -50,'nsSeparator' => -50,'*' => -50,'namePart' => -50,'|' => -50,'+' => -50,'::' => -50,'->' => -50,],93 => ['$eof' => -23,'||' => -23,'&&' => -23,')' => -23,],94 => [')' => -39,'::' => -39,'->' => -39,],96 => ['$eof' => -27,'||' => -27,'&&' => -27,')' => -27,],97 => ['$eof' => -28,'||' => -28,'&&' => -28,')' => -28,],99 => ['*' => -41,'namePart' => -41,],100 => ['*' => -42,'namePart' => -42,],101 => ['**' => -61,'*' => -61,'namePart' => -61,'public' => -61,'protected' => -61,'private' => -61,'final' => -61,'readonly' => -61,'private(set)' => -61,'protected(set)' => -61,],102 => ['$eof' => -24,'||' => -24,'&&' => -24,')' => -24,],103 => ['$eof' => -25,'||' => -25,'&&' => -25,')' => -25,],104 => ['$eof' => -26,'||' => -26,'&&' => -26,')' => -26,],106 => [')' => -40,],108 => ['nsSeparator' => -46,')' => -46,'+' => -46,'::' => -46,'->' => -46,],109 => ['$eof' => -51,'||' => -51,'&&' => -51,')' => -51,'(' => -51,'nsSeparator' => -51,'*' => -51,'namePart' => -51,'|' => -51,'+' => -51,'::' => -51,'->' => -51,],],'goto' => [0 => ['pointcutExpression' => 1,'conjugatedExpression' => 2,'negatedExpression' => 3,'brakedExpression' => 5,'singlePointcut' => 7,'accessPointcut' => 8,'annotatedAccessPointcut' => 9,'executionPointcut' => 10,'annotatedExecutionPointcut' => 11,'withinPointcut' => 12,'annotatedWithinPointcut' => 13,'initializationPointcut' => 14,'staticInitializationPointcut' => 15,'matchInheritedPointcut' => 16,'matchReturningByReferencePointcut' => 17,'pointcutReference' => 18,'namespaceName' => 27,],4 => ['brakedExpression' => 31,'singlePointcut' => 7,'accessPointcut' => 8,'annotatedAccessPointcut' => 9,'executionPointcut' => 10,'annotatedExecutionPointcut' => 11,'withinPointcut' => 12,'annotatedWithinPointcut' => 13,'initializationPointcut' => 14,'staticInitializationPointcut' => 15,'matchInheritedPointcut' => 16,'matchReturningByReferencePointcut' => 17,'pointcutReference' => 18,'namespaceName' => 27,],6 => ['pointcutExpression' => 32,'conjugatedExpression' => 2,'negatedExpression' => 3,'brakedExpression' => 5,'singlePointcut' => 7,'accessPointcut' => 8,'annotatedAccessPointcut' => 9,'executionPointcut' => 10,'annotatedExecutionPointcut' => 11,'withinPointcut' => 12,'annotatedWithinPointcut' => 13,'initializationPointcut' => 14,'staticInitializationPointcut' => 15,'matchInheritedPointcut' => 16,'matchReturningByReferencePointcut' => 17,'pointcutReference' => 18,'namespaceName' => 27,],29 => ['conjugatedExpression' => 45,'negatedExpression' => 3,'brakedExpression' => 5,'singlePointcut' => 7,'accessPointcut' => 8,'annotatedAccessPointcut' => 9,'executionPointcut' => 10,'annotatedExecutionPointcut' => 11,'withinPointcut' => 12,'annotatedWithinPointcut' => 13,'initializationPointcut' => 14,'staticInitializationPointcut' => 15,'matchInheritedPointcut' => 16,'matchReturningByReferencePointcut' => 17,'pointcutReference' => 18,'namespaceName' => 27,],30 => ['negatedExpression' => 46,'brakedExpression' => 5,'singlePointcut' => 7,'accessPointcut' => 8,'annotatedAccessPointcut' => 9,'executionPointcut' => 10,'annotatedExecutionPointcut' => 11,'withinPointcut' => 12,'annotatedWithinPointcut' => 13,'initializationPointcut' => 14,'staticInitializationPointcut' => 15,'matchInheritedPointcut' => 16,'matchReturningByReferencePointcut' => 17,'pointcutReference' => 18,'namespaceName' => 27,],33 => ['propertyAccessReference' => 48,'memberReference' => 49,'memberModifiers' => 50,'modifierGroup' => 51,'memberModifier' => 52,],37 => ['methodExecutionReference' => 63,'functionExecutionReference' => 64,'memberReference' => 65,'namespacePattern' => 66,'memberModifiers' => 50,'namePatternPart' => 68,'modifierGroup' => 51,'memberModifier' => 52,],38 => ['classFilter' => 71,'namespacePattern' => 72,'namePatternPart' => 68,],39 => ['classFilter' => 73,'namespacePattern' => 72,'namePatternPart' => 68,],40 => ['classFilter' => 74,'namespacePattern' => 72,'namePatternPart' => 68,],43 => ['namePatternPart' => 77,],50 => ['classFilter' => 80,'namespacePattern' => 72,'namePatternPart' => 68,],51 => ['memberModifiers' => 81,'modifierGroup' => 51,'memberModifier' => 52,],60 => ['namespaceName' => 83,],61 => ['namespaceName' => 84,],62 => ['namespaceName' => 85,],80 => ['memberAccessType' => 98,],82 => ['modifierGroup' => 101,'memberModifier' => 52,],88 => ['argumentList' => 105,],89 => ['namePatternPart' => 107,],95 => ['namePatternPart' => 110,],98 => ['namePatternPart' => 111,],113 => ['argumentList' => 115,],114 => ['returnTypePattern' => 116,'returnTypeMember' => 117,'namespaceName' => 119,],118 => ['namespaceName' => 123,],121 => ['returnTypeMember' => 125,'namespaceName' => 119,],122 => ['namespaceName' => 126,],124 => ['returnTypePattern' => 127,'returnTypeMember' => 117,'namespaceName' => 119,],]]; diff --git a/src/Aop/WeavingDriver.php b/src/Aop/WeavingDriver.php deleted file mode 100644 index bd33f65e..00000000 --- a/src/Aop/WeavingDriver.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * 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 681dc931..84894e23 100644 --- a/src/Core/AspectContainer.php +++ b/src/Core/AspectContainer.php @@ -73,12 +73,6 @@ interface AspectContainer extends ContainerInterface */ 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 a7292987..0bfb4c79 100644 --- a/src/Core/AspectKernel.php +++ b/src/Core/AspectKernel.php @@ -16,8 +16,10 @@ use Go\Aop\Aspect; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Features; -use Go\Aop\WeavingDriver; use Go\Core\Cache\CachedAspectLoader; +use Go\Driver\SourceTransformingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; +use Go\Driver\WeavingDriver; use Go\Instrument\ClassLoading\AopComposerLoader; use Go\Instrument\ClassLoading\CachePathManager; use Go\Instrument\ClassLoading\SourceTransformingLoader; @@ -26,7 +28,6 @@ use Go\Instrument\Transformer\FilterInjectorTransformer; use Go\Instrument\Transformer\MagicConstantTransformer; use Go\Instrument\Transformer\WeavingTransformer; -use Go\Instrument\ZEngine\ZEngineDriver; use ReflectionClass; /** @@ -41,7 +42,7 @@ * includePaths: string[], * excludePaths: string[], * containerClass: class-string, - * driver: WeavingDriver + * driver: class-string * } * @phpstan-type UserKernelOptions array{ * debug?: bool, @@ -52,7 +53,7 @@ * includePaths?: string[], * excludePaths?: string[], * containerClass?: class-string, - * driver?: WeavingDriver|string + * driver?: class-string * } */ abstract class AspectKernel @@ -63,19 +64,11 @@ abstract class AspectKernel private const array RUNTIME_DEPENDENCIES = [ 'goaop/dissect', 'goaop/parser-reflection', - 'lisachenko/z-engine', 'nikic/php-parser', 'psr/container', 'symfony/finder', ]; - /** - * Feature flags implemented by source transformers: meaningless for a driver that never rewrites sources - */ - private const int STREAM_ONLY_FEATURES = Features::INTERCEPT_INITIALIZATIONS - | Features::INTERCEPT_INCLUDES - | Features::PREBUILT_CACHE; - /** * Kernel options * @@ -90,7 +83,7 @@ abstract class AspectKernel 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]; /** @@ -170,21 +163,18 @@ 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)); - 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. + // The driver decides how the classes of the application are woven (see WeavingDriver). The + // transformer chain only exists for a driver transforming sources, and even then only on a + // cache miss: 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 only registers the chain. + $driver = new $this->options['driver'](); + $container->add(WeavingDriver::class, $driver); + if ($driver instanceof SourceTransformingDriver) { $this->registerTransformerServices($container); - - AopComposerLoader::init($this->options, $container); } + $driver->boot($this, $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 @@ -262,9 +252,8 @@ 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) + * driver - class-string Class of the weaving driver (Go\Driver\WeavingDriver): the stream filter driver + * (source transformation at load time, the default) or a driver of another package, see docs/driver/ * * @phpstan-return KernelOptions */ @@ -279,7 +268,7 @@ protected function getDefaultOptions(): array 'includePaths' => [], 'excludePaths' => [], 'containerClass' => static::$containerClass, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]; } @@ -335,13 +324,12 @@ protected function normalizeOptions(array $options): array if (!is_int($features) || ($features & ~Features::ALL) !== 0) { throw InvalidConfigurationException::invalidFeatures($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_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE features are only ' - . 'available with the stream driver.', - ); + $driver = $merged['driver'] ?? StreamFilterDriver::class; + if (!is_string($driver) || !class_exists($driver)) { + throw InvalidConfigurationException::driverClassNotFound($driver); + } + if (!is_a($driver, WeavingDriver::class, true)) { + throw InvalidConfigurationException::driverClassNotWeavingDriver($driver); } $rawIncludePaths = is_array($merged['includePaths'] ?? null) ? $merged['includePaths'] : []; $includePaths = array_values(array_filter($rawIncludePaths, is_string(...))); diff --git a/src/Driver/SourceTransformingDriver.php b/src/Driver/SourceTransformingDriver.php new file mode 100644 index 00000000..d6d3898e --- /dev/null +++ b/src/Driver/SourceTransformingDriver.php @@ -0,0 +1,24 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver; + +/** + * A weaving driver that weaves by transforming sources: the file of a loaded class runs through + * the transformer chain and the result is cached below the cache directory + * + * The kernel registers the transformer services for such a driver only + * (AspectKernel::registerTransformerServices()), and the cache warmer (`cache:warmup:aop`, + * `debug:weaving`) can transform the whole application ahead of time for it. A driver weaving + * otherwise (the ffi driver rewires loaded classes in the engine) never gets the chain. + */ +interface SourceTransformingDriver extends WeavingDriver {} diff --git a/src/Driver/StreamFilter/StreamFilterDriver.php b/src/Driver/StreamFilter/StreamFilterDriver.php new file mode 100644 index 00000000..ae77f504 --- /dev/null +++ b/src/Driver/StreamFilter/StreamFilterDriver.php @@ -0,0 +1,33 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamFilter; + +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Driver\SourceTransformingDriver; +use Go\Instrument\ClassLoading\AopComposerLoader; + +/** + * The default weaving driver: source transformation at load time through a PHP stream filter + * + * A wrapper around composer's class loader (AopComposerLoader) redirects the file of an advised + * class through the `php://filter` stream filter (SourceTransformingLoader), which serves the + * cached woven source or runs the transformer chain on a cache miss. Pure PHP, no extension. + */ +final class StreamFilterDriver implements SourceTransformingDriver +{ + public function boot(AspectKernel $kernel, AspectContainer $container): void + { + AopComposerLoader::init($kernel->getOptions(), $container); + } +} diff --git a/src/Driver/WeavingDriver.php b/src/Driver/WeavingDriver.php new file mode 100644 index 00000000..37c1d162 --- /dev/null +++ b/src/Driver/WeavingDriver.php @@ -0,0 +1,46 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver; + +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; + +/** + * A weaving driver decides HOW the classes of the application get their advices woven in + * + * Everything else is shared: aspects, pointcuts, the advice matcher, the advisor cache and the + * join-point runtime. The kernel's `driver` option names the class of the driver to use; the + * kernel instantiates it (no constructor arguments), registers it in the container under this + * interface and boots it once from AspectKernel::init(). The built-in default is the stream + * filter driver (Go\Driver\StreamFilter\StreamFilterDriver); other drivers live in packages of + * their own (the stream wrapper driver, the ffi driver built on lisachenko/z-engine) and plug + * in through this interface only. + * + * A driver that weaves by transforming the source of a file through the transformer chain + * (AspectKernel::registerTransformerServices()) implements SourceTransformingDriver, so the + * kernel registers the chain for it and the cache warmer can build its cache ahead of time. + */ +interface WeavingDriver +{ + /** + * Hooks the driver into the class loading of the application + * + * Called once by AspectKernel::init(): the kernel options are normalized, the framework + * services are registered, no aspect is loaded yet and configureAop() runs right after. + * The driver registers the services it needs and takes over class loading (wrapping + * composer's loader, registering a stream filter or wrapper, hooking the engine). + * + * @throws \Go\Aop\Exception\InvalidConfigurationException When the host or the kernel options do not fit the driver + */ + public function boot(AspectKernel $kernel, AspectContainer $container): void; +} diff --git a/src/Instrument/AGENTS.md b/src/Instrument/AGENTS.md index e94b2fa7..5dcebbd9 100644 --- a/src/Instrument/AGENTS.md +++ b/src/Instrument/AGENTS.md @@ -7,14 +7,12 @@ 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); 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_INITIALIZATIONS, INTERCEPT_INCLUDES and PREBUILT_CACHE (refused in normalizeOptions), CacheWarmer/debug:weaving (refused), property/introduction/init join points. +## Weaving drivers (src/Driver/, `driver` kernel option) +- Go\Driver\WeavingDriver: `boot(AspectKernel, AspectContainer)`, called once from AspectKernel::init() after the framework services, before configureAop(); the option is the driver class-string (default Go\Driver\StreamFilter\StreamFilterDriver, validated with class_exists/is_a in normalizeOptions), the instance is registered under WeavingDriver::class. +- Go\Driver\SourceTransformingDriver (marker): the kernel registers the transformer chain (registerTransformerServices()) for such a driver only; CacheWarmer refuses any other (InvalidConfigurationException::cacheWarmerRequiresSourceTransformingDriver). +- StreamFilterDriver::boot() = AopComposerLoader::init() (steps 2-5 above). SourceTransformingLoader::transformSource($uri, $source, $stream) is the one entry point shared by the stream filter (filter()) and the stream wrapper: cache hit → cached content, miss → transformer chain + saveToCache(), no usable cache dir → source untouched. +- ComposerLoaderDecorator: every composer loader wrapper (AopComposerLoader, the ffi loader) implements it, so tools unwrap it and a later kernel wraps the original loader again. +- Drivers of their own packages: `packages/stream-wrapper-driver/AGENTS.md` (file:// stream wrapper, includes always intercepted) and `packages/ffi-driver/AGENTS.md` (z-engine runtime weaving, no transformer chain). ## 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 43dc7e11..ded0ad17 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -100,7 +100,7 @@ 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 - * (of either weaving driver) is wrapped again around its original composer loader, with the + * (of any weaving driver) is wrapped again around its original composer loader, with the * given options and container. * * @phpstan-param KernelOptions $options Aspect kernel options @@ -147,7 +147,7 @@ public function getOriginalLoader(): ClassLoader /** * 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 + * loader or by the loader of another weaving driver), without loading the class * * @return string|null Path given by composer, or null when no wrapped composer loader knows the class */ diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index a6b64972..3bd44937 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -332,7 +332,7 @@ public function queryFreshCacheState(string $resource, AspectContainer $containe * 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. + * of the source-transforming drivers and the donor index of the ffi driver), so staleness is decided in one place. * * @param array $record Cache record carrying `filemtime`, `filesize` and `cachedAt` */ @@ -483,7 +483,7 @@ 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 + * Writes an index file of another cache (the donor index of the ffi 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 @@ -494,7 +494,7 @@ public function flushCacheState(bool $force = false): void 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'); + throw InvalidConfigurationException::cacheIndexWithoutCacheDir($relativeFileName); } $this->writeCacheFile($relativeFileName, $data); } diff --git a/src/Instrument/ClassLoading/CacheWarmer.php b/src/Instrument/ClassLoading/CacheWarmer.php index 10f6ba91..3db2aa86 100644 --- a/src/Instrument/ClassLoading/CacheWarmer.php +++ b/src/Instrument/ClassLoading/CacheWarmer.php @@ -14,8 +14,8 @@ use ErrorException; use Go\Aop\Exception\InvalidConfigurationException; -use Go\Aop\WeavingDriver; use Go\Core\AspectKernel; +use Go\Driver\SourceTransformingDriver; use Go\Instrument\FileSystem\Enumerator; use Go\Instrument\Transformer\FilterInjectorTransformer; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -71,13 +71,9 @@ public function warmUp(): int if (empty($options['cacheDir'])) { throw InvalidConfigurationException::cacheWarmerWithoutCacheDir(); } - 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.', - ); + // Only a driver weaving by source transformation has a cache the warmer can build ahead of time + if (!is_a($options['driver'], SourceTransformingDriver::class, true)) { + throw InvalidConfigurationException::cacheWarmerRequiresSourceTransformingDriver($options['driver']); } // The transformation pipeline is registered lazily; the filter URIs built below diff --git a/src/Instrument/ClassLoading/ComposerLoaderDecorator.php b/src/Instrument/ClassLoading/ComposerLoaderDecorator.php index 1dc7dee5..669e707a 100644 --- a/src/Instrument/ClassLoading/ComposerLoaderDecorator.php +++ b/src/Instrument/ClassLoading/ComposerLoaderDecorator.php @@ -17,10 +17,10 @@ /** * 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. + * Weaving drivers decorate composer's loader (the stream filter driver to redirect advised files + * through the stream filter, the ffi driver to weave a class right after it was loaded), so tools + * that look for a ClassLoader in spl_autoload_functions() unwrap any of them through this + * interface, and a later kernel wraps the original loader again instead of the wrapper. */ interface ComposerLoaderDecorator { diff --git a/src/Instrument/ClassLoading/SourceTransformingLoader.php b/src/Instrument/ClassLoading/SourceTransformingLoader.php index b38168f7..d2c5f0f4 100644 --- a/src/Instrument/ClassLoading/SourceTransformingLoader.php +++ b/src/Instrument/ClassLoading/SourceTransformingLoader.php @@ -167,39 +167,53 @@ public function filter($in, $out, &$consumed, $closing): int $consumed = strlen($this->data); // $this->stream contains pointer to the source - $originalUri = $this->resolveOriginalUri(); - $cacheUri = self::$cachePathManager?->getCachePathForResource($originalUri); - - // Guard to disable overwriting of original files or when cache is unavailable: - // the source passes through untouched (previously an aborted result in the wrapper) - if ($cacheUri === null || $cacheUri === $originalUri) { - stream_bucket_append($out, stream_bucket_new($this->stream, $this->data)); - - return PSFS_PASS_ON; - } + $source = self::transformSource($this->resolveOriginalUri(), $this->data, $this->stream); + stream_bucket_append($out, stream_bucket_new($this->stream, $source)); - // Cache hit: return the cached content as a result right from here - - // no StreamMetaData, no parsing, no transformers - $cachedContent = self::findCachedContent($originalUri, $this->data); - if ($cachedContent !== null) { - stream_bucket_append($out, stream_bucket_new($this->stream, $cachedContent)); + return PSFS_PASS_ON; + } - return PSFS_PASS_ON; - } + return PSFS_FEED_ME; + } - // Cache miss: parse the source, run the transformer chain and persist the result - $metadata = new StreamMetaData($this->stream, $this->data); - $result = self::transformCode($metadata); - // An aborted chain reverts every change: the original source is served and recorded as untransformed - $source = $result === TransformerResult::Aborted ? $this->data : $metadata->source; - self::saveToCache($originalUri, $cacheUri, $source, $result); + /** + * Returns the source of a file the way the application must see it + * + * The one entry point of the source-transforming drivers: the stream filter calls it with the + * buffered filtered stream, a stream wrapper with the file it opened for inclusion. A usable cache + * record serves the cached woven source (or the source itself, when it is known to need no + * transformation) without parsing anything; a miss runs the transformer chain and persists the + * result; without a usable cache directory the source passes through untouched. + * + * @param string $originalUri Resolved path of the source file + * @param string $source Content of the source file + * @param resource $stream Open stream of the source file, the transformer metadata is built from it + */ + public static function transformSource(string $originalUri, string $source, $stream): string + { + $cacheUri = self::$cachePathManager?->getCachePathForResource($originalUri); - stream_bucket_append($out, stream_bucket_new($this->stream, $source)); + // Guard to disable overwriting of original files or when cache is unavailable: + // the source passes through untouched (previously an aborted result in the wrapper) + if ($cacheUri === null || $cacheUri === $originalUri) { + return $source; + } - return PSFS_PASS_ON; + // Cache hit: return the cached content as a result right from here - + // no StreamMetaData, no parsing, no transformers + $cachedContent = self::findCachedContent($originalUri, $source); + if ($cachedContent !== null) { + return $cachedContent; } - return PSFS_FEED_ME; + // Cache miss: parse the source, run the transformer chain and persist the result + $metadata = new StreamMetaData($stream, $source); + $result = self::transformCode($metadata); + // An aborted chain reverts every change: the original source is served and recorded as untransformed + $transformedSource = $result === TransformerResult::Aborted ? $source : $metadata->source; + self::saveToCache($originalUri, $cacheUri, $transformedSource, $result); + + return $transformedSource; } /** diff --git a/src/Instrument/ZEngine/JitGuard.php b/src/Instrument/ZEngine/JitGuard.php deleted file mode 100644 index db55935c..00000000 --- a/src/Instrument/ZEngine/JitGuard.php +++ /dev/null @@ -1,115 +0,0 @@ - - * - * 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; - - 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 ' - . '(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; - } - - /** - * 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) - */ - 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/ZEngineDriver.php b/src/Instrument/ZEngine/ZEngineDriver.php deleted file mode 100644 index 82584bac..00000000 --- a/src/Instrument/ZEngine/ZEngineDriver.php +++ /dev/null @@ -1,129 +0,0 @@ - - * - * 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; -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() {} - - /** - * @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) ' - . 'with ext-ffi enabled.', - ); - } - - $jitReport = JitGuard::enforce(); - - 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, [ - '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), - (bool) $container->getValue('kernel.interceptFunctions'), - )); - - 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 c1643c24..df55419a 100644 --- a/src/Proxy/AGENTS.md +++ b/src/Proxy/AGENTS.md @@ -9,12 +9,10 @@ - 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) +- Generators compiling dispatcher bodies OUTSIDE the woven class (the donor trait generator of packages/ffi-driver, + a ClassProxyGenerator subclass) use TypeGenerator::resolveScopeKeywords() (spells out `self`/`parent` in a + signature, `static` kept), ParameterGenerator::withType() and MethodGenerator::getParameters()/setParameters(); + keep them when refactoring the generators ## 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 3e33af38..aa912651 100644 --- a/src/Proxy/ClassProxyGenerator.php +++ b/src/Proxy/ClassProxyGenerator.php @@ -401,7 +401,7 @@ protected function getJoinpointInvocationStatements(ReflectionMethod $method, ?R $this->imports->import(InterceptorInjector::class), $isStatic ? 'forStaticMethod' : 'forMethod', [ - new Arg($this->createJoinpointClassReference($originalClass)), + new Arg(new ClassConstFetch(new Name('self'), 'class')), new Arg(new String_($method->name)), new Arg((new InterceptorListGenerator($adviceNames, $this->imports))->getNode()), new Arg($this->createOriginalMethodCallable($method, $originalClass)), @@ -421,20 +421,6 @@ 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/Generator/TypeGenerator.php b/src/Proxy/Generator/TypeGenerator.php index 00ae6b74..72f17768 100644 --- a/src/Proxy/Generator/TypeGenerator.php +++ b/src/Proxy/Generator/TypeGenerator.php @@ -108,8 +108,8 @@ public function getNode(): Identifier|Name|NullableType|UnionType|IntersectionTy /** * 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. + * For a declaration compiled outside the woven class (the donor trait of the ffi driver), where the engine + * would resolve the keywords against the declaring type. `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 diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 88cecefb..6c5c64da 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -30,15 +30,17 @@ 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. - 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 +- Testsuites (phpunit.xml.dist): `framework` (tests/), `stream-wrapper-driver` (packages/stream-wrapper-driver/tests/, + part of the default run) and `ffi-driver` (packages/ffi-driver/tests/, excluded by `composer test`, run by + `composer test:ffi` with ffi on and the JIT off; needs lisachenko/z-engine, self-skips where the engine cannot boot). + Driver tests live in their package: packages/ffi-driver/tests/ (unit tests, InProcess/ boots z-engine inside the + runner and weaves packages/ffi-driver/tests/Stubs for real - the only coverage of the driver - kernel created without + the singleton, autoloaders restored, one stub class per test since a woven class stays woven; Functional/ runs the + shared probe tests/Fixtures/project/bin/driver-weaving.php in subprocesses, `GO_AOP_CONFIGURATION=ffi*`, both opcache + legs, `ZENGINE_AUTOBOOT=0`, and compares its JSON report with the stream filter driver on the same fixtures). + Fixture configurations in tests/Fixtures/project/web/configuration.php: `ffi`, `ffi_production`, `ffi_reference` + (StreamFilterDriver on the same fixtures), `ffi_unsupported`, and `stream_wrapper*` for the stream wrapper driver; + fixtures Application/Ffi*, Aspect/FfiAspect, Kernel/FfiAspectKernel. Driver package phpstan: `composer analyze:ffi`. - If phpstan fails: fix errors before offering to commit ## PHPStan gate diff --git a/tests/Aop/Exception/ExceptionMessagesTest.php b/tests/Aop/Exception/ExceptionMessagesTest.php index f621140a..b0451b22 100644 --- a/tests/Aop/Exception/ExceptionMessagesTest.php +++ b/tests/Aop/Exception/ExceptionMessagesTest.php @@ -141,6 +141,35 @@ public static function namedConstructors(): array InvalidConfigurationException::class, 'Cache warmer requires the `cacheDir` kernel option to be configured', ], + 'driver class not found' => [ + static fn() => InvalidConfigurationException::driverClassNotFound('App\Driver'), + InvalidConfigurationException::class, + 'Option "driver" must name the class of a weaving driver (an implementation of Go\Driver\WeavingDriver), ' + . 'got the unknown class "App\Driver".', + ], + 'driver option not a string' => [ + static fn() => InvalidConfigurationException::driverClassNotFound(null), + InvalidConfigurationException::class, + 'Option "driver" must name the class of a weaving driver (an implementation of Go\Driver\WeavingDriver), got null.', + ], + 'driver class not a driver' => [ + static fn() => InvalidConfigurationException::driverClassNotWeavingDriver('App\Driver'), + InvalidConfigurationException::class, + 'Weaving driver class "App\Driver" must implement Go\Driver\WeavingDriver.', + ], + 'warmer needs transformation' => [ + static fn() => InvalidConfigurationException::cacheWarmerRequiresSourceTransformingDriver('App\Driver'), + InvalidConfigurationException::class, + 'The cache warmer transforms sources through the transformer chain, which the weaving driver App\Driver ' + . 'never uses (it is no Go\Driver\SourceTransformingDriver), so there is nothing to warm up ahead of ' + . 'time. Warm the application by loading its classes (e.g. a smoke request), or warm the cache with a ' + . 'kernel configured for a source-transforming driver.', + ], + 'cache index without dir' => [ + static fn() => InvalidConfigurationException::cacheIndexWithoutCacheDir('/_ffi.cache'), + InvalidConfigurationException::class, + 'The cache index /_ffi.cache needs the `cacheDir` option to be configured.', + ], 'path outside root' => [ static fn() => InvalidConfigurationException::pathOutsideRoot('/other', '/app'), InvalidConfigurationException::class, diff --git a/tests/Aop/WeavingDriverTest.php b/tests/Aop/WeavingDriverTest.php deleted file mode 100644 index 54795097..00000000 --- a/tests/Aop/WeavingDriverTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * 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 e89c7227..fbff9aa9 100644 --- a/tests/Core/AspectKernelTest.php +++ b/tests/Core/AspectKernelTest.php @@ -12,7 +12,8 @@ namespace Go\Core; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; +use Go\Stubs\Driver\RewiringDriver; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Features; use Go\Instrument\Transformer\ConstructorExecutionTransformer; @@ -238,61 +239,54 @@ public function testGetDefaultOptionsReturnsExpectedShapeAndContainerClass(): vo $this->assertSame([], $default['includePaths']); $this->assertSame([], $default['excludePaths']); $this->assertSame(Container::class, $default['containerClass']); - $this->assertSame(WeavingDriver::Stream, $default['driver']); + $this->assertSame(StreamFilterDriver::class, $default['driver']); } - public function testNormalizeOptionsResolvesTheDriverFromItsName(): void + public function testNormalizeOptionsAcceptsTheClassOfAWeavingDriver(): void { $kernel = $this->makeKernel(); - $normalized = $this->invokeProtectedArray($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 'zengine']]); + $normalized = $this->invokeProtectedArray($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => RewiringDriver::class]]); - $this->assertSame(WeavingDriver::ZEngine, $normalized['driver']); + $this->assertSame(RewiringDriver::class, $normalized['driver']); $this->assertSame( - WeavingDriver::Stream, + StreamFilterDriver::class, $this->invokeProtectedArray($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir']])['driver'], ); } - public function testNormalizeOptionsThrowsForAnUnknownDriver(): void + public function testNormalizeOptionsThrowsForAnUnknownDriverClass(): void { $kernel = $this->makeKernel(); try { - $this->invokeProtected($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 'ffi']]); - self::fail('An unknown driver must be refused'); + $this->invokeProtected($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 'Go\\Driver\\Missing\\MissingDriver']]); + self::fail('An unknown driver class must be refused'); } catch (InvalidConfigurationException $exception) { - $this->assertStringStartsWith('Option "driver" must be one of "stream", "zengine"', $exception->getMessage()); + $this->assertSame( + 'Option "driver" must name the class of a weaving driver (an implementation of Go\\Driver\\WeavingDriver), ' + . 'got the unknown class "Go\\Driver\\Missing\\MissingDriver".', + $exception->getMessage(), + ); } - } - - public function testNormalizeOptionsRefusesStreamOnlyFeaturesWithTheEngineDriver(): void - { - $kernel = $this->makeKernel(); - try { - $this->invokeProtected($kernel, 'normalizeOptions', [[ - 'cacheDir' => '/some/cache/dir', - 'driver' => WeavingDriver::ZEngine, - 'features' => Features::INTERCEPT_INITIALIZATIONS, - ]]); - self::fail('Stream-only features must be refused with the engine driver'); + $this->invokeProtected($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => 42]]); + self::fail('A driver option that is no class name must be refused'); } catch (InvalidConfigurationException $exception) { - $this->assertStringContainsString('only available with the stream driver', $exception->getMessage()); + $this->assertStringEndsWith('got int.', $exception->getMessage()); } } - public function testNormalizeOptionsAcceptsFunctionInterceptionWithTheEngineDriver(): void + public function testNormalizeOptionsThrowsForAClassThatIsNoWeavingDriver(): 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']); + try { + $this->invokeProtected($kernel, 'normalizeOptions', [['cacheDir' => '/some/cache/dir', 'driver' => \stdClass::class]]); + self::fail('A class that is no weaving driver must be refused'); + } catch (InvalidConfigurationException $exception) { + $this->assertSame('Weaving driver class "stdClass" must implement Go\\Driver\\WeavingDriver.', $exception->getMessage()); + } } public function testNormalizeOptionsExcludesRuntimeDependenciesFromWeaving(): void diff --git a/tests/Core/Cache/CachedAspectLoaderTest.php b/tests/Core/Cache/CachedAspectLoaderTest.php index 23678a7d..4b581407 100644 --- a/tests/Core/Cache/CachedAspectLoaderTest.php +++ b/tests/Core/Cache/CachedAspectLoaderTest.php @@ -18,7 +18,7 @@ use Go\Aop\Exception\NotCompilableException; use Go\Aop\Features; use Go\Aop\Pointcut\TruePointcut; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; use Go\Core\AspectContainer; use Go\Core\AspectLoader; use Go\Core\AspectLoaderInterface; @@ -100,7 +100,7 @@ private function createLoader(int $features, bool $withCacheDir = true): CachedA 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); } diff --git a/tests/Core/ContainerTest.php b/tests/Core/ContainerTest.php index 4ffaf33f..413d1167 100644 --- a/tests/Core/ContainerTest.php +++ b/tests/Core/ContainerTest.php @@ -32,7 +32,7 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use stdClass; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] class ContainerTest extends TestCase @@ -52,7 +52,7 @@ protected function setUp(): void 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $this->container->add(AspectKernel::class, $mockKernel); FrameworkServices::register($this->container); diff --git a/tests/Fixtures/project/bin/zengine-weaving.php b/tests/Fixtures/project/bin/driver-weaving.php similarity index 65% rename from tests/Fixtures/project/bin/zengine-weaving.php rename to tests/Fixtures/project/bin/driver-weaving.php index 818eeb9f..41168118 100644 --- a/tests/Fixtures/project/bin/zengine-weaving.php +++ b/tests/Fixtures/project/bin/driver-weaving.php @@ -2,9 +2,10 @@ declare(strict_types = 1); /** - * Probe of the z-engine weaving driver, run in a subprocess by the functional tests (tests/Functional/ZEngine) + * Probe of the weaving drivers, run in a subprocess by the functional tests of a driver package (the same + * scenario under the ffi driver and under a source-transforming driver, compared) * - * Usage: GO_AOP_CONFIGURATION= php -d opcache.jit=off -d ffi.enable=1 zengine-weaving.php + * Usage: GO_AOP_CONFIGURATION= php -d opcache.jit=off -d ffi.enable=1 driver-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. */ @@ -13,17 +14,17 @@ 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; -use Go\Tests\TestProject\Application\ZEngineUnsupported; -use Go\Tests\TestProject\Aspect\ZEngineAspect; +use Go\Driver\Ffi\ClassWeaver; +use Go\Driver\Ffi\FfiDriver; +use Go\Tests\TestProject\Application\FfiChild; +use Go\Tests\TestProject\Application\FfiFunctions; +use Go\Tests\TestProject\Application\FfiLate; +use Go\Tests\TestProject\Application\FfiParent; +use Go\Tests\TestProject\Application\FfiUnadvised; +use Go\Tests\TestProject\Application\FfiUnsupported; +use Go\Tests\TestProject\Aspect\FfiAspect; -$configurationName = ($env = getenv('GO_AOP_CONFIGURATION')) ? $env : 'zengine'; +$configurationName = ($env = getenv('GO_AOP_CONFIGURATION')) ? $env : 'ffi'; $scenario = $argv[1] ?? 'weave'; $settings = require __DIR__ . '/../web/configuration.php'; $configuration = $settings[$configurationName]; @@ -34,14 +35,14 @@ 'configuration' => $configurationName, 'php' => PHP_VERSION, 'opcache' => function_exists('opcache_get_status') && opcache_get_status(false) !== false, - 'indexExisted' => is_file($cacheDir . '/_zengine.cache'), + 'indexExisted' => is_file($cacheDir . '/_ffi.cache'), ]; $describeWeaving = static function (AspectKernel $kernel, string ...$classes) use (&$report): void { - if ($kernel->getOptions()['driver']->value !== 'zengine') { + if ($kernel->getOptions()['driver'] !== FfiDriver::class) { return; } - $weaver = $kernel->getContainer()->getService(ZEngineClassWeaver::class); + $weaver = $kernel->getContainer()->getService(ClassWeaver::class); foreach ($classes as $class) { $result = $weaver->weave($class); $report['weave'][$class] = ['outcome' => $result->outcome->value, 'methods' => $result->methods, 'reason' => $result->reason]; @@ -51,7 +52,7 @@ try { if ($scenario === 'late') { // Declared before the kernel exists, so never seen by the kernel's class loader - require __DIR__ . '/../src/Application/ZEngineLate.php'; + require __DIR__ . '/../src/Application/FfiLate.php'; } /** @var AspectKernel $kernel */ @@ -59,56 +60,56 @@ // 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); + $report['driver'] = $kernel->getOptions()['driver']; + if ($report['driver'] === FfiDriver::class) { + $report['boot'] = FfiDriver::getBootReport($container); } switch ($scenario) { case 'weave': - $parent = new ZEngineParent(); - $child = new ZEngineChild(); + $parent = new FfiParent(); + $child = new FfiChild(); $report['calls'] = [ 'parent.greet' => $parent->greet('Ann'), 'child.greet' => $child->greet('Bob'), 'child.describe' => $child->describe()::class, - 'child.create' => ZEngineChild::create('Cid')::class, + 'child.create' => FfiChild::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(), + 'unadvised.ping' => (new FfiUnadvised())->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.describe.class' => (new ReflectionMethod(FfiChild::class, 'describe'))->class, + 'child.greet.class' => (new ReflectionMethod(FfiChild::class, 'greet'))->class, + 'parent.greet.file' => basename((string) (new ReflectionMethod(FfiParent::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, + 'unadvised.proxy' => new FfiUnadvised() instanceof Proxy, ]; - $describeWeaving($kernel, ZEngineParent::class, ZEngineChild::class, ZEngineUnadvised::class); + $describeWeaving($kernel, FfiParent::class, FfiChild::class, FfiUnadvised::class); break; case 'late': - $describeWeaving($kernel, ZEngineLate::class); - $late = new ZEngineLate(); + $describeWeaving($kernel, FfiLate::class); + $late = new FfiLate(); $report['calls'] = ['late.compute' => $late->compute(2, 3)]; break; case 'unsupported': - $report['loaded'] = class_exists(ZEngineUnsupported::class); + $report['loaded'] = class_exists(FfiUnsupported::class); break; case 'functions': - $functions = new ZEngineFunctions(); + $functions = new FfiFunctions(); $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); + $describeWeaving($kernel, FfiFunctions::class); break; default: @@ -122,10 +123,10 @@ ]; } -$report['log'] = ZEngineAspect::$log; +$report['log'] = FfiAspect::$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), + [FfiParent::class, FfiChild::class, FfiLate::class, FfiUnadvised::class], + static fn(string $class): bool => trait_exists($class . 'OriginalTrait', false), )); $cacheFiles = []; if (is_dir($cacheDir)) { diff --git a/tests/Fixtures/project/src/Application/ZEngineChild.php b/tests/Fixtures/project/src/Application/FfiChild.php similarity index 79% rename from tests/Fixtures/project/src/Application/ZEngineChild.php rename to tests/Fixtures/project/src/Application/FfiChild.php index 7a0fda87..b9dcd510 100644 --- a/tests/Fixtures/project/src/Application/ZEngineChild.php +++ b/tests/Fixtures/project/src/Application/FfiChild.php @@ -4,10 +4,10 @@ namespace Go\Tests\TestProject\Application; /** - * Child fixture of the z-engine driver: own methods get their body swapped, the inherited public methods get an + * Child fixture of the ffi driver: own methods get their body swapped, the inherited public methods get an * override, signatures use the self keyword and a class constant default */ -final class ZEngineChild extends ZEngineParent +final class FfiChild extends FfiParent { public const int TIMES = 3; diff --git a/tests/Fixtures/project/src/Application/ZEngineFunctions.php b/tests/Fixtures/project/src/Application/FfiFunctions.php similarity index 77% rename from tests/Fixtures/project/src/Application/ZEngineFunctions.php rename to tests/Fixtures/project/src/Application/FfiFunctions.php index c4cf2765..a175cc57 100644 --- a/tests/Fixtures/project/src/Application/ZEngineFunctions.php +++ b/tests/Fixtures/project/src/Application/FfiFunctions.php @@ -4,10 +4,10 @@ namespace Go\Tests\TestProject\Application; /** - * Calls internal functions the z-engine driver proxies (Features::INTERCEPT_FUNCTIONS): unqualified calls are + * Calls internal functions the ffi driver proxies (function pointcuts): unqualified calls are * served by the namespaced proxy, a qualified call reaches the internal function directly */ -final class ZEngineFunctions +final class FfiFunctions { /** * @param list $numbers diff --git a/tests/Fixtures/project/src/Application/ZEngineLate.php b/tests/Fixtures/project/src/Application/FfiLate.php similarity index 82% rename from tests/Fixtures/project/src/Application/ZEngineLate.php rename to tests/Fixtures/project/src/Application/FfiLate.php index 578f4c4e..537a87c6 100644 --- a/tests/Fixtures/project/src/Application/ZEngineLate.php +++ b/tests/Fixtures/project/src/Application/FfiLate.php @@ -5,9 +5,9 @@ /** * Declared BEFORE the aspect kernel is initialized (required directly, never autoloaded): woven on demand - * through ZEngineClassWeaver::weave() + * through ClassWeaver::weave() */ -final class ZEngineLate +final class FfiLate { public function compute(int $left, int $right): int { diff --git a/tests/Fixtures/project/src/Application/ZEngineParent.php b/tests/Fixtures/project/src/Application/FfiParent.php similarity index 81% rename from tests/Fixtures/project/src/Application/ZEngineParent.php rename to tests/Fixtures/project/src/Application/FfiParent.php index 2ac73737..537685c5 100644 --- a/tests/Fixtures/project/src/Application/ZEngineParent.php +++ b/tests/Fixtures/project/src/Application/FfiParent.php @@ -4,9 +4,9 @@ namespace Go\Tests\TestProject\Application; /** - * Parent of the z-engine driver fixtures: only greet() is advised here, the rest is advised through ZEngineChild + * Parent of the ffi driver fixtures: only greet() is advised here, the rest is advised through FfiChild */ -class ZEngineParent +class FfiParent { public function greet(string $name): string { diff --git a/tests/Fixtures/project/src/Application/ZEngineUnadvised.php b/tests/Fixtures/project/src/Application/FfiUnadvised.php similarity index 54% rename from tests/Fixtures/project/src/Application/ZEngineUnadvised.php rename to tests/Fixtures/project/src/Application/FfiUnadvised.php index 046347b6..8af9b924 100644 --- a/tests/Fixtures/project/src/Application/ZEngineUnadvised.php +++ b/tests/Fixtures/project/src/Application/FfiUnadvised.php @@ -4,9 +4,9 @@ namespace Go\Tests\TestProject\Application; /** - * No advisor matches this class: the z-engine driver records it as unadvised and never generates a donor + * No advisor matches this class: the ffi driver records it as unadvised and never generates a donor */ -final class ZEngineUnadvised +final class FfiUnadvised { public function ping(): string { diff --git a/tests/Fixtures/project/src/Application/ZEngineUnsupported.php b/tests/Fixtures/project/src/Application/FfiUnsupported.php similarity index 64% rename from tests/Fixtures/project/src/Application/ZEngineUnsupported.php rename to tests/Fixtures/project/src/Application/FfiUnsupported.php index 19811bc0..d156c8eb 100644 --- a/tests/Fixtures/project/src/Application/ZEngineUnsupported.php +++ b/tests/Fixtures/project/src/Application/FfiUnsupported.php @@ -4,9 +4,9 @@ namespace Go\Tests\TestProject\Application; /** - * Advised by a property pointcut: a join point kind the z-engine driver refuses + * Advised by a property pointcut: a join point kind the ffi driver refuses */ -final class ZEngineUnsupported +final class FfiUnsupported { public int $counter = 0; diff --git a/tests/Fixtures/project/src/Aspect/ZEngineAspect.php b/tests/Fixtures/project/src/Aspect/FfiAspect.php similarity index 79% rename from tests/Fixtures/project/src/Aspect/ZEngineAspect.php rename to tests/Fixtures/project/src/Aspect/FfiAspect.php index 72bdafbf..aac8a73f 100644 --- a/tests/Fixtures/project/src/Aspect/ZEngineAspect.php +++ b/tests/Fixtures/project/src/Aspect/FfiAspect.php @@ -9,9 +9,9 @@ use Go\Lang\Attribute as Pointcut; /** - * Method-execution advices for the z-engine driver fixtures (the join point kind the driver supports) + * Method-execution advices for the ffi driver fixtures (the join point kind the driver supports) */ -class ZEngineAspect implements Aspect +class FfiAspect implements Aspect { /** * Advice calls, in order: ":: on " @@ -20,9 +20,9 @@ class ZEngineAspect implements Aspect */ 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->*(*))")] + #[Pointcut\Before("execution(public Go\Tests\TestProject\Application\FfiParent->greet(*)) + || execution(public Go\Tests\TestProject\Application\FfiChild->*(*)) + || execution(public Go\Tests\TestProject\Application\FfiLate->*(*))")] public function beforeMethod(MethodInvocation $invocation): void { $method = $invocation->getMethod(); @@ -35,7 +35,7 @@ public function beforeMethod(MethodInvocation $invocation): void ); } - #[Pointcut\Around("execution(public Go\Tests\TestProject\Application\ZEngineChild->greet(*))")] + #[Pointcut\Around("execution(public Go\Tests\TestProject\Application\FfiChild->greet(*))")] public function aroundGreet(MethodInvocation $invocation): string { $result = $invocation->proceed(); diff --git a/tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php b/tests/Fixtures/project/src/Aspect/FfiUnsupportedAspect.php similarity index 67% rename from tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php rename to tests/Fixtures/project/src/Aspect/FfiUnsupportedAspect.php index ba757a53..94f90e15 100644 --- a/tests/Fixtures/project/src/Aspect/ZEngineUnsupportedAspect.php +++ b/tests/Fixtures/project/src/Aspect/FfiUnsupportedAspect.php @@ -8,11 +8,11 @@ use Go\Lang\Attribute as Pointcut; /** - * A property-access advice: the z-engine driver refuses the class it matches + * A property-access advice: the ffi driver refuses the class it matches */ -class ZEngineUnsupportedAspect implements Aspect +class FfiUnsupportedAspect implements Aspect { - #[Pointcut\Before("access(public Go\Tests\TestProject\Application\ZEngineUnsupported->counter)")] + #[Pointcut\Before("access(public Go\Tests\TestProject\Application\FfiUnsupported->counter)")] public function beforeCounterAccess(FieldAccess $access): void { echo 'counter accessed'; diff --git a/tests/Fixtures/project/src/Kernel/FfiAspectKernel.php b/tests/Fixtures/project/src/Kernel/FfiAspectKernel.php new file mode 100644 index 00000000..8cb1324d --- /dev/null +++ b/tests/Fixtures/project/src/Kernel/FfiAspectKernel.php @@ -0,0 +1,19 @@ +addLazyService(FfiAspect::class, fn(): FfiAspect => new FfiAspect()); + } +} diff --git a/tests/Fixtures/project/src/Kernel/FfiUnsupportedAspectKernel.php b/tests/Fixtures/project/src/Kernel/FfiUnsupportedAspectKernel.php new file mode 100644 index 00000000..821643bf --- /dev/null +++ b/tests/Fixtures/project/src/Kernel/FfiUnsupportedAspectKernel.php @@ -0,0 +1,21 @@ +addLazyService(FfiAspect::class, fn(): FfiAspect => new FfiAspect()); + $container->addLazyService(FfiUnsupportedAspect::class, fn(): FfiUnsupportedAspect => new FfiUnsupportedAspect()); + } +} diff --git a/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php b/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php deleted file mode 100644 index 2016bbf1..00000000 --- a/tests/Fixtures/project/src/Kernel/ZEngineAspectKernel.php +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 6430cb19..00000000 --- a/tests/Fixtures/project/src/Kernel/ZEngineUnsupportedAspectKernel.php +++ /dev/null @@ -1,21 +0,0 @@ -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 4801669c..3da25f70 100644 --- a/tests/Fixtures/project/web/configuration.php +++ b/tests/Fixtures/project/web/configuration.php @@ -69,59 +69,56 @@ ], ], - // 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, + // The ffi weaving driver: classes load natively and are rewired through the engine (packages/ffi-driver/tests/Functional) + 'ffi' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\FfiAspectKernel::class, 'console' => __DIR__ . '/../bin/console', 'frontController' => __DIR__ . '/../web/index.php', 'appDir' => __DIR__ . '/../', 'debug' => true, - 'driver' => 'zengine', - 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine', - 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, + 'driver' => \Go\Driver\Ffi\FfiDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-ffi', 'includePaths' => [ __DIR__ . '/../src/' ], ], - 'zengine_production' => [ - 'kernel' => \Go\Tests\TestProject\Kernel\ZEngineAspectKernel::class, + 'ffi_production' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\FfiAspectKernel::class, 'console' => __DIR__ . '/../bin/console', 'frontController' => __DIR__ . '/../web/index.php', 'appDir' => __DIR__ . '/../', 'debug' => false, - 'driver' => 'zengine', - 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-production', - 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, + 'driver' => \Go\Driver\Ffi\FfiDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-ffi-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, + // The same aspects woven by the stream driver: the reference the ffi driver's behaviour is compared with + 'ffi_reference' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\FfiAspectKernel::class, 'console' => __DIR__ . '/../bin/console', 'frontController' => __DIR__ . '/../web/index.php', 'appDir' => __DIR__ . '/../', 'debug' => true, - 'driver' => 'stream', - 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-reference', - 'features' => \Go\Aop\Features::INTERCEPT_FUNCTIONS, + 'driver' => \Go\Driver\StreamFilter\StreamFilterDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-ffi-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, + // An aspect with a property pointcut: the ffi driver refuses the class it matches + 'ffi_unsupported' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\FfiUnsupportedAspectKernel::class, 'console' => __DIR__ . '/../bin/console', 'frontController' => __DIR__ . '/../web/index.php', 'appDir' => __DIR__ . '/../', 'debug' => true, - 'driver' => 'zengine', - 'cacheDir' => __DIR__ . '/../var/cache/aspect-zengine-unsupported', + 'driver' => \Go\Driver\Ffi\FfiDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-ffi-unsupported', 'includePaths' => [ __DIR__ . '/../src/' ], @@ -138,4 +135,46 @@ __DIR__ . '/../src/' ], ], + + // The stream wrapper weaving driver: every include within the scope is woven through the replaced file:// wrapper + // (packages/stream-wrapper-driver/tests/Functional) + 'stream_wrapper' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\DefaultAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => true, + 'driver' => \Go\Driver\StreamWrapper\StreamWrapperDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-stream-wrapper', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + + 'stream_wrapper_production' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\DefaultAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => false, + 'driver' => \Go\Driver\StreamWrapper\StreamWrapperDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-stream-wrapper-production', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], + + // The ffi driver fixtures woven by the stream wrapper driver: compared with 'ffi_reference' (the stream filter driver) + 'stream_wrapper_methods' => [ + 'kernel' => \Go\Tests\TestProject\Kernel\FfiAspectKernel::class, + 'console' => __DIR__ . '/../bin/console', + 'frontController' => __DIR__ . '/../web/index.php', + 'appDir' => __DIR__ . '/../', + 'debug' => true, + 'driver' => \Go\Driver\StreamWrapper\StreamWrapperDriver::class, + 'cacheDir' => __DIR__ . '/../var/cache/aspect-stream-wrapper-methods', + 'includePaths' => [ + __DIR__ . '/../src/' + ], + ], ]; diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 65273dc9..2b365e86 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -24,7 +24,6 @@ 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. @@ -170,14 +169,14 @@ protected function execute( /** * 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. + * On PHP 8.5 the tracing JIT is switched off: a php.ini enabling it makes the functional suite fail there. + * Override to add options. * * @return list */ protected function getPhpOptions(): array { - if (JitGuard::hasTracingJitBug()) { + if (PHP_VERSION_ID >= 80500 && PHP_VERSION_ID < 80600) { return ['-d', 'opcache.jit=off', '-d', 'opcache.jit_buffer_size=0']; } diff --git a/tests/Instrument/ClassLoading/AopComposerLoaderTest.php b/tests/Instrument/ClassLoading/AopComposerLoaderTest.php index a0e3050d..df458024 100644 --- a/tests/Instrument/ClassLoading/AopComposerLoaderTest.php +++ b/tests/Instrument/ClassLoading/AopComposerLoaderTest.php @@ -13,7 +13,7 @@ namespace Go\Instrument\ClassLoading; use Composer\Autoload\ClassLoader; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; use Go\Core\AspectContainer; use Go\Core\Container; use Go\Core\AspectKernel; @@ -58,7 +58,7 @@ public function testExcludedPathsAreNotWoven(): void 'includePaths' => [], 'excludePaths' => [$this->appDir . '/vendor/goaop/dissect'], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $fileEnumerator = new ReflectionProperty(AopComposerLoader::class, 'fileEnumerator')->getValue($loader); @@ -264,11 +264,11 @@ private function findWrapperOf(object $originalLoader): ?AopComposerLoader return null; } - public function testInitRewrapsALoaderWrappedByTheEngineDriver(): void + public function testInitRewrapsALoaderWrappedByAnotherDriver(): void { $originalLoaders = spl_autoload_functions(); $composerLoader = new ClassLoader(); - $engineLoader = new ZEngineComposerLoader($composerLoader, $this->createContainer(), $this->createOptions([])); + $engineLoader = new DecoratingLoaderStub($composerLoader); self::registerLoader([$engineLoader, 'loadClass']); try { $this->assertTrue(AopComposerLoader::init($this->createOptions([]), $this->createContainer())); @@ -278,7 +278,7 @@ public function testInitRewrapsALoaderWrappedByTheEngineDriver(): void 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->assertFalse(is_array($loader) && $loader[0] === $engineLoader, 'The wrapper of the other driver is replaced'); } $this->assertCount(1, $wrappers); $this->assertInstanceOf(AopComposerLoader::class, $wrappers[0]); @@ -311,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, driver: WeavingDriver} + * @return array{debug: bool, appDir: string, cacheDir: string|null, cacheFileMode: int, features: int, includePaths: list, excludePaths: list, containerClass: class-string, driver: class-string<\Go\Driver\WeavingDriver>} */ private function createOptions(array $excludePaths): array { @@ -324,7 +324,7 @@ private function createOptions(array $excludePaths): array 'includePaths' => [$this->appDir . '/src'], 'excludePaths' => $excludePaths, 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]; } @@ -371,3 +371,21 @@ private function createContainer(?CachePathManager $cachePathManager = null): As return $container; } } + +/** + * The composer loader wrapper of another weaving driver + */ +final class DecoratingLoaderStub implements ComposerLoaderDecorator +{ + public function __construct(private readonly ClassLoader $original) {} + + public function getOriginalLoader(): ClassLoader + { + return $this->original; + } + + public function loadClass(string $class): ?true + { + return null; + } +} diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php index 29e28698..5c9d7e25 100644 --- a/tests/Instrument/ClassLoading/CachePathManagerTest.php +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -15,7 +15,7 @@ use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Exception\WeavingException; use Go\Aop\Features; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; use Go\Core\AspectContainer; use Go\Core\AspectKernel; use Go\Core\Container; @@ -59,7 +59,7 @@ private function createManager(bool $prebuiltCache = false, ?string $cacheDir = 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $kernel->method('hasFeature')->willReturn($prebuiltCache); @@ -414,7 +414,7 @@ public function testWriteIndexFileWritesALoadableIndexNextToTheTransformationMet 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $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 f9fc5d17..2920d4e3 100644 --- a/tests/Instrument/ClassLoading/CacheWarmerTest.php +++ b/tests/Instrument/ClassLoading/CacheWarmerTest.php @@ -28,8 +28,9 @@ use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\StreamOutput; -use Go\Aop\WeavingDriver; use Go\Aop\Exception\InvalidConfigurationException; +use Go\Driver\StreamFilter\StreamFilterDriver; +use Go\Stubs\Driver\RewiringDriver; // Separate processes: warming up registers the process-wide source transforming // stream filter, which must not leak into the rest of the PHPUnit process @@ -60,7 +61,7 @@ protected function tearDown(): void self::removeTemporaryDirectory($this->appDir); } - public function testRefusesAKernelOfTheEngineDriver(): void + public function testRefusesAKernelOfADriverThatDoesNotTransformSources(): void { $kernel = $this->createMock(AspectKernel::class); $kernel->method('getOptions')->willReturn([ @@ -72,16 +73,16 @@ public function testRefusesAKernelOfTheEngineDriver(): void 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::ZEngine, + 'driver' => RewiringDriver::class, ]); $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'); + self::fail('A driver that rewires classes at runtime has nothing to warm up'); } catch (InvalidConfigurationException $refusal) { - $this->assertStringContainsString('zengine weaving driver', $refusal->getMessage()); - $this->assertStringContainsString('stream filter', $refusal->getMessage()); + $this->assertStringContainsString(RewiringDriver::class . ' never uses', $refusal->getMessage()); + $this->assertStringContainsString('no Go\\Driver\\SourceTransformingDriver', $refusal->getMessage()); } } @@ -111,7 +112,7 @@ private function createKernel( 'includePaths' => $includePaths, 'excludePaths' => $excludePaths, 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $container = $this->createMock(AspectContainer::class); diff --git a/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php b/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php index e882d888..088eb605 100644 --- a/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php +++ b/tests/Instrument/ClassLoading/SourceTransformingLoaderTest.php @@ -25,7 +25,7 @@ use PhpToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] class SourceTransformingLoaderTest extends TestCase @@ -92,7 +92,7 @@ private function registerLoader(array $transformers, int $features = 0, ?string 'includePaths' => [], 'excludePaths' => [], 'containerClass' => Container::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); $kernel->method('hasFeature')->willReturnCallback( static fn(int $featureToCheck): bool => ($features & $featureToCheck) !== 0, diff --git a/tests/Instrument/ZEngine/ZEngineDriverTest.php b/tests/Instrument/ZEngine/ZEngineDriverTest.php deleted file mode 100644 index 57813e55..00000000 --- a/tests/Instrument/ZEngine/ZEngineDriverTest.php +++ /dev/null @@ -1,66 +0,0 @@ - - * - * 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/Performance/TransformationPerformanceTest.php b/tests/Performance/TransformationPerformanceTest.php index 184ec644..a2c52d6e 100644 --- a/tests/Performance/TransformationPerformanceTest.php +++ b/tests/Performance/TransformationPerformanceTest.php @@ -37,7 +37,7 @@ use RecursiveIteratorIterator; use ReflectionClass; use SplFileInfo; -use Go\Aop\WeavingDriver; +use Go\Driver\StreamFilter\StreamFilterDriver; /** * Performance of the source transformation, which runs on every cache miss of a loaded file @@ -327,7 +327,7 @@ private function createKernel(): AspectKernel 'includePaths' => [], 'excludePaths' => [], 'containerClass' => AspectContainer::class, - 'driver' => WeavingDriver::Stream, + 'driver' => StreamFilterDriver::class, ]); return $kernel; diff --git a/tests/Stubs/Driver/RewiringDriver.php b/tests/Stubs/Driver/RewiringDriver.php new file mode 100644 index 00000000..ee5782f8 --- /dev/null +++ b/tests/Stubs/Driver/RewiringDriver.php @@ -0,0 +1,22 @@ + Date: Sun, 18 Oct 2026 21:45:44 +0000 Subject: [PATCH 12/19] feat(driver): add the stream wrapper driver package `goaop/stream-wrapper-driver` (`Go\Driver\StreamWrapper\StreamWrapperDriver`, a SourceTransformingDriver) takes the place of the `file://` stream wrapper (`stream_wrapper_unregister('file')` + `stream_wrapper_register('file', AopStreamWrapper::class)`): every file included from within the weaving scope is served through `SourceTransformingLoader::transformSource()`, every other file operation is delegated to the native wrapper. Same join points, transformer chain, caches and warmup as the stream filter driver; includes are intercepted by nature (`Features::INTERCEPT_INCLUDES` is refused as implied) and opcache caches the woven sources under their original path, which it never does for a `php://filter` include. The functional tests run the core class weaving and magic constant suites through the wrapper and compare the fixture probe with the stream filter driver. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- packages/stream-wrapper-driver/AGENTS.md | 38 ++ packages/stream-wrapper-driver/composer.json | 40 ++ .../src/AopStreamWrapper.php | 475 ++++++++++++++++++ .../StreamWrapperConfigurationException.php | 32 ++ .../StreamWrapperRegistrationException.php | 30 ++ .../src/StreamWrapperDriver.php | 59 +++ .../tests/AopStreamWrapperTest.php | 358 +++++++++++++ .../StreamWrapperClassWeavingTest.php | 34 ++ .../StreamWrapperMagicConstantTest.php | 27 + ...treamWrapperProductionClassWeavingTest.php | 29 ++ .../Functional/StreamWrapperWeavingTest.php | 212 ++++++++ .../tests/StreamWrapperDriverTest.php | 81 +++ 12 files changed, 1415 insertions(+) create mode 100644 packages/stream-wrapper-driver/AGENTS.md create mode 100644 packages/stream-wrapper-driver/composer.json create mode 100644 packages/stream-wrapper-driver/src/AopStreamWrapper.php create mode 100644 packages/stream-wrapper-driver/src/Exception/StreamWrapperConfigurationException.php create mode 100644 packages/stream-wrapper-driver/src/Exception/StreamWrapperRegistrationException.php create mode 100644 packages/stream-wrapper-driver/src/StreamWrapperDriver.php create mode 100644 packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php create mode 100644 packages/stream-wrapper-driver/tests/Functional/StreamWrapperClassWeavingTest.php create mode 100644 packages/stream-wrapper-driver/tests/Functional/StreamWrapperMagicConstantTest.php create mode 100644 packages/stream-wrapper-driver/tests/Functional/StreamWrapperProductionClassWeavingTest.php create mode 100644 packages/stream-wrapper-driver/tests/Functional/StreamWrapperWeavingTest.php create mode 100644 packages/stream-wrapper-driver/tests/StreamWrapperDriverTest.php diff --git a/packages/stream-wrapper-driver/AGENTS.md b/packages/stream-wrapper-driver/AGENTS.md new file mode 100644 index 00000000..6561ab44 --- /dev/null +++ b/packages/stream-wrapper-driver/AGENTS.md @@ -0,0 +1,38 @@ +# packages/stream-wrapper-driver — stream wrapper weaving driver +goaop/stream-wrapper-driver | NS: Go\Driver\StreamWrapper | `'driver' => StreamWrapperDriver::class` + +## What it is +A source-transforming driver (Go\Driver\SourceTransformingDriver) that replaces PHP's `file://` stream wrapper +instead of wrapping composer's loader: every file included from within the weaving scope (appDir, includePaths, +excludePaths) is transformed at load time, whoever includes it. Same transformer chain, cache and warmup +(`cache:warmup:aop`) as the stream filter driver, so it covers the same join points. + +## Mechanism (src/) +- StreamWrapperDriver::boot(): refuses Features::INTERCEPT_INCLUDES (StreamWrapperConfigurationException, includes + are intercepted by nature and the flag would rewrite them into php://filter URIs), then AopStreamWrapper::register(). +- AopStreamWrapper (`@internal`): `stream_wrapper_unregister('file')` + `stream_wrapper_register('file', ...)`. Every + protocol method delegates to the native wrapper through `native()`: `stream_wrapper_restore('file')`, the native + call, re-register in `finally`; a depth counter keeps nested calls from restoring twice. Opened handles are native + streams (or php://memory), so reads/writes never re-enter the wrapper. +- stream_open(): opens natively first (a missing file fails natively). `($options & 0x80)` is the include flag + (`STREAM_OPEN_FOR_INCLUDE` of main/php_streams.h, no userland constant). An include within the scope + (Enumerator path filter on the PathResolver::realpath()-resolved path) runs + SourceTransformingLoader::ensureRegistered() + transformSource() WITH the wrapper registered (the chain autoloads + parent classes meanwhile, they must be woven too); a result differing from the source is served from php://memory. +- stream_stat() of a memory-served include: native stat() of the original path with `size`/[7] replaced by the served + length. Verified facts (PHP 8.4/8.5): opcache caches a file served through a user `file://` wrapper under its + original path as long as stream_stat() reports an mtime (and opcache.file_update_protection does not block it); PHP + reads exactly `st_size` bytes of an included stream, so the size MUST be the served length. +- Lost: AopComposerLoader::findOriginalFile() finds no wrapped composer loader, the kernel falls back to reflection + when tracking aspect files (debug mode only). + +## Tests (tests/) +- `vendor/bin/phpunit --testsuite stream-wrapper-driver` (part of the default `composer test`); root `composer analyze` + and `composer cs` cover the package. +- AopStreamWrapperTest: real temporary directories (Go\PhpUnit\UsesTemporaryDirectory), kernel/container stubs, a + marker-rewriting SourceTransformer; always unregisters the wrapper in tearDown(). +- Functional/: subclasses of the core ClassWeavingTest (debug + production) and MagicConstantTest on the fixture + configurations `stream_wrapper`, `stream_wrapper_production`; StreamWrapperWeavingTest runs the probe + tests/Fixtures/project/bin/driver-weaving.php under `stream_wrapper_methods` vs `ffi_reference` (stream filter + driver, same FfiAspectKernel fixtures) with opcache off and on, warm cache included, and checks opcache lists the + woven files under their original paths. diff --git a/packages/stream-wrapper-driver/composer.json b/packages/stream-wrapper-driver/composer.json new file mode 100644 index 00000000..4316c7f7 --- /dev/null +++ b/packages/stream-wrapper-driver/composer.json @@ -0,0 +1,40 @@ +{ + "name": "goaop/stream-wrapper-driver", + "description": "Weaving driver of the Go! AOP framework that replaces the file:// stream wrapper and weaves every included file of the application at load time (opcache keeps caching the woven files).", + "license": "MIT", + "type": "library", + "keywords": [ + "aop", + "php", + "aspect", + "stream-wrapper" + ], + "authors": [ + { + "name": "Lisachenko Alexander", + "homepage": "https://github.com/lisachenko" + } + ], + "require": { + "php": "^8.4.0", + "goaop/framework": "^4.0" + }, + "autoload": { + "psr-4": { + "Go\\Driver\\StreamWrapper\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Go\\Driver\\StreamWrapper\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + } +} diff --git a/packages/stream-wrapper-driver/src/AopStreamWrapper.php b/packages/stream-wrapper-driver/src/AopStreamWrapper.php new file mode 100644 index 00000000..8c16ac35 --- /dev/null +++ b/packages/stream-wrapper-driver/src/AopStreamWrapper.php @@ -0,0 +1,475 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper; + +use Closure; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Driver\StreamWrapper\Exception\StreamWrapperRegistrationException; +use Go\Instrument\ClassLoading\SourceTransformingLoader; +use Go\Instrument\FileSystem\Enumerator; +use Go\Instrument\PathResolver; + +use function is_array; +use function is_int; +use function is_resource; +use function is_string; +use function strlen; + +/** + * The `file://` stream wrapper of the stream wrapper driver: every file of the process is served by delegating + * to the native wrapper, a file included from within the weaving scope is served woven + * + * PHP has one `file://` wrapper per process, so this class takes the place of the native one + * (stream_wrapper_unregister('file') + stream_wrapper_register('file', ...)). Every protocol method restores + * the native wrapper for the duration of one native call and registers this class again afterwards + * ({@see self::native()}); the opened handle is a native stream, so reads and writes never re-enter the wrapper. + * + * An include (`$options & STREAM_OPEN_FOR_INCLUDE`, see {@see self::STREAM_OPEN_FOR_INCLUDE}) of a file within + * the weaving scope reads the file and hands it to {@see SourceTransformingLoader::transformSource()}, the shared + * cache-or-transform pipeline of the source-transforming drivers; a result differing from the source is served + * from a `php://memory` stream under the original path. opcache caches such a file by its original path like any + * other (a `php://filter` include never is cached), as long as {@see self::stream_stat()} reports the mtime of the + * original file and the length of the served content as size: PHP reads exactly `st_size` bytes of an included + * stream. + * + * @internal Registered by StreamWrapperDriver, instantiated by PHP + * + * @phpstan-import-type KernelOptions from AspectKernel + */ +final class AopStreamWrapper +{ + /** + * `STREAM_OPEN_FOR_INCLUDE` of main/php_streams.h: set on the options of stream_open() for include/require, + * not exposed to userland as a constant + */ + private const int STREAM_OPEN_FOR_INCLUDE = 0x80; + + private const string PROTOCOL = 'file'; + + /** + * Stream context, set by PHP on the instance when the caller passed one + */ + public mixed $context = null; + + /** + * Container the transformation pipeline is wired to + */ + private static ?AspectContainer $container = null; + + /** + * Decides whether a resolved path lies within the weaving scope (appDir, includePaths, excludePaths) + * + * @var (Closure(string): bool)|null + */ + private static ?Closure $isWithinWeavingScope = null; + + /** + * Whether this class is the registered `file://` wrapper + */ + private static bool $registered = false; + + /** + * Nesting depth of {@see self::native()}: the native wrapper is restored by the outermost call only + */ + private static int $nativeDepth = 0; + + /** + * Native stream of the opened file, or the `php://memory` stream serving a woven include + * + * @var resource|null + */ + private $stream; + + /** + * Native directory handle of dir_opendir() + * + * @var resource|null + */ + private $directory; + + /** + * Original path of an include served from memory: its stat is reported with the served length + */ + private ?string $servedPath = null; + + /** + * Length of the content served from memory + */ + private int $servedLength = 0; + + /** + * Takes the place of the native `file://` wrapper for the given container and weaving scope + * + * Idempotent: a second call reconfigures the scope and keeps the registration. + * + * @phpstan-param KernelOptions $options Normalized kernel options + * + * @throws StreamWrapperRegistrationException When PHP refuses the registration (the native wrapper is restored) + */ + public static function register(AspectContainer $container, array $options): void + { + self::$container = $container; + self::$isWithinWeavingScope = new Enumerator($options['appDir'], $options['includePaths'], $options['excludePaths']) + ->getPathFilter(); + + if (self::$registered) { + return; + } + // Loaded ahead: an include triggers stream_open(), which needs both classes before it can tell whether the + // included file lies within the scope. Autoloading them from there would include their own files through + // this wrapper while the class is already being autoloaded, which PHP reports as "class not found" + class_exists(PathResolver::class); + class_exists(SourceTransformingLoader::class); + stream_wrapper_unregister(self::PROTOCOL); + if (!stream_wrapper_register(self::PROTOCOL, self::class)) { + stream_wrapper_restore(self::PROTOCOL); + + throw StreamWrapperRegistrationException::fileWrapperNotReplaced(self::class); + } + self::$registered = true; + self::$nativeDepth = 0; + } + + /** + * Restores the native `file://` wrapper and forgets the container and the scope + * + * @internal For tests and processes that boot the framework again + */ + public static function unregister(): void + { + if (self::$registered) { + stream_wrapper_restore(self::PROTOCOL); + } + self::$registered = false; + self::$nativeDepth = 0; + self::$container = null; + self::$isWithinWeavingScope = null; + } + + public static function isRegistered(): bool + { + return self::$registered; + } + + /** + * @param string|null $openedPath Set to the path actually opened when STREAM_USE_PATH searched the include path + */ + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + $usePath = ($options & STREAM_USE_PATH) !== 0; + $reportErrors = ($options & STREAM_REPORT_ERRORS) !== 0; + // Opened natively first, so that a missing or unreadable file fails exactly like it does without the driver + $stream = self::native( + static fn(): mixed => $reportErrors ? fopen($path, $mode, $usePath) : @fopen($path, $mode, $usePath), + ); + if (!is_resource($stream)) { + return false; + } + $this->stream = $stream; + if ($usePath) { + // The include path was searched: the uri of the native stream is the path that was found + $path = stream_get_meta_data($stream)['uri'] ?? $path; + $openedPath = $path; + } + + if (($options & self::STREAM_OPEN_FOR_INCLUDE) !== 0) { + $this->serveInclude($path); + } + + return true; + } + + public function stream_read(int $count): string|false + { + if ($this->stream === null) { + return false; + } + + return $count > 0 ? fread($this->stream, $count) : ''; + } + + public function stream_write(string $data): int|false + { + return $this->stream === null ? false : fwrite($this->stream, $data); + } + + public function stream_eof(): bool + { + return $this->stream === null || feof($this->stream); + } + + public function stream_close(): void + { + if ($this->stream !== null) { + fclose($this->stream); + $this->stream = null; + } + } + + public function stream_flush(): bool + { + return $this->stream !== null && fflush($this->stream); + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + return $this->stream !== null && fseek($this->stream, $offset, $whence) === 0; + } + + public function stream_tell(): int|false + { + return $this->stream === null ? false : ftell($this->stream); + } + + /** + * Stat of the opened file: for a woven include the native stat of the original path (its mtime keeps opcache + * validating the file as usual) with the served length as size, as PHP reads exactly `st_size` bytes of an + * included stream + * + * @return array|false + */ + public function stream_stat(): array|false + { + if ($this->servedPath !== null) { + $servedPath = $this->servedPath; + $stat = self::native(static fn(): array|false => @stat($servedPath)); + if ($stat === false) { + return false; + } + $stat[7] = $this->servedLength; + $stat['size'] = $this->servedLength; + + return $stat; + } + + return $this->stream === null ? false : fstat($this->stream); + } + + public function stream_truncate(int $newSize): bool + { + return $this->stream !== null && $newSize >= 0 && ftruncate($this->stream, $newSize); + } + + public function stream_lock(int $operation): bool + { + if ($this->stream === null) { + return false; + } + // flock() first asks whether the stream supports locking at all (operation 0); a memory-served include has + // no file to lock, so every request is answered with success + if ($operation === 0 || $this->servedPath !== null) { + return true; + } + + // PHP hands over LOCK_SH, LOCK_EX or LOCK_UN, with LOCK_NB possibly set: the values flock() accepts + return flock($this->stream, $operation & (LOCK_SH | LOCK_EX | LOCK_UN | LOCK_NB)); + } + + public function stream_set_option(int $option, int $arg1, ?int $arg2): bool + { + return false; + } + + /** + * @return resource|false The native handle for stream_select(), nothing for a memory-served include + */ + public function stream_cast(int $castAs): mixed + { + if ($this->stream === null || $this->servedPath !== null) { + return false; + } + + return $this->stream; + } + + public function stream_metadata(string $path, int $option, mixed $value): bool + { + return self::native(static function () use ($path, $option, $value): bool { + switch ($option) { + case STREAM_META_TOUCH: + $times = is_array($value) ? $value : []; + $mtime = is_int($times[0] ?? null) ? $times[0] : null; + $atime = is_int($times[1] ?? null) ? $times[1] : null; + + return touch($path, $mtime, $atime); + case STREAM_META_OWNER: + case STREAM_META_OWNER_NAME: + return (is_int($value) || is_string($value)) && chown($path, $value); + case STREAM_META_GROUP: + case STREAM_META_GROUP_NAME: + return (is_int($value) || is_string($value)) && chgrp($path, $value); + case STREAM_META_ACCESS: + return is_int($value) && chmod($path, $value); + default: + return false; + } + }); + } + + /** + * @return array|false + */ + public function url_stat(string $path, int $flags): array|false + { + $quiet = ($flags & STREAM_URL_STAT_QUIET) !== 0; + $link = ($flags & STREAM_URL_STAT_LINK) !== 0; + + return self::native(static function () use ($path, $quiet, $link): array|false { + if ($link) { + return $quiet ? @lstat($path) : lstat($path); + } + + return $quiet ? @stat($path) : stat($path); + }); + } + + public function unlink(string $path): bool + { + return self::native(static fn(): bool => unlink($path)); + } + + public function rename(string $from, string $to): bool + { + return self::native(static fn(): bool => rename($from, $to)); + } + + public function mkdir(string $path, int $mode, int $options): bool + { + $recursive = ($options & STREAM_MKDIR_RECURSIVE) !== 0; + $reportErrors = ($options & STREAM_REPORT_ERRORS) !== 0; + + return self::native( + static fn(): bool => $reportErrors ? mkdir($path, $mode, $recursive) : @mkdir($path, $mode, $recursive), + ); + } + + public function rmdir(string $path, int $options): bool + { + $reportErrors = ($options & STREAM_REPORT_ERRORS) !== 0; + + return self::native(static fn(): bool => $reportErrors ? rmdir($path) : @rmdir($path)); + } + + public function dir_opendir(string $path, int $options): bool + { + $directory = self::native(static fn(): mixed => @opendir($path)); + if (!is_resource($directory)) { + return false; + } + $this->directory = $directory; + + return true; + } + + public function dir_readdir(): string|false + { + return $this->directory === null ? false : readdir($this->directory); + } + + public function dir_rewinddir(): bool + { + if ($this->directory === null) { + return false; + } + rewinddir($this->directory); + + return true; + } + + public function dir_closedir(): bool + { + if ($this->directory !== null) { + closedir($this->directory); + $this->directory = null; + } + + return true; + } + + /** + * Serves an included file woven when it lies within the weaving scope + * + * Runs with this wrapper registered on purpose: the transformer chain includes other files meanwhile (parent + * classes and interfaces of the woven class are autoloaded for reflection) and they must be woven too. + */ + private function serveInclude(string $path): void + { + $container = self::$container; + $isWithinWeavingScope = self::$isWithinWeavingScope; + $stream = $this->stream; + if ($container === null || $isWithinWeavingScope === null || $stream === null) { + return; + } + $resolvedPath = PathResolver::realpath($path); + $resolvedPath = is_string($resolvedPath) ? $resolvedPath : $path; + if (!$isWithinWeavingScope($resolvedPath)) { + return; + } + + $source = stream_get_contents($stream); + if ($source === false) { + return; + } + SourceTransformingLoader::ensureRegistered($container); + // The transformer metadata reads the uri of the given stream: the native handle of the file + $served = SourceTransformingLoader::transformSource($resolvedPath, $source, $stream); + if ($served === $source) { + rewind($stream); + + return; + } + + $memory = fopen('php://memory', 'r+b'); + if ($memory === false) { + rewind($stream); + + return; + } + fwrite($memory, $served); + rewind($memory); + fclose($stream); + $this->stream = $memory; + $this->servedPath = $resolvedPath; + $this->servedLength = strlen($served); + } + + /** + * Runs one operation against the native `file://` wrapper + * + * The outermost call restores the native wrapper and registers this class again afterwards; a nested call + * (an operation that calls into this class again) runs with the wrapper already restored. + * + * @template T + * + * @param Closure(): T $operation + * + * @return T + */ + private static function native(Closure $operation): mixed + { + if (self::$nativeDepth === 0 && self::$registered) { + stream_wrapper_restore(self::PROTOCOL); + } + self::$nativeDepth++; + try { + return $operation(); + } finally { + self::$nativeDepth--; + if (self::$nativeDepth === 0 && self::$registered) { + stream_wrapper_unregister(self::PROTOCOL); + stream_wrapper_register(self::PROTOCOL, self::class); + } + } + } +} diff --git a/packages/stream-wrapper-driver/src/Exception/StreamWrapperConfigurationException.php b/packages/stream-wrapper-driver/src/Exception/StreamWrapperConfigurationException.php new file mode 100644 index 00000000..390ba954 --- /dev/null +++ b/packages/stream-wrapper-driver/src/Exception/StreamWrapperConfigurationException.php @@ -0,0 +1,32 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Exception; + +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Features; + +/** + * The kernel options do not fit the stream wrapper driver + */ +final class StreamWrapperConfigurationException extends InvalidConfigurationException +{ + public static function includeInterceptionImplied(): self + { + return new self(sprintf( + 'The stream wrapper weaving driver intercepts every include of the application by itself: ' + . 'Features::INTERCEPT_INCLUDES (%d) would rewrite includes into php://filter URIs instead, ' + . 'which opcache never caches. Drop the flag from the "features" kernel option.', + Features::INTERCEPT_INCLUDES, + )); + } +} diff --git a/packages/stream-wrapper-driver/src/Exception/StreamWrapperRegistrationException.php b/packages/stream-wrapper-driver/src/Exception/StreamWrapperRegistrationException.php new file mode 100644 index 00000000..df76e5f6 --- /dev/null +++ b/packages/stream-wrapper-driver/src/Exception/StreamWrapperRegistrationException.php @@ -0,0 +1,30 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Exception; + +use Go\Aop\Exception\WeavingException; + +/** + * PHP refused to replace its `file://` stream wrapper with the weaving one + */ +final class StreamWrapperRegistrationException extends WeavingException +{ + public static function fileWrapperNotReplaced(string $wrapperClass): self + { + return new self(sprintf( + 'The stream wrapper weaving driver could not register %s as the "file://" stream wrapper ' + . '(stream_wrapper_register() failed); the native wrapper was restored.', + $wrapperClass, + )); + } +} diff --git a/packages/stream-wrapper-driver/src/StreamWrapperDriver.php b/packages/stream-wrapper-driver/src/StreamWrapperDriver.php new file mode 100644 index 00000000..d89f9ba4 --- /dev/null +++ b/packages/stream-wrapper-driver/src/StreamWrapperDriver.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\Driver\StreamWrapper; + +use Go\Aop\Features; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Driver\SourceTransformingDriver; +use Go\Driver\StreamWrapper\Exception\StreamWrapperConfigurationException; + +/** + * Weaving driver that replaces the `file://` stream wrapper: every file included from within the weaving scope + * is transformed at load time, whoever includes it + * + * Mechanism: {@see AopStreamWrapper} takes the place of PHP's native `file://` wrapper + * (`stream_wrapper_unregister('file')` + `stream_wrapper_register('file', AopStreamWrapper::class)`). An include + * of a file within the scope (appDir, includePaths, excludePaths) runs the same cache-or-transform pipeline as the + * stream filter driver ({@see \Go\Instrument\ClassLoading\SourceTransformingLoader::transformSource()}) and is + * served from memory under its original path; every other file operation is delegated to the native wrapper. + * The driver covers every join point the stream filter driver covers, with the same transformer chain, caches + * and warmup (`cache:warmup:aop`). + * + * Trade-offs against the stream filter driver: + * - Every file operation of the process (fopen, stat, is_file, file_exists, opendir, rename, ...) now passes + * through userland PHP and the restore/re-register dance of the wrapper, so work that is not an include is + * slower than with the native wrapper. The include path itself costs one extra read of the file. + * - In exchange the woven sources are cached by opcache under their original path (a `php://filter` include + * never is), and includes are intercepted by nature: no composer loader wrapper, no rewriting of + * include/require statements. {@see Features::INTERCEPT_INCLUDES} is therefore refused: it would make the + * FilterInjectorTransformer rewrite includes into `php://filter` URIs, which bypass the wrapper and opcache. + * - Without a wrapped composer loader, {@see \Go\Instrument\ClassLoading\AopComposerLoader::findOriginalFile()} + * (used by the kernel in debug mode to track the files of aspects without loading them) finds nothing, so the + * kernel falls back to reflection there: the aspect classes are loaded when they are registered (debug only). + */ +final class StreamWrapperDriver implements SourceTransformingDriver +{ + /** + * @throws StreamWrapperConfigurationException When the kernel options ask for the include interception + */ + public function boot(AspectKernel $kernel, AspectContainer $container): void + { + $options = $kernel->getOptions(); + if (($options['features'] & Features::INTERCEPT_INCLUDES) !== 0) { + throw StreamWrapperConfigurationException::includeInterceptionImplied(); + } + + AopStreamWrapper::register($container, $options); + } +} diff --git a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php new file mode 100644 index 00000000..06fd5fa4 --- /dev/null +++ b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php @@ -0,0 +1,358 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper; + +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Core\Container; +use Go\Instrument\ClassLoading\CachePathManager; +use Go\Instrument\ClassLoading\SourceTransformingLoader; +use Go\Instrument\Transformer\SourceTransformer; +use Go\Instrument\Transformer\StreamMetaData; +use Go\Instrument\Transformer\TransformerResult; +use Go\PhpUnit\UsesTemporaryDirectory; +use PhpToken; +use PHPUnit\Framework\TestCase; + +/** + * The replaced `file://` wrapper: plain file operations keep working natively, an include from within the weaving + * scope is served transformed under its original path + */ +final class AopStreamWrapperTest extends TestCase +{ + use UsesTemporaryDirectory; + + private const int STREAM_OPEN_FOR_INCLUDE = 0x80; + + private string $appDir; + + private string $cacheDir; + + private string $includePath; + + private MarkerRewritingTransformer $transformer; + + private ?CachePathManager $cachePathManager = null; + + protected function setUp(): void + { + // Real directories: the wrapper resolves the included path and includes files, the cache is written to disk + $this->appDir = self::createTemporaryDirectory('stream-wrapper-app'); + $this->cacheDir = self::createTemporaryDirectory('stream-wrapper-cache'); + mkdir($this->appDir . '/src'); + mkdir($this->appDir . '/lib'); + $this->includePath = get_include_path(); + $this->transformer = new MarkerRewritingTransformer(); + SourceTransformingLoader::reset(); + } + + protected function tearDown(): void + { + try { + AopStreamWrapper::unregister(); + SourceTransformingLoader::reset(); + set_include_path($this->includePath); + $this->cachePathManager?->clearCacheState(); + } finally { + self::removeTemporaryDirectory($this->cacheDir); + self::removeTemporaryDirectory($this->appDir); + } + } + + public function testPlainFileFunctionsAreDelegatedToTheNativeWrapper(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/plain.txt'; + + $this->assertSame(5, file_put_contents($file, 'hello')); + $this->assertSame('hello', file_get_contents($file)); + $this->assertTrue(is_file($file)); + $this->assertFalse(is_dir($file)); + $this->assertSame(5, filesize($file)); + $this->assertSame(['hello'], file($file)); + $this->assertFalse(file_exists($this->appDir . '/lib/missing.txt')); + $this->assertIsInt(filemtime($file)); + + $this->assertTrue(rename($file, $this->appDir . '/lib/renamed.txt')); + $this->assertFalse(file_exists($file)); + $this->assertTrue(copy($this->appDir . '/lib/renamed.txt', $this->appDir . '/lib/copied.txt')); + $this->assertSame('hello', file_get_contents($this->appDir . '/lib/copied.txt')); + $this->assertTrue(unlink($this->appDir . '/lib/renamed.txt')); + $this->assertTrue(unlink($this->appDir . '/lib/copied.txt')); + $this->assertFalse(file_exists($this->appDir . '/lib/renamed.txt')); + } + + public function testStreamOperationsAreDelegatedToTheNativeHandle(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/stream.txt'; + file_put_contents($file, '0123456789', LOCK_EX); + + $handle = fopen($file, 'r+'); + $this->assertIsResource($handle); + $this->assertSame('0123', fread($handle, 4)); + $this->assertSame(4, ftell($handle)); + $this->assertSame(0, fseek($handle, 8)); + $this->assertSame('89', fread($handle, 4)); + $this->assertSame('', fread($handle, 1)); + $this->assertTrue(feof($handle)); + $stat = fstat($handle); + $this->assertIsArray($stat); + $this->assertSame(10, $stat['size']); + $this->assertTrue(flock($handle, LOCK_EX)); + $this->assertTrue(flock($handle, LOCK_UN)); + $this->assertTrue(ftruncate($handle, 3)); + $this->assertTrue(rewind($handle)); + $this->assertSame(2, fwrite($handle, 'ab')); + $this->assertTrue(fflush($handle)); + $this->assertTrue(fclose($handle)); + $this->assertSame('ab2', file_get_contents($file)); + + $this->assertSame(1, file_put_contents($file, 'x', FILE_APPEND | LOCK_EX)); + $this->assertSame('ab2x', file_get_contents($file)); + } + + public function testDirectoryOperationsAreDelegatedToTheNativeWrapper(): void + { + $this->registerWrapper(); + $nested = $this->appDir . '/lib/a/b/c'; + + $this->assertTrue(mkdir($nested, 0777, true)); + $this->assertTrue(is_dir($nested)); + touch($nested . '/one.txt'); + touch($nested . '/two.txt'); + + $directory = opendir($nested); + $this->assertIsResource($directory); + $entries = []; + while (($entry = readdir($directory)) !== false) { + $entries[] = $entry; + } + rewinddir($directory); + $this->assertIsString(readdir($directory), 'The directory can be read again after rewinding'); + closedir($directory); + sort($entries); + $this->assertSame(['.', '..', 'one.txt', 'two.txt'], $entries); + $this->assertSame(['.', '..', 'one.txt', 'two.txt'], scandir($nested)); + + unlink($nested . '/one.txt'); + unlink($nested . '/two.txt'); + $this->assertTrue(rmdir($nested)); + $this->assertFalse(is_dir($nested)); + $this->assertFalse(@rmdir($this->appDir . '/lib/a'), 'A directory that is not empty is refused natively'); + $this->assertTrue(rmdir($this->appDir . '/lib/a/b')); + $this->assertTrue(rmdir($this->appDir . '/lib/a')); + } + + public function testMetadataOperationsAreDelegatedToTheNativeWrapper(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/meta.txt'; + + $this->assertTrue(touch($file, 1_000_000_000)); + $this->assertTrue(file_exists($file)); + clearstatcache(); + $this->assertSame(1_000_000_000, filemtime($file)); + $this->assertTrue(chmod($file, 0600)); + clearstatcache(); + $this->assertSame(0600, fileperms($file) & 0777); + } + + public function testAMissingFileFailsNatively(): void + { + $this->registerWrapper(); + $missing = $this->appDir . '/lib/missing.php'; + + $this->assertFalse(@fopen($missing, 'r')); + $this->assertFalse(@file_get_contents($missing)); + $this->assertFalse(file_exists($missing)); + $this->assertFalse(@include $missing); + } + + public function testIncludeOutsideTheWeavingScopeIsServedUntouched(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/outside.php'; + file_put_contents($file, " 'MARKER', 'file' => __FILE__];"); + + $this->assertSame(['value' => 'MARKER', 'file' => $file], include $file); + $this->assertSame(0, $this->transformer->callCount, 'A file outside the scope never reaches the chain'); + $this->assertFileDoesNotExist($this->cacheDir . '/lib/outside.php'); + } + + public function testIncludeWithinTheWeavingScopeIsTransformedAndCached(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/src/inside.php'; + file_put_contents($file, " 'MARKER', 'file' => __FILE__];"); + + // Served from memory under the original path: the magic constants resolve to the original file + $this->assertSame(['value' => 'REWRITTEN', 'file' => $file], include $file); + $this->assertSame(1, $this->transformer->callCount); + $cachedFile = $this->cacheDir . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'inside.php'; + $this->assertFileExists($cachedFile); + $this->assertStringContainsString('REWRITTEN', (string) file_get_contents($cachedFile)); + $this->assertNotNull($this->cachePathManager?->queryCacheState($file), 'The transformation is recorded'); + + // A fresh cache record serves the cached content without running the chain again + $this->assertSame(['value' => 'REWRITTEN', 'file' => $file], include $file); + $this->assertSame(1, $this->transformer->callCount); + } + + public function testIncludeStreamReportsTheServedLengthAndTheOriginalMtime(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/src/served.php'; + file_put_contents($file, "assertTrue($wrapper->stream_open($file, 'rb', self::STREAM_OPEN_FOR_INCLUDE, $openedPath)); + $served = "stream_stat(); + $this->assertIsArray($stat); + // PHP reads exactly st_size bytes of an included stream, opcache validates the file by its mtime + $this->assertSame(strlen($served), $stat['size']); + $this->assertSame(strlen($served), $stat[7]); + $this->assertSame(1_000_000_000, $stat['mtime']); + $this->assertSame($served, $wrapper->stream_read(1024)); + $this->assertTrue($wrapper->stream_eof()); + $this->assertFalse($wrapper->stream_cast(STREAM_CAST_AS_STREAM), 'A memory-served include has no native handle'); + $this->assertTrue($wrapper->stream_lock(LOCK_UN), 'A memory-served include has nothing to lock'); + $wrapper->stream_close(); + } + + public function testIncludeStreamOfAnUntransformedFileKeepsTheNativeHandle(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/src/plain.php'; + file_put_contents($file, "assertTrue($wrapper->stream_open($file, 'rb', self::STREAM_OPEN_FOR_INCLUDE, $openedPath)); + $stat = $wrapper->stream_stat(); + $this->assertIsArray($stat); + $this->assertSame(filesize($file), $stat['size']); + $this->assertSame("stream_read(1024)); + $this->assertIsResource($wrapper->stream_cast(STREAM_CAST_AS_STREAM)); + $wrapper->stream_close(); + $this->assertSame(1, $this->transformer->callCount, 'The chain ran and abstained'); + } + + public function testTheIncludePathIsSearchedForStreamUsePath(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/on-include-path.php'; + file_put_contents($file, "appDir . '/lib'); + + $wrapper = new AopStreamWrapper(); + $openedPath = null; + $this->assertTrue($wrapper->stream_open('on-include-path.php', 'r', STREAM_USE_PATH, $openedPath)); + $this->assertSame($file, $openedPath); + $wrapper->stream_close(); + $this->assertSame("appDir . '/src/round.php'; + file_put_contents($file, "registerWrapper(); + $this->assertTrue(AopStreamWrapper::isRegistered()); + $this->assertSame('REWRITTEN', include $file); + + AopStreamWrapper::unregister(); + $this->assertFalse(AopStreamWrapper::isRegistered()); + $this->assertContains('file', stream_get_wrappers(), 'The native wrapper is back'); + $this->assertSame('MARKER', include $file); + + $this->registerWrapper(); + $this->assertTrue(AopStreamWrapper::isRegistered()); + $this->assertSame('REWRITTEN', include $file); + AopStreamWrapper::unregister(); + $this->assertSame('MARKER', include $file); + } + + public function testRegisterIsIdempotent(): void + { + $this->registerWrapper(); + $this->registerWrapper(); + $this->assertTrue(AopStreamWrapper::isRegistered()); + $this->assertContains('file', stream_get_wrappers()); + + AopStreamWrapper::unregister(); + $this->assertFalse(AopStreamWrapper::isRegistered()); + AopStreamWrapper::unregister(); + $this->assertContains('file', stream_get_wrappers(), 'A second unregister() is harmless'); + } + + /** + * Registers the wrapper for a container serving the marker-rewriting chain, with the application sources + * (appDir/src) as the weaving scope + */ + private function registerWrapper(): void + { + $options = [ + 'debug' => true, + 'appDir' => $this->appDir, + 'cacheDir' => $this->cacheDir, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [$this->appDir . '/src'], + 'excludePaths' => [$this->cacheDir], + 'containerClass' => Container::class, + 'driver' => StreamWrapperDriver::class, + ]; + $kernel = $this->createStub(AspectKernel::class); + $kernel->method('getOptions')->willReturn($options); + $kernel->method('hasFeature')->willReturn(false); + $this->cachePathManager = new CachePathManager($kernel); + + $container = $this->createStub(AspectContainer::class); + $container->method('getService')->willReturnMap([ + [AspectKernel::class, $kernel], + [CachePathManager::class, $this->cachePathManager], + ]); + $container->method('getServicesByInterface')->willReturn([$this->transformer]); + // The tracked resources never change during a test: a cache record stays fresh + $container->method('isFreshSince')->willReturn(true); + + AopStreamWrapper::register($container, $options); + } +} + +/** + * Transformer stub rewriting the string MARKER into REWRITTEN and counting its invocations + */ +final class MarkerRewritingTransformer implements SourceTransformer +{ + public int $callCount = 0; + + public function transform(StreamMetaData $metadata): TransformerResult + { + $this->callCount++; + if (!str_contains($metadata->originalSource, 'MARKER')) { + return TransformerResult::Abstain; + } + $metadata->setTokenStreamFromRawTokens( + ...PhpToken::tokenize(str_replace('MARKER', 'REWRITTEN', $metadata->originalSource)), + ); + + return TransformerResult::Transformed; + } +} diff --git a/packages/stream-wrapper-driver/tests/Functional/StreamWrapperClassWeavingTest.php b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperClassWeavingTest.php new file mode 100644 index 00000000..8141ee68 --- /dev/null +++ b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperClassWeavingTest.php @@ -0,0 +1,34 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Functional; + +use Go\Functional\ClassWeavingTest; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; + +/** + * The class weaving suite of the framework, run through the stream wrapper driver in debug mode: the same + * transformer chain, so every join point of the fixture project is woven alike (the woven classes run in + * subprocesses booting the driver). + * + * Every test runs in a process of its own: ReflectionFilenameTest of the core suite boots a kernel inside the test + * runner and weaves the fixture class Main there, after which native reflection reports the proxy of the `aspect` + * cache as its file and the woven-constraints look up the proxy of this configuration under a wrong path. + */ +#[RunTestsInSeparateProcesses] +final class StreamWrapperClassWeavingTest extends ClassWeavingTest +{ + protected function getConfigurationName(): string + { + return 'stream_wrapper'; + } +} diff --git a/packages/stream-wrapper-driver/tests/Functional/StreamWrapperMagicConstantTest.php b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperMagicConstantTest.php new file mode 100644 index 00000000..03ca14a5 --- /dev/null +++ b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperMagicConstantTest.php @@ -0,0 +1,27 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Functional; + +use Go\Functional\MagicConstantTest; + +/** + * Magic constants resolve to the original location with the stream wrapper driver too: a woven file is served + * from memory under its original path, an untransformed one by its native handle + */ +final class StreamWrapperMagicConstantTest extends MagicConstantTest +{ + protected function getConfigurationName(): string + { + return 'stream_wrapper'; + } +} diff --git a/packages/stream-wrapper-driver/tests/Functional/StreamWrapperProductionClassWeavingTest.php b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperProductionClassWeavingTest.php new file mode 100644 index 00000000..2639ac1a --- /dev/null +++ b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperProductionClassWeavingTest.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\Driver\StreamWrapper\Functional; + +use Go\Functional\ClassWeavingTest; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; + +/** + * Same as StreamWrapperClassWeavingTest with debug mode off: the woven files are served from the warmed cache + * (process isolation for the same reason) + */ +#[RunTestsInSeparateProcesses] +final class StreamWrapperProductionClassWeavingTest extends ClassWeavingTest +{ + protected function getConfigurationName(): string + { + return 'stream_wrapper_production'; + } +} diff --git a/packages/stream-wrapper-driver/tests/Functional/StreamWrapperWeavingTest.php b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperWeavingTest.php new file mode 100644 index 00000000..ae4cc0b8 --- /dev/null +++ b/packages/stream-wrapper-driver/tests/Functional/StreamWrapperWeavingTest.php @@ -0,0 +1,212 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Functional; + +use Go\Driver\StreamFilter\StreamFilterDriver; +use Go\Driver\StreamWrapper\StreamWrapperDriver; +use Go\Tests\TestProject\Application\FfiChild; +use Go\Tests\TestProject\Application\FfiParent; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * The stream wrapper driver weaves the driver fixtures exactly like the stream filter driver: the probe + * tests/Fixtures/project/bin/driver-weaving.php runs in subprocesses under both drivers (`stream_wrapper_methods` + * and `ffi_reference` share the FfiAspectKernel fixtures) and its reports are compared, with opcache off and on + */ +final class StreamWrapperWeavingTest extends TestCase +{ + private const string PROJECT_DIR = __DIR__ . '/../../../../tests/Fixtures/project'; + + private const string PROBE = self::PROJECT_DIR . '/bin/driver-weaving.php'; + + private const string WRAPPER_CONFIGURATION = 'stream_wrapper_methods'; + + private const string REFERENCE_CONFIGURATION = 'ffi_reference'; + + /** + * PHP options of every subprocess: the JIT off (the PHP 8.5 tracing JIT miscompiles the framework), freshly + * written files cacheable by opcache at once + */ + private const array PHP_OPTIONS = [ + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + '-d', 'opcache.file_update_protection=0', + '-d', 'opcache.enable=1', + ]; + + protected function setUp(): void + { + $this->clearCaches(); + } + + protected function tearDown(): void + { + $this->clearCaches(); + } + + /** + * @return iterable + */ + public static function provideOpcacheLegs(): iterable + { + yield 'opcache off' => [false]; + yield 'opcache on' => [true]; + } + + #[DataProvider('provideOpcacheLegs')] + public function testWeavesMethodsLikeTheStreamFilterDriver(bool $opcache): void + { + $wrapper = $this->runProbe(self::WRAPPER_CONFIGURATION, 'weave', $opcache); + $reference = $this->runProbe(self::REFERENCE_CONFIGURATION, 'weave', $opcache); + + self::assertArrayNotHasKey('exception', $wrapper, 'The stream wrapper driver must weave the scenario'); + self::assertArrayNotHasKey('exception', $reference, 'The stream filter driver must weave the scenario'); + self::assertSame(StreamWrapperDriver::class, $wrapper['driver']); + self::assertSame(StreamFilterDriver::class, $reference['driver']); + + // Advised calls return the same results, run the same advices in the same order and reflect alike + self::assertSame($reference['calls'], $wrapper['calls']); + self::assertSame($reference['log'], $wrapper['log']); + self::assertNotEmpty($wrapper['log']); + self::assertSame($reference['reflection'], $wrapper['reflection']); + // The same transformer chain writes the same cache files + self::assertSame($reference['cache'], $wrapper['cache']); + + if (!$opcache) { + return; + } + // The warm cache serves the second request, with the woven files already in opcache + $warm = $this->runProbe(self::WRAPPER_CONFIGURATION, 'weave', true); + self::assertArrayNotHasKey('exception', $warm, 'The warm cache must serve the scenario'); + self::assertSame($reference['calls'], $warm['calls']); + self::assertSame($reference['log'], $warm['log']); + } + + #[DataProvider('provideOpcacheLegs')] + public function testWeavesFunctionsLikeTheStreamFilterDriver(bool $opcache): void + { + $wrapper = $this->runProbe(self::WRAPPER_CONFIGURATION, 'functions', $opcache); + $reference = $this->runProbe(self::REFERENCE_CONFIGURATION, 'functions', $opcache); + + self::assertArrayNotHasKey('exception', $wrapper, 'The stream wrapper driver must weave the scenario'); + self::assertArrayNotHasKey('exception', $reference, 'The stream filter driver must weave the scenario'); + self::assertSame($reference['calls'], $wrapper['calls']); + self::assertSame($reference['log'], $wrapper['log']); + self::assertNotEmpty($wrapper['log']); + self::assertIsArray($wrapper['calls']); + self::assertTrue($wrapper['calls']['functions.proxied'] ?? false, 'The function proxy is declared'); + } + + /** + * The woven sources are served under their original paths, so opcache caches them like any other file + * (a php://filter include never is): the point of the driver + */ + public function testOpcacheCachesTheWovenFilesUnderTheirOriginalPaths(): void + { + $script = sprintf( + 'include %s; $child = new %s(); echo json_encode([' + . '"greet" => $child->greet("Ann"), ' + . '"scripts" => array_keys((array) (opcache_get_status(true)["scripts"] ?? []))' + . ']);', + var_export(self::PROJECT_DIR . '/web/index.php', true), + '\\' . FfiChild::class, + ); + $expectedScripts = [ + realpath(self::PROJECT_DIR . '/src/Application/' . basename(str_replace('\\', '/', FfiParent::class)) . '.php'), + realpath(self::PROJECT_DIR . '/src/Application/' . basename(str_replace('\\', '/', FfiChild::class)) . '.php'), + ]; + + // Cold cache (woven now) and warm cache (served from the cache): both runs are cached by opcache + foreach (['cold', 'warm'] as $run) { + $process = new Process( + [self::phpExecutable(), ...self::PHP_OPTIONS, '-d', 'opcache.enable_cli=1', '-r', $script], + null, + ['GO_AOP_CONFIGURATION' => self::WRAPPER_CONFIGURATION], + ); + $process->run(); + self::assertTrue($process->isSuccessful(), $run . ' run: ' . $process->getOutput() . $process->getErrorOutput()); + $report = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($report); + self::assertSame('HI, ANN FROM CHILD', $report['greet'] ?? null, $run . ' run: the advices ran'); + $scripts = $report['scripts'] ?? null; + self::assertIsArray($scripts); + if ($scripts === []) { + self::markTestSkipped('opcache is not available in the PHP subprocess'); + } + foreach ($expectedScripts as $expectedScript) { + self::assertContains($expectedScript, $scripts, $run . ' run: the woven file is cached by its original path'); + } + } + } + + /** + * Runs one probe scenario and returns its report + * + * @return array + */ + private function runProbe(string $configuration, string $scenario, bool $opcache): array + { + $process = new Process( + [ + self::phpExecutable(), + ...self::PHP_OPTIONS, + '-d', 'opcache.enable_cli=' . ($opcache ? '1' : '0'), + self::PROBE, + $scenario, + ], + null, + ['GO_AOP_CONFIGURATION' => $configuration], + ); + $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; + } + + /** + * Removes the cache directories of both configurations: every test starts from a cold cache + */ + private 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 ([self::WRAPPER_CONFIGURATION, self::REFERENCE_CONFIGURATION] as $name) { + $filesystem->remove($configurations[$name]['cacheDir']); + } + } + + 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/packages/stream-wrapper-driver/tests/StreamWrapperDriverTest.php b/packages/stream-wrapper-driver/tests/StreamWrapperDriverTest.php new file mode 100644 index 00000000..d77c2129 --- /dev/null +++ b/packages/stream-wrapper-driver/tests/StreamWrapperDriverTest.php @@ -0,0 +1,81 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper; + +use Go\Aop\Features; +use Go\Core\AspectContainer; +use Go\Core\AspectKernel; +use Go\Core\Container; +use Go\Driver\SourceTransformingDriver; +use Go\Driver\StreamWrapper\Exception\StreamWrapperConfigurationException; +use PHPUnit\Framework\TestCase; + +/** + * The boot of the driver: the include interception flag is refused, otherwise the file wrapper is replaced + */ +final class StreamWrapperDriverTest extends TestCase +{ + protected function tearDown(): void + { + AopStreamWrapper::unregister(); + } + + public function testIsASourceTransformingDriver(): void + { + // The kernel registers the transformer chain and the cache warmer accepts the driver by this interface + $this->assertContains(SourceTransformingDriver::class, class_implements(StreamWrapperDriver::class)); + } + + public function testRefusesIncludeInterception(): void + { + try { + new StreamWrapperDriver()->boot($this->kernel(Features::INTERCEPT_INCLUDES), $this->createStub(AspectContainer::class)); + self::fail('Includes are intercepted by the wrapper itself, the flag must be refused'); + } catch (StreamWrapperConfigurationException $refusal) { + $this->assertStringContainsString('Features::INTERCEPT_INCLUDES', $refusal->getMessage()); + $this->assertStringContainsString('Drop the flag', $refusal->getMessage()); + } + $this->assertFalse(AopStreamWrapper::isRegistered(), 'Nothing is registered on a refusal'); + } + + public function testBootReplacesTheFileWrapper(): void + { + new StreamWrapperDriver()->boot($this->kernel(0), $this->createStub(AspectContainer::class)); + + $this->assertTrue(AopStreamWrapper::isRegistered()); + $this->assertContains('file', stream_get_wrappers()); + $this->assertFileExists(__FILE__, 'Plain file operations are delegated to the native wrapper'); + + AopStreamWrapper::unregister(); + $this->assertFalse(AopStreamWrapper::isRegistered()); + $this->assertContains('file', stream_get_wrappers()); + } + + private function kernel(int $features): AspectKernel + { + $kernel = $this->createStub(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => true, + 'appDir' => __DIR__, + 'cacheDir' => __DIR__ . '/cache', + 'cacheFileMode' => 0770, + 'features' => $features, + 'includePaths' => [], + 'excludePaths' => [__DIR__ . '/cache'], + 'containerClass' => Container::class, + 'driver' => StreamWrapperDriver::class, + ]); + + return $kernel; + } +} From b8085c40faa4af4dc8f784f3919c70967c5b3067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:15:02 +0000 Subject: [PATCH 13/19] fix(driver): address the review of the driver restructure Boot of the ffi driver: the JIT guard runs before anything of z-engine is autoloaded, the installed engine is probed without autoloading, and an engine release without the method-table API (redefine() with preserveAs, addMethod(), fromClassTable()) is refused at boot instead of failing at the first weave; the functional tests skip on such an engine. ClassWeaver keys its results by the declared class name, never lets a file outside the application root decide for its namespace, and takes no "no functions" decision while no advisor is registered yet. The class-table refusal gets a named constructor of its own. Core: an init()-level test pins the driver boot (once, registered in the container, before configureAop(), transformer chain for source-transforming drivers only); the matrix test job excludes the ffi suite like the coverage job; the init-flow notes of src/Instrument/AGENTS.md follow transformSource() and isFreshRecord(); codecov flags are scoped to their paths; the ffi workflow also reacts to the fixture project it consumes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .github/workflows/ffi-driver.yml | 2 + .github/workflows/phpunit.yml | 2 +- README.md | 2 +- codecov.yml | 5 + docs/driver/README.md | 2 +- docs/driver/ffi.md | 4 +- packages/ffi-driver/AGENTS.md | 10 +- packages/ffi-driver/src/ClassWeaver.php | 23 +++- .../EngineConfigurationException.php | 10 ++ .../UnsupportedJoinpointException.php | 11 ++ packages/ffi-driver/src/FfiDriver.php | 56 +++++++- .../ffi-driver/src/MethodTableRewriter.php | 5 +- .../ffi-driver/tests/DonorCacheIndexTest.php | 2 + .../tests/DonorTraitGeneratorTest.php | 2 + .../Exception/ExceptionConventionTest.php | 125 ++++++++++++++++++ .../UnsupportedJoinpointExceptionTest.php | 2 + .../tests/FfiComposerLoaderTest.php | 2 + packages/ffi-driver/tests/FfiDriverTest.php | 2 + .../Functional/FfiFunctionalTestCase.php | 7 +- .../tests/InProcess/InProcessTestCase.php | 4 + .../MethodTableRewriterInProcessTest.php | 1 - packages/ffi-driver/tests/JitGuardTest.php | 2 + src/Instrument/AGENTS.md | 6 +- tests/Core/AspectKernelTest.php | 46 +++++++ tests/Functional/BaseFunctionalTestCase.php | 4 +- tests/Stubs/Driver/RewiringDriver.php | 12 ++ tests/Stubs/Driver/TransformingDriver.php | 17 +++ 27 files changed, 339 insertions(+), 27 deletions(-) create mode 100644 packages/ffi-driver/tests/Exception/ExceptionConventionTest.php create mode 100644 tests/Stubs/Driver/TransformingDriver.php diff --git a/.github/workflows/ffi-driver.yml b/.github/workflows/ffi-driver.yml index 04660371..7a7d6561 100644 --- a/.github/workflows/ffi-driver.yml +++ b/.github/workflows/ffi-driver.yml @@ -12,6 +12,8 @@ on: paths: - "packages/ffi-driver/**" - "src/Driver/**" + - "tests/Fixtures/project/**" + - "phpunit.xml.dist" - ".github/workflows/ffi-driver.yml" - "composer.json" push: diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index cd4ea052..91f38110 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -80,7 +80,7 @@ jobs: run: "composer update --no-interaction --no-progress${{ matrix.experimental && ' --ignore-platform-req=php+' || '' }}" - name: "Tests" - run: "vendor/bin/phpunit" + run: "vendor/bin/phpunit --exclude-testsuite ffi-driver" coverage: # Coverage is measured on the oldest supported PHP version diff --git a/README.md b/README.md index d49b53bd..f5b46d06 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ $applicationAspectKernel->init([ |--------|-------| | [Stream filter](docs/driver/stream-filter.md) (default, built in) | `Go\Driver\StreamFilter\StreamFilterDriver` — source transformation at load time through a `php://filter` stream filter behind Composer's loader | | [Stream wrapper](docs/driver/stream-wrapper.md) (`composer require goaop/stream-wrapper-driver`) | `Go\Driver\StreamWrapper\StreamWrapperDriver` — source transformation of every included file through a `file://` stream wrapper; includes are always intercepted, opcache caches the woven sources | -| [ffi](docs/driver/ffi.md) (`composer require goaop/ffi-driver`, experimental) | `Go\Driver\Ffi\FfiDriver` — runtime rewiring of loaded classes through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution join points only; needs `ext-ffi` and the opcache JIT off | +| [ffi](docs/driver/ffi.md) (`composer require goaop/ffi-driver`, experimental) | `Go\Driver\Ffi\FfiDriver` — runtime rewiring of loaded classes through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution and function join points only; needs `ext-ffi` and the opcache JIT off | See [docs/driver/](docs/driver/README.md) for the driver interface and how to write one. diff --git a/codecov.yml b/codecov.yml index 2d76692f..36b64149 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,4 +3,9 @@ flag_management: carryforward: true individual_flags: - name: framework + paths: + - src/ + - packages/stream-wrapper-driver/src/ - name: ffi-driver + paths: + - packages/ffi-driver/src/ diff --git a/docs/driver/README.md b/docs/driver/README.md index 4e29ba6d..707b7197 100644 --- a/docs/driver/README.md +++ b/docs/driver/README.md @@ -16,7 +16,7 @@ ApplicationAspectKernel::getInstance()->init([ |--------|---------|-----------| | [`Go\Driver\StreamFilter\StreamFilterDriver`](stream-filter.md) (default) | `goaop/framework` (built in) | Source transformation of advised files at load time through a `php://filter` stream filter behind composer's loader. Pure PHP. | | [`Go\Driver\StreamWrapper\StreamWrapperDriver`](stream-wrapper.md) | `goaop/stream-wrapper-driver` | Source transformation of every included file within the weaving scope through a `file://` stream wrapper; includes are always intercepted, opcache caches the woven sources. Pure PHP. | -| [`Go\Driver\Ffi\FfiDriver`](ffi.md) (experimental) | `goaop/ffi-driver` | Runtime rewiring of the methods of a loaded class through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution join points only. | +| [`Go\Driver\Ffi\FfiDriver`](ffi.md) (experimental) | `goaop/ffi-driver` | Runtime rewiring of the methods of a loaded class through [lisachenko/z-engine](https://github.com/lisachenko/z-engine) (PHP FFI): no source rewriting, method execution and function join points only. | An option value that is not an existing class, or a class that does not implement `Go\Driver\WeavingDriver`, throws `Go\Aop\Exception\InvalidConfigurationException` from `init()`. diff --git a/docs/driver/ffi.md b/docs/driver/ffi.md index b5ebe778..66d2bfe4 100644 --- a/docs/driver/ffi.md +++ b/docs/driver/ffi.md @@ -22,7 +22,9 @@ limits listed below. composer require lisachenko/z-engine ``` - A project installed on several PHP minors constrains the engine accordingly, e.g. `"lisachenko/z-engine": "~8.4.0|~8.5.0"`. + The releases carrying the method-table API the driver is built on are 8.4.3 and 8.5.1; a project installed on + several PHP minors constrains the engine accordingly, e.g. `"lisachenko/z-engine": "~8.4.3 || ~8.5.1"` (the + constraint of `goaop/ffi-driver` itself). An older release is refused by `init()`. - 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. A JIT found active is switched off at runtime as a safety net and reported in the [boot report](#boot-report); the process-level setting stays the diff --git a/packages/ffi-driver/AGENTS.md b/packages/ffi-driver/AGENTS.md index 6bb1d1e1..f1bff6b6 100644 --- a/packages/ffi-driver/AGENTS.md +++ b/packages/ffi-driver/AGENTS.md @@ -1,7 +1,7 @@ # packages/ffi-driver — goaop/ffi-driver (Go\Driver\Ffi\) Weaving driver of Go! AOP: runtime method-table weaving through lisachenko/z-engine (ext-ffi), `'driver' => FfiDriver::class`. -Separate composer package (own composer.json: php ~8.4 || ~8.5, ext-ffi, goaop/framework ^4.0, lisachenko/z-engine), +Separate composer package (own composer.json: php ~8.4.0 || ~8.5.0, ext-ffi, goaop/framework ^4.0, lisachenko/z-engine ~8.4.3 || ~8.5.1), mapped by the root autoload-dev (`Go\Driver\Ffi\` → src/ and tests/). Docs: docs/driver/ffi.md. ## Commands (repo root) @@ -12,8 +12,8 @@ mapped by the root autoload-dev (`Go\Driver\Ffi\` → src/ and tests/). Docs: do ## Boot (FfiDriver::boot(), WeavingDriver, NOT a SourceTransformingDriver → no transformer chain) 1. refuses Features::INTERCEPT_INCLUDES (EngineConfigurationException::includeInterceptionNotSupported) -2. requires z-engine (class_exists ZEngine\Core) → EngineConfigurationException::engineNotInstalled -3. JitGuard::enforce() BEFORE Core::init() (z-engine's classes must compile with the JIT off): detection via `opcache_get_status()['jit']['on']` (ini value unusable); active → `ini_set('opcache.jit', 'off')` (never 'disable', it locks the directive), still on → jitStillOn; report {jitWasActive, previousMode, disabledAtRuntime}. Process-level `opcache.jit=off` + `opcache.jit_buffer_size=0` stays the documented requirement (code compiled before the kernel keeps its JIT state) +2. JitGuard::enforce() BEFORE anything of z-engine is autoloaded (z-engine's classes must compile with the JIT off) (z-engine's classes must compile with the JIT off): detection via `opcache_get_status()['jit']['on']` (ini value unusable); active → `ini_set('opcache.jit', 'off')` (never 'disable', it locks the directive), still on → jitStillOn; report {jitWasActive, previousMode, disabledAtRuntime}. Process-level `opcache.jit=off` + `opcache.jit_buffer_size=0` stays the documented requirement (code compiled before the kernel keeps its JIT state) +3. requires z-engine (loaded, or known to composer: InstalledVersions) → engineNotInstalled; requires the method-table API (redefine() with preserveAs, addMethod(), fromClassTable(): releases 8.4.3 / 8.5.1) → engineWithoutMethodTableApi 4. Core::init() unless initialized (failure → engineBootFailed) 5. boot report in the container under `kernel.ffi.boot` (FfiDriver::BOOT_REPORT; getBootReport($container)): driver, php, opcache, jit 6. installed z-engine package joins excludePaths; lazy services DonorCacheIndex, MethodTableRewriter, ClassWeaver @@ -42,9 +42,9 @@ mapped by the root autoload-dev (`Go\Driver\Ffi\` → src/ and tests/). Docs: do - donorFileFor()/functionsFileFor() need cacheDir (InvalidConfigurationException::cacheIndexWithoutCacheDir); isApplicationSource() = below appDir ## Exceptions (src/Exception/) -- EngineConfigurationException extends Go\Aop\Exception\InvalidConfigurationException: engineNotInstalled, engineBootFailed, jitStillOn, includeInterceptionNotSupported +- EngineConfigurationException extends Go\Aop\Exception\InvalidConfigurationException: engineNotInstalled, engineWithoutMethodTableApi, engineBootFailed, jitStillOn, includeInterceptionNotSupported - FfiWeavingException extends Go\Aop\Exception\WeavingException: unsupportedDonorKinds, sourceOutsideApplicationRoot, donorNotDeclared -- UnsupportedJoinpointException extends FfiWeavingException: forClass, engineRefusal +- UnsupportedJoinpointException extends FfiWeavingException: forClass, classNotInEngineClassTable, engineRefusal ## Not supported (source-transforming drivers only) property access, introductions, initialization/staticinitialization, Features::INTERCEPT_INCLUDES, traits/enums/interfaces as targets, CacheWarmer / cache:warmup:aop / debug:weaving (refused by the framework: no SourceTransformingDriver). Known deviations: reflection of an advised method reports the donor file; a single-literal private/final body may be inlined by the optimizer before the swap; a call site run before the function proxies were declared keeps the global function. diff --git a/packages/ffi-driver/src/ClassWeaver.php b/packages/ffi-driver/src/ClassWeaver.php index 5322e56b..d2036128 100644 --- a/packages/ffi-driver/src/ClassWeaver.php +++ b/packages/ffi-driver/src/ClassWeaver.php @@ -106,6 +106,7 @@ public function __construct( */ public function weave(string $className): WeaveResult { + $className = self::declaredName($className); if (isset($this->failures[$className])) { throw $this->failures[$className]; } @@ -132,6 +133,7 @@ public function weave(string $className): WeaveResult */ public function weaveLoadedClass(string $className, string $file): WeaveResult { + $className = self::declaredName($className); if (isset($this->failures[$className])) { throw $this->failures[$className]; } @@ -182,16 +184,24 @@ public function weaveFunctions(string $namespace, string $file): ?string if (array_key_exists($namespace, $this->functionNamespaces)) { return $this->functionNamespaces[$namespace]; } - $this->functionNamespaces[$namespace] = null; + // A file outside the application root decides nothing for its namespace if (!$this->donorCache->isApplicationSource($file)) { return null; } + $this->functionNamespaces[$namespace] = null; // A fresh record decides without the aspects; only a miss asks the advisor index, which loads them $record = $this->donorCache->findFreshFunctions($namespace, $this->container); if ($record === null) { if (!$this->advisorIndex->hasPointcutKind(Pointcut::KIND_FUNCTION)) { - // No function pointcut registered at all: recorded, so a warm request does not ask again + if ($this->advisorIndex->getAdvisors() === []) { + // No advisor at all yet (a class loaded while the kernel is still being configured): + // nothing is decided, the question is asked again for the next class of the namespace + unset($this->functionNamespaces[$namespace]); + + return null; + } + // No function pointcut registered: recorded, so a warm request does not ask again $this->donorCache->recordFunctions($namespace, null, []); return null; @@ -246,6 +256,15 @@ private function generateFunctionProxies(string $namespace, string $file): array return ['file' => $proxyFile, 'functions' => $functions]; } + /** + * The name a loaded class was declared with: the loader hands over the name as the code spelled it, which + * may differ in case; the results, the index records and the engine lookups all use the declared spelling + */ + private static function declaredName(string $className): string + { + return class_exists($className, false) ? new ReflectionClass($className)->getName() : $className; + } + /** * Namespace of a class name, '' for the global namespace */ diff --git a/packages/ffi-driver/src/Exception/EngineConfigurationException.php b/packages/ffi-driver/src/Exception/EngineConfigurationException.php index ea488efe..740392cc 100644 --- a/packages/ffi-driver/src/Exception/EngineConfigurationException.php +++ b/packages/ffi-driver/src/Exception/EngineConfigurationException.php @@ -35,6 +35,16 @@ public static function engineBootFailed(Throwable $previous): self return new self('The ffi weaving driver cannot boot z-engine: ' . $previous->getMessage(), 0, $previous); } + public static function engineWithoutMethodTableApi(string $installedVersion): self + { + return new self(sprintf( + 'The ffi weaving driver requires lisachenko/z-engine ~8.4.3 || ~8.5.1 (ReflectionMethod::redefine() with ' + . 'preserveAs, ReflectionClass::addMethod() and fromClassTable()), %s is installed: ' + . 'composer require lisachenko/z-engine installs the release of the running PHP minor.', + $installedVersion, + )); + } + public static function jitStillOn(): self { return new self( diff --git a/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php b/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php index 8a6d1e4f..fa77f13e 100644 --- a/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php +++ b/packages/ffi-driver/src/Exception/UnsupportedJoinpointException.php @@ -40,6 +40,17 @@ public static function forClass(string $className, array $kinds, array $advisorI )); } + /** + * The class is loaded by PHP but has no entry in the engine class table, so there is nothing to rewire + */ + public static function classNotInEngineClassTable(string $className): self + { + return new self(sprintf( + 'The ffi weaving driver cannot weave %s: the class is not published in the engine class table', + $className, + )); + } + /** * The class cannot be rewired because z-engine refused the engine mutation */ diff --git a/packages/ffi-driver/src/FfiDriver.php b/packages/ffi-driver/src/FfiDriver.php index fdf28809..dbb0916f 100644 --- a/packages/ffi-driver/src/FfiDriver.php +++ b/packages/ffi-driver/src/FfiDriver.php @@ -23,8 +23,12 @@ use Go\Driver\WeavingDriver; use Go\Instrument\ClassLoading\CachePathManager; use Go\Instrument\PathResolver; +use ReflectionClass; +use ReflectionParameter; use Throwable; use ZEngine\Core; +use ZEngine\Reflection\ReflectionClass as EngineClass; +use ZEngine\Reflection\ReflectionMethod as EngineMethod; /** * The ffi weaving driver: runtime method-table weaving through lisachenko/z-engine (PHP FFI) @@ -66,12 +70,14 @@ final class FfiDriver implements WeavingDriver /** * The kernel creates the driver without arguments; the closures are seams for the tests * - * @param (Closure(): bool)|null $isEngineInstalled Whether z-engine is installed, class_exists(Core::class) by default + * @param (Closure(): bool)|null $isEngineInstalled Whether z-engine is installed: loaded already, or known to composer, + * by default (nothing of the engine is autoloaded before the JIT guard ran) * @param (Closure(): void)|null $bootEngine Boots z-engine unless it is initialized, Core::init() by default */ public function __construct(?Closure $isEngineInstalled = null, ?Closure $bootEngine = null) { - $this->isEngineInstalled = $isEngineInstalled ?? static fn(): bool => class_exists(Core::class); + $this->isEngineInstalled = $isEngineInstalled ?? static fn(): bool => class_exists(Core::class, false) + || InstalledVersions::isInstalled(self::ENGINE_PACKAGE); $this->bootEngine = $bootEngine ?? static function (): void { if (!Core::isInitialized()) { Core::init(); @@ -80,7 +86,7 @@ public function __construct(?Closure $isEngineInstalled = null, ?Closure $bootEn } /** - * @throws EngineConfigurationException When z-engine is missing or cannot boot, the JIT stays on, or the + * @throws EngineConfigurationException When z-engine is missing, too old or cannot boot, the JIT stays on, or the * kernel options enable include interception */ public function boot(AspectKernel $kernel, AspectContainer $container): void @@ -90,11 +96,16 @@ public function boot(AspectKernel $kernel, AspectContainer $container): void if (($options['features'] & Features::INTERCEPT_INCLUDES) !== 0) { throw EngineConfigurationException::includeInterceptionNotSupported(); } + + // Before anything of the engine is autoloaded: its classes must be compiled with the JIT off + $jitReport = JitGuard::enforce(); + if (!($this->isEngineInstalled)()) { throw EngineConfigurationException::engineNotInstalled(); } - - $jitReport = JitGuard::enforce(); + if (!self::hasMethodTableApi()) { + throw EngineConfigurationException::engineWithoutMethodTableApi(self::installedEngineVersion()); + } try { ($this->bootEngine)(); @@ -133,6 +144,41 @@ public function boot(AspectKernel $kernel, AspectContainer $container): void FfiComposerLoader::init($options, $container); } + /** + * Whether the installed engine has the method-table API the rewriter is built on: `redefine()` keeping the + * previous body under `preserveAs`, `addMethod()` adopting a donor method and `fromClassTable()` + * (lisachenko/z-engine 8.4.3 / 8.5.1 and later); an older release would fail at the first weave instead + */ + private static function hasMethodTableApi(): bool + { + if (!class_exists(EngineClass::class) || !class_exists(EngineMethod::class)) { + return false; + } + $engineClass = new ReflectionClass(EngineClass::class); + $engineMethod = new ReflectionClass(EngineMethod::class); + if (!$engineClass->hasMethod('addMethod') || !$engineClass->hasMethod('fromClassTable') || !$engineMethod->hasMethod('redefine')) { + return false; + } + $parameters = array_map( + static fn(ReflectionParameter $parameter): string => $parameter->getName(), + $engineMethod->getMethod('redefine')->getParameters(), + ); + + return in_array('preserveAs', $parameters, true); + } + + /** + * Version of the installed engine package as composer knows it, for messages + */ + private static function installedEngineVersion(): string + { + if (!InstalledVersions::isInstalled(self::ENGINE_PACKAGE)) { + return 'an unknown version'; + } + + return InstalledVersions::getPrettyVersion(self::ENGINE_PACKAGE) ?? 'an unknown version'; + } + /** * What the boot did: PHP version, opcache state and the JIT guard report * diff --git a/packages/ffi-driver/src/MethodTableRewriter.php b/packages/ffi-driver/src/MethodTableRewriter.php index 061ede94..ae75240b 100644 --- a/packages/ffi-driver/src/MethodTableRewriter.php +++ b/packages/ffi-driver/src/MethodTableRewriter.php @@ -59,10 +59,7 @@ public function rewire(string $className, string $donorClassName, array $methods // 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"), - ); + throw UnsupportedJoinpointException::classNotInEngineClassTable($className); } $native = new ReflectionMethod($className, $methodName); try { diff --git a/packages/ffi-driver/tests/DonorCacheIndexTest.php b/packages/ffi-driver/tests/DonorCacheIndexTest.php index e1a45ef3..48d554fd 100644 --- a/packages/ffi-driver/tests/DonorCacheIndexTest.php +++ b/packages/ffi-driver/tests/DonorCacheIndexTest.php @@ -19,12 +19,14 @@ use Go\Core\Container; use Go\Instrument\ClassLoading\CachePathManager; use Go\PhpUnit\UsesTemporaryDirectory; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** * The donor index of the ffi driver: records per class, freshness through the shared rule, persisted as PHP */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +#[Group('ffi')] final class DonorCacheIndexTest extends TestCase { use UsesTemporaryDirectory; diff --git a/packages/ffi-driver/tests/DonorTraitGeneratorTest.php b/packages/ffi-driver/tests/DonorTraitGeneratorTest.php index 6cf4e7dc..10900c0b 100644 --- a/packages/ffi-driver/tests/DonorTraitGeneratorTest.php +++ b/packages/ffi-driver/tests/DonorTraitGeneratorTest.php @@ -23,12 +23,14 @@ use Go\Tests\TestProject\Application\FfiChild; use Go\Tests\TestProject\Application\FfiParent; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ReflectionClass; /** * The donor trait of the ffi driver: dispatcher bodies compiled outside the woven class */ +#[Group('ffi')] final class DonorTraitGeneratorTest extends TestCase { use AssertsCompilablePhp; diff --git a/packages/ffi-driver/tests/Exception/ExceptionConventionTest.php b/packages/ffi-driver/tests/Exception/ExceptionConventionTest.php new file mode 100644 index 00000000..ef49a39c --- /dev/null +++ b/packages/ffi-driver/tests/Exception/ExceptionConventionTest.php @@ -0,0 +1,125 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\Ffi\Exception; + +use Go\Aop\Exception\ExceptionInterface; +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Exception\WeavingException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; +use Symfony\Component\Finder\Finder; + +/** + * The exceptions of the package follow the convention of src/Aop/Exception/AGENTS.md: grouped below the framework + * exceptions, created through named constructors only + */ +#[Group('ffi')] +final class ExceptionConventionTest extends TestCase +{ + /** + * @return array, class-string<\Throwable>}> + */ + public static function exceptionTypes(): array + { + return [ + 'engine configuration' => [EngineConfigurationException::class, InvalidConfigurationException::class], + 'ffi weaving' => [FfiWeavingException::class, WeavingException::class], + 'unsupported joinpoint' => [UnsupportedJoinpointException::class, FfiWeavingException::class], + ]; + } + + /** + * @return array}> + */ + public static function exceptionClasses(): array + { + return array_map(static fn(array $types): array => [$types[0]], self::exceptionTypes()); + } + + /** + * @param class-string<\Throwable> $exceptionClass + * @param class-string<\Throwable> $parentClass + */ + #[DataProvider('exceptionTypes')] + public function testExceptionTypeImplementsTheMarkerAndKeepsItsFrameworkParent(string $exceptionClass, string $parentClass): void + { + $exception = new $exceptionClass('message'); + + $this->assertInstanceOf(ExceptionInterface::class, $exception); + $this->assertInstanceOf($parentClass, $exception); + } + + public function testEveryExceptionClassOfThePackageIsListed(): void + { + $listed = array_column(self::exceptionTypes(), 0); + foreach (Finder::create()->files()->in(__DIR__ . '/../../src/Exception')->name('*.php') as $file) { + $this->assertContains(__NAMESPACE__ . '\\' . $file->getBasename('.php'), $listed); + } + } + + public function testExceptionsAreCreatedOnlyThroughNamedConstructors(): void + { + $offenders = []; + foreach (Finder::create()->files()->in(__DIR__ . '/../../src')->exclude('Exception')->name('*.php') as $file) { + preg_match_all('/\bnew \\\\?([\w\\\\]*Exception)\(/', $file->getContents(), $matches); + foreach ($matches[1] as $exceptionClass) { + $offenders[] = $file->getRelativePathname() . ': ' . $exceptionClass; + } + } + + $this->assertSame([], $offenders); + } + + /** + * @param class-string<\Throwable> $exceptionClass + */ + #[DataProvider('exceptionClasses')] + public function testNamedConstructorsReturnTheirOwnClass(string $exceptionClass): void + { + $constructors = 0; + foreach (new ReflectionClass($exceptionClass)->getMethods(ReflectionMethod::IS_STATIC) as $method) { + if ($method->class !== $exceptionClass || !$method->isPublic()) { + continue; + } + $constructors++; + $returnType = $method->getReturnType(); + $this->assertInstanceOf(ReflectionNamedType::class, $returnType); + $this->assertContains($returnType->getName(), ['self', $exceptionClass], $exceptionClass . '::' . $method->name . '() must return self'); + } + $this->assertGreaterThan(0, $constructors); + } + + public function testTheMessagesNameTheConditionAndTheRemedy(): void + { + $tooOld = EngineConfigurationException::engineWithoutMethodTableApi('8.5.0'); + $this->assertStringContainsString('~8.4.3 || ~8.5.1', $tooOld->getMessage()); + $this->assertStringContainsString('8.5.0 is installed', $tooOld->getMessage()); + $this->assertStringContainsString('composer require lisachenko/z-engine', $tooOld->getMessage()); + + $this->assertStringContainsString('composer require lisachenko/z-engine', EngineConfigurationException::engineNotInstalled()->getMessage()); + $this->assertStringContainsString('opcache.jit=off', EngineConfigurationException::jitStillOn()->getMessage()); + $this->assertStringContainsString('Features::INTERCEPT_INCLUDES', EngineConfigurationException::includeInterceptionNotSupported()->getMessage()); + + $this->assertSame( + 'The ffi weaving driver cannot weave App\Entity: the class is not published in the engine class table', + UnsupportedJoinpointException::classNotInEngineClassTable('App\Entity')->getMessage(), + ); + $this->assertStringContainsString('join-point kind(s) prop', FfiWeavingException::unsupportedDonorKinds('App\Entity', ['prop'])->getMessage()); + $this->assertStringContainsString('outside the application root /app', FfiWeavingException::sourceOutsideApplicationRoot('/other/x.php', '/app')->getMessage()); + $this->assertStringContainsString('does not declare the donor trait App\EntityOriginalTrait', FfiWeavingException::donorNotDeclared('/cache/x.php', 'App\Entity', 'App\EntityOriginalTrait')->getMessage()); + } +} diff --git a/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php b/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php index 5d32e3a6..c05c4a0d 100644 --- a/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php +++ b/packages/ffi-driver/tests/Exception/UnsupportedJoinpointExceptionTest.php @@ -12,9 +12,11 @@ namespace Go\Driver\Ffi\Exception; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ReflectionException; +#[Group('ffi')] final class UnsupportedJoinpointExceptionTest extends TestCase { public function testNamesTheAdvisorsAndKindsTheDriverCannotWeave(): void diff --git a/packages/ffi-driver/tests/FfiComposerLoaderTest.php b/packages/ffi-driver/tests/FfiComposerLoaderTest.php index 54d38e38..042a926e 100644 --- a/packages/ffi-driver/tests/FfiComposerLoaderTest.php +++ b/packages/ffi-driver/tests/FfiComposerLoaderTest.php @@ -17,11 +17,13 @@ use Go\Core\Container; use Go\Instrument\ClassLoading\AopComposerLoader; use Go\PhpUnit\UsesTemporaryDirectory; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** * The composer loader wrapper of the ffi driver: native loading, weaving only within the woven paths */ +#[Group('ffi')] final class FfiComposerLoaderTest extends TestCase { use UsesTemporaryDirectory; diff --git a/packages/ffi-driver/tests/FfiDriverTest.php b/packages/ffi-driver/tests/FfiDriverTest.php index 38a5c8ea..ebf8f139 100644 --- a/packages/ffi-driver/tests/FfiDriverTest.php +++ b/packages/ffi-driver/tests/FfiDriverTest.php @@ -17,6 +17,7 @@ use Go\Core\AspectKernel; use Go\Core\Container; use Go\Driver\Ffi\Exception\EngineConfigurationException; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -25,6 +26,7 @@ * driver cannot honour (the successful boot is covered in-process, see InProcess/FfiDriverInProcessTest) */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +#[Group('ffi')] final class FfiDriverTest extends TestCase { public function testRefusesToBootWithoutZEngine(): void diff --git a/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php b/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php index 2d39c946..41df3c56 100644 --- a/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php +++ b/packages/ffi-driver/tests/Functional/FfiFunctionalTestCase.php @@ -219,13 +219,18 @@ private static function engineUnavailableReason(): ?string ...self::PHP_OPTIONS, '-d', 'opcache.enable_cli=0', '-r', - 'require $argv[1]; ZEngine\Core::init(); echo "booted";', + 'require $argv[1]; ZEngine\Core::init(); echo "booted";' + . ' echo in_array("preserveAs", array_map(fn($p) => $p->getName(), (new ReflectionMethod(ZEngine\Reflection\ReflectionMethod::class, "redefine"))->getParameters()), true) ? ":api" : ":old";', __DIR__ . '/../../../../vendor/autoload.php', ], null, ['ZENGINE_AUTOBOOT' => '0'], ); $process->run(); + if ($process->isSuccessful() && str_contains($process->getOutput(), 'booted:old')) { + return self::$engineUnavailableReason = 'The installed lisachenko/z-engine lacks the method-table API ' + . '(redefine() with preserveAs): the ffi driver needs ~8.4.3 || ~8.5.1'; + } if (!$process->isSuccessful() || !str_contains($process->getOutput(), 'booted')) { $failure = trim($process->getErrorOutput() . "\n" . $process->getOutput()); diff --git a/packages/ffi-driver/tests/InProcess/InProcessTestCase.php b/packages/ffi-driver/tests/InProcess/InProcessTestCase.php index 40293a64..9dc918b9 100644 --- a/packages/ffi-driver/tests/InProcess/InProcessTestCase.php +++ b/packages/ffi-driver/tests/InProcess/InProcessTestCase.php @@ -75,6 +75,10 @@ protected function setUp(): void protected function tearDown(): void { + if (!isset($this->cacheDir)) { + // setUp() skipped the test before the fixture was built + return; + } foreach (spl_autoload_functions() as $loader) { spl_autoload_unregister($loader); } diff --git a/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php b/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php index 93a312f8..e85a4882 100644 --- a/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php +++ b/packages/ffi-driver/tests/InProcess/MethodTableRewriterInProcessTest.php @@ -38,7 +38,6 @@ public function testAClassMissingFromTheEngineClassTableIsRefused(): void } catch (UnsupportedJoinpointException $refusal) { $this->assertStringContainsString('Go\Driver\Ffi\Stubs\NeverDeclared', $refusal->getMessage()); $this->assertStringContainsString('not published in the engine class table', $refusal->getMessage()); - $this->assertInstanceOf(ReflectionException::class, $refusal->getPrevious()); } } diff --git a/packages/ffi-driver/tests/JitGuardTest.php b/packages/ffi-driver/tests/JitGuardTest.php index a6627ddf..0e6ba0b0 100644 --- a/packages/ffi-driver/tests/JitGuardTest.php +++ b/packages/ffi-driver/tests/JitGuardTest.php @@ -13,8 +13,10 @@ namespace Go\Driver\Ffi; use Go\Driver\Ffi\Exception\EngineConfigurationException; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +#[Group('ffi')] final class JitGuardTest extends TestCase { /** diff --git a/src/Instrument/AGENTS.md b/src/Instrument/AGENTS.md index 5dcebbd9..07d70879 100644 --- a/src/Instrument/AGENTS.md +++ b/src/Instrument/AGENTS.md @@ -1,11 +1,11 @@ # src/Instrument — AOP interception pipeline ## Init flow -1. AspectKernel::init() — singleton, registers deferred transformer services, calls configureAop() +1. AspectKernel::init() — singleton, registers the framework services + CachePathManager, instantiates the `driver` option, registers the deferred transformer services for a SourceTransformingDriver only, calls $driver->boot(), then configureAop() 2. SourceTransformingLoader — PHP stream filter (php://filter/read=go.source.transforming.loader/resource=... protocol); registered lazily on the first cache miss via ensureRegistered() 3. AopComposerLoader::init() — hooks Composer autoloader → redirects through stream filter (production: woven classes via composer's class map; debug: a fresh untransformed record → original path, so opcache caches it; woven/stale/miss → php://filter) -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 +4. Caching lives in SourceTransformingLoader::transformSource() (filter() only buffers the stream and delegates, the stream wrapper delegates too) — no usable cache dir → source untouched; cache hit → cached content returned as-is (no parsing, no transformers); miss → StreamMetaData + transformer chain → saveToCache() +5. Freshness rule lives in one place: CachePathManager::isFreshRecord() (record filemtime/filesize, container isFreshSince(cachedAt); PREBUILT_CACHE trusts records); queryFreshCacheState() = record lookup + that rule, used by transformSource() and the debug autoloader; the ffi DonorCacheIndex calls isFreshRecord() directly ## Weaving drivers (src/Driver/, `driver` kernel option) - Go\Driver\WeavingDriver: `boot(AspectKernel, AspectContainer)`, called once from AspectKernel::init() after the framework services, before configureAop(); the option is the driver class-string (default Go\Driver\StreamFilter\StreamFilterDriver, validated with class_exists/is_a in normalizeOptions), the instance is registered under WeavingDriver::class. diff --git a/tests/Core/AspectKernelTest.php b/tests/Core/AspectKernelTest.php index fbff9aa9..6fd1608d 100644 --- a/tests/Core/AspectKernelTest.php +++ b/tests/Core/AspectKernelTest.php @@ -13,7 +13,9 @@ namespace Go\Core; use Go\Driver\StreamFilter\StreamFilterDriver; +use Go\Driver\WeavingDriver; use Go\Stubs\Driver\RewiringDriver; +use Go\Stubs\Driver\TransformingDriver; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Features; use Go\Instrument\Transformer\ConstructorExecutionTransformer; @@ -28,6 +30,8 @@ use PhpParser\Node; use PhpParser\Node\Scalar\String_; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; @@ -289,6 +293,35 @@ public function testNormalizeOptionsThrowsForAClassThatIsNoWeavingDriver(): void } } + /** + * In a process of its own: init() defines the AOP_ROOT_DIR / AOP_CACHE_DIR constants for the rest of the process + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testInitBootsTheConfiguredDriverOnceAndRegistersTheChainForSourceTransformingDriversOnly(): void + { + RewiringDriver::reset(); + AspectKernelTestDriverOrderKernel::$bootsWhenConfigured = null; + $kernel = (new ReflectionClass(AspectKernelTestDriverOrderKernel::class))->newInstanceWithoutConstructor(); + $options = ['appDir' => __DIR__, 'cacheDir' => sys_get_temp_dir(), 'driver' => RewiringDriver::class]; + + $kernel->init($options); + $kernel->init($options); + + $container = $kernel->getContainer(); + $this->assertSame(1, RewiringDriver::$boots, 'The driver is booted once, a second init() is a no-op'); + $this->assertInstanceOf(RewiringDriver::class, $container->get(WeavingDriver::class)); + $this->assertTrue(RewiringDriver::$registeredWhenBooted, 'The driver is in the container when it boots'); + $this->assertSame(1, AspectKernelTestDriverOrderKernel::$bootsWhenConfigured, 'The driver boots before configureAop()'); + $this->assertFalse($container->has(WeavingTransformer::class), 'No transformer chain for a driver that does not transform sources'); + + $transforming = (new ReflectionClass(AspectKernelTestConcreteKernel::class))->newInstanceWithoutConstructor(); + $transforming->init(['appDir' => __DIR__, 'cacheDir' => sys_get_temp_dir(), 'driver' => TransformingDriver::class]); + + $this->assertInstanceOf(TransformingDriver::class, $transforming->getContainer()->get(WeavingDriver::class)); + $this->assertTrue($transforming->getContainer()->has(WeavingTransformer::class), 'The transformer chain is registered for a source-transforming driver'); + } + public function testNormalizeOptionsExcludesRuntimeDependenciesFromWeaving(): void { $kernel = $this->makeKernel(); @@ -675,3 +708,16 @@ public function rewriteNode(Node $node, StreamMetaData $file, array $ancestors): return true; } } + +/** + * Kernel recording how often the driver had booted when configureAop() ran + */ +final class AspectKernelTestDriverOrderKernel extends AspectKernel +{ + public static ?int $bootsWhenConfigured = null; + + protected function configureAop(AspectContainer $container): void + { + self::$bootsWhenConfigured = RewiringDriver::$boots; + } +} diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 2b365e86..479c7bdd 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -169,8 +169,8 @@ protected function execute( /** * Command-line options of the PHP subprocess running the console, `-d` ini overrides included * - * On PHP 8.5 the tracing JIT is switched off: a php.ini enabling it makes the functional suite fail there. - * Override to add options. + * On PHP 8.5 the subprocess runs with the opcache JIT off (`opcache.jit=off`, `opcache.jit_buffer_size=0`), + * whatever php.ini says. Override to add options. * * @return list */ diff --git a/tests/Stubs/Driver/RewiringDriver.php b/tests/Stubs/Driver/RewiringDriver.php index ee5782f8..da083616 100644 --- a/tests/Stubs/Driver/RewiringDriver.php +++ b/tests/Stubs/Driver/RewiringDriver.php @@ -15,8 +15,20 @@ final class RewiringDriver implements WeavingDriver { public static int $boots = 0; + /** + * Whether the kernel had registered the driver in the container when boot() ran + */ + public static bool $registeredWhenBooted = false; + + public static function reset(): void + { + self::$boots = 0; + self::$registeredWhenBooted = false; + } + public function boot(AspectKernel $kernel, AspectContainer $container): void { self::$boots++; + self::$registeredWhenBooted = $container->has(WeavingDriver::class) && $container->get(WeavingDriver::class) === $this; } } diff --git a/tests/Stubs/Driver/TransformingDriver.php b/tests/Stubs/Driver/TransformingDriver.php new file mode 100644 index 00000000..6a771e2e --- /dev/null +++ b/tests/Stubs/Driver/TransformingDriver.php @@ -0,0 +1,17 @@ + Date: Sun, 18 Oct 2026 23:11:26 +0000 Subject: [PATCH 14/19] test(ffi): follow the property-based Pointcut and PointcutAdvisor interfaces of master Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../ffi-driver/tests/Stubs/MatcherErrorAdvisor.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php b/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php index e9535b3c..6730f67b 100644 --- a/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php +++ b/packages/ffi-driver/tests/Stubs/MatcherErrorAdvisor.php @@ -26,19 +26,17 @@ final class MatcherErrorAdvisor implements PointcutAdvisor, Pointcut { public const string FAILURE = 'The pointcut cannot decide on'; - public function getPointcut(): Pointcut - { - return $this; + public Pointcut $pointcut { + get => $this; } - public function getAdvice(): Advice - { - return new BeforeInterceptor(static function (): void {}); + public int $kind { + get => self::KIND_METHOD; } - public function getKind(): int + public function getAdvice(): Advice { - return self::KIND_METHOD; + return new BeforeInterceptor(static function (): void {}); } public function matches( From e1c166b237d16be0a21e7541e7554ce98475b81a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:16:05 +0000 Subject: [PATCH 15/19] test(stream-wrapper): cover the wrapper's edge operations and exception messages Ownership metadata, symlink stats, every operation of an unopened instance, the stat of a deleted served file and an include through an unregistered instance are exercised; the three guards PHP cannot trigger (a refused registration, an unreadable native handle, a php://memory that does not open) are marked as not coverable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../src/AopStreamWrapper.php | 6 ++ .../tests/AopStreamWrapperTest.php | 74 +++++++++++++++++++ .../Exception/StreamWrapperExceptionsTest.php | 45 +++++++++++ 3 files changed, 125 insertions(+) create mode 100644 packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php diff --git a/packages/stream-wrapper-driver/src/AopStreamWrapper.php b/packages/stream-wrapper-driver/src/AopStreamWrapper.php index 8c16ac35..fd8ab84c 100644 --- a/packages/stream-wrapper-driver/src/AopStreamWrapper.php +++ b/packages/stream-wrapper-driver/src/AopStreamWrapper.php @@ -133,9 +133,11 @@ class_exists(PathResolver::class); class_exists(SourceTransformingLoader::class); stream_wrapper_unregister(self::PROTOCOL); if (!stream_wrapper_register(self::PROTOCOL, self::class)) { + // @codeCoverageIgnoreStart (PHP refuses only a protocol still taken or an unknown class) stream_wrapper_restore(self::PROTOCOL); throw StreamWrapperRegistrationException::fileWrapperNotReplaced(self::class); + // @codeCoverageIgnoreEnd } self::$registered = true; self::$nativeDepth = 0; @@ -419,7 +421,9 @@ private function serveInclude(string $path): void $source = stream_get_contents($stream); if ($source === false) { + // @codeCoverageIgnoreStart (a readable native file handle never fails here) return; + // @codeCoverageIgnoreEnd } SourceTransformingLoader::ensureRegistered($container); // The transformer metadata reads the uri of the given stream: the native handle of the file @@ -432,9 +436,11 @@ private function serveInclude(string $path): void $memory = fopen('php://memory', 'r+b'); if ($memory === false) { + // @codeCoverageIgnoreStart (php://memory always opens) rewind($stream); return; + // @codeCoverageIgnoreEnd } fwrite($memory, $served); rewind($memory); diff --git a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php index 06fd5fa4..cd454792 100644 --- a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php +++ b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php @@ -301,6 +301,80 @@ public function testRegisterIsIdempotent(): void $this->assertContains('file', stream_get_wrappers(), 'A second unregister() is harmless'); } + public function testOwnershipMetadataAndSymlinkStatsAreDelegatedToTheNativeWrapper(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/lib/owned.txt'; + file_put_contents($file, 'mine'); + + // chown/chgrp to the current owner and group are allowed without privileges + $this->assertTrue(chown($file, (int) getmyuid())); + $this->assertTrue(chgrp($file, (int) getmygid())); + $this->assertSame((int) getmyuid(), fileowner($file)); + + $link = $this->appDir . '/lib/owned.link'; + $this->assertTrue(symlink($file, $link)); + $this->assertTrue(is_link($link), 'lstat() goes through url_stat() with STREAM_URL_STAT_LINK'); + $this->assertSame('mine', file_get_contents($link)); + $linkStat = lstat($link); + $this->assertIsArray($linkStat); + $this->assertNotSame(filesize($file), $linkStat['size'], 'The stat of the link itself, not of its target'); + $this->assertTrue(unlink($link)); + } + + public function testAnUnopenedWrapperInstanceAnswersEveryStreamAndDirectoryOperationWithFalse(): void + { + $this->registerWrapper(); + $wrapper = new AopStreamWrapper(); + + $this->assertFalse($wrapper->stream_read(8)); + $this->assertFalse($wrapper->stream_write('x')); + $this->assertTrue($wrapper->stream_eof()); + $this->assertFalse($wrapper->stream_flush()); + $this->assertFalse($wrapper->stream_seek(0)); + $this->assertFalse($wrapper->stream_tell()); + $this->assertFalse($wrapper->stream_stat()); + $this->assertFalse($wrapper->stream_truncate(0)); + $this->assertFalse($wrapper->stream_lock(LOCK_SH)); + $this->assertFalse($wrapper->stream_cast(STREAM_CAST_FOR_SELECT)); + $this->assertFalse($wrapper->dir_readdir()); + $this->assertFalse($wrapper->dir_rewinddir()); + $this->assertTrue($wrapper->dir_closedir()); + $this->assertFalse($wrapper->stream_metadata($this->appDir . '/lib/none.txt', 999, null), 'An unknown metadata option'); + $this->assertFalse($wrapper->dir_opendir($this->appDir . '/lib/none', 0)); + $wrapper->stream_close(); + } + + public function testAnIncludeStreamOfADeletedFileHasNoStatAnyMore(): void + { + $this->registerWrapper(); + $file = $this->appDir . '/src/Vanishing.php'; + file_put_contents($file, "assertTrue($wrapper->stream_open($file, 'rb', self::STREAM_OPEN_FOR_INCLUDE, $openedPath)); + $this->assertStringContainsString('REWRITTEN', (string) $wrapper->stream_read(1024), 'Served from memory'); + $this->assertTrue(unlink($file)); + + $this->assertFalse($wrapper->stream_stat(), 'The original file is gone, so is its stat'); + $wrapper->stream_close(); + } + + public function testAnIncludeThroughAnUnregisteredWrapperIsServedUntouched(): void + { + $file = $this->appDir . '/src/Unwired.php'; + file_put_contents($file, "assertFalse(AopStreamWrapper::isRegistered()); + $wrapper = new AopStreamWrapper(); + $openedPath = null; + + // No container, no scope: the instance behaves like the native wrapper + $this->assertTrue($wrapper->stream_open($file, 'rb', self::STREAM_OPEN_FOR_INCLUDE, $openedPath)); + $this->assertSame("stream_read(1024)); + $wrapper->stream_close(); + } + /** * Registers the wrapper for a container serving the marker-rewriting chain, with the application sources * (appDir/src) as the weaving scope diff --git a/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php b/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php new file mode 100644 index 00000000..c97f80fc --- /dev/null +++ b/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php @@ -0,0 +1,45 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Driver\StreamWrapper\Exception; + +use Go\Aop\Exception\ExceptionInterface; +use Go\Aop\Exception\InvalidConfigurationException; +use Go\Aop\Exception\WeavingException; +use PHPUnit\Framework\TestCase; + +/** + * The exceptions of the package: grouped below the framework exceptions, messages naming condition and remedy + */ +final class StreamWrapperExceptionsTest extends TestCase +{ + public function testTheRegistrationFailureNamesTheWrapperAndTheRestoredState(): void + { + $exception = StreamWrapperRegistrationException::fileWrapperNotReplaced('App\Wrapper'); + + $this->assertInstanceOf(WeavingException::class, $exception); + $this->assertInstanceOf(ExceptionInterface::class, $exception); + $this->assertSame( + 'The stream wrapper weaving driver could not register App\Wrapper as the "file://" stream wrapper ' + . '(stream_wrapper_register() failed); the native wrapper was restored.', + $exception->getMessage(), + ); + } + + public function testTheImpliedIncludeInterceptionIsAConfigurationError(): void + { + $exception = StreamWrapperConfigurationException::includeInterceptionImplied(); + + $this->assertInstanceOf(InvalidConfigurationException::class, $exception); + $this->assertStringContainsString('INTERCEPT_INCLUDES', $exception->getMessage()); + } +} From c9a8f9c25cd27d24b809614bd4cf9d67e34f400f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:16:24 +0000 Subject: [PATCH 16/19] test(stream-wrapper): drop the always-true type assertions Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../tests/Exception/StreamWrapperExceptionsTest.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php b/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php index c97f80fc..a6816e63 100644 --- a/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php +++ b/packages/stream-wrapper-driver/tests/Exception/StreamWrapperExceptionsTest.php @@ -12,13 +12,10 @@ namespace Go\Driver\StreamWrapper\Exception; -use Go\Aop\Exception\ExceptionInterface; -use Go\Aop\Exception\InvalidConfigurationException; -use Go\Aop\Exception\WeavingException; use PHPUnit\Framework\TestCase; /** - * The exceptions of the package: grouped below the framework exceptions, messages naming condition and remedy + * The messages of the package's exceptions name the condition and the remedy */ final class StreamWrapperExceptionsTest extends TestCase { @@ -26,8 +23,6 @@ public function testTheRegistrationFailureNamesTheWrapperAndTheRestoredState(): { $exception = StreamWrapperRegistrationException::fileWrapperNotReplaced('App\Wrapper'); - $this->assertInstanceOf(WeavingException::class, $exception); - $this->assertInstanceOf(ExceptionInterface::class, $exception); $this->assertSame( 'The stream wrapper weaving driver could not register App\Wrapper as the "file://" stream wrapper ' . '(stream_wrapper_register() failed); the native wrapper was restored.', @@ -39,7 +34,6 @@ public function testTheImpliedIncludeInterceptionIsAConfigurationError(): void { $exception = StreamWrapperConfigurationException::includeInterceptionImplied(); - $this->assertInstanceOf(InvalidConfigurationException::class, $exception); $this->assertStringContainsString('INTERCEPT_INCLUDES', $exception->getMessage()); } } From 023aa0d7044b96646ef4799c80dde73127853ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:17:26 +0000 Subject: [PATCH 17/19] test(stream-wrapper): make the wrapper unit tests hold on Windows __FILE__ and the opened include-path entry are compared through realpath() (backslashes on Windows), the exact mode after chmod() is asserted on POSIX only, and the ownership/symlink test skips where chown()/chgrp() and unprivileged symlinks do not exist. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../tests/AopStreamWrapperTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php index cd454792..6a5c9e0b 100644 --- a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php +++ b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php @@ -165,7 +165,10 @@ public function testMetadataOperationsAreDelegatedToTheNativeWrapper(): void $this->assertSame(1_000_000_000, filemtime($file)); $this->assertTrue(chmod($file, 0600)); clearstatcache(); - $this->assertSame(0600, fileperms($file) & 0777); + if (DIRECTORY_SEPARATOR === '/') { + // Windows keeps only the read-only bit of a mode + $this->assertSame(0600, fileperms($file) & 0777); + } } public function testAMissingFileFailsNatively(): void @@ -185,7 +188,8 @@ public function testIncludeOutsideTheWeavingScopeIsServedUntouched(): void $file = $this->appDir . '/lib/outside.php'; file_put_contents($file, " 'MARKER', 'file' => __FILE__];"); - $this->assertSame(['value' => 'MARKER', 'file' => $file], include $file); + // __FILE__ is the path as the platform spells it (backslashes on Windows) + $this->assertSame(['value' => 'MARKER', 'file' => realpath($file)], include $file); $this->assertSame(0, $this->transformer->callCount, 'A file outside the scope never reaches the chain'); $this->assertFileDoesNotExist($this->cacheDir . '/lib/outside.php'); } @@ -197,7 +201,7 @@ public function testIncludeWithinTheWeavingScopeIsTransformedAndCached(): void file_put_contents($file, " 'MARKER', 'file' => __FILE__];"); // Served from memory under the original path: the magic constants resolve to the original file - $this->assertSame(['value' => 'REWRITTEN', 'file' => $file], include $file); + $this->assertSame(['value' => 'REWRITTEN', 'file' => realpath($file)], include $file); $this->assertSame(1, $this->transformer->callCount); $cachedFile = $this->cacheDir . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'inside.php'; $this->assertFileExists($cachedFile); @@ -262,7 +266,8 @@ public function testTheIncludePathIsSearchedForStreamUsePath(): void $wrapper = new AopStreamWrapper(); $openedPath = null; $this->assertTrue($wrapper->stream_open('on-include-path.php', 'r', STREAM_USE_PATH, $openedPath)); - $this->assertSame($file, $openedPath); + $this->assertNotNull($openedPath); + $this->assertSame(realpath($file), realpath($openedPath)); $wrapper->stream_close(); $this->assertSame("registerWrapper(); $file = $this->appDir . '/lib/owned.txt'; file_put_contents($file, 'mine'); From df5b5c3d5519dcfb1edc6ce17f6cdc4fceaeccb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:21:05 +0000 Subject: [PATCH 18/19] test(stream-wrapper): query the cache record by the resolved path, isolate the refused registration The transformation record is keyed by the resolved path of the included file, which Windows spells with backslashes; the unreachable registration failure moves into a method of its own that coverage ignores. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- .../src/AopStreamWrapper.php | 20 ++++++++++++++----- .../tests/AopStreamWrapperTest.php | 3 ++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/stream-wrapper-driver/src/AopStreamWrapper.php b/packages/stream-wrapper-driver/src/AopStreamWrapper.php index fd8ab84c..68467d09 100644 --- a/packages/stream-wrapper-driver/src/AopStreamWrapper.php +++ b/packages/stream-wrapper-driver/src/AopStreamWrapper.php @@ -133,16 +133,26 @@ class_exists(PathResolver::class); class_exists(SourceTransformingLoader::class); stream_wrapper_unregister(self::PROTOCOL); if (!stream_wrapper_register(self::PROTOCOL, self::class)) { - // @codeCoverageIgnoreStart (PHP refuses only a protocol still taken or an unknown class) - stream_wrapper_restore(self::PROTOCOL); - - throw StreamWrapperRegistrationException::fileWrapperNotReplaced(self::class); - // @codeCoverageIgnoreEnd + self::refuseRegistration(); } self::$registered = true; self::$nativeDepth = 0; } + /** + * PHP refused to register this class: the native wrapper is restored before the failure is reported + * + * Not reachable in practice (PHP refuses only a protocol still taken or an unknown class) + * + * @codeCoverageIgnore + */ + private static function refuseRegistration(): never + { + stream_wrapper_restore(self::PROTOCOL); + + throw StreamWrapperRegistrationException::fileWrapperNotReplaced(self::class); + } + /** * Restores the native `file://` wrapper and forgets the container and the scope * diff --git a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php index 6a5c9e0b..132496b2 100644 --- a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php +++ b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php @@ -206,7 +206,8 @@ public function testIncludeWithinTheWeavingScopeIsTransformedAndCached(): void $cachedFile = $this->cacheDir . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'inside.php'; $this->assertFileExists($cachedFile); $this->assertStringContainsString('REWRITTEN', (string) file_get_contents($cachedFile)); - $this->assertNotNull($this->cachePathManager?->queryCacheState($file), 'The transformation is recorded'); + // Recorded under the resolved path (backslashes on Windows) + $this->assertNotNull($this->cachePathManager?->queryCacheState((string) realpath($file)), 'The transformation is recorded'); // A fresh cache record serves the cached content without running the chain again $this->assertSame(['value' => 'REWRITTEN', 'file' => $file], include $file); From 41ef03ad5cb80e0b4f1cf36d9c49d67848f485c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:23:40 +0000 Subject: [PATCH 19/19] test(stream-wrapper): compare the second include's __FILE__ through realpath() as well Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2 --- packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php index 132496b2..0e93119c 100644 --- a/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php +++ b/packages/stream-wrapper-driver/tests/AopStreamWrapperTest.php @@ -210,7 +210,7 @@ public function testIncludeWithinTheWeavingScopeIsTransformedAndCached(): void $this->assertNotNull($this->cachePathManager?->queryCacheState((string) realpath($file)), 'The transformation is recorded'); // A fresh cache record serves the cached content without running the chain again - $this->assertSame(['value' => 'REWRITTEN', 'file' => $file], include $file); + $this->assertSame(['value' => 'REWRITTEN', 'file' => realpath($file)], include $file); $this->assertSame(1, $this->transformer->callCount); }