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 @@ -45,6 +45,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide.
* [Performance] [BC BREAK] **Truly lazy container services** — `AspectContainer::addLazyService()` stores a factory instead of building a PHP 8.4 lazy proxy, so registering the built-in services no longer reflects/autoloads them on every request; a service is constructed on first retrieval. Code relying on lazy-proxy instances being available from boot must use `getService()` instead.
* [Performance] [BC BREAK] **Aspects are typical container services; `registerAspect()` removed** — the container is now a fully AOP-unaware DI implementation. Register aspects in `configureAop()` through the generic API: `$container->add(Foo::class, new Foo())` for an eager instance, or `$container->addLazyService(Foo::class, static fn() => new Foo(...))` for deferred construction on first use (first advice hit or first real interaction with the lazy object). `AspectContainer::registerAspect()`, the internal per-aspect validators and the double per-aspect validation pass are gone; a factory returning an incompatible object now fails on first use via the container's `instanceof` guard instead of at retrieval. Aspect enumeration on the weaving path is unchanged (`getServicesByInterface(Aspect::class)` finds deferred aspects by id).
* [Performance] [BC BREAK] **Class-keyed runtime cache map, integrated into composer** — the weaver records the FQCN of every discovered class into the cache metadata, and the runtime cache file `_include.cache` holds a woven-class => cached-file map plus a skip set of known untransformed classes. At production boot the class map is handed to composer via `ClassLoader::addClassMap()`, so woven classes resolve natively to their cached files (composer consults the class map before PSR-4) with no per-class `realpath()`; untransformed classes are served untouched. `_transformation.cache` keeps the full build metadata and is loaded lazily, only on the cache-miss/weaving paths. Both files are constant arrays: records are keyed by the source path relative to the application root and cached files are written as `__DIR__ . '/…'`, so opcache keeps them as they are and a moved application or cache directory stays valid. **Cache format bump**: a pre-4.0 cache directory carries no class names and is treated as stale — everything re-weaves once (or run `cache:warmup:aop` at deploy). New accessors: `CachePathManager::queryClassMap()`, `querySkippedClasses()`, `registerClassForResource()`.
* [Performance] **Known included files are included natively in production** — with `Features::INTERCEPT_INCLUDES`, `_include.cache` also maps every source known to the cache (path relative to the application root) to its cached file, or to `null` when it was not transformed. In production `FilterInjectorTransformer::rewrite()` serves an include of a known file like the class map serves a known class: the original file when it is untransformed, the recorded cached file when it is woven, with no `php://filter` pass (which opcache never caches), no freshness check and no load of `_transformation.cache`; unknown files still go through the filter. An include of an untransformed file on a warm request drops from ~250 µs to ~11 µs (#746). **Cache format bump** (`CachePathManager::FORMAT_VERSION` 4): an older cache re-weaves once, a `PREBUILT_CACHE` one must be rebuilt with `cache:warmup:aop`. New accessor: `CachePathManager::findIncludeFile()`.
* [Performance] [BC BREAK] **`PREBUILT_CACHE` now really trusts the cache** — with `Features::PREBUILT_CACHE` enabled, an existing cache record is used without any freshness checks: cache directory existence/writability probes, source `filemtime` comparisons, tracked-resource checks and advisor cache freshness are all skipped (previously the flag only skipped one writability check). Build the cache at deploy time (`bin/aspect cache:warmup:aop`); staleness is the deployer's responsibility. An advisor cache file of an incompatible (older) format falls back to the direct loader without writing (read-only file systems stay safe); a corrupt, non-includable file throws.
* [Performance] [BC BREAK] **Lazy transformation pipeline** — every source transformer is a deferred container service, tagged by the `SourceTransformer` interface and assembled in registration order; the stream filter and the transformers are only registered/constructed on the first cache miss (`SourceTransformingLoader::ensureRegistered()`), never on a warm-cache request. The protected `AspectKernel::registerTransformers()` hook (returning transformer instances, used e.g. by AspectMock to swap in its own weaver) is replaced by `AspectKernel::registerTransformerServices(AspectContainer $container)`, which registers deferred container definitions: override it to replace, omit, reorder or extend the built-in transformers, or simply `addLazyService()` an extra `SourceTransformer` service from `configureAop()` to append one. The rewrites of single syntax nodes (`new`, `include`/`require`, `__DIR__`/`__FILE__`) are `NodeRewriter` rules dispatched by one `SyntaxTreeRewriter` walk per file before weaving, instead of transformers walking the tree on their own; register a `NodeRewriter` service to add one. A `PrefilteredNodeRewriter` declares source markers, so files without them skip the rule, and the walk itself when no rule is left: with the default rules most files are not walked at all.
* [Performance] [BC BREAK] **Caching moved into the core: `CachingTransformer` removed** — the cache decision now lives directly in `SourceTransformingLoader::filter()`: a usable cache record makes the filter emit the cached file content as-is, without parsing the source, constructing a single transformer or building `StreamMetaData`; only a real miss parses and runs the transformer chain, then persists the result. This also removes the old cached-branch double work of re-parsing the cached file to rebuild the token stream, so the warm-cache fast path now applies in **debug mode** too (freshness checks preserved). `SourceTransformingLoader::transformCode()` returns the overall `TransformerResult` and `addTransformer()` is gone — register transformers as container services instead (`AspectKernel::registerTransformerServices()`).
Expand Down
3 changes: 2 additions & 1 deletion UPGRADE-4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Every cache format changed, and no 3.x cache file is reused:
source file below the cache directory, and the woven source (the original class body as a
`<Class>OriginalTrait` trait) next to it.
- **Class map.** `_include.cache` maps every woven class to its cached file and is handed to Composer
at boot. A cache directory without it is treated as stale.
at boot, and maps every known source file to its cached file (or `null` when untransformed) for the
includes intercepted by `INTERCEPT_INCLUDES`. A cache directory without it is treated as stale.
- **Advisor cache.** The serialized `{cacheDir}/_aspect/sha1(FQCN)` blobs are replaced by plain PHP
files next to the woven sources: `{appDir}/src/Aspect/LoggingAspect.php` is cached as
`{cacheDir}/src/Aspect/LoggingAspect.cache.php`. Interceptors are no longer serializable
Expand Down
2 changes: 1 addition & 1 deletion src/Instrument/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Applied per loaded file. Each returns TransformerResult: Transformed|Abstain|Abo

1. SyntaxTreeRewriter — framework service (FrameworkServices, so it survives an overridden registerTransformerServices()); walks the syntax tree once and dispatches every node to the NodeRewriter rules (container tag) declaring its type (`getNodeTypes()`), in registration order. Rules are stateless per node (weaving re-enters the loader for other files mid-chain); ancestors are passed to the rules from the dispatcher's stack (no parent attributes on the shared tree, they cost ~3x a walk). PrefilteredNodeRewriter rules declare getSourceMarkers(): looked up case-insensitively in StreamMetaData::$originalSource before the walk, a rule without a marker in the file is skipped, no rule left → no walk (rules-per-node-class cache keyed by the set of applicable rules). Built-in rules:
- ConstructorExecutionTransformer — new expressions outside constant-expression contexts (only if INTERCEPT_INITIALIZATIONS enabled)
- FilterInjectorTransformer — include/require (only if INTERCEPT_INCLUDES enabled)
- FilterInjectorTransformer — include/require (only if INTERCEPT_INCLUDES enabled); at runtime rewrite() serves, in production, a file known to the include map natively (original file if untransformed, recorded cacheUri if woven, no file_exists/freshness check, no _transformation.cache); unknown files and debug mode → php://filter
- MagicConstantTransformer — `__FILE__`/`__DIR__` → original paths, wraps `getFileName()`; never counts as a transformation (only cache-dir files need it; PHP resolves magic constants of a `php://filter/.../resource=<path>` include to <path>)
2. WeavingTransformer — main; AdviceMatcher + CachedAspectLoader → proxy generators. Sees the token edits of the rules.

Expand Down
48 changes: 41 additions & 7 deletions src/Instrument/ClassLoading/CachePathManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class CachePathManager
private const string CACHE_FILE_NAME = '/_transformation.cache';

/**
* Name of the file with the minimal runtime include map (originalPath => cacheUri|null)
* Name of the file with the minimal runtime include map: woven classes, untransformed classes and known files
*/
private const string INCLUDE_MAP_FILE_NAME = '/_include.cache';

Expand All @@ -47,7 +47,7 @@ class CachePathManager
* root, and cached files are written as `__DIR__ . '/…'`. opcache keeps such an array as is, so including
* the files on a warm request copies and checks nothing.
*/
public const int FORMAT_VERSION = 3;
public const int FORMAT_VERSION = 4;

/** @phpstan-var KernelOptions */
protected array $options;
Expand Down Expand Up @@ -97,6 +97,16 @@ class CachePathManager
*/
protected array $skippedClasses = [];

/**
* Minimal runtime map of every source known to the cache, keyed by {@see self::getRecordKey()}, to its cached
* file, or to null when the source was not transformed
*
* Serves the includes rewritten by FilterInjectorTransformer without the full metadata.
*
* @var array<string, string|null>
*/
private array $fileMap = [];

/**
* Class names discovered by the weaver per original file, pending until
* setCacheState() folds them into the metadata record
Expand Down Expand Up @@ -152,9 +162,10 @@ public function __construct(protected readonly AspectKernel $kernel)
} else {
// A file of the current format is written by flushCacheState() only, so its arrays are used
// as they are: checking every entry would cost a loop over the whole map on every request
/** @var array{map: array<class-string, string>, skip: array<class-string, true>} $includeData */
/** @var array{map: array<class-string, string>, skip: array<class-string, true>, files: array<string, string|null>} $includeData */
$this->classMap = $includeData['map'];
$this->skippedClasses = $includeData['skip'];
$this->fileMap = $includeData['files'];
}
} elseif (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) {
// Legacy cache directory (pre-class-map format): the metadata records carry
Expand Down Expand Up @@ -240,6 +251,23 @@ public function querySkippedClasses(): array
return $this->skippedClasses;
}

