Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:8.4.x-dev
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"
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/zengine.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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"
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<m>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 |
Expand All @@ -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 (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`).
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<method>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`.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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');\""
Expand All @@ -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)"
}
}
5 changes: 4 additions & 1 deletion demos/autoload_aspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-----------------------------|--------|
Expand Down
Loading
Loading