Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide.
* [Performance] **Proxy bodies built as AST** — the proxy generators build every intercepted method, function and (static) initialization body directly as syntax tree nodes (`static $__joinPoint` with its `@var` docblock, the `InterceptorInjector::for*()` call and the `__invoke()`), instead of printing each body to a string and parsing it back; the proxy is printed once. Intercepted methods are looked up in a name index built once per woven class, declared types are converted from the syntax tree without a round trip through reflection types, and attribute arguments are cloned by one shared traverser. Generated proxies are byte-identical; proxy generation takes about a third of its former time (weaving case of the transformation benchmark: −43%). `ClassProxyGenerator::getJoinpointInvocationBody()` is replaced by `getJoinpointInvocationStatements()` and `createOriginalMethodCallable()`.

**Removed**
* [BC BREAK] Removed `Features::INTERCEPT_FUNCTIONS` and `Features::INTERCEPT_INITIALIZATIONS`: the framework enables function and initialization interception from the registered pointcuts themselves. `execution(Namespace\function(*))` pointcuts weave function proxies without a flag, and the rewriting of `new` turns itself on while an `initialization(...)` pointcut is registered, lazily registered aspects included (the new internal `AdvisorIndex` loads them and tracks the pointcut kinds of every advisor). Before, an `initialization(...)` pointcut without the flag wove `__initialization()` but never ran its advice.
* [BC BREAK] Removed `Features::PARAMETER_WIDENING` and the parameter-widening code path in the proxy generators. The feature was a PHP 7.0/7.1 compatibility aid (parameter type widening, wiki.php.net/rfc/parameter-no-type-variance) that has been a no-op concern since the PHP 7.2 baseline: generated proxies always keep the original parameter types. Remove the flag from `AspectKernel::configureAop()` options if you passed it.
* [BC BREAK] Removed DeclareError support, including the `DeclareError` attribute, `DeclareErrorInterceptor`, and `PointcutBuilder::declareError()`. Use `Before` or `Around` interceptors to emit user warnings or throw exceptions instead.
* [BC BREAK] Removed support for the "dynamic" pointcut (`dynamic(public Foo->method*(*))`), including `MagicMethodDynamicPointcut`, `DynamicInvocationMatcherInterceptor`, the `Pointcut::KIND_DYNAMIC` constant and the `$instanceOrScope`/`$arguments` parameters of `Pointcut::matches()`. Use a traditional execution pointcut for the magic methods instead, e.g. `execution(public Foo->__call(*))` or `execution(public Foo::__callStatic(*))`, and check the invoked method name from `$invocation->getArguments()[0]` inside the advice.
Expand Down
3 changes: 0 additions & 3 deletions demos/autoload_aspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
*/

use Demo\Aspect\AwesomeAspectKernel;
use Go\Aop\Features;

include __DIR__ . '/../vendor/autoload.php';

Expand All @@ -20,6 +19,4 @@
'debug' => true,
'appDir' => __DIR__,
'cacheDir' => __DIR__ . '/cache',

'features' => Features::INTERCEPT_FUNCTIONS,
]);
5 changes: 2 additions & 3 deletions docs/advices.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,13 @@ protected null $jsonSerializable;

## Function interception

With `Features::INTERCEPT_FUNCTIONS`, `execution(App\Service\file_get_contents(*))` intercepts calls of
`execution(App\Service\file_get_contents(*))` intercepts calls of
`file_get_contents()` from code in the `App\Service` namespace that calls the function without a leading backslash.
The advice receives a `FunctionInvocation`.

## Object creation

With `Features::INTERCEPT_INITIALIZATIONS`, `initialization(App\Model\Order)` intercepts `new Order(...)` in woven
files. The advice receives a `ConstructorInvocation`. `staticinitialization(App\Model\*)` runs once, after the class
`initialization(App\Model\Order)` intercepts `new Order(...)` in woven files. The advice receives a `ConstructorInvocation`. `staticinitialization(App\Model\*)` runs once, after the class
is loaded.

## Registering aspects
Expand Down
13 changes: 9 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ bit throws `Go\Aop\Exception\InvalidConfigurationException`.

## Features

Combine the flags with `|`, e.g. `'features' => Features::INTERCEPT_FUNCTIONS | Features::PREBUILT_CACHE`.
Combine the flags with `|`, e.g. `'features' => Features::INTERCEPT_INCLUDES | Features::PREBUILT_CACHE`.