/**
* Returns the file to include natively for a source known to the include map
*
* The cached file of a woven source, the source itself for a source recorded as untransformed, or null for a
* source unknown to the cache. Like a class of the class map, a known source is trusted without any freshness
* check, and the full metadata is never loaded.
*/
public function findIncludeFile(string $resource): ?string
{
$recordKey = $this->getRecordKey($resource);
if (isset($this->fileMap[$recordKey])) {
return $this->fileMap[$recordKey];
}

return array_key_exists($recordKey, $this->fileMap) ? $resource : null;
}

/**
* Records a class name discovered by the weaver in the given original file
*
Expand Down Expand Up @@ -353,10 +381,12 @@ public function setCacheState(string $resource, array $metadata): void
unset($this->pendingClasses[$resource]);
$metadata['classes'] = $classNames;

$this->newCacheState[$this->getRecordKey($resource)] = $metadata;
$recordKey = $this->getRecordKey($resource);
$this->newCacheState[$recordKey] = $metadata;

// Keep the in-memory runtime map coherent within this request
// Keep the in-memory runtime maps coherent within this request
$cacheUri = $metadata['cacheUri'] ?? null;
$this->fileMap[$recordKey] = is_string($cacheUri) ? $cacheUri : null;
foreach ($classNames as $className) {
if (is_string($cacheUri)) {
$this->classMap[$className] = $cacheUri;
Expand Down Expand Up @@ -408,11 +438,13 @@ public function flushCacheState(bool $force = false): void

$classMap = [];
$skippedClasses = [];
foreach ($fullCacheMap as $metadata) {
$fileMap = [];
foreach ($fullCacheMap as $recordKey => $metadata) {
if (!is_array($metadata)) {
continue;
}
$cacheUri = $metadata['cacheUri'] ?? null;
$fileMap[$recordKey] = is_string($cacheUri) ? $cacheUri : null;
$classNames = is_array($metadata['classes'] ?? null) ? $metadata['classes'] : [];
foreach ($classNames as $className) {
if (!is_string($className)) {
Expand All @@ -430,12 +462,13 @@ public function flushCacheState(bool $force = false): void
$this->writeCacheFile(self::CACHE_FILE_NAME, ['version' => self::FORMAT_VERSION, 'files' => $fullCacheMap]);
$this->writeCacheFile(
self::INCLUDE_MAP_FILE_NAME,
['version' => self::FORMAT_VERSION, 'map' => $classMap, 'skip' => $skippedClasses],
['version' => self::FORMAT_VERSION, 'map' => $classMap, 'skip' => $skippedClasses, 'files' => $fileMap],
);

$this->cacheState = $fullCacheMap;
$this->classMap = $classMap;
$this->skippedClasses = $skippedClasses;
$this->fileMap = $fileMap;
$this->newCacheState = [];
}
}
Expand Down Expand Up @@ -476,6 +509,7 @@ public function clearCacheState(): void
$this->cacheStateLoaded = true;
$this->classMap = [];
$this->skippedClasses = [];
$this->fileMap = [];
$this->pendingClasses = [];
$this->newCacheState = [];

Expand Down
14 changes: 14 additions & 0 deletions src/Instrument/Transformer/FilterInjectorTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ public static function rewrite(string $originalResource, string $originalDir = '
?: PathResolver::realpath("{$originalDir}/{$resource}", $shouldCheckExistence)
?: $originalResource;
}

// In production a source known to the include map is included natively, like a class of the class map:
// its cached file when it was woven, the source itself otherwise (no filter, no freshness check).
// Records are keyed by resolved paths, so `__DIR__ . '/../file.php'` is resolved first
if (!$debug && $cacheDir !== null && $cacheDir !== '') {
$resolvedResource = str_contains($resource, '/.') ? PathResolver::realpath($resource) : $resource;
$includeFile = self::$cachePathManager?->findIncludeFile(
is_string($resolvedResource) ? $resolvedResource : $resource,
);
if ($includeFile !== null) {
return $includeFile;
}
}

$cachedResource = self::$cachePathManager?->getCachePathForResource($resource);

// If the cache is disabled, resource path not resolvable, or no cache yet, then use on-fly method
Expand Down
24 changes: 24 additions & 0 deletions tests/Fixtures/project/bin/includes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);

/**
* Boots the fixture project, includes files from a woven class and prints, as JSON, what the included files
* returned, which files were included and what the intercepted includes are rewritten to (issue #746)
*/

