From 79bb38dedc644b7d6b64312f4480fbc1a690c456 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 17:23:54 +0200 Subject: [PATCH 1/4] Store result cache paths relative to the install location behind a toggle The result cache stores absolute paths in its meta, keys and stored objects, and compares metadata with a strict whole-array match, so a changed absolute prefix (a fresh CI checkout dir, a git worktree) throws the whole cache away even when the relative layout is identical. Add a bleeding-edge featureToggle, relativePathResultCache, that stores the paths relative to the phpstan install (%rootDir%) and re-absolutizes them against the current install on load. Only paths reachable from the anchor become relative; the rest stay absolute, following ccache's rule. Error gains relativizePaths()/absolutizePaths(), building on its existing immutable changeFilePath() pattern, and a new ResultCachePathTransformer handles the rest of the cache structure at the save/restore boundary. The toggle state is folded into the cache meta and CACHE_VERSION is bumped so flipping it or upgrading migrates with one cold run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 12 + conf/bleedingEdge.neon | 1 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + e2e/result-cache-relative-path/.gitignore | 1 + e2e/result-cache-relative-path/phpstan.neon | 7 + .../src/HelloWorld.php | 13 + src/Analyser/Error.php | 46 +++ .../ResultCache/ResultCacheManager.php | 65 ++- .../ResultCachePathTransformer.php | 386 ++++++++++++++++++ .../ResultCachePathTransformerTest.php | 195 +++++++++ 11 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 e2e/result-cache-relative-path/.gitignore create mode 100644 e2e/result-cache-relative-path/phpstan.neon create mode 100644 e2e/result-cache-relative-path/src/HelloWorld.php create mode 100644 src/Analyser/ResultCache/ResultCachePathTransformer.php create mode 100644 tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 11504b44489..bc2ec5e4e2e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -410,6 +410,18 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'Composer metadata changed but no package versions changed; keeping the result cache.' "$OUTPUT" ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + - script: | + cd e2e/result-cache-relative-path + # Cold run with the relativePathResultCache toggle on: paths are stored relative to the + # phpstan install (the anchor), so the cache no longer embeds the absolute checkout path. + ../../bin/phpstan analyse + ../bashunit -a contains "'e2e/result-cache-relative-path/src/HelloWorld.php'" "$(cat tmp/resultCache.php)" + # the analysed file must NOT be stored under its absolute checkout path + if grep -q "'$(pwd)/src/HelloWorld.php'" tmp/resultCache.php; then echo 'cache still holds an absolute analysed path'; exit 1; fi + # Warm run: the relative cache re-absolutizes against the current anchor and is fully reused. + OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" - script: | cd e2e/result-cache-package-update composer install diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index ecae15e4ac1..6076ee8439c 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -25,3 +25,4 @@ parameters: unnecessaryNullCoalesce: true finiteTypesInHaystack: true switchConditionAlwaysFalse: true + relativePathResultCache: true diff --git a/conf/config.neon b/conf/config.neon index 8fc949c9361..f9251165203 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -56,6 +56,7 @@ parameters: unnecessaryNullCoalesce: false finiteTypesInHaystack: false switchConditionAlwaysFalse: false + relativePathResultCache: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 953bab24371..4442a226686 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -54,6 +54,7 @@ parametersSchema: unnecessaryNullCoalesce: bool() finiteTypesInHaystack: bool() switchConditionAlwaysFalse: bool() + relativePathResultCache: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/e2e/result-cache-relative-path/.gitignore b/e2e/result-cache-relative-path/.gitignore new file mode 100644 index 00000000000..ceeb05b4108 --- /dev/null +++ b/e2e/result-cache-relative-path/.gitignore @@ -0,0 +1 @@ +/tmp diff --git a/e2e/result-cache-relative-path/phpstan.neon b/e2e/result-cache-relative-path/phpstan.neon new file mode 100644 index 00000000000..efed8568d39 --- /dev/null +++ b/e2e/result-cache-relative-path/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + featureToggles: + relativePathResultCache: true diff --git a/e2e/result-cache-relative-path/src/HelloWorld.php b/e2e/result-cache-relative-path/src/HelloWorld.php new file mode 100644 index 00000000000..30af0ac3f60 --- /dev/null +++ b/e2e/result-cache-relative-path/src/HelloWorld.php @@ -0,0 +1,13 @@ +traitFilePath; } + /** + * Rewrites the absolute paths this error carries to paths relative to the helper's base, + * for portable storage in the result cache. Inverse of absolutizePaths(). + */ + public function relativizePaths(RelativePathHelper $relativePathHelper): self + { + return new self( + $this->message, + $relativePathHelper->getRelativePath($this->file), + $this->line, + $this->canBeIgnored, + $this->filePath === null ? null : $relativePathHelper->getRelativePath($this->filePath), + $this->traitFilePath === null ? null : $relativePathHelper->getRelativePath($this->traitFilePath), + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + $this->fixedErrorDiff, + ); + } + + /** + * Rewrites the relative paths stored by relativizePaths() back to absolute paths against + * the helper's base. Inverse of relativizePaths(). + */ + public function absolutizePaths(FileHelper $fileHelper): self + { + return new self( + $this->message, + $fileHelper->normalizePath($fileHelper->absolutizePath($this->file)), + $this->line, + $this->canBeIgnored, + $this->filePath === null ? null : $fileHelper->normalizePath($fileHelper->absolutizePath($this->filePath)), + $this->traitFilePath === null ? null : $fileHelper->normalizePath($fileHelper->absolutizePath($this->traitFilePath)), + $this->tip, + $this->nodeLine, + $this->nodeType, + $this->identifier, + $this->metadata, + $this->fixedErrorDiff, + ); + } + public function getLine(): ?int { return $this->line; diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 51736cb0292..6aaba099ef8 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -70,11 +70,13 @@ final class ResultCacheManager { - private const CACHE_VERSION = 'v13-packageDependencies'; + private const CACHE_VERSION = 'v14-relativePaths'; /** @var array */ private array $fileHashes = []; + private ?ResultCachePathTransformer $pathTransformer = null; + /** @var array */ private array $alreadyProcessed = []; @@ -123,10 +125,19 @@ public function __construct( private array $parametersNotInvalidatingCache, #[AutowiredParameter(ref: '%resultCacheSkipIfOlderThanDays%')] private int $skipResultCacheIfOlderThanDays, + #[AutowiredParameter(ref: '%rootDir%')] + private string $anchorDirectory, + #[AutowiredParameter(ref: '%featureToggles.relativePathResultCache%')] + private bool $relativePathResultCache, ) { } + private function getPathTransformer(): ResultCachePathTransformer + { + return $this->pathTransformer ??= new ResultCachePathTransformer($this->anchorDirectory); + } + /** * @param string[] $allAnalysedFiles * @param mixed[]|null $projectConfigArray @@ -263,6 +274,30 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } + if (($data['meta']['relativePaths'] ?? false) === true) { + // The cache was written with paths relative to the anchor directory. Re-absolutize them + // against the current anchor before anything reads them, so a moved project (a fresh CI + // checkout dir, a git worktree) resolves to its new location. projectConfig stays a relative + // Neon string here; isMetaDifferent()/getMetaKeyDifferences() relativize the current side to + // compare. Gated on the cached flag, not the current toggle, so an old cache is left untouched. + $transformer = $this->getPathTransformer(); + $data['meta'] = $transformer->absolutizeMeta($data['meta']); + $data['projectExtensionFiles'] = $transformer->absolutizeFileKeyed($data['projectExtensionFiles']); + $data['linesToIgnore'] = $transformer->absolutizeCompoundKeyed($data['linesToIgnore']); + $data['unmatchedLineIgnores'] = $transformer->absolutizeCompoundKeyed($data['unmatchedLineIgnores']); + $data['dependencies'] = $transformer->absolutizeDependencies($data['dependencies']); + $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); + + $errorsCallback = $data['errorsCallback']; + $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); + $locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback']; + $data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback()); + $collectedDataCallback = $data['collectedDataCallback']; + $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($collectedDataCallback()); + $exportedNodesCallback = $data['exportedNodesCallback']; + $data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback()); + } + $meta = $this->getMeta($allAnalysedFiles, $projectConfigArray); $packageDependencies = $data['packageDependencies'] ?? []; $packageSeededFiles = []; @@ -636,6 +671,10 @@ private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); + if ($this->relativePathResultCache) { + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); + } + $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -657,6 +696,10 @@ private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): a if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); + if ($this->relativePathResultCache) { + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); + } + $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -740,6 +783,9 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $meta = $resultCache->getMeta(); $projectConfigArray = $meta['projectConfig']; if ($projectConfigArray !== null) { + if ($this->relativePathResultCache) { + $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); + } $meta['projectConfig'] = Neon::encode($projectConfigArray); } $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $packageDependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { @@ -1210,6 +1256,22 @@ private function save( ksort($exportedNodes); + if ($this->relativePathResultCache) { + $transformer = $this->getPathTransformer(); + // projectConfig inside $meta is already a Neon-encoded string here (encoded in process()), + // so it is relativized at the array level before that encode; only the other meta paths remain. + $meta = $transformer->relativizeMeta($meta); + $errors = $transformer->relativizeErrors($errors); + $locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors); + $linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore); + $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); + $collectedData = $transformer->relativizeFileKeyed($collectedData); + $invertedDependencies = $transformer->relativizeDependencies($invertedDependencies); + $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); + $exportedNodes = $transformer->relativizeFileKeyed($exportedNodes); + $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); + } + $file = $this->cacheFilePath; // streamed to the file section by section - building the whole @@ -1457,6 +1519,7 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a return [ 'cacheVersion' => self::CACHE_VERSION, + 'relativePaths' => $this->relativePathResultCache, 'phpstanVersion' => ComposerHelper::getPhpStanVersion(), 'fnsr' => $fnsr, 'metaExtensions' => $this->getMetaFromPhpStanExtensions(), diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php new file mode 100644 index 00000000000..c1a45406fb0 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -0,0 +1,386 @@ +relativePathHelper = new ParentDirectoryRelativePathHelper($anchorDirectory); + $this->anchorFileHelper = new FileHelper($anchorDirectory); + } + + public function relativizePath(string $path): string + { + if (!$this->isAbsolutePath($path)) { + return $path; + } + + return $this->relativePathHelper->getRelativePath($path); + } + + public function absolutizePath(string $path): string + { + return $this->anchorFileHelper->normalizePath($this->anchorFileHelper->absolutizePath($path)); + } + + /** + * @param array> $errorsByFile + * @return array> + */ + public function relativizeErrors(array $errorsByFile): array + { + $result = []; + foreach ($errorsByFile as $file => $errors) { + $relativized = []; + foreach ($errors as $error) { + $relativized[] = $error->relativizePaths($this->relativePathHelper); + } + $result[$this->relativizePath($file)] = $relativized; + } + + return $result; + } + + /** + * @param array> $errorsByFile + * @return array> + */ + public function absolutizeErrors(array $errorsByFile): array + { + $result = []; + foreach ($errorsByFile as $file => $errors) { + $absolutized = []; + foreach ($errors as $error) { + $absolutized[] = $error->absolutizePaths($this->anchorFileHelper); + } + $result[$this->absolutizePath($file)] = $absolutized; + } + + return $result; + } + + /** + * Rewrites only the top-level file-path keys, leaving the values untouched. Used for sections whose + * values carry no paths: collectedData, packageDependencies, exportedNodes, projectExtensionFiles. + * + * @param array $byFile + * @return array + */ + public function relativizeFileKeyed(array $byFile): array + { + $result = []; + foreach ($byFile as $file => $value) { + $result[$this->relativizePath($file)] = $value; + } + + return $result; + } + + /** + * @param array $byFile + * @return array + */ + public function absolutizeFileKeyed(array $byFile): array + { + $result = []; + foreach ($byFile as $file => $value) { + $result[$this->absolutizePath($file)] = $value; + } + + return $result; + } + + /** + * linesToIgnore/unmatchedLineIgnores: outer keys are plain file paths, inner keys are a file path + * OR a compound "path (in context of class X)"; leaf values carry no paths. + * + * @param array $byFile + * @return array + */ + public function relativizeCompoundKeyed(array $byFile): array + { + $result = []; + foreach ($byFile as $file => $inner) { + $relativizedInner = []; + foreach ($inner as $innerKey => $value) { + $relativizedInner[$this->relativizeCompoundKey((string) $innerKey)] = $value; + } + $result[$this->relativizePath($file)] = $relativizedInner; + } + + return $result; + } + + /** + * @param array $byFile + * @return array + */ + public function absolutizeCompoundKeyed(array $byFile): array + { + $result = []; + foreach ($byFile as $file => $inner) { + $absolutizedInner = []; + foreach ($inner as $innerKey => $value) { + $absolutizedInner[$this->absolutizeCompoundKey((string) $innerKey)] = $value; + } + $result[$this->absolutizePath($file)] = $absolutizedInner; + } + + return $result; + } + + /** + * @param array, usedTraitDependentFiles?: list}> $dependencies + * @return array, usedTraitDependentFiles?: list}> + */ + public function relativizeDependencies(array $dependencies): array + { + $result = []; + foreach ($dependencies as $file => $data) { + $data['dependentFiles'] = $this->relativizeList($data['dependentFiles']); + if (array_key_exists('usedTraitDependentFiles', $data)) { + $data['usedTraitDependentFiles'] = $this->relativizeList($data['usedTraitDependentFiles']); + } + $result[$this->relativizePath($file)] = $data; + } + + return $result; + } + + /** + * @param array, usedTraitDependentFiles?: list}> $dependencies + * @return array, usedTraitDependentFiles?: list}> + */ + public function absolutizeDependencies(array $dependencies): array + { + $result = []; + foreach ($dependencies as $file => $data) { + $data['dependentFiles'] = $this->absolutizeList($data['dependentFiles']); + if (array_key_exists('usedTraitDependentFiles', $data)) { + $data['usedTraitDependentFiles'] = $this->absolutizeList($data['usedTraitDependentFiles']); + } + $result[$this->absolutizePath($file)] = $data; + } + + return $result; + } + + /** + * Rewrites the absolute-path-bearing meta keys. projectConfig is handled separately by + * relativizeProjectConfig()/absolutizeProjectConfig() because it is Neon-encoded to a string. + * + * @param mixed[] $meta + * @return mixed[] + */ + public function relativizeMeta(array $meta): array + { + return $this->transformMeta($meta, false); + } + + /** + * @param mixed[] $meta + * @return mixed[] + */ + public function absolutizeMeta(array $meta): array + { + return $this->transformMeta($meta, true); + } + + /** + * @param mixed[] $projectConfig + * @return mixed[] + */ + public function relativizeProjectConfig(array $projectConfig): array + { + return $this->transformProjectConfig($projectConfig, false); + } + + /** + * @param mixed[] $projectConfig + * @return mixed[] + */ + public function absolutizeProjectConfig(array $projectConfig): array + { + return $this->transformProjectConfig($projectConfig, true); + } + + /** + * @param mixed[] $meta + * @return mixed[] + */ + private function transformMeta(array $meta, bool $absolutize): array + { + if (array_key_exists('analysedPaths', $meta) && is_array($meta['analysedPaths'])) { + $meta['analysedPaths'] = $this->transformList($meta['analysedPaths'], $absolutize); + } + + foreach (['scannedFiles', 'composerLocks', 'executedFilesHashes', 'stubFiles'] as $key) { + if (!array_key_exists($key, $meta) || !is_array($meta[$key])) { + continue; + } + $meta[$key] = $this->transformKeys($meta[$key], $absolutize); + } + + if (array_key_exists('composerInstalled', $meta) && is_array($meta['composerInstalled'])) { + $meta['composerInstalled'] = $this->transformComposerInstalled($meta['composerInstalled'], $absolutize); + } + + return $meta; + } + + /** + * @param mixed[] $composerInstalled + * @return array + */ + private function transformComposerInstalled(array $composerInstalled, bool $absolutize): array + { + $result = []; + foreach ($composerInstalled as $file => $installed) { + if (is_array($installed) && array_key_exists('versions', $installed) && is_array($installed['versions'])) { + foreach ($installed['versions'] as $package => $packageData) { + if (!is_array($packageData) || !array_key_exists('install_path', $packageData) || !is_string($packageData['install_path'])) { + continue; + } + $installed['versions'][$package]['install_path'] = $this->transformPath($packageData['install_path'], $absolutize); + } + } + $result[$this->transformPath((string) $file, $absolutize)] = $installed; + } + + return $result; + } + + /** + * @param mixed[] $projectConfig + * @return mixed[] + */ + private function transformProjectConfig(array $projectConfig, bool $absolutize): array + { + if (!array_key_exists('parameters', $projectConfig) || !is_array($projectConfig['parameters'])) { + return $projectConfig; + } + + $parameters = $projectConfig['parameters']; + if (array_key_exists('paths', $parameters) && is_array($parameters['paths'])) { + $parameters['paths'] = $this->transformList($parameters['paths'], $absolutize); + } + if (array_key_exists('tmpDir', $parameters) && is_string($parameters['tmpDir'])) { + $parameters['tmpDir'] = $this->transformPath($parameters['tmpDir'], $absolutize); + } + $projectConfig['parameters'] = $parameters; + + return $projectConfig; + } + + private function transformPath(string $path, bool $absolutize): string + { + return $absolutize ? $this->absolutizePath($path) : $this->relativizePath($path); + } + + /** + * @param mixed[] $paths + * @return list + */ + private function transformList(array $paths, bool $absolutize): array + { + $result = []; + foreach ($paths as $path) { + $result[] = $this->transformPath((string) $path, $absolutize); + } + + return $result; + } + + /** + * @param list $paths + * @return list + */ + private function relativizeList(array $paths): array + { + return $this->transformList($paths, false); + } + + /** + * @param list $paths + * @return list + */ + private function absolutizeList(array $paths): array + { + return $this->transformList($paths, true); + } + + /** + * @param mixed[] $byKey + * @return array + */ + private function transformKeys(array $byKey, bool $absolutize): array + { + $result = []; + foreach ($byKey as $key => $value) { + $result[$this->transformPath((string) $key, $absolutize)] = $value; + } + + return $result; + } + + private function relativizeCompoundKey(string $key): string + { + $suffixPosition = strpos($key, ' (in context of '); + if ($suffixPosition === false) { + return $this->relativizePath($key); + } + + return $this->relativizePath(substr($key, 0, $suffixPosition)) . substr($key, $suffixPosition); + } + + private function absolutizeCompoundKey(string $key): string + { + $suffixPosition = strpos($key, ' (in context of '); + if ($suffixPosition === false) { + return $this->absolutizePath($key); + } + + return $this->absolutizePath(substr($key, 0, $suffixPosition)) . substr($key, $suffixPosition); + } + + private function isAbsolutePath(string $path): bool + { + if (DIRECTORY_SEPARATOR === '/') { + if (str_starts_with($path, '/')) { + return true; + } + } elseif (substr($path, 1, 1) === ':') { + return true; + } + + return preg_match('~^[a-z0-9+\-.]+://~i', $path) === 1; + } + +} diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php new file mode 100644 index 00000000000..cb19e4c56c3 --- /dev/null +++ b/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php @@ -0,0 +1,195 @@ +relativizePath('/home/ci/build-123/src/Service.php'); + // project code is above the phar dir, so it relativizes to a "../" offset, not an absolute path + $this->assertSame('../../../src/Service.php', $relative); + + // reading the same relative path against a different anchor yields the file at its new location + $this->assertSame('/srv/runner/x9/src/Service.php', $b->absolutizePath($relative)); + } + + public function testSameAnchorRoundTripIsIdentity(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + + $original = '/home/ci/build-123/tests/FooTest.php'; + $this->assertSame($original, $a->absolutizePath($a->relativizePath($original))); + } + + public function testPathOutsideAnchorStaysAbsolute(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + // no shared prefix with the anchor: left absolute (ccache rule), so it survives a move unchanged + $outside = '/usr/share/php/global-stub.php'; + $relative = $a->relativizePath($outside); + $this->assertSame($outside, $relative); + $this->assertSame($outside, $b->absolutizePath($relative)); + } + + public function testErrorsRebaseKeysAndObjects(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $errorsByFile = [ + '/home/ci/build-123/src/Service.php' => [ + new Error('oops', '/home/ci/build-123/src/Service.php', 10), + ], + ]; + + $rebased = $b->absolutizeErrors($a->relativizeErrors($errorsByFile)); + + $this->assertSame(['/srv/runner/x9/src/Service.php'], array_keys($rebased)); + $error = $rebased['/srv/runner/x9/src/Service.php'][0]; + $this->assertSame('/srv/runner/x9/src/Service.php', $error->getFile()); + $this->assertSame('/srv/runner/x9/src/Service.php', $error->getFilePath()); + $this->assertSame('oops', $error->getMessage()); + $this->assertSame(10, $error->getLine()); + } + + public function testErrorInTraitRebasesAllThreePaths(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $error = new Error( + 'trait oops', + '/home/ci/build-123/src/UsingClass.php', + 7, + true, + '/home/ci/build-123/src/UsingClass.php', + '/home/ci/build-123/src/MyTrait.php', + ); + + $rebased = $b->absolutizeErrors($a->relativizeErrors(['/home/ci/build-123/src/UsingClass.php' => [$error]])); + $rebasedError = $rebased['/srv/runner/x9/src/UsingClass.php'][0]; + + $this->assertSame('/srv/runner/x9/src/UsingClass.php', $rebasedError->getFile()); + $this->assertSame('/srv/runner/x9/src/UsingClass.php', $rebasedError->getFilePath()); + $this->assertSame('/srv/runner/x9/src/MyTrait.php', $rebasedError->getTraitFilePath()); + } + + public function testDependenciesRebaseKeysAndValueLists(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $dependencies = [ + '/home/ci/build-123/src/A.php' => [ + 'fileHash' => 'abc', + 'dependentFiles' => ['/home/ci/build-123/src/B.php', '/home/ci/build-123/src/C.php'], + 'usedTraitDependentFiles' => ['/home/ci/build-123/src/T.php'], + ], + ]; + + $rebased = $b->absolutizeDependencies($a->relativizeDependencies($dependencies)); + + $this->assertSame(['/srv/runner/x9/src/A.php'], array_keys($rebased)); + $entry = $rebased['/srv/runner/x9/src/A.php']; + $this->assertSame('abc', $entry['fileHash']); + $this->assertSame( + ['/srv/runner/x9/src/B.php', '/srv/runner/x9/src/C.php'], + $entry['dependentFiles'], + ); + $this->assertArrayHasKey('usedTraitDependentFiles', $entry); + $this->assertSame(['/srv/runner/x9/src/T.php'], $entry['usedTraitDependentFiles']); + } + + public function testCompoundTraitContextKeyRebasesOnlyThePath(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $linesToIgnore = [ + '/home/ci/build-123/src/UsingClass.php' => [ + '/home/ci/build-123/src/MyTrait.php (in context of class App\\UsingClass)' => [12 => 'foo.bar'], + ], + ]; + + $rebased = $b->absolutizeCompoundKeyed($a->relativizeCompoundKeyed($linesToIgnore)); + + $this->assertSame(['/srv/runner/x9/src/UsingClass.php'], array_keys($rebased)); + $this->assertSame( + ['/srv/runner/x9/src/MyTrait.php (in context of class App\\UsingClass)'], + array_keys($rebased['/srv/runner/x9/src/UsingClass.php']), + ); + } + + public function testMetaRebasesPathBearingKeys(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $meta = [ + 'cacheVersion' => 'v14-relativePaths', + 'analysedPaths' => ['/home/ci/build-123/src'], + 'scannedFiles' => ['/home/ci/build-123/stubs/x.stub' => 'h1'], + 'composerInstalled' => [ + '/home/ci/build-123/vendor/composer/installed.php' => [ + 'versions' => [ + 'acme/lib' => ['install_path' => '/home/ci/build-123/vendor/acme/lib'], + ], + ], + ], + 'level' => '9', + ]; + + $rebased = $b->absolutizeMeta($a->relativizeMeta($meta)); + + $this->assertSame(['/srv/runner/x9/src'], $rebased['analysedPaths']); + $this->assertSame(['/srv/runner/x9/stubs/x.stub' => 'h1'], $rebased['scannedFiles']); + $this->assertSame( + '/srv/runner/x9/vendor/acme/lib', + $rebased['composerInstalled']['/srv/runner/x9/vendor/composer/installed.php']['versions']['acme/lib']['install_path'], + ); + // non-path keys are untouched + $this->assertSame('v14-relativePaths', $rebased['cacheVersion']); + $this->assertSame('9', $rebased['level']); + } + + public function testProjectConfigRebasesPathsAndTmpDirButNotPlaceholders(): void + { + $a = new ResultCachePathTransformer(self::ANCHOR_A); + $b = new ResultCachePathTransformer(self::ANCHOR_B); + + $projectConfig = [ + 'parameters' => [ + 'level' => 9, + 'paths' => ['/home/ci/build-123/src'], + 'tmpDir' => '/home/ci/build-123/tmp', + 'editorUrl' => '%relFile%', + ], + ]; + + $rebased = $b->absolutizeProjectConfig($a->relativizeProjectConfig($projectConfig)); + + $this->assertSame(['/srv/runner/x9/src'], $rebased['parameters']['paths']); + $this->assertSame('/srv/runner/x9/tmp', $rebased['parameters']['tmpDir']); + // a placeholder value is not a path and must not be rewritten + $this->assertSame('%relFile%', $rebased['parameters']['editorUrl']); + $this->assertSame(9, $rebased['parameters']['level']); + } + +} From e5a2d61a664e86faeb1beb74d254e814c6da0fbc Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 18:02:48 +0200 Subject: [PATCH 2/4] Add a git worktree e2e for the relative-path result cache Warms the cache in one checkout, creates a git worktree at a different absolute path with its own phpstan install, carries the warm cache over, and asserts it is reused with 0 files reanalysed. Proves the relative paths re-absolutize against the worktree, the scenario the toggle targets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bc2ec5e4e2e..4b3b6079809 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -422,6 +422,25 @@ jobs: OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv") echo "$OUTPUT" ../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT" + - script: | + cd e2e/result-cache-relative-path + # Warm the cache in this checkout; with the toggle on, paths are stored relative to the + # phpstan install, so the cache is portable to another checkout with the same layout. + ../../bin/phpstan analyse + # A git worktree is a second checkout of the same repo at a different absolute path. Give it + # its own phpstan install (vendor) so %rootDir% points at the worktree, and carry the warm + # cache across (a real setup would CoW-clone the checkout or share the tmpDir). + WORKTREE="$(mktemp -d)/phpstan" + git -C ../.. worktree add --detach "$WORKTREE" HEAD + cp -al ../../vendor "$WORKTREE/vendor" + cp -R tmp "$WORKTREE/e2e/result-cache-relative-path/tmp" + rm -rf "$WORKTREE/e2e/result-cache-relative-path/tmp/cache" + # Running in the worktree (a different absolute prefix) must re-absolutize the relative cache + # against the worktree and reuse it, with 0 files reanalysed. + cd "$WORKTREE/e2e/result-cache-relative-path" + OUTPUT=$(../../bin/phpstan analyse -vv) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q 'Result cache restored. 0 files will be reanalysed.' || { echo 'result cache was not reused in the git worktree'; exit 1; } - script: | cd e2e/result-cache-package-update composer install From c91bda0eb55116b6f11e9fbf7580b4e3a3ef60a4 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 18:30:14 +0200 Subject: [PATCH 3/4] Capture stderr when asserting result cache reuse in the worktree e2e PHPStan's -vv progress, including the "Result cache restored" line, is written to stderr. The assertion captured stdout only, so it missed the message and failed even though the cache was reused. Redirect stderr into the captured output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4b3b6079809..91f88586db0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -438,7 +438,8 @@ jobs: # Running in the worktree (a different absolute prefix) must re-absolutize the relative cache # against the worktree and reuse it, with 0 files reanalysed. cd "$WORKTREE/e2e/result-cache-relative-path" - OUTPUT=$(../../bin/phpstan analyse -vv) + # -vv progress (incl. "Result cache restored") goes to stderr, so capture both streams + OUTPUT=$(../../bin/phpstan analyse -vv 2>&1) echo "$OUTPUT" echo "$OUTPUT" | grep -q 'Result cache restored. 0 files will be reanalysed.' || { echo 'result cache was not reused in the git worktree'; exit 1; } - script: | From ee12a4fef1d05374982ac228906494dfb321d6ba Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 21:10:19 +0200 Subject: [PATCH 4/4] Make the relative-path result cache the default instead of a toggle Per review: this is not a BC break. For a project analysed on the same machine the relativized paths re-absolutize to the exact same absolute paths, so behaviour is unchanged; the only difference is that a moved project (a CI checkout dir, a git worktree) now reuses the cache instead of discarding it. Drop the featureToggle and relativize/absolutize unconditionally. The CACHE_VERSION bump migrates old caches with one cold run, and every result cache e2e now exercises the new path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 4 +- conf/bleedingEdge.neon | 1 - conf/config.neon | 1 - conf/parametersSchema.neon | 1 - e2e/result-cache-relative-path/phpstan.neon | 2 - .../ResultCache/ResultCacheManager.php | 95 ++++++++----------- 6 files changed, 44 insertions(+), 60 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 91f88586db0..d7e057f779a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -412,8 +412,8 @@ jobs: ../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" - script: | cd e2e/result-cache-relative-path - # Cold run with the relativePathResultCache toggle on: paths are stored relative to the - # phpstan install (the anchor), so the cache no longer embeds the absolute checkout path. + # Cold run: paths are stored relative to the phpstan install (the anchor), so the cache + # no longer embeds the absolute checkout path. ../../bin/phpstan analyse ../bashunit -a contains "'e2e/result-cache-relative-path/src/HelloWorld.php'" "$(cat tmp/resultCache.php)" # the analysed file must NOT be stored under its absolute checkout path diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 6076ee8439c..ecae15e4ac1 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -25,4 +25,3 @@ parameters: unnecessaryNullCoalesce: true finiteTypesInHaystack: true switchConditionAlwaysFalse: true - relativePathResultCache: true diff --git a/conf/config.neon b/conf/config.neon index f9251165203..8fc949c9361 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -56,7 +56,6 @@ parameters: unnecessaryNullCoalesce: false finiteTypesInHaystack: false switchConditionAlwaysFalse: false - relativePathResultCache: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 4442a226686..953bab24371 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -54,7 +54,6 @@ parametersSchema: unnecessaryNullCoalesce: bool() finiteTypesInHaystack: bool() switchConditionAlwaysFalse: bool() - relativePathResultCache: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/e2e/result-cache-relative-path/phpstan.neon b/e2e/result-cache-relative-path/phpstan.neon index efed8568d39..411ecb266ee 100644 --- a/e2e/result-cache-relative-path/phpstan.neon +++ b/e2e/result-cache-relative-path/phpstan.neon @@ -3,5 +3,3 @@ parameters: tmpDir: tmp paths: - src - featureToggles: - relativePathResultCache: true diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 6aaba099ef8..166e9b1cec6 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -127,8 +127,6 @@ public function __construct( private int $skipResultCacheIfOlderThanDays, #[AutowiredParameter(ref: '%rootDir%')] private string $anchorDirectory, - #[AutowiredParameter(ref: '%featureToggles.relativePathResultCache%')] - private bool $relativePathResultCache, ) { } @@ -274,32 +272,32 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } - if (($data['meta']['relativePaths'] ?? false) === true) { - // The cache was written with paths relative to the anchor directory. Re-absolutize them - // against the current anchor before anything reads them, so a moved project (a fresh CI - // checkout dir, a git worktree) resolves to its new location. projectConfig stays a relative - // Neon string here; isMetaDifferent()/getMetaKeyDifferences() relativize the current side to - // compare. Gated on the cached flag, not the current toggle, so an old cache is left untouched. - $transformer = $this->getPathTransformer(); - $data['meta'] = $transformer->absolutizeMeta($data['meta']); - $data['projectExtensionFiles'] = $transformer->absolutizeFileKeyed($data['projectExtensionFiles']); - $data['linesToIgnore'] = $transformer->absolutizeCompoundKeyed($data['linesToIgnore']); - $data['unmatchedLineIgnores'] = $transformer->absolutizeCompoundKeyed($data['unmatchedLineIgnores']); - $data['dependencies'] = $transformer->absolutizeDependencies($data['dependencies']); - $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); - - $errorsCallback = $data['errorsCallback']; - $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); - $locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback']; - $data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback()); - $collectedDataCallback = $data['collectedDataCallback']; - $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($collectedDataCallback()); - $exportedNodesCallback = $data['exportedNodesCallback']; - $data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback()); - } + // The cache stores paths relative to the anchor directory. Re-absolutize them against the current + // anchor before anything reads them, so a moved project (a fresh CI checkout dir, a git worktree) + // resolves to its new location. projectConfig stays a relative Neon string here; + // isMetaDifferent()/getMetaKeyDifferences() relativize the current side to compare. Absolutizing an + // already-absolute path is a no-op, so a cache from an older format is left untouched (and then + // discarded by the cacheVersion check below). + $transformer = $this->getPathTransformer(); + $data['meta'] = $transformer->absolutizeMeta($data['meta']); + $data['projectExtensionFiles'] = $transformer->absolutizeFileKeyed($data['projectExtensionFiles']); + $data['linesToIgnore'] = $transformer->absolutizeCompoundKeyed($data['linesToIgnore']); + $data['unmatchedLineIgnores'] = $transformer->absolutizeCompoundKeyed($data['unmatchedLineIgnores']); + $data['dependencies'] = $transformer->absolutizeDependencies($data['dependencies']); + $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); + + $errorsCallback = $data['errorsCallback']; + $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); + $locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback']; + $data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback()); + $collectedDataCallback = $data['collectedDataCallback']; + $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($collectedDataCallback()); + $exportedNodesCallback = $data['exportedNodesCallback']; + $data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback()); $meta = $this->getMeta($allAnalysedFiles, $projectConfigArray); - $packageDependencies = $data['packageDependencies'] ?? []; + // absolutized above, so it is always present here + $packageDependencies = $data['packageDependencies']; $packageSeededFiles = []; if ($this->isMetaDifferent($data['meta'], $meta)) { $diffs = $this->getMetaKeyDifferences($data['meta'], $meta); @@ -671,10 +669,7 @@ private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); - if ($this->relativePathResultCache) { - $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); - } - + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -696,10 +691,7 @@ private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): a if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); - if ($this->relativePathResultCache) { - $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); - } - + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -783,9 +775,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $meta = $resultCache->getMeta(); $projectConfigArray = $meta['projectConfig']; if ($projectConfigArray !== null) { - if ($this->relativePathResultCache) { - $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); - } + $projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray); $meta['projectConfig'] = Neon::encode($projectConfigArray); } $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $packageDependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { @@ -1256,21 +1246,21 @@ private function save( ksort($exportedNodes); - if ($this->relativePathResultCache) { - $transformer = $this->getPathTransformer(); - // projectConfig inside $meta is already a Neon-encoded string here (encoded in process()), - // so it is relativized at the array level before that encode; only the other meta paths remain. - $meta = $transformer->relativizeMeta($meta); - $errors = $transformer->relativizeErrors($errors); - $locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors); - $linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore); - $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); - $collectedData = $transformer->relativizeFileKeyed($collectedData); - $invertedDependencies = $transformer->relativizeDependencies($invertedDependencies); - $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); - $exportedNodes = $transformer->relativizeFileKeyed($exportedNodes); - $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); - } + // Store paths relative to the anchor so the cache survives a change of the project's absolute + // path prefix (a fresh CI checkout dir, a git worktree). projectConfig inside $meta is already a + // Neon-encoded string here (encoded in process()), so it is relativized at the array level before + // that encode; only the other meta paths remain. + $transformer = $this->getPathTransformer(); + $meta = $transformer->relativizeMeta($meta); + $errors = $transformer->relativizeErrors($errors); + $locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors); + $linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore); + $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); + $collectedData = $transformer->relativizeFileKeyed($collectedData); + $invertedDependencies = $transformer->relativizeDependencies($invertedDependencies); + $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); + $exportedNodes = $transformer->relativizeFileKeyed($exportedNodes); + $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); $file = $this->cacheFilePath; @@ -1519,7 +1509,6 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a return [ 'cacheVersion' => self::CACHE_VERSION, - 'relativePaths' => $this->relativePathResultCache, 'phpstanVersion' => ComposerHelper::getPhpStanVersion(), 'fnsr' => $fnsr, 'metaExtensions' => $this->getMetaFromPhpStanExtensions(),