| Flag | Effect |
|-----------------------------|--------|
| `INTERCEPT_FUNCTIONS` | Enables `execution(Namespace\function(*))` pointcuts for system functions called from your namespaced code. Expensive: every woven file is scanned for function calls. |
| `INTERCEPT_INITIALIZATIONS` | Enables `initialization(...)` pointcuts by rewriting `new Foo(...)` expressions in woven files. |
| `INTERCEPT_INCLUDES` | Routes `include`/`require` in woven files through the weaver, for legacy code that is not loaded by Composer. |
| `PREBUILT_CACHE` | Trusts the cache built at deploy time completely. Skips the cache directory and writability probes, source `filemtime` comparisons, tracked-resource checks and advisor cache freshness checks, and never writes to the cache directory. Rebuilding the cache on every deploy is your responsibility. Works on read-only file systems. |

Function and initialization pointcuts need no flag: the framework enables their machinery from the registered
aspects, lazily registered ones included. An `execution(Namespace\function(*))` pointcut weaves function proxies into
the matching namespaces. An `initialization(...)` pointcut turns on the rewriting of `new Foo(...)` expressions in
woven files; while one is registered, every rewritten `new` goes through the interceptor, so keep such pointcuts to
the code that needs them.

## Extending the transformation pipeline

Every file is transformed by a chain of `Go\Instrument\Transformer\SourceTransformer` services, in registration
Expand All @@ -46,7 +50,8 @@ order:
1. `SyntaxTreeRewriter` walks the syntax tree of the file once, for all `NodeRewriter` rules. It is a framework
service, so it always opens the chain.
2. The transformers registered by `AspectKernel::registerTransformerServices()`: `WeavingTransformer`, plus the
`INTERCEPT_INITIALIZATIONS`, `INTERCEPT_INCLUDES` and magic-constant rules.
`new` rewriting rule (active while an `initialization(...)` pointcut is registered), the `INTERCEPT_INCLUDES`
rule and the magic-constant rule.
3. Your own transformers.