use Go\Instrument\Transformer\FilterInjectorTransformer;
use Go\Tests\TestProject\Application\FileIncluder;

include __DIR__ . '/../web/index.php';

$result = new FileIncluder()->includeFiles();
// The included files list the resource itself for a `php://filter` include, so the rewritten paths are reported too
$rewrites = [];
foreach (['plain-file.php', 'woven-file.php'] as $fileName) {
$rewrites[$fileName] = FilterInjectorTransformer::rewrite($fileName, __DIR__ . '/../src/Application/includes');
}

echo json_encode(
['result' => $result, 'includedFiles' => get_included_files(), 'rewrites' => $rewrites],
JSON_THROW_ON_ERROR,
);
21 changes: 21 additions & 0 deletions tests/Fixtures/project/src/Application/FileIncluder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace Go\Tests\TestProject\Application;

/**
* Class including plain files, woven by Features::INTERCEPT_INCLUDES only (issue #746)
*/
class FileIncluder
{
/**
* @return array<string, mixed>
*/
public function includeFiles(): array
{
return [
'plain' => require __DIR__ . '/includes/plain-file.php',
'woven' => require __DIR__ . '/includes/woven-file.php',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);

// Plain file without includes: recorded as untransformed
return ['file' => __FILE__];
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);

// Plain file including another one through a relative segment: woven by Features::INTERCEPT_INCLUDES
return ['file' => __FILE__, 'plain' => require __DIR__ . '/../includes/plain-file.php'];
28 changes: 28 additions & 0 deletions tests/Fixtures/project/web/configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@
],
],

