From 243012b71a00c2dc397997928cd775ee341e1ba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:39:32 +0000 Subject: [PATCH 1/3] perf(cache): split runtime include map from transformation metadata The autoloader materialized the full _transformation.cache array (filemtime plus cacheUri per file) on every request while only needing the original-path => cached-path mapping. The cache state is now written as two opcache-friendly files: - _include.cache: minimal originalPath => cacheUri|null map, read by AopComposerLoader on every request (roughly half the data); - _transformation.cache: full build metadata, now loaded lazily and only on the cache-miss/weaving paths (queryCacheState/flush) - a hot request never materializes it. A legacy cache directory without _include.cache keeps working: the map is derived from the metadata once and the next flush writes both files. New CachePathManager::queryIncludeMap() serves the runtime map. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V1Z87HZ2iz23WPTtTYqFxE --- CHANGELOG.md | 1 + .../ClassLoading/AopComposerLoader.php | 17 +- .../ClassLoading/CachePathManager.php | 146 ++++++++++++++---- .../ClassLoading/CachePathManagerTest.php | 121 +++++++++++++++ 4 files changed, 250 insertions(+), 35 deletions(-) create mode 100644 tests/Instrument/ClassLoading/CachePathManagerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index df364499..16dcf6cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Changelog * [Performance] **Direct static joinpoint initialization** — leveraging PHP 8.3+ support for dynamic expressions in static variable initializers, all generated proxy method bodies now initialize their static joinpoint variables directly. * [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] **Lazy aspect registration** — `AspectContainer::registerAspect()` accepts an aspect class-name plus an optional factory closure (required for aspects with constructor dependencies); such aspects are constructed on first use instead of during `configureAop()`. The signature widened to `registerAspect(Aspect|string $aspectOrClassName, ?Closure $aspectFactory = null)`; instance registration behaves as before. +* [Performance] **Split runtime include map from transformation metadata** — the cache state is written as two files: `_include.cache`, a minimal `originalPath => cacheUri|null` map that the autoloader reads on every request, and `_transformation.cache` with the full build metadata (filemtime etc.), which is now loaded lazily and only on the cache-miss/weaving paths. A legacy cache directory without `_include.cache` keeps working (the map is derived from the metadata once); rebuild via `cache:warmup:aop` to get the split files. New `CachePathManager::queryIncludeMap()` accessor serves the runtime map. * [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. A corrupt advisor cache file falls back to the direct loader without writing (read-only file systems stay safe). * [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. diff --git a/src/Instrument/ClassLoading/AopComposerLoader.php b/src/Instrument/ClassLoading/AopComposerLoader.php index 4b76d937..3f319c98 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -46,11 +46,11 @@ class AopComposerLoader protected Enumerator $fileEnumerator; /** - * Cache state + * Runtime include map: original file path => cached counterpart (null = not transformed) * - * @var array + * @var array */ - private array $cacheState; + private array $includeMap; /** * Was initialization successful or not @@ -89,7 +89,7 @@ public function __construct(ClassLoader $original, AspectContainer $container, a $fileEnumerator = new Enumerator($options['appDir'], $options['includePaths'], $excludePaths); $this->fileEnumerator = $fileEnumerator; - $this->cacheState = $container->getService(CachePathManager::class)->queryCacheState() ?? []; + $this->includeMap = $container->getService(CachePathManager::class)->queryIncludeMap(); } /** @@ -155,12 +155,11 @@ public function findFile(string $class): false|string if (is_string($resolved)) { $file = $resolved; } - $cacheState = $this->cacheState[$file] ?? null; - if ($cacheState && $this->isProduction) { - $cacheUri = is_array($cacheState) && is_string($cacheState['cacheUri'] ?? null) ? $cacheState['cacheUri'] : null; - $file = $cacheUri ?: $file; + if ($this->isProduction && array_key_exists($file, $this->includeMap)) { + // Known file: use its cached counterpart, or the original when untransformed + $file = $this->includeMap[$file] ?? $file; } elseif (($this->isAllowedFilter)(new SplFileInfo($file))) { - // can be optimized here with $cacheState even for debug mode, but no needed right now + // can be optimized here with the include map even for debug mode, but no needed right now $file = FilterInjectorTransformer::rewrite($file); } } diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index 1615e07a..87a38124 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -27,10 +27,15 @@ class CachePathManager { /** - * Name of the file with cache paths + * Name of the file with full transformation metadata (build-time data, loaded lazily) */ private const CACHE_FILE_NAME = '/_transformation.cache'; + /** + * Name of the file with the minimal runtime include map (originalPath => cacheUri|null) + */ + private const INCLUDE_MAP_FILE_NAME = '/_include.cache'; + /** @phpstan-var KernelOptions */ protected array $options; @@ -51,10 +56,26 @@ class CachePathManager /** * Cached metadata for transformation state for the concrete file * + * Loaded lazily from the metadata file: only the cache-miss/weaving paths need it, + * a hot request works from the include map alone. + * * @var array */ protected array $cacheState = []; + /** + * Whether the full transformation metadata was already loaded from its file + */ + private bool $cacheStateLoaded = false; + + /** + * Minimal runtime map of original file path to its cached counterpart + * (null value = file is known but was not transformed) + * + * @var array + */ + protected array $includeMap = []; + /** * New metadata items, that was not present in $cacheState * @@ -91,15 +112,58 @@ public function __construct(AspectKernel $kernel) } } - if (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { - $cacheData = include $this->cacheDir . self::CACHE_FILE_NAME; - if (is_array($cacheData)) { - $this->cacheState = $cacheData; + if (file_exists($this->cacheDir . self::INCLUDE_MAP_FILE_NAME)) { + $includeMap = include $this->cacheDir . self::INCLUDE_MAP_FILE_NAME; + if (is_array($includeMap)) { + foreach ($includeMap as $originalPath => $cacheUri) { + if (is_string($originalPath)) { + $this->includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; + } + } + } + } elseif (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { + // Legacy cache directory (pre-split format): derive the include map from + // the full metadata once; the next flush writes both files + $this->loadCacheState(); + foreach ($this->cacheState as $originalPath => $metadata) { + $cacheUri = is_array($metadata) ? ($metadata['cacheUri'] ?? null) : null; + $this->includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; } } } } + /** + * Loads the full transformation metadata from its file on first demand + */ + private function loadCacheState(): void + { + if ($this->cacheStateLoaded) { + return; + } + $this->cacheStateLoaded = true; + + if ($this->cacheDir !== null && file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { + $cacheData = include $this->cacheDir . self::CACHE_FILE_NAME; + if (is_array($cacheData)) { + $this->cacheState = $cacheData; + } + } + } + + /** + * Returns the minimal runtime map of original file paths to their cached counterparts + * + * A null value means the file is known to the cache but was not transformed. Unlike + * queryCacheState(), this accessor never materializes the full metadata array. + * + * @return array + */ + public function queryIncludeMap(): array + { + return $this->includeMap; + } + /** * Returns current cache directory for aspects, can be null */ @@ -146,6 +210,8 @@ public function getCachePathForResource(string $resource) */ public function queryCacheState(?string $resource = null): ?array { + $this->loadCacheState(); + if ($resource === null) { return $this->cacheState; } @@ -173,6 +239,9 @@ public function queryCacheState(?string $resource = null): ?array public function setCacheState(string $resource, array $metadata): void { $this->newCacheState[$resource] = $metadata; + + $cacheUri = $metadata['cacheUri'] ?? null; + $this->includeMap[$resource] = is_string($cacheUri) ? $cacheUri : null; } /** @@ -191,37 +260,62 @@ public function __destruct() public function flushCacheState(bool $force = false): void { if ((!empty($this->newCacheState) && $this->cacheDir !== null && is_writable($this->cacheDir)) || $force) { - $fullCacheMap = $this->newCacheState + $this->cacheState; - $cachePath = substr(var_export($this->cacheDir, true), 1, -1); - $rootPath = substr(var_export($this->appDir, true), 1, -1); - $cacheData = ' 'AOP_CACHE_DIR . \'', - '\'' . $rootPath => 'AOP_ROOT_DIR . \'' - ] - ); - $fullCacheFileName = $this->cacheDir . self::CACHE_FILE_NAME; - file_put_contents($fullCacheFileName, $cacheData, LOCK_EX); - // For cache files we don't want executable bits by default - chmod($fullCacheFileName, $this->fileMode & (~0111)); - - if (function_exists('opcache_invalidate')) { - opcache_invalidate($fullCacheFileName, true); + // The full metadata must be loaded before merging, otherwise entries that were + // never queried during this request would be dropped from the written file + $this->loadCacheState(); + $fullCacheMap = $this->newCacheState + $this->cacheState; + + $includeMap = []; + foreach ($fullCacheMap as $originalPath => $metadata) { + $cacheUri = is_array($metadata) ? ($metadata['cacheUri'] ?? null) : null; + $includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; } - $this->cacheState = $this->newCacheState + $this->cacheState; + + $this->writeCacheFile(self::CACHE_FILE_NAME, $fullCacheMap); + $this->writeCacheFile(self::INCLUDE_MAP_FILE_NAME, $includeMap); + + $this->cacheState = $fullCacheMap; + $this->includeMap = $includeMap; $this->newCacheState = []; } } + /** + * Writes one cache file as an opcache-friendly PHP return-array with portable paths + * + * @param array $data + */ + private function writeCacheFile(string $relativeFileName, array $data): void + { + $cachePath = substr(var_export($this->cacheDir, true), 1, -1); + $rootPath = substr(var_export($this->appDir, true), 1, -1); + $cacheData = ' 'AOP_CACHE_DIR . \'', + '\'' . $rootPath => 'AOP_ROOT_DIR . \'' + ] + ); + $fullCacheFileName = $this->cacheDir . $relativeFileName; + file_put_contents($fullCacheFileName, $cacheData, LOCK_EX); + // For cache files we don't want executable bits by default + chmod($fullCacheFileName, $this->fileMode & (~0111)); + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($fullCacheFileName, true); + } + } + /** * Clear the cache state. */ public function clearCacheState(): void { - $this->cacheState = []; - $this->newCacheState = []; + $this->cacheState = []; + $this->cacheStateLoaded = true; + $this->includeMap = []; + $this->newCacheState = []; $this->flushCacheState(true); } diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php new file mode 100644 index 00000000..85dd37c9 --- /dev/null +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -0,0 +1,121 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ClassLoading; + +use Go\Core\AspectKernel; +use Go\Core\Container; +use PHPUnit\Framework\TestCase; +use ReflectionProperty; + +// Separate processes: the cache files reference the AOP_ROOT_DIR/AOP_CACHE_DIR constants, +// which other tests in the shared process define with the fixture-project paths +#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +class CachePathManagerTest extends TestCase +{ + private static string $appDir; + private static string $cacheDir; + + public static function setUpBeforeClass(): void + { + // The cache files reference these constants for path portability, so they must + // match the directories used by every test in this class exactly + self::$appDir = sys_get_temp_dir() . '/goaop-cpm-app'; + self::$cacheDir = sys_get_temp_dir() . '/goaop-cpm-cache'; + if (!defined('AOP_ROOT_DIR')) { + define('AOP_ROOT_DIR', self::$appDir); + define('AOP_CACHE_DIR', self::$cacheDir); + } + if (!is_dir(self::$appDir)) { + mkdir(self::$appDir, 0777, true); + } + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0777, true); + } + } + + public static function tearDownAfterClass(): void + { + array_map(unlink(...), glob(self::$cacheDir . '/*') ?: []); + @rmdir(self::$cacheDir); + @rmdir(self::$appDir); + } + + protected function setUp(): void + { + array_map(unlink(...), glob(self::$cacheDir . '/*') ?: []); + } + + private function createManager(): CachePathManager + { + $kernel = $this->createMock(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => false, + 'appDir' => self::$appDir, + 'cacheDir' => self::$cacheDir, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + ]); + $kernel->method('hasFeature')->willReturn(false); + + return new CachePathManager($kernel); + } + + public function testFlushWritesBothFilesAndIncludeMapLoadsWithoutFullMetadata(): void + { + $original = self::$appDir . '/src/Some.php'; + $transformed = self::$cacheDir . '/src/Some.php'; + $known = self::$appDir . '/src/Untransformed.php'; + + $writer = $this->createManager(); + $writer->setCacheState($original, ['filemtime' => 12345, 'cacheUri' => $transformed]); + $writer->setCacheState($known, ['filemtime' => 12345, 'cacheUri' => null]); + $writer->flushCacheState(); + + $this->assertFileExists(self::$cacheDir . '/_transformation.cache'); + $this->assertFileExists(self::$cacheDir . '/_include.cache'); + + $reader = $this->createManager(); + // The include map is available immediately... + $this->assertSame( + [$original => $transformed, $known => null], + $reader->queryIncludeMap() + ); + // ...while the full metadata was not materialized yet (loaded lazily on demand) + $loadedFlag = new ReflectionProperty(CachePathManager::class, 'cacheStateLoaded'); + $this->assertFalse($loadedFlag->getValue($reader), 'Full metadata should not be loaded eagerly'); + + $this->assertSame( + ['filemtime' => 12345, 'cacheUri' => $transformed], + $reader->queryCacheState($original) + ); + $this->assertTrue($loadedFlag->getValue($reader)); + } + + public function testLegacyCacheDirectoryWithoutIncludeMapStillWorks(): void + { + $original = self::$appDir . '/src/Legacy.php'; + $transformed = self::$cacheDir . '/src/Legacy.php'; + + $writer = $this->createManager(); + $writer->setCacheState($original, ['filemtime' => 777, 'cacheUri' => $transformed]); + $writer->flushCacheState(); + unlink(self::$cacheDir . '/_include.cache'); + + $reader = $this->createManager(); + $this->assertSame([$original => $transformed], $reader->queryIncludeMap()); + } +} From 78ab46fcaf756a867080395adb08bf0eb16ff2e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 19:40:25 +0000 Subject: [PATCH 2/3] test: guard cache cleanup against deleting outside test directories Per review: replace glob-sweep deletions with prefix-asserted, known-file removals so a wrong directory value can never erase unrelated files. Applies the same hardening to CachedAspectLoaderTest which used the same pattern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V1Z87HZ2iz23WPTtTYqFxE --- tests/Core/CachedAspectLoaderTest.php | 9 ++++++++- .../ClassLoading/CachePathManagerTest.php | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/Core/CachedAspectLoaderTest.php b/tests/Core/CachedAspectLoaderTest.php index 01ea6a1f..6c5a3744 100644 --- a/tests/Core/CachedAspectLoaderTest.php +++ b/tests/Core/CachedAspectLoaderTest.php @@ -34,7 +34,14 @@ protected function setUp(): void protected function tearDown(): void { - array_map(unlink(...), glob($this->cacheDir . '/_aspect/*') ?: []); + // Guard the cleanup: deletions must stay inside the uniquely-named temp + // directory this test created, whatever happens to the property value + $this->assertStringStartsWith(sys_get_temp_dir() . '/goaop-cached-loader-', $this->cacheDir); + foreach (glob($this->cacheDir . '/_aspect/*') ?: [] as $cacheFile) { + if (is_file($cacheFile)) { + unlink($cacheFile); + } + } @rmdir($this->cacheDir . '/_aspect'); @rmdir($this->cacheDir); } diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php index 85dd37c9..c272d6ee 100644 --- a/tests/Instrument/ClassLoading/CachePathManagerTest.php +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -46,14 +46,28 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { - array_map(unlink(...), glob(self::$cacheDir . '/*') ?: []); + self::removeKnownCacheFiles(); @rmdir(self::$cacheDir); @rmdir(self::$appDir); } protected function setUp(): void { - array_map(unlink(...), glob(self::$cacheDir . '/*') ?: []); + self::removeKnownCacheFiles(); + } + + /** + * Deletes only the exact files this test writes, never a glob/recursive sweep: + * a wrong directory value must not be able to erase anything else + */ + private static function removeKnownCacheFiles(): void + { + self::assertStringStartsWith(sys_get_temp_dir() . '/goaop-cpm-', self::$cacheDir); + foreach (['/_transformation.cache', '/_include.cache'] as $knownFile) { + if (is_file(self::$cacheDir . $knownFile)) { + unlink(self::$cacheDir . $knownFile); + } + } } private function createManager(): CachePathManager From 87f8ef6b4678426b91dc51c8088f776a08d0f8b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:15:22 +0000 Subject: [PATCH 3/3] perf(cache): class-keyed runtime map integrated into composer via addClassMap() Per review confirmation (Option A): WeavingTransformer records the FQCN of every discovered class into the cache metadata; the runtime cache file now 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 through ClassLoader::addClassMap() - composer's findFile() consults the class map before PSR-4, so woven classes resolve natively to their cached files with no wrapper work and no per-class realpath(); untransformed classes are served untouched (kept out of the class map, so nothing is overridden). Cache format bump: a pre-class-map cache directory carries no class names and is treated as stale - everything re-weaves once, or run cache:warmup:aop at deploy time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V1Z87HZ2iz23WPTtTYqFxE --- CHANGELOG.md | 2 +- .../ClassLoading/AopComposerLoader.php | 38 +++-- .../ClassLoading/CachePathManager.php | 143 ++++++++++++++---- .../Transformer/WeavingTransformer.php | 4 + .../ClassLoading/CachePathManagerTest.php | 22 +-- 5 files changed, 158 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16dcf6cd..9e6ac885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Changelog * [Performance] **Direct static joinpoint initialization** — leveraging PHP 8.3+ support for dynamic expressions in static variable initializers, all generated proxy method bodies now initialize their static joinpoint variables directly. * [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] **Lazy aspect registration** — `AspectContainer::registerAspect()` accepts an aspect class-name plus an optional factory closure (required for aspects with constructor dependencies); such aspects are constructed on first use instead of during `configureAop()`. The signature widened to `registerAspect(Aspect|string $aspectOrClassName, ?Closure $aspectFactory = null)`; instance registration behaves as before. -* [Performance] **Split runtime include map from transformation metadata** — the cache state is written as two files: `_include.cache`, a minimal `originalPath => cacheUri|null` map that the autoloader reads on every request, and `_transformation.cache` with the full build metadata (filemtime etc.), which is now loaded lazily and only on the cache-miss/weaving paths. A legacy cache directory without `_include.cache` keeps working (the map is derived from the metadata once); rebuild via `cache:warmup:aop` to get the split files. New `CachePathManager::queryIncludeMap()` accessor serves the runtime map. +* [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. **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] [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. A corrupt advisor cache file falls back to the direct loader without writing (read-only file systems stay safe). * [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. diff --git a/src/Instrument/ClassLoading/AopComposerLoader.php b/src/Instrument/ClassLoading/AopComposerLoader.php index 3f319c98..96b5e170 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -46,11 +46,18 @@ class AopComposerLoader protected Enumerator $fileEnumerator; /** - * Runtime include map: original file path => cached counterpart (null = not transformed) + * Runtime class map: woven class name => cached file (also fed to composer's classmap) * - * @var array + * @var array */ - private array $includeMap; + private array $classMap; + + /** + * Classes known to the cache but not transformed - served natively by composer + * + * @var array + */ + private array $skippedClasses; /** * Was initialization successful or not @@ -89,7 +96,18 @@ public function __construct(ClassLoader $original, AspectContainer $container, a $fileEnumerator = new Enumerator($options['appDir'], $options['includePaths'], $excludePaths); $this->fileEnumerator = $fileEnumerator; - $this->includeMap = $container->getService(CachePathManager::class)->queryIncludeMap(); + + $cachePathManager = $container->getService(CachePathManager::class); + $this->classMap = $cachePathManager->queryClassMap(); + $this->skippedClasses = $cachePathManager->querySkippedClasses(); + + // In production the woven class map is handed to composer directly: its findFile() + // consults the class map before PSR-4/PSR-0, so woven classes resolve natively to + // their cached files. Untransformed classes are deliberately NOT added - composer + // already resolves them to their original files. + if (!$options['debug'] && $this->classMap !== []) { + $original->addClassMap($this->classMap); + } } /** @@ -151,15 +169,17 @@ public function findFile(string $class): false|string $file = $this->original->findFile($class); if ($file !== false) { + if ($this->isProduction && (isset($this->classMap[$class]) || isset($this->skippedClasses[$class]))) { + // Known class: composer already resolved it to the cached file (via the + // injected class map) or to the untouched original - nothing left to do + return $file; + } $resolved = PathResolver::realpath($file); if (is_string($resolved)) { $file = $resolved; } - if ($this->isProduction && array_key_exists($file, $this->includeMap)) { - // Known file: use its cached counterpart, or the original when untransformed - $file = $this->includeMap[$file] ?? $file; - } elseif (($this->isAllowedFilter)(new SplFileInfo($file))) { - // can be optimized here with the include map even for debug mode, but no needed right now + if (($this->isAllowedFilter)(new SplFileInfo($file))) { + // can be optimized here with the class map even for debug mode, but no needed right now $file = FilterInjectorTransformer::rewrite($file); } } diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index 87a38124..35e90363 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -69,12 +69,28 @@ class CachePathManager private bool $cacheStateLoaded = false; /** - * Minimal runtime map of original file path to its cached counterpart - * (null value = file is known but was not transformed) + * Minimal runtime map of woven class name to its cached file, integrateable + * directly into the composer loader via ClassLoader::addClassMap() * - * @var array + * @var array */ - protected array $includeMap = []; + protected array $classMap = []; + + /** + * Set of class names that are known to the cache but were not transformed, + * so the autoloader can serve them natively without any filtering + * + * @var array + */ + protected array $skippedClasses = []; + + /** + * Class names discovered by the weaver per original file, pending until + * setCacheState() folds them into the metadata record + * + * @var array> + */ + private array $pendingClasses = []; /** * New metadata items, that was not present in $cacheState @@ -113,22 +129,29 @@ public function __construct(AspectKernel $kernel) } if (file_exists($this->cacheDir . self::INCLUDE_MAP_FILE_NAME)) { - $includeMap = include $this->cacheDir . self::INCLUDE_MAP_FILE_NAME; - if (is_array($includeMap)) { - foreach ($includeMap as $originalPath => $cacheUri) { - if (is_string($originalPath)) { - $this->includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; + $includeData = include $this->cacheDir . self::INCLUDE_MAP_FILE_NAME; + if (is_array($includeData)) { + $rawClassMap = is_array($includeData['map'] ?? null) ? $includeData['map'] : []; + foreach ($rawClassMap as $className => $cacheUri) { + if (is_string($className) && is_string($cacheUri)) { + /** @var class-string $className */ + $this->classMap[$className] = $cacheUri; + } + } + $rawSkip = is_array($includeData['skip'] ?? null) ? $includeData['skip'] : []; + foreach (array_keys($rawSkip) as $className) { + if (is_string($className)) { + /** @var class-string $className */ + $this->skippedClasses[$className] = true; } } } } elseif (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { - // Legacy cache directory (pre-split format): derive the include map from - // the full metadata once; the next flush writes both files - $this->loadCacheState(); - foreach ($this->cacheState as $originalPath => $metadata) { - $cacheUri = is_array($metadata) ? ($metadata['cacheUri'] ?? null) : null; - $this->includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; - } + // Legacy cache directory (pre-class-map format): the metadata records carry + // no class names, so the cache cannot serve the class map. Treat the whole + // cache as stale - everything re-weaves once and both files are rewritten + // in the new format (or run `cache:warmup:aop` at deploy time). + $this->cacheStateLoaded = true; } } } @@ -152,16 +175,41 @@ private function loadCacheState(): void } /** - * Returns the minimal runtime map of original file paths to their cached counterparts + * Returns the runtime map of woven class names to their cached files + * + * Suitable for direct integration into composer via ClassLoader::addClassMap(). + * Unlike queryCacheState(), this accessor never materializes the full metadata array. + * + * @return array + */ + public function queryClassMap(): array + { + return $this->classMap; + } + + /** + * Returns the set of class names known to the cache but not transformed + * + * The autoloader serves these natively, without any include-path filtering. + * + * @return array + */ + public function querySkippedClasses(): array + { + return $this->skippedClasses; + } + + /** + * Records a class name discovered by the weaver in the given original file * - * A null value means the file is known to the cache but was not transformed. Unlike - * queryCacheState(), this accessor never materializes the full metadata array. + * The pending names are folded into the file's metadata record by setCacheState() + * and become the runtime class map / skip set on flush. * - * @return array + * @param class-string $className */ - public function queryIncludeMap(): array + public function registerClassForResource(string $resource, string $className): void { - return $this->includeMap; + $this->pendingClasses[$resource][] = $className; } /** @@ -238,10 +286,23 @@ public function queryCacheState(?string $resource = null): ?array */ public function setCacheState(string $resource, array $metadata): void { + $classNames = $this->pendingClasses[$resource] ?? []; + unset($this->pendingClasses[$resource]); + $metadata['classes'] = $classNames; + $this->newCacheState[$resource] = $metadata; + // Keep the in-memory runtime map coherent within this request $cacheUri = $metadata['cacheUri'] ?? null; - $this->includeMap[$resource] = is_string($cacheUri) ? $cacheUri : null; + foreach ($classNames as $className) { + if (is_string($cacheUri)) { + $this->classMap[$className] = $cacheUri; + unset($this->skippedClasses[$className]); + } else { + $this->skippedClasses[$className] = true; + unset($this->classMap[$className]); + } + } } /** @@ -265,18 +326,34 @@ public function flushCacheState(bool $force = false): void $this->loadCacheState(); $fullCacheMap = $this->newCacheState + $this->cacheState; - $includeMap = []; - foreach ($fullCacheMap as $originalPath => $metadata) { - $cacheUri = is_array($metadata) ? ($metadata['cacheUri'] ?? null) : null; - $includeMap[$originalPath] = is_string($cacheUri) ? $cacheUri : null; + $classMap = []; + $skippedClasses = []; + foreach ($fullCacheMap as $metadata) { + if (!is_array($metadata)) { + continue; + } + $cacheUri = $metadata['cacheUri'] ?? null; + $classNames = is_array($metadata['classes'] ?? null) ? $metadata['classes'] : []; + foreach ($classNames as $className) { + if (!is_string($className)) { + continue; + } + /** @var class-string $className */ + if (is_string($cacheUri)) { + $classMap[$className] = $cacheUri; + } else { + $skippedClasses[$className] = true; + } + } } $this->writeCacheFile(self::CACHE_FILE_NAME, $fullCacheMap); - $this->writeCacheFile(self::INCLUDE_MAP_FILE_NAME, $includeMap); + $this->writeCacheFile(self::INCLUDE_MAP_FILE_NAME, ['map' => $classMap, 'skip' => $skippedClasses]); - $this->cacheState = $fullCacheMap; - $this->includeMap = $includeMap; - $this->newCacheState = []; + $this->cacheState = $fullCacheMap; + $this->classMap = $classMap; + $this->skippedClasses = $skippedClasses; + $this->newCacheState = []; } } @@ -314,7 +391,9 @@ public function clearCacheState(): void { $this->cacheState = []; $this->cacheStateLoaded = true; - $this->includeMap = []; + $this->classMap = []; + $this->skippedClasses = []; + $this->pendingClasses = []; $this->newCacheState = []; $this->flushCacheState(true); diff --git a/src/Instrument/Transformer/WeavingTransformer.php b/src/Instrument/Transformer/WeavingTransformer.php index 4454bcb3..992565a1 100644 --- a/src/Instrument/Transformer/WeavingTransformer.php +++ b/src/Instrument/Transformer/WeavingTransformer.php @@ -98,6 +98,10 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum foreach ($namespaces as $namespace) { $classes = $namespace->getClasses(); foreach ($classes as $class) { + // Every discovered class (woven or not) is recorded so the runtime class + // map / skip set can be built for the autoloader at flush time + $this->cachePathManager->registerClassForResource($metadata->uri, $class->getName()); + // Skip interfaces and aspects — enums are now supported via EnumProxyGenerator if ($class->isInterface() || in_array(Aspect::class, $class->getInterfaceNames(), true)) { continue; diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php index c272d6ee..a469532b 100644 --- a/tests/Instrument/ClassLoading/CachePathManagerTest.php +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -88,14 +88,16 @@ private function createManager(): CachePathManager return new CachePathManager($kernel); } - public function testFlushWritesBothFilesAndIncludeMapLoadsWithoutFullMetadata(): void + public function testFlushWritesBothFilesAndClassMapLoadsWithoutFullMetadata(): void { $original = self::$appDir . '/src/Some.php'; $transformed = self::$cacheDir . '/src/Some.php'; $known = self::$appDir . '/src/Untransformed.php'; $writer = $this->createManager(); + $writer->registerClassForResource($original, 'App\Some'); $writer->setCacheState($original, ['filemtime' => 12345, 'cacheUri' => $transformed]); + $writer->registerClassForResource($known, 'App\Untransformed'); $writer->setCacheState($known, ['filemtime' => 12345, 'cacheUri' => null]); $writer->flushCacheState(); @@ -103,23 +105,21 @@ public function testFlushWritesBothFilesAndIncludeMapLoadsWithoutFullMetadata(): $this->assertFileExists(self::$cacheDir . '/_include.cache'); $reader = $this->createManager(); - // The include map is available immediately... - $this->assertSame( - [$original => $transformed, $known => null], - $reader->queryIncludeMap() - ); + // The runtime class map and skip set are available immediately... + $this->assertSame(['App\Some' => $transformed], $reader->queryClassMap()); + $this->assertSame(['App\Untransformed' => true], $reader->querySkippedClasses()); // ...while the full metadata was not materialized yet (loaded lazily on demand) $loadedFlag = new ReflectionProperty(CachePathManager::class, 'cacheStateLoaded'); $this->assertFalse($loadedFlag->getValue($reader), 'Full metadata should not be loaded eagerly'); $this->assertSame( - ['filemtime' => 12345, 'cacheUri' => $transformed], + ['filemtime' => 12345, 'cacheUri' => $transformed, 'classes' => ['App\Some']], $reader->queryCacheState($original) ); $this->assertTrue($loadedFlag->getValue($reader)); } - public function testLegacyCacheDirectoryWithoutIncludeMapStillWorks(): void + public function testLegacyCacheDirectoryWithoutClassMapIsTreatedAsStale(): void { $original = self::$appDir . '/src/Legacy.php'; $transformed = self::$cacheDir . '/src/Legacy.php'; @@ -129,7 +129,11 @@ public function testLegacyCacheDirectoryWithoutIncludeMapStillWorks(): void $writer->flushCacheState(); unlink(self::$cacheDir . '/_include.cache'); + // Pre-class-map cache directories carry no class names, so the metadata is + // ignored entirely: everything re-weaves once and both files are rewritten $reader = $this->createManager(); - $this->assertSame([$original => $transformed], $reader->queryIncludeMap()); + $this->assertSame([], $reader->queryClassMap()); + $this->assertSame([], $reader->querySkippedClasses()); + $this->assertNull($reader->queryCacheState($original)); } }