Skip to content
Open
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 @@ -60,6 +60,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide.
* [BC BREAK] Interceptors are no longer `serialize()`-able: `AbstractInterceptor::__serialize()`/`__unserialize()` and the protected `serializeAdvice()`/`unserializeAdvice()` hooks are removed; advisor caches restore advices via first-class callables on aspect instances instead.

**Fixed**
* [Fixed] A file declaring several woven classes writes all their proxies into its one proxy file (a braced namespace block per proxy) and includes it once; the proxies used to overwrite each other, so all classes but the last were missing. Code running before that include that needs one of the woven classes is rejected with a `WeavingException` (#760).
* [Fixed] Generated proxies keep readable short `use` imports, and imports that collide with names of the original file (its imports, the class itself, names used in its body) or with each other (aspects sharing a short name) get a distinct alias automatically, e.g. `use Go\Aop\Framework\Interceptor as AopInterceptor;`. Such collisions used to make the proxy fail to compile (#668).
* [Fixed] Every cache file (woven sources, class and function proxies, metadata maps, advisor caches) is written atomically through `CacheFileWriter` (temporary file + rename, opcache invalidation), so a concurrent request can no longer include a half-written proxy (#670). Cache directories get search permissions matching the read bits of `cacheFileMode`, and an invalid `cacheFileMode` (outside `0600..0777` or without owner read/write) is rejected by the kernel.
* [Fixed] Intercepted property declarations spanning several lines (multi-line defaults, attributes on their own lines) are removed from the woven trait completely, and grouped declarations such as `public int $a, $b;` keep their non-intercepted items; line numbers are preserved (#669).
Expand Down
19 changes: 19 additions & 0 deletions src/Instrument/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ Short names above are `use` aliases managed by ProxyImports: a name colliding wi
file's imports or body gets an adjusted alias (e.g. `use Go\Aop\Framework\Interceptor as AopInterceptor;`),
see src/Proxy/AGENTS.md. WeavingTransformer passes the original imports to the proxy generator constructors.

### Several woven class-likes in one file (#760)
One proxy file per source file, never one per class: processSingleClass() only converts the class and returns a
WovenProxy; transform() collects them in a local list (transform() is re-entered mid-transform) and writeProxies()
writes them once (ProxyFileGenerator) and appends ONE include_once.
- One proxy: byte-identical to the single-class output (`<?php`, optional `declare(strict_types=1);`, `namespace X;` ...).
- Several: `declare(strict_types=1);` once, then one braced block per proxy (`namespace X { use ...; class ... }`,
`namespace { ... }` for global classes), even for the same namespace (each keeps its own ProxyImports). Source
order, a proxy extending another proxy of the file after it (ProxyIncludePlanner::sortForProxyFile()).
- Include point (ProxyIncludePlanner::findIncludePoint()): after the last woven class-like whose proxy depends on its
position, else after the first one. Position-bound = its woven trait keeps trait uses (PHP declares such a trait at
runtime) or it extends/implements an UNWOVEN class-like of the file. A trait without trait uses is declared at compile
time, so proxies of classes declared after the include still find their trait.
- Gap → WeavingException with the file, the woven class-likes, the include point and each offending line: a class-like
declared up to the include point that needs a woven class-like of the file (unwoven: extends/implements/uses; woven:
its trait uses), or a top-level statement between the first woven class-like and the include point that loads an
earlier woven class-like (`new X`, `X::m()`, `X::$p`, `X::C`, classes it declares; not `X::class`, `instanceof`,
types, function/closure/arrow function bodies — LoadedClassNameCollector).
- The runtime class map maps every class of the file to its woven file (CachePathManager, unchanged).

### Key invariants
- Proxy re-inherits parent+interfaces via reflection (not from woven source)
- self:: in trait body → proxy class (no rewrite needed)
Expand Down
105 changes: 105 additions & 0 deletions src/Instrument/Transformer/LoadedClassNameCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?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\Instrument\Transformer;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;

/**
* Collects the names of the classes code loads when it runs: instantiations, static members, class constants (not
* `X::class`) and the parents, interfaces and traits of the classes it declares. Bodies of functions, closures, arrow
* functions and declared classes run later and are skipped; `instanceof`, type declarations and other references
* that do not load a class are ignored.
*
* @internal
*/
final class LoadedClassNameCollector extends NodeVisitorAbstract
{
/**
* Fully qualified names of the loaded classes
*
* @var list<string>
*/
public array $names = [];

public function enterNode(Node $node): ?int
{
if ($node instanceof Stmt\Function_ || $node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) {
return NodeVisitor::DONT_TRAVERSE_CHILDREN;
}
if ($node instanceof Stmt\ClassLike) {
array_push($this->names, ...self::getInheritedNames($node), ...self::getTraitUseNames($node));

return NodeVisitor::DONT_TRAVERSE_CHILDREN;
}
$isLoadingReference = $node instanceof Expr\New_
|| $node instanceof Expr\StaticCall
|| $node instanceof Expr\StaticPropertyFetch
|| ($node instanceof Expr\ClassConstFetch
&& !($node->name instanceof Identifier && $node->name->toLowerString() === 'class'));
if ($isLoadingReference && $node->class instanceof Name) {
$this->names[] = self::resolveName($node->class);
}

return null;
}

/**
* Names of the class-likes the declaration extends or implements
*
* @return list<string>
*/
public static function getInheritedNames(Stmt\ClassLike $node): array
{
$names = match (true) {
$node instanceof Stmt\Class_ => $node->extends !== null ? [$node->extends, ...$node->implements] : $node->implements,
$node instanceof Stmt\Enum_ => $node->implements,
$node instanceof Stmt\Interface_ => $node->extends,
default => [],
};

return array_values(array_map(self::resolveName(...), $names));
}

/**
* Names of the traits used by the declaration
*
* @return list<string>
*/
public static function getTraitUseNames(Stmt\ClassLike $node): array
{
$names = [];
foreach ($node->getTraitUses() as $traitUse) {
foreach ($traitUse->traits as $trait) {
$names[] = self::resolveName($trait);
}
}

return $names;
}

/**
* Fully qualified name of a class reference, resolved by the name resolver of the parser
*/
public static function resolveName(Name $name): string
{
$resolvedName = $name->getAttribute('resolvedName');

return $resolvedName instanceof Name ? $resolvedName->toString() : $name->toString();
}
}
259 changes: 259 additions & 0 deletions src/Instrument/Transformer/ProxyIncludePlanner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
<?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\Instrument\Transformer;

use Go\Aop\Exception\WeavingException;
use PhpParser\Node;
use PhpParser\Node\Stmt;
use PhpParser\NodeTraverser;

/**
* Places the proxies of a source file declaring several woven class-likes: the order of the proxies in the proxy file
* and the declaration after which the woven source includes that file.
*
* A proxy always uses its `…OriginalTrait`, so PHP declares it at runtime, when the include runs. A woven trait without
* trait uses is declared at compile time, before any code of the file runs; one with trait uses is declared at runtime,
* at its position. The include goes after the last woven class-like whose proxy depends on such a position (its trait
* uses something, or it extends/implements a class-like of the file that is not woven), else after the first one.
*
* Code that runs between the first woven class-like and that point can not use the woven class-likes before it, as it
* could with one proxy per class: the file is rejected with a WeavingException instead of failing with "Class not found"
* while it is loaded. Detected:
* - a class-like declared up to the include point that needs a woven class-like of the file: an unwoven one extending,
* implementing or using it, a woven one using it (its trait uses stay in its `…OriginalTrait`);
* - a top-level statement between the first woven class-like and the include point that loads a woven class-like
* declared before it: `new X`, `X::method()`, `X::$property`, `X::CONSTANT` (not `X::class`) or a class declared
* by it extending/implementing/using X. Bodies of closures, arrow functions and functions are not looked into,
* `instanceof`, type declarations and other references that do not load a class are ignored.
*
* @internal
*/
final class ProxyIncludePlanner
{
/**
* Sorts the proxies for the proxy file: source order, a proxy extending another proxy of the file comes after it
*
* @param non-empty-list<WovenProxy> $proxies Proxies in source order
*
* @return list<WovenProxy>
*/
public static function sortForProxyFile(array $proxies): array
{
$byName = [];
foreach ($proxies as $proxy) {
$byName[strtolower($proxy->class->getName())] = $proxy;
}
$placed = [];
$sorted = [];
foreach ($proxies as $proxy) {
self::place($proxy, $byName, $placed, $sorted);
}

return $sorted;
}

/**
* Returns the proxy after whose declaration the proxy file is included
*
* @param array<Node> $syntaxTree Syntax tree of the source file
* @param non-empty-list<WovenProxy> $proxies Proxies in source order
* @param string $fileName Source file name, for the error message
*
* @throws WeavingException When code running before the include point needs a woven class-like
*/
public static function findIncludePoint(array $syntaxTree, array $proxies, string $fileName): WovenProxy
{
$includePoint = $proxies[0];
if (count($proxies) === 1) {
return $includePoint;
}

$statements = self::getTopLevelStatements($syntaxTree);
$wovenNames = [];
$wovenNodes = [];
foreach ($proxies as $proxy) {
$wovenNames[strtolower($proxy->class->getName())] = $proxy;
$wovenNodes[spl_object_id($proxy->node)] = true;
}
$unwovenNames = [];
foreach ($statements as $statement) {
if ($statement instanceof Stmt\ClassLike && $statement->namespacedName !== null) {
$name = strtolower($statement->namespacedName->toString());
if (!isset($wovenNames[$name])) {
$unwovenNames[$name] = true;
}
}
}
foreach ($proxies as $proxy) {
if (self::isPositionBound($proxy->node, $unwovenNames)) {
$includePoint = $proxy;
}
}

$problems = self::findLoadTimeDependencies($statements, $proxies, $wovenNames, $wovenNodes, $includePoint);
if ($problems !== []) {
$wovenClassNames = array_map(static fn(WovenProxy $proxy): string => $proxy->class->getName(), $proxies);

throw new WeavingException(sprintf(
'Can not weave %s: the proxies of its woven class-likes %s are declared by one proxy file, included '
. 'after %s (line %d), the first point where all of them can be declared. Code running before that '
. 'point needs a woven class-like: %s. Move that code or the woven class-likes so that the code runs '
. 'after %s, split the file into one class per file, or exclude the class-likes from the pointcut.',
$fileName,
implode(', ', $wovenClassNames),
$includePoint->class->getName(),
$includePoint->node->getEndLine(),
implode('; ', $problems),
$includePoint->class->getName(),
));
}

return $includePoint;
}

/**
* @param list<Stmt> $statements Top-level statements of the file
* @param non-empty-list<WovenProxy> $proxies Proxies in source order
* @param array<string, WovenProxy> $wovenNames Woven class-likes by lowercase name
* @param array<int, true> $wovenNodes Object ids of the woven declarations
*
* @return list<string> Descriptions of the code needing a woven class-like before the include point
*/
private static function findLoadTimeDependencies(
array $statements,
array $proxies,
array $wovenNames,
array $wovenNodes,
WovenProxy $includePoint,
): array {
$problems = [];
$firstWovenEnd = $proxies[0]->endTokenPosition;
foreach ($statements as $statement) {
if ($statement->getEndTokenPos() > $includePoint->endTokenPosition) {
continue;
}
if ($statement instanceof Stmt\ClassLike) {
// Extends/implements of a woven class-like move to its proxy, its trait uses stay in the woven trait
$neededNames = LoadedClassNameCollector::getTraitUseNames($statement);
if (!isset($wovenNodes[spl_object_id($statement)])) {
$neededNames = [...LoadedClassNameCollector::getInheritedNames($statement), ...$neededNames];
}
foreach ($neededNames as $neededName) {
if (isset($wovenNames[strtolower($neededName)])) {
$problems[] = sprintf(
'%s (line %d) needs %s',
$statement->namespacedName?->toString() ?? 'class',
$statement->getStartLine(),
$wovenNames[strtolower($neededName)]->class->getName(),
);
}
}
continue;
}
$statementStart = $statement->getStartTokenPos();
if ($statementStart <= $firstWovenEnd || self::isDeclarationOnly($statement)) {
continue;
}
$collector = new LoadedClassNameCollector();
$traverser = new NodeTraverser($collector);
$traverser->traverse([$statement]);
foreach ($collector->names as $reference) {
$woven = $wovenNames[strtolower($reference)] ?? null;
if ($woven !== null && $woven->endTokenPosition < $statementStart) {
$problems[] = sprintf(
'the statement on line %d uses %s',
$statement->getStartLine(),
$woven->class->getName(),
);
}
}
}

return array_values(array_unique($problems));
}

/**
* A proxy depends on the position of its declaration when its woven trait keeps trait uses (PHP declares such a
* trait at runtime) or when it extends/implements a class-like of the file that is not woven
*
* @param array<string, true> $unwovenNames Lowercase names of the class-likes of the file that are not woven
*/
private static function isPositionBound(Stmt\ClassLike $node, array $unwovenNames): bool
{
if ($node->getTraitUses() !== []) {
return true;
}
foreach (LoadedClassNameCollector::getInheritedNames($node) as $inheritedName) {
if (isset($unwovenNames[strtolower($inheritedName)])) {
return true;
}
}

return false;
}

/**
* @param array<string, WovenProxy> $byName Proxies by lowercase class name
* @param array<string, true> $placed Lowercase names of the visited proxies
* @param list<WovenProxy> $sorted
*/
private static function place(WovenProxy $proxy, array $byName, array &$placed, array &$sorted): void
{
$key = strtolower($proxy->class->getName());
if (isset($placed[$key])) {
return;
}
$placed[$key] = true;
if ($proxy->node instanceof Stmt\Class_ && $proxy->node->extends !== null) {
$parent = $byName[strtolower(LoadedClassNameCollector::resolveName($proxy->node->extends))] ?? null;
if ($parent !== null) {
self::place($parent, $byName, $placed, $sorted);
}
}
$sorted[] = $proxy;
}

/**
* Statements of the namespace blocks of the file. Parser-reflection wraps the statements of a file without a
* namespace declaration into a global namespace block, only declare statements stay outside of the blocks.
*
* @param array<Node> $syntaxTree
*
* @return list<Stmt>
*/
private static function getTopLevelStatements(array $syntaxTree): array
{
$statements = [];
foreach ($syntaxTree as $node) {
if ($node instanceof Stmt\Namespace_) {
array_push($statements, ...$node->stmts);
}
}

return $statements;
}

/**
* Statements that run no code while the file is loaded
*/
private static function isDeclarationOnly(Stmt $statement): bool
{
return $statement instanceof Stmt\Function_
|| $statement instanceof Stmt\Use_
|| $statement instanceof Stmt\GroupUse
|| $statement instanceof Stmt\Nop
|| $statement instanceof Stmt\InlineHTML
|| $statement instanceof Stmt\HaltCompiler
|| $statement instanceof Stmt\Declare_;
}
}
Loading
Loading