To change single syntax nodes, implement `NodeRewriter`. It declares the node classes it handles and rewrites
Expand Down
8 changes: 4 additions & 4 deletions docs/php85-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ Backed enum cases declared with constant expressions (e.g. `case Negative = -1;`
the generated proxy enum, producing a fatal error at load time. Fixed by re-emitting the original
case expression verbatim in the proxy enum.

### `new` in initializers under `INTERCEPT_INITIALIZATIONS` — tracked in [#603](https://github.com/goaop/framework/issues/603)
### `new` in initializers with an `initialization(...)` pointcut — tracked in [#603](https://github.com/goaop/framework/issues/603)

`new` expressions in property/parameter initializers do not work correctly when the
`INTERCEPT_INITIALIZATIONS` kernel feature is enabled.
`new` expressions in property/parameter initializers do not work correctly while an
`initialization(...)` pointcut is registered.

### Global constants in attribute arguments — tracked in [#602](https://github.com/goaop/framework/issues/602)

Expand Down Expand Up @@ -104,7 +104,7 @@ properties from property join points. This is a PHP engine constraint, not a bug
| Closures / FCC in attribute arguments | ❌ Limited | Tracked in [#601](https://github.com/goaop/framework/issues/601) |
| Promoted-property interception | ❌ Limited | Tracked in [#599](https://github.com/goaop/framework/issues/599) |
| Enum constant-expression case values | ❌→✅ Fixed | Tracked in [#600](https://github.com/goaop/framework/issues/600) |
| `new` in initializers + `INTERCEPT_INITIALIZATIONS` | ❌ Limited | Tracked in [#603](https://github.com/goaop/framework/issues/603) |
| `new` in initializers + `initialization(...)` pointcut | ❌ Limited | Tracked in [#603](https://github.com/goaop/framework/issues/603) |
| Global constants in attribute arguments | ❌ Limited | Tracked in [#602](https://github.com/goaop/framework/issues/602) |
| Class-level attributes on woven classes | ❌ Limited | Tracked in [#598](https://github.com/goaop/framework/issues/598) |
| Static property interception via `access()` | ❌ Never | No property hooks for static properties (PHP engine constraint) |
4 changes: 2 additions & 2 deletions docs/pointcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ creation. It is the argument of an advice attribute, e.g. `#[Before('execution(p
|------------------------------------------|---------|
| `execution(modifiers Class->method(*))` | Execution of a dynamic method |
| `execution(modifiers Class::method(*))` | Execution of a static method |
| `execution(Namespace\function(*))` | Calls of a system function from that namespace (`Features::INTERCEPT_FUNCTIONS`) |
| `execution(Namespace\function(*))` | Calls of a system function from that namespace |
| `access(modifiers Class->property)` | Reads and writes of an instance property |
| `within(ClassFilter)` | Every member (methods and properties) of the matching classes |
| `initialization(ClassFilter)` | Object creation with `new` (`Features::INTERCEPT_INITIALIZATIONS`) |
| `initialization(ClassFilter)` | Object creation with `new` |
| `staticinitialization(ClassFilter)` | The first load of the matching classes |
| `@execution(Attribute)` | Methods that carry the attribute |
| `@access(Attribute)` | Properties that carry the attribute |
Expand Down
5 changes: 3 additions & 2 deletions src/Aop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Proxy generators use TypeGenerator::renderTypeForPhpDoc() to emit V as 2nd gener
| AbstractMethodInvocation | MethodInvocation | Base; protected readonly Closure $closureToCall (FCC); TRAIT_ALIAS_SUFFIX='OriginalAlias'; keeps method reflection |
| DynamicTraitAliasMethodInvocation | DynamicMethodInvocation | receives $this->mOriginalAlias(...) or parent::m(...); proceed() via ReflectionMethod::invokeArgs (handles by-ref correctly) |
| StaticTraitAliasMethodInvocation | StaticMethodInvocation | FCC shim: static fn(array $args) => forward_static_call_array(...); bindTo(null, $scope) per call |
| ReflectionConstructorInvocation | ConstructorInvocation | newInstanceWithoutConstructor() then call constructor (requires INTERCEPT_INITIALIZATIONS feature) |
| ReflectionConstructorInvocation | ConstructorInvocation | newInstanceWithoutConstructor() then call constructor (`new` rewritten while an initialization pointcut is registered) |
| ReflectionFunctionInvocation | FunctionInvocation | receives FCC to global fn (e.g. \strlen(...) with leading \ to avoid recursive proxy call) |
| ClassFieldAccess | FieldAccess | Property hooks call read()/write() by value; readByReference() only for the &get hook of arrays (binding released after) |
| StaticInitializationJoinpoint | ClassJoinpoint | Fired once after proxy class loaded via `Proxy::__staticInitialization()` |
Expand Down Expand Up @@ -69,5 +69,6 @@ when `$level > 0` and pops it in `finally`. Check every change with `composer te

## Features (src/Aop/Features.php)
Final class with bitmask constants (`ALL` is the mask of every flag):
- INTERCEPT_FUNCTIONS=1, INTERCEPT_INITIALIZATIONS=2, INTERCEPT_INCLUDES=4
- INTERCEPT_INCLUDES=4
- No flag for function/initialization interception: pointcut kinds decide (AdvisorIndex, see src/Core/AGENTS.md)
- PREBUILT_CACHE=64 — assume cache already prepared, skip freshness checks
19 changes: 4 additions & 15 deletions src/Aop/Features.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,12 @@

/**
* Framework feature flags for the `features` kernel option, combined with `|`
*
* Interception of system functions and of `new` needs no flag: it is enabled by the registered
* `execution(Namespace\function(*))` and `initialization(...)` pointcuts themselves.
*/
final class Features
{
/**
* Enables interception of system function.
* By default this feature is disabled, because this option is very expensive.
*/
public const int INTERCEPT_FUNCTIONS = 1;

/**
* Enables interception of "new" operator in the source code
* By default this feature is disabled, because it's very tricky
*/
public const int INTERCEPT_INITIALIZATIONS = 2;

/**
* Enables interception of "include"/"require" operations in legacy code
* By default this feature is disabled, because only composer should be used
Expand All @@ -49,9 +40,7 @@ final class Features
/**
* Mask of every defined feature flag
*/
public const int ALL = self::INTERCEPT_FUNCTIONS
| self::INTERCEPT_INITIALIZATIONS
| self::INTERCEPT_INCLUDES
public const int ALL = self::INTERCEPT_INCLUDES
| self::PREBUILT_CACHE;

/**
Expand Down
1 change: 1 addition & 0 deletions src/Core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

## Aspect loading
- AspectLoader — scans aspect classes for pointcut/advice attributes → Advisor[]; CachedAspectLoader decorates it (both implement AspectLoaderInterface)
- AdvisorIndex (@internal) — loads unloaded aspects, returns advisors, hasPointcutKind(Pointcut::KIND_*) = union of advisor pointcut kinds, recollected whenever getServicesByInterface(Advisor) returns another array (after any registration). Gates kind-specific machinery instead of feature flags (`new` rewriting ← KIND_INIT); function advices need no gate (AdviceMatcher skips non-KIND_FUNCTION advisors)

### Compiled advisor cache
- Lives in the nested Go\Core\Cache namespace (CachedAspectLoader, AdvisorCacheCompiler, AdvisorCachePrinter, CacheFileWriter — all @internal); format, naming and error-handling rules: see src/Core/Cache/AGENTS.md
Expand Down
12 changes: 1 addition & 11 deletions src/Core/AdviceMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,13 @@
*/
class AdviceMatcher implements AdviceMatcherInterface
{
/**
* Constructor
*
* @param bool $isInterceptFunctions Optional flag to enable function interception
*/
/**
* Reflections of every internal function, built on the first function match of the request
*
* @var array<non-empty-string, ReflectionFunction>|null
*/
private static ?array $internalFunctions = null;

public function __construct(private readonly bool $isInterceptFunctions = false) {}

/**
* Returns list of function advices for namespace
*
Expand All @@ -52,10 +45,7 @@ public function __construct(private readonly bool $isInterceptFunctions = false)
*/
public function getAdvicesForFunctions(ReflectionFileNamespace $namespace, array $advisors): array
{
if (!$this->isInterceptFunctions) {
return [];
}

// Only advisors with a function pointcut are matched: without one a namespace costs a scan of the advisors
$advices = [];

foreach ($advisors as $advisorId => $advisor) {
Expand Down
84 changes: 84 additions & 0 deletions src/Core/AdvisorIndex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);
/*
* Go! AOP framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/

namespace Go\Core;

use Go\Aop\Advisor;
use Go\Aop\Pointcut;
use Go\Aop\PointcutAdvisor;

/**
* Index of the advisors of every registered aspect and of the pointcut kinds they use
*
* Lets the transformation pipeline enable the machinery a pointcut kind needs (e.g. the rewriting of `new` for
* initialization pointcuts) only while an advisor of that kind is registered. Aspects that are not loaded yet are
* loaded on the first query, and the kinds are collected again after every registration in the container, so an
* aspect registered or loaded later is taken into account as well.
*
* @internal Framework service, not a public extension point
*/
final class AdvisorIndex
{
/**
* Advisors the kinds below were collected from
*
* @var array<Advisor>|null
*/
private ?array $indexedAdvisors = null;

/**
* Bitmask of the pointcut kinds of the indexed advisors
*/
private int $pointcutKinds = 0;

public function __construct(
private readonly AspectContainer $container,
private readonly AspectLoaderInterface $aspectLoader,
) {}

/**
* Returns the advisors of every registered aspect, loading the aspects that are not loaded yet
*
* @return array<Advisor>
*/
public function getAdvisors(): array
{
foreach ($this->aspectLoader->getUnloadedAspects() as $unloadedAspect) {
$this->aspectLoader->loadAndRegister($unloadedAspect);
}

return $this->container->getServicesByInterface(Advisor::class);
}

/**
* Checks whether a pointcut of any registered advisor can match a join point of the given kind
*
* @param int $pointcutKind One of the Pointcut::KIND_* constants
*/
public function hasPointcutKind(int $pointcutKind): bool
{
$advisors = $this->getAdvisors();
// The container returns the same array until the next registration, compared without a walk
if ($advisors !== $this->indexedAdvisors) {
$pointcutKinds = 0;
foreach ($advisors as $advisor) {
if ($advisor instanceof PointcutAdvisor) {
$pointcutKinds |= $advisor->getPointcut()->getKind();
}
}
$this->indexedAdvisors = $advisors;
$this->pointcutKinds = $pointcutKinds;
}

return ($this->pointcutKinds & $pointcutKind) !== 0;
}
}
20 changes: 10 additions & 10 deletions src/Core/AspectKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ public function init(array $options = []): void

$container = $this->container = new $this->options['containerClass']($resourcesToTrack);
$container->add(AspectKernel::class, $this);
$container->add('kernel.interceptFunctions', $this->hasFeature(Features::INTERCEPT_FUNCTIONS));

// The framework's own services are deferred definitions registered through the
// generic lazy container API - the container itself knows nothing about them.
Expand Down Expand Up @@ -379,9 +378,10 @@ abstract protected function configureAop(AspectContainer $container): void;
*
* Every registered container service implementing SourceTransformer becomes part of
* the chain, in registration order (registration order IS the transformation order);
* feature flags gate registration exactly as they used to gate construction. Nothing
* is constructed here - these are deferred definitions, materialized on the first
* cache miss only.
* feature flags gate registration exactly as they used to gate construction, and the
* rewriting of `new` turns itself on only while an initialization pointcut is
* registered (AdvisorIndex). Nothing is constructed here - these are deferred
* definitions, materialized on the first cache miss only.
*
* Rewrites of single syntax tree nodes are NodeRewriter services instead: the
* SyntaxTreeRewriter stage, a framework service registered before this hook (so
Expand All @@ -395,12 +395,12 @@ abstract protected function configureAop(AspectContainer $container): void;
*/
protected function registerTransformerServices(AspectContainer $container): void
{
if ($this->hasFeature(Features::INTERCEPT_INITIALIZATIONS)) {
$container->addLazyService(
ConstructorExecutionTransformer::class,
fn(): ConstructorExecutionTransformer => new ConstructorExecutionTransformer(),
);
}
$container->addLazyService(
ConstructorExecutionTransformer::class,
fn(AspectContainer $container): ConstructorExecutionTransformer => new ConstructorExecutionTransformer(
$container->getService(AdvisorIndex::class),
),
);
if ($this->hasFeature(Features::INTERCEPT_INCLUDES)) {
$container->addLazyService(
FilterInjectorTransformer::class,
Expand Down
Loading
Loading