// Includes inside woven files are intercepted (issue #746)
'production_includes' => [
'kernel' => \Go\Tests\TestProject\Kernel\DefaultAspectKernel::class,
'console' => __DIR__ . '/../bin/console',
'frontController' => __DIR__ . '/../web/index.php',
'appDir' => __DIR__ . '/../',
'debug' => false,
'cacheDir' => __DIR__ . '/../var/cache/aspect',
'features' => \Go\Aop\Features::INTERCEPT_INCLUDES,
'includePaths' => [
__DIR__ . '/../src/'
],
],

// The cache warmed up with the 'production_includes' configuration, moved to another directory and trusted as prebuilt
'moved_includes_cache' => [
'kernel' => \Go\Tests\TestProject\Kernel\DefaultAspectKernel::class,
'console' => __DIR__ . '/../bin/console',
'frontController' => __DIR__ . '/../web/index.php',
'appDir' => __DIR__ . '/../',
'debug' => false,
'cacheDir' => __DIR__ . '/../var/cache/aspect-moved',
'features' => \Go\Aop\Features::INTERCEPT_INCLUDES | \Go\Aop\Features::PREBUILT_CACHE,
'includePaths' => [
__DIR__ . '/../src/'
],
],

'inconsistent_weaving' => [
'kernel' => \Go\Tests\TestProject\Kernel\InconsistentlyWeavingAspectKernel::class,
'console' => __DIR__ . '/../bin/console',
Expand Down
Loading
Loading