From 561bf34b192d64da3cb93793e10b5cb42c7e5de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:56:35 +0000 Subject: [PATCH 1/2] perf(instrument): include known files natively in production With Features::INTERCEPT_INCLUDES every include inside woven code went through FilterInjectorTransformer::rewrite(), which loaded the full _transformation.cache, stat-ed a cache path that does not exist for an untransformed file and fell back to php://filter: the file was filtered, checked for freshness and compiled again on every request (opcache never caches php://filter includes). _include.cache now also carries a 'files' map: every source known to the cache (path relative to the application root) => cached file (__DIR__ . '/...') or null when untransformed. In production rewrite() serves an include of a known source like the class map serves a known class: the original file when untransformed, the recorded cached file when woven, with no filter, no file_exists/freshness check and no load of the full metadata. Absolute paths with relative segments (__DIR__ . '/../x.php') are resolved before the lookup. Unknown files and debug mode still go through php://filter. CachePathManager::FORMAT_VERSION is bumped to 4 (an outdated PREBUILT_CACHE is rejected as before), the map follows a moved cache directory like the class map. Measured on a warm production request through php -S with opcache (JIT off), 20 includes of 150-line untransformed files + 1 woven file, base/head interleaved, 25 rounds x2, medians: - 20 untransformed includes: 5021-5286 us -> 210-224 us (~255 -> ~11 us per include) - woven include (which includes one more file): 231-243 us -> 6 us - whole request: 5874-5927 us -> 652-653 us - _transformation.cache loaded on a warm request: 25/25 -> 0/25 Transformation performance group (8 interleaved runs, alternating order): all head ratios within noise (-1.7%, +7.6%, +0.5%). Closes #746 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c --- CHANGELOG.md | 1 + UPGRADE-4.0.md | 3 +- src/Instrument/AGENTS.md | 2 +- .../ClassLoading/CachePathManager.php | 48 ++++++- .../Transformer/FilterInjectorTransformer.php | 14 ++ tests/Fixtures/project/bin/includes.php | 24 ++++ .../project/src/Application/FileIncluder.php | 21 +++ .../src/Application/includes/plain-file.php | 5 + .../src/Application/includes/woven-file.php | 5 + tests/Fixtures/project/web/configuration.php | 28 ++++ tests/Functional/IncludeInterceptionTest.php | 127 ++++++++++++++++++ .../ClassLoading/CachePathManagerTest.php | 49 +++++++ .../FilterInjectorTransformerTest.php | 75 +++++++++++ 13 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/project/bin/includes.php create mode 100644 tests/Fixtures/project/src/Application/FileIncluder.php create mode 100644 tests/Fixtures/project/src/Application/includes/plain-file.php create mode 100644 tests/Fixtures/project/src/Application/includes/woven-file.php create mode 100644 tests/Functional/IncludeInterceptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fcd9292..a8e1d70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()`). diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md index e21924d8..bcf47ccd 100644 --- a/UPGRADE-4.0.md +++ b/UPGRADE-4.0.md @@ -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 `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 diff --git a/src/Instrument/AGENTS.md b/src/Instrument/AGENTS.md index e976d8b0..482cc0c2 100644 --- a/src/Instrument/AGENTS.md +++ b/src/Instrument/AGENTS.md @@ -11,7 +11,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=` include to ) 2. WeavingTransformer — main; AdviceMatcher + CachedAspectLoader → proxy generators. Sees the token edits of the rules. diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index d5883383..0fcedc14 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -34,7 +34,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'; @@ -46,7 +46,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; @@ -96,6 +96,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 + */ + private array $fileMap = []; + /** * Class names discovered by the weaver per original file, pending until * setCacheState() folds them into the metadata record @@ -151,9 +161,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, skip: array} $includeData */ + /** @var array{map: array, skip: array, files: array} $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 @@ -239,6 +250,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 * @@ -322,10 +350,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; @@ -377,11 +407,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)) { @@ -399,12 +431,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 = []; } } @@ -445,6 +478,7 @@ public function clearCacheState(): void $this->cacheStateLoaded = true; $this->classMap = []; $this->skippedClasses = []; + $this->fileMap = []; $this->pendingClasses = []; $this->newCacheState = []; diff --git a/src/Instrument/Transformer/FilterInjectorTransformer.php b/src/Instrument/Transformer/FilterInjectorTransformer.php index b6dd92ec..7dcad7f1 100644 --- a/src/Instrument/Transformer/FilterInjectorTransformer.php +++ b/src/Instrument/Transformer/FilterInjectorTransformer.php @@ -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 diff --git a/tests/Fixtures/project/bin/includes.php b/tests/Fixtures/project/bin/includes.php new file mode 100644 index 00000000..38527072 --- /dev/null +++ b/tests/Fixtures/project/bin/includes.php @@ -0,0 +1,24 @@ +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, +); diff --git a/tests/Fixtures/project/src/Application/FileIncluder.php b/tests/Fixtures/project/src/Application/FileIncluder.php new file mode 100644 index 00000000..a9b8b3b0 --- /dev/null +++ b/tests/Fixtures/project/src/Application/FileIncluder.php @@ -0,0 +1,21 @@ + + */ + public function includeFiles(): array + { + return [ + 'plain' => require __DIR__ . '/includes/plain-file.php', + 'woven' => require __DIR__ . '/includes/woven-file.php', + ]; + } +} diff --git a/tests/Fixtures/project/src/Application/includes/plain-file.php b/tests/Fixtures/project/src/Application/includes/plain-file.php new file mode 100644 index 00000000..1dd255ce --- /dev/null +++ b/tests/Fixtures/project/src/Application/includes/plain-file.php @@ -0,0 +1,5 @@ + __FILE__]; diff --git a/tests/Fixtures/project/src/Application/includes/woven-file.php b/tests/Fixtures/project/src/Application/includes/woven-file.php new file mode 100644 index 00000000..ec4b2445 --- /dev/null +++ b/tests/Fixtures/project/src/Application/includes/woven-file.php @@ -0,0 +1,5 @@ + __FILE__, 'plain' => require __DIR__ . '/../includes/plain-file.php']; diff --git a/tests/Fixtures/project/web/configuration.php b/tests/Fixtures/project/web/configuration.php index 042b6a27..49d5d131 100644 --- a/tests/Fixtures/project/web/configuration.php +++ b/tests/Fixtures/project/web/configuration.php @@ -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', diff --git a/tests/Functional/IncludeInterceptionTest.php b/tests/Functional/IncludeInterceptionTest.php new file mode 100644 index 00000000..0627f9d6 --- /dev/null +++ b/tests/Functional/IncludeInterceptionTest.php @@ -0,0 +1,127 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Functional; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Intercepted includes of files known to the cache are served natively in production (issue #746) + * + * An untransformed file is included from its original location, a woven file from its cached file: no + * `php://filter` include (opcache never caches those) and no load of the full `_transformation.cache`. + */ +class IncludeInterceptionTest extends BaseFunctionalTestCase +{ + private const string MOVED_CONFIGURATION = 'moved_includes_cache'; + + private string $includesDir; + + private string $movedCacheDir; + + public function setUp(): void + { + parent::setUp(); + $includesDir = realpath(__DIR__ . '/../Fixtures/project/src/Application/includes'); + $this->assertIsString($includesDir); + $this->includesDir = $includesDir; + $this->movedCacheDir = $this->configuration['cacheDir'] . '-moved'; + (new Filesystem())->remove($this->movedCacheDir); + } + + public function tearDown(): void + { + (new Filesystem())->remove($this->movedCacheDir); + parent::tearDown(); + } + + public function testKnownFilesAreIncludedNativelyOnEveryRequest(): void + { + $cacheDir = realpath($this->configuration['cacheDir']); + $this->assertIsString($cacheDir); + + // The cache was warmed up in setUp(): both requests are served from the cache + $this->assertIncludedNatively($cacheDir, $this->runScript($this->getConfigurationName())); + $this->assertIncludedNatively($cacheDir, $this->runScript($this->getConfigurationName())); + } + + public function testKnownFilesAreIncludedNativelyFromMovedPrebuiltCache(): void + { + (new Filesystem())->mirror($this->configuration['cacheDir'], $this->movedCacheDir); + // Without the original cache only the moved copy can serve the woven files + $this->clearCache(); + $movedCacheDir = realpath($this->movedCacheDir); + $this->assertIsString($movedCacheDir); + + $this->assertIncludedNatively($movedCacheDir, $this->runScript(self::MOVED_CONFIGURATION)); + $this->assertDirectoryDoesNotExist($this->configuration['cacheDir'], 'Nothing may be woven again'); + } + + protected function getConfigurationName(): string + { + return 'production_includes'; + } + + /** + * @param array $output + */ + private function assertIncludedNatively(string $cacheDir, array $output): void + { + $plainFile = $this->includesDir . DIRECTORY_SEPARATOR . 'plain-file.php'; + $wovenFile = $this->includesDir . DIRECTORY_SEPARATOR . 'woven-file.php'; + // Magic constants resolve to the original location, wherever the file was included from + $this->assertSame( + ['plain' => ['file' => $plainFile], 'woven' => ['file' => $wovenFile, 'plain' => ['file' => $plainFile]]], + $output['result'] ?? null, + ); + + $cachedWovenFile = $cacheDir . '/src/Application/includes/woven-file.php'; + $this->assertSame( + ['plain-file.php' => $plainFile, 'woven-file.php' => $cachedWovenFile], + $output['rewrites'] ?? null, + 'Known files are included natively, the untransformed one from its location, not through the filter', + ); + + $includedFiles = $output['includedFiles'] ?? null; + $this->assertIsArray($includedFiles); + $this->assertContains($plainFile, $includedFiles); + $this->assertContains($cachedWovenFile, $includedFiles, 'The woven file is included from its cached file'); + $this->assertNotContains($wovenFile, $includedFiles); + foreach ($includedFiles as $includedFile) { + $this->assertIsString($includedFile); + $this->assertStringEndsNotWith('_transformation.cache', $includedFile, 'The full metadata is not needed'); + } + } + + /** + * @return array + */ + private function runScript(string $configurationName): array + { + $phpExecutable = (new PhpExecutableFinder())->find(); + $this->assertIsString($phpExecutable); + $process = new Process( + [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/includes.php'], + null, + ['GO_AOP_CONFIGURATION' => $configurationName], + ); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($result); + + return $result; + } +} diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php index 5ba96667..91c88eba 100644 --- a/tests/Instrument/ClassLoading/CachePathManagerTest.php +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -96,6 +96,54 @@ public function testFlushWritesBothFilesAndClassMapLoadsWithoutFullMetadata(): v $this->assertTrue($loadedFlag->getValue($reader)); } + public function testIncludeMapServesKnownFilesWithoutFullMetadata(): void + { + $woven = self::$appDir . '/src/woven.php'; + $cached = self::$cacheDir . '/src/woven.php'; + $untransformed = self::$appDir . '/templates/plain.php'; + $outside = '/outside/the/application/plain.php'; + + $writer = $this->createManager(); + $writer->setCacheState($woven, ['filemtime' => 1, 'cacheUri' => $cached]); + $writer->setCacheState($untransformed, ['filemtime' => 1, 'cacheUri' => null]); + $writer->setCacheState($outside, ['filemtime' => 1, 'cacheUri' => null]); + // Known within the request that recorded them, before any flush + $this->assertSame($cached, $writer->findIncludeFile($woven)); + $this->assertSame($untransformed, $writer->findIncludeFile($untransformed)); + $writer->flushCacheState(); + + $content = file_get_contents(self::$cacheDir . '/_include.cache'); + $this->assertIsString($content); + $this->assertStringContainsString("'/src/woven.php' => __DIR__ . '/src/woven.php'", $content); + $this->assertStringContainsString("'/templates/plain.php' => NULL", $content); + + $reader = $this->createManager(); + // The woven file is served from its cached file, an untransformed one as it is + $this->assertSame($cached, $reader->findIncludeFile($woven)); + $this->assertSame($untransformed, $reader->findIncludeFile($untransformed)); + $this->assertSame($outside, $reader->findIncludeFile($outside)); + // An unknown file is not answered by the include map + $this->assertNull($reader->findIncludeFile(self::$appDir . '/templates/unknown.php')); + $loadedFlag = new ReflectionProperty(CachePathManager::class, 'cacheStateLoaded'); + $this->assertFalse($loadedFlag->getValue($reader), 'Full metadata should not be loaded for includes'); + } + + public function testRecordedFileChangesItsIncludeFileWithinTheRequest(): void + { + $original = self::$appDir . '/src/rewoven.php'; + $cached = self::$cacheDir . '/src/rewoven.php'; + + $manager = $this->createManager(); + $manager->setCacheState($original, ['filemtime' => 1, 'cacheUri' => null]); + $manager->flushCacheState(); + $manager->setCacheState($original, ['filemtime' => 2, 'cacheUri' => $cached]); + + $this->assertSame($cached, $manager->findIncludeFile($original)); + + $manager->clearCacheState(); + $this->assertNull($manager->findIncludeFile($original)); + } + public function testLegacyCacheDirectoryWithoutClassMapIsTreatedAsStale(): void { $original = self::$appDir . '/src/Legacy.php'; @@ -245,6 +293,7 @@ public function testRecordsAreFoundWhenTheApplicationAndTheCacheAreMoved(): void $reader->queryCacheState($movedAppDir . '/src/Moved.php'), ); $this->assertSame($movedCacheDir . '/src/Moved.php', $reader->getCachePathForResource($movedAppDir . '/src/Moved.php')); + $this->assertSame($movedCacheDir . '/src/Moved.php', $reader->findIncludeFile($movedAppDir . '/src/Moved.php')); } finally { self::removeTemporaryDirectory($movedCacheDir); } diff --git a/tests/Instrument/Transformer/FilterInjectorTransformerTest.php b/tests/Instrument/Transformer/FilterInjectorTransformerTest.php index 691c9492..cac175e4 100644 --- a/tests/Instrument/Transformer/FilterInjectorTransformerTest.php +++ b/tests/Instrument/Transformer/FilterInjectorTransformerTest.php @@ -158,6 +158,81 @@ public function testCanRewriteRelativePathsWithFilter(): void $this->assertEquals($expectedPath, $actualPath); } + /** + * Configures the rewriting with a cache directory and the given cache manager + */ + private function configureWithCache(CachePathManager $cachePathManager, bool $debug = false): void + { + FilterInjectorTransformer::reset(); + new FilterInjectorTransformer( + $this->getKernelMock( + ['cacheDir' => '/cache', 'appDir' => '/app', 'debug' => $debug, 'features' => 0], + $this->createStub(AspectContainer::class), + ), + 'unit.test.cache', + $cachePathManager, + ); + } + + public function testProductionIncludesSourceRecordedAsUntransformedNatively(): void + { + $resolvedPath = PathResolver::realpath(__DIR__ . '/_files/class.php'); + $cachePathManager = $this->createMock(CachePathManager::class); + $cachePathManager->expects($this->once())->method('findIncludeFile')->with($resolvedPath)->willReturnArgument(0); + $cachePathManager->expects($this->never())->method('getCachePathForResource'); + $this->configureWithCache($cachePathManager); + + // The relative resource is resolved first, the original file is then included without the filter + $this->assertSame($resolvedPath, FilterInjectorTransformer::rewrite('_files/class.php', __DIR__)); + } + + public function testProductionResolvesRelativeSegmentsOfAbsolutePathBeforeLookingItUp(): void + { + $resolvedPath = PathResolver::realpath(__DIR__ . '/_files/class.php'); + $cachePathManager = $this->createMock(CachePathManager::class); + $cachePathManager->expects($this->once())->method('findIncludeFile')->with($resolvedPath)->willReturnArgument(0); + $this->configureWithCache($cachePathManager); + + $this->assertSame($resolvedPath, FilterInjectorTransformer::rewrite(__DIR__ . '/../Transformer/./_files/class.php')); + } + + public function testProductionIncludesRecordedCacheFileOfWovenSourceWithoutCheckingIt(): void + { + $cachePathManager = $this->createMock(CachePathManager::class); + $cachePathManager->expects($this->once())->method('findIncludeFile')->with('/app/src/woven.php')->willReturn('/cache/src/woven.php'); + $cachePathManager->expects($this->never())->method('getCachePathForResource'); + $this->configureWithCache($cachePathManager); + + // The recorded cache file is trusted as it is: it does not even exist here + $this->assertSame('/cache/src/woven.php', FilterInjectorTransformer::rewrite('/app/src/woven.php')); + } + + public function testProductionIncludesUnknownSourceThroughTheFilter(): void + { + $cachePathManager = $this->createStub(CachePathManager::class); + $cachePathManager->method('findIncludeFile')->willReturn(null); + $cachePathManager->method('getCachePathForResource')->willReturn('/cache/src/unknown.php'); + $this->configureWithCache($cachePathManager); + + $this->assertSame( + FilterInjectorTransformer::PHP_FILTER_READ . 'unit.test.cache/resource=/app/src/unknown.php', + FilterInjectorTransformer::rewrite('/app/src/unknown.php'), + ); + } + + public function testDebugModeIncludesKnownSourceThroughTheFilter(): void + { + $cachePathManager = $this->createMock(CachePathManager::class); + $cachePathManager->expects($this->never())->method('findIncludeFile'); + $cachePathManager->method('getCachePathForResource')->willReturn('/cache/src/woven.php'); + $this->configureWithCache($cachePathManager, debug: true); + + $this->assertSame( + FilterInjectorTransformer::PHP_FILTER_READ . 'unit.test.cache/resource=/app/src/woven.php', + FilterInjectorTransformer::rewrite('/app/src/woven.php'), + ); + } + public function testResetForgetsTheConfiguredFilter(): void { FilterInjectorTransformer::reset(); From ba7be192656083fa32ae5b4c547cb4dc88d1ac39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:41 +0000 Subject: [PATCH 2/2] test(instrument): expect the native separators of the recorded cached include On Windows the cached file recorded for a woven include keeps the native separators of the woven source path, so the functional test builds the expected path with DIRECTORY_SEPARATOR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c --- tests/Functional/IncludeInterceptionTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Functional/IncludeInterceptionTest.php b/tests/Functional/IncludeInterceptionTest.php index 0627f9d6..6e8e88ce 100644 --- a/tests/Functional/IncludeInterceptionTest.php +++ b/tests/Functional/IncludeInterceptionTest.php @@ -86,7 +86,8 @@ private function assertIncludedNatively(string $cacheDir, array $output): void $output['result'] ?? null, ); - $cachedWovenFile = $cacheDir . '/src/Application/includes/woven-file.php'; + // The recorded cached file keeps the native separators of the woven source path + $cachedWovenFile = $cacheDir . str_replace('/', DIRECTORY_SEPARATOR, '/src/Application/includes/woven-file.php'); $this->assertSame( ['plain-file.php' => $plainFile, 'woven-file.php' => $cachedWovenFile], $output['rewrites'] ?? null,