From 2520cf0ccf0d0bbd2c95660aa1bda88e2425e848 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 17:23:54 +0200 Subject: [PATCH 01/10] 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 b9df7364e26..a35e6cbd513 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -418,6 +418,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 09b87551148b3ab88e2b8f4de4f92d5ca5c2468f Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 18:02:48 +0200 Subject: [PATCH 02/10] 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 a35e6cbd513..84ea047b15c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -430,6 +430,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 47dd298a4ba635e9e2e9b27a98715dfec28cc53a Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 18:30:14 +0200 Subject: [PATCH 03/10] 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 84ea047b15c..9e475171e01 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -446,7 +446,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 da9d2546ca35d8c928e7c1010a12b5194b9948be Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 21:10:19 +0200 Subject: [PATCH 04/10] 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 9e475171e01..374be8f5f60 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -420,8 +420,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(), From 7530b021da8477c148a223a65ac083fe75d9d177 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 7 Aug 2026 10:58:30 +0200 Subject: [PATCH 05/10] Tighten the relative-path result cache e2e per review Drop the redundant "relative path is present" assertion (the explicit "absolute path is absent" check on the next line already proves the path was relativized), fix a stale "toggle on" comment, and after the git worktree run remove the worktree and re-run in the original checkout to confirm it still reuses its own cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 374be8f5f60..dfb327529c4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -421,10 +421,9 @@ jobs: - script: | cd e2e/result-cache-relative-path # Cold run: paths are stored relative to the phpstan install (the anchor), so the cache - # no longer embeds the absolute checkout path. + # no longer embeds the absolute checkout path. Assert the analysed file is NOT stored under + # its absolute checkout path (i.e. it was relativized). ../../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") @@ -432,12 +431,14 @@ jobs: ../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. + MAIN="$(pwd)" + # Warm the cache in this checkout; 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). + # cache across. The copy stands in for cache discovery, which is a follow-up and not part of + # this PR; 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" @@ -450,6 +451,13 @@ jobs: 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; } + # Remove the worktree and confirm the original checkout still reuses its own cache, i.e. + # running phpstan in a different worktree in between did not disturb it. + cd "$MAIN" + git -C ../.. worktree remove --force "$WORKTREE" + 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 From 5214de92a94b8f8c3d2f1289a97918b99c19179c Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 7 Aug 2026 11:47:27 +0200 Subject: [PATCH 06/10] Remove the ResultCachePathTransformer unit test in favour of e2e coverage The transformer's behaviour is covered end to end by the result cache e2e tests (relative storage, warm reuse, and reuse across a git worktree at a different absolute path). The unit test additionally hardcoded POSIX absolute paths, which the Windows Tests matrix cannot satisfy since path handling there is drive-letter based. Drop it, matching the project's convention of testing result cache behaviour through e2e fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResultCachePathTransformerTest.php | 195 ------------------ 1 file changed, 195 deletions(-) delete mode 100644 tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php deleted file mode 100644 index cb19e4c56c3..00000000000 --- a/tests/PHPStan/Analyser/ResultCache/ResultCachePathTransformerTest.php +++ /dev/null @@ -1,195 +0,0 @@ -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 11fda9e81c15f9f40bc9fa9024c520910ccba5d9 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 7 Aug 2026 12:28:17 +0200 Subject: [PATCH 07/10] Store result cache paths with forward slashes for cross-OS portability The relative path helper already emits '/'-separated paths for anything reachable from the anchor, but returns a path with no shared prefix unchanged, which on Windows keeps backslashes. Normalise the stored paths to '/' so a cache written on Windows is usable on Linux and vice versa. On load the paths are absolutized back to the OS-native separator that FileFinder uses for analysed-file keys. Namespace separators in class names (FQCNs) are untouched, only path values are normalised. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/Error.php | 12 ++++++++---- .../ResultCache/ResultCachePathTransformer.php | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Analyser/Error.php b/src/Analyser/Error.php index 48a4d3e53d2..b94006f9696 100644 --- a/src/Analyser/Error.php +++ b/src/Analyser/Error.php @@ -14,6 +14,7 @@ use Throwable; use function is_bool; use function sprintf; +use function str_replace; /** * @api @@ -137,17 +138,20 @@ public function getTraitFilePath(): ?string /** * 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(). + * for portable storage in the result cache. Separators are normalised to forward slashes so the + * cache is portable between Windows and Linux. Inverse of absolutizePaths(). */ public function relativizePaths(RelativePathHelper $relativePathHelper): self { + $relativize = static fn (string $path): string => str_replace('\\', '/', $relativePathHelper->getRelativePath($path)); + return new self( $this->message, - $relativePathHelper->getRelativePath($this->file), + $relativize($this->file), $this->line, $this->canBeIgnored, - $this->filePath === null ? null : $relativePathHelper->getRelativePath($this->filePath), - $this->traitFilePath === null ? null : $relativePathHelper->getRelativePath($this->traitFilePath), + $this->filePath === null ? null : $relativize($this->filePath), + $this->traitFilePath === null ? null : $relativize($this->traitFilePath), $this->tip, $this->nodeLine, $this->nodeType, diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php index c1a45406fb0..2cec17f5c04 100644 --- a/src/Analyser/ResultCache/ResultCachePathTransformer.php +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -9,6 +9,7 @@ use function is_array; use function is_string; use function preg_match; +use function str_replace; use function str_starts_with; use function strpos; use function substr; @@ -42,7 +43,10 @@ public function relativizePath(string $path): string return $path; } - return $this->relativePathHelper->getRelativePath($path); + // Always store forward slashes so the cache is portable between Windows and Linux. + // getRelativePath() already yields '/'-separated output for a path reachable from the anchor; + // a path with no shared prefix is returned unchanged, so normalise its separators too. + return str_replace('\\', '/', $this->relativePathHelper->getRelativePath($path)); } public function absolutizePath(string $path): string From 5bc264c6e2f136bce5070970331f21257c55107d Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 7 Aug 2026 13:02:39 +0200 Subject: [PATCH 08/10] Drop the unused absolutizeProjectConfig from ResultCachePathTransformer projectConfig is stored as a relative Neon string and is never absolutized on load; the metadata comparison relativizes the current config instead. So absolutizeProjectConfig() had no caller once the unit test was removed and self-analysis flagged it. Inline the remaining relativize-only logic into relativizeProjectConfig() and drop the now single-use helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResultCachePathTransformer.php | 54 +++++++------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php index 2cec17f5c04..7d1bdeb8aca 100644 --- a/src/Analyser/ResultCache/ResultCachePathTransformer.php +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -198,7 +198,7 @@ public function absolutizeDependencies(array $dependencies): array /** * Rewrites the absolute-path-bearing meta keys. projectConfig is handled separately by - * relativizeProjectConfig()/absolutizeProjectConfig() because it is Neon-encoded to a string. + * relativizeProjectConfig() because it is Neon-encoded to a string. * * @param mixed[] $meta * @return mixed[] @@ -218,21 +218,29 @@ public function absolutizeMeta(array $meta): array } /** + * Only relativizes: projectConfig is stored as a relative Neon string and never absolutized on + * load. isMetaDifferent()/getMetaKeyDifferences() relativize the current config the same way to + * compare it against the cached string. + * * @param mixed[] $projectConfig * @return mixed[] */ public function relativizeProjectConfig(array $projectConfig): array { - return $this->transformProjectConfig($projectConfig, false); - } + if (!array_key_exists('parameters', $projectConfig) || !is_array($projectConfig['parameters'])) { + return $projectConfig; + } - /** - * @param mixed[] $projectConfig - * @return mixed[] - */ - public function absolutizeProjectConfig(array $projectConfig): array - { - return $this->transformProjectConfig($projectConfig, true); + $parameters = $projectConfig['parameters']; + if (array_key_exists('paths', $parameters) && is_array($parameters['paths'])) { + $parameters['paths'] = $this->relativizeList($parameters['paths']); + } + if (array_key_exists('tmpDir', $parameters) && is_string($parameters['tmpDir'])) { + $parameters['tmpDir'] = $this->relativizePath($parameters['tmpDir']); + } + $projectConfig['parameters'] = $parameters; + + return $projectConfig; } /** @@ -281,28 +289,6 @@ private function transformComposerInstalled(array $composerInstalled, bool $abso 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); @@ -323,7 +309,7 @@ private function transformList(array $paths, bool $absolutize): array } /** - * @param list $paths + * @param mixed[] $paths * @return list */ private function relativizeList(array $paths): array @@ -332,7 +318,7 @@ private function relativizeList(array $paths): array } /** - * @param list $paths + * @param mixed[] $paths * @return list */ private function absolutizeList(array $paths): array From 6dddb503b874e164bde833db65b3b4c6cc6ea6a3 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Mon, 10 Aug 2026 22:48:16 +0200 Subject: [PATCH 09/10] Keep the URL scheme when relativizing result cache paths A bootstrapFile that lives inside a phar reaches the path transformer as a phar:// URL, and PHPStan registers the four runtime stubs it ships inside phpstan.phar exactly that way. getRelativePath() treated the URL as a plain filesystem path and dropped the scheme, which absolutizePath() cannot put back: the restored key could never equal the phar://... key the next run computes, so executedFilesHashes differed on every run and the cache was thrown away. A phar install never reused its result cache at all. Split the scheme off, rewrite only the filesystem path behind it, and restore the scheme verbatim. That keeps the round trip lossless and keeps the phar's own path portable, so a phar install now also survives the project moving to a different absolute path. Error::relativizePaths()/absolutizePaths() are replaced by a single transformPaths(callable) so the error's paths go through the very same relativizePath()/absolutizePath() as the cache's file-path keys, instead of reimplementing them against a RelativePathHelper and a FileHelper. Both directions are covered by the new result-cache-phar-bootstrap e2e, which builds a small phar, registers a bootstrap file inside it, and asserts that no scheme-less key is stored and that an identical rerun reuses the cache. --- .github/workflows/e2e-tests.yml | 18 ++++++++ e2e/result-cache-phar-bootstrap/.gitignore | 2 + .../build-boot-phar.php | 12 +++++ e2e/result-cache-phar-bootstrap/phpstan.neon | 7 +++ .../src/HelloWorld.php | 13 ++++++ src/Analyser/Error.php | 44 +++++-------------- .../ResultCachePathTransformer.php | 44 ++++++++++++++----- 7 files changed, 95 insertions(+), 45 deletions(-) create mode 100644 e2e/result-cache-phar-bootstrap/.gitignore create mode 100644 e2e/result-cache-phar-bootstrap/build-boot-phar.php create mode 100644 e2e/result-cache-phar-bootstrap/phpstan.neon create mode 100644 e2e/result-cache-phar-bootstrap/src/HelloWorld.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index dfb327529c4..c4888fb183f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -418,6 +418,24 @@ 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-phar-bootstrap + # A bootstrapFile living inside a phar is hashed into executedFilesHashes under its + # phar:// URL, and PHPStan registers its own runtime stubs exactly like that inside + # phpstan.phar. Only the path after the scheme may be relativized: dropping the scheme + # leaves absolutizePath() unable to reconstruct the URL, so the restored key never + # matches and the cache is discarded on every run. + php -d phar.readonly=0 build-boot-phar.php + ../../bin/phpstan analyse + # Every stored reference to the bootstrap file must keep its scheme. Grepping for + # 'phar://' alone would also match the Neon-encoded projectConfig, so assert the + # negative: no scheme-less key pointing at boot.phar/boot.php. + if grep -oE "'[^']*boot\.phar/boot\.php'" tmp/resultCache.php | grep -v 'phar://'; then + echo 'bootstrap path stored without its phar:// scheme'; exit 1 + fi + 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 # Cold run: paths are stored relative to the phpstan install (the anchor), so the cache diff --git a/e2e/result-cache-phar-bootstrap/.gitignore b/e2e/result-cache-phar-bootstrap/.gitignore new file mode 100644 index 00000000000..da732e65c57 --- /dev/null +++ b/e2e/result-cache-phar-bootstrap/.gitignore @@ -0,0 +1,2 @@ +/tmp +/boot.phar diff --git a/e2e/result-cache-phar-bootstrap/build-boot-phar.php b/e2e/result-cache-phar-bootstrap/build-boot-phar.php new file mode 100644 index 00000000000..a9cb3cc07d6 --- /dev/null +++ b/e2e/result-cache-phar-bootstrap/build-boot-phar.php @@ -0,0 +1,12 @@ +addFromString('boot.php', "setStub($phar->createDefaultStub('boot.php')); diff --git a/e2e/result-cache-phar-bootstrap/phpstan.neon b/e2e/result-cache-phar-bootstrap/phpstan.neon new file mode 100644 index 00000000000..1c2f08f6214 --- /dev/null +++ b/e2e/result-cache-phar-bootstrap/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + bootstrapFiles: + - phar://%currentWorkingDirectory%/boot.phar/boot.php diff --git a/e2e/result-cache-phar-bootstrap/src/HelloWorld.php b/e2e/result-cache-phar-bootstrap/src/HelloWorld.php new file mode 100644 index 00000000000..479e6cc19fb --- /dev/null +++ b/e2e/result-cache-phar-bootstrap/src/HelloWorld.php @@ -0,0 +1,13 @@ + str_replace('\\', '/', $relativePathHelper->getRelativePath($path)); - - return new self( - $this->message, - $relativize($this->file), - $this->line, - $this->canBeIgnored, - $this->filePath === null ? null : $relativize($this->filePath), - $this->traitFilePath === null ? null : $relativize($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(). + * Rewrites every path this error carries, for portable storage in the result cache. The caller + * owns the transformation - see ResultCachePathTransformer, which passes relativizePath() when + * storing and absolutizePath() when loading - so both directions apply exactly the same rules + * to the error's paths as to the cache's file-path keys. + * + * @param callable(string): string $transformPath */ - public function absolutizePaths(FileHelper $fileHelper): self + public function transformPaths(callable $transformPath): self { return new self( $this->message, - $fileHelper->normalizePath($fileHelper->absolutizePath($this->file)), + $transformPath($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->filePath === null ? null : $transformPath($this->filePath), + $this->traitFilePath === null ? null : $transformPath($this->traitFilePath), $this->tip, $this->nodeLine, $this->nodeType, diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php index 7d1bdeb8aca..2ec88f06412 100644 --- a/src/Analyser/ResultCache/ResultCachePathTransformer.php +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -11,6 +11,7 @@ use function preg_match; use function str_replace; use function str_starts_with; +use function strlen; use function strpos; use function substr; use const DIRECTORY_SEPARATOR; @@ -39,19 +40,22 @@ public function __construct(string $anchorDirectory) public function relativizePath(string $path): string { - if (!$this->isAbsolutePath($path)) { + [$scheme, $filesystemPath] = $this->splitScheme($path); + if (!$this->isAbsolutePath($filesystemPath)) { return $path; } // Always store forward slashes so the cache is portable between Windows and Linux. // getRelativePath() already yields '/'-separated output for a path reachable from the anchor; // a path with no shared prefix is returned unchanged, so normalise its separators too. - return str_replace('\\', '/', $this->relativePathHelper->getRelativePath($path)); + return $scheme . str_replace('\\', '/', $this->relativePathHelper->getRelativePath($filesystemPath)); } public function absolutizePath(string $path): string { - return $this->anchorFileHelper->normalizePath($this->anchorFileHelper->absolutizePath($path)); + [$scheme, $filesystemPath] = $this->splitScheme($path); + + return $scheme . $this->anchorFileHelper->normalizePath($this->anchorFileHelper->absolutizePath($filesystemPath)); } /** @@ -64,7 +68,7 @@ public function relativizeErrors(array $errorsByFile): array foreach ($errorsByFile as $file => $errors) { $relativized = []; foreach ($errors as $error) { - $relativized[] = $error->relativizePaths($this->relativePathHelper); + $relativized[] = $error->transformPaths(fn (string $path): string => $this->relativizePath($path)); } $result[$this->relativizePath($file)] = $relativized; } @@ -82,7 +86,7 @@ public function absolutizeErrors(array $errorsByFile): array foreach ($errorsByFile as $file => $errors) { $absolutized = []; foreach ($errors as $error) { - $absolutized[] = $error->absolutizePaths($this->anchorFileHelper); + $absolutized[] = $error->transformPaths(fn (string $path): string => $this->absolutizePath($path)); } $result[$this->absolutizePath($file)] = $absolutized; } @@ -360,17 +364,35 @@ private function absolutizeCompoundKey(string $key): string return $this->absolutizePath(substr($key, 0, $suffixPosition)) . substr($key, $suffixPosition); } + /** + * Splits a stream-wrapper URL into its scheme and the filesystem path that follows it. PHPStan + * ships the runtime stubs it registers as bootstrapFiles inside its own phar, so in a phar + * install those arrive here as `phar:///path/to/phpstan.phar/stubs/runtime/...`. + * + * Only the part after the scheme is rewritten, and the scheme is put back verbatim. Handing the + * whole URL to getRelativePath() drops the scheme, which absolutizePath() cannot reconstruct - + * the restored key then never equals the `phar://...` key the next run computes, so + * executedFilesHashes differs on every run and the cache is discarded every time. + * + * @return array{string, string} the scheme including `://` (empty when the path carries none), + * and the path following it + */ + private function splitScheme(string $path): array + { + if (preg_match('~^[a-z0-9+\-.]+://~i', $path, $matches) !== 1) { + return ['', $path]; + } + + return [$matches[0], substr($path, strlen($matches[0]))]; + } + private function isAbsolutePath(string $path): bool { if (DIRECTORY_SEPARATOR === '/') { - if (str_starts_with($path, '/')) { - return true; - } - } elseif (substr($path, 1, 1) === ':') { - return true; + return str_starts_with($path, '/'); } - return preg_match('~^[a-z0-9+\-.]+://~i', $path) === 1; + return substr($path, 1, 1) === ':'; } } From d229dafca98e87d3c4079a7b90b035ee42ff72dc Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 13 Aug 2026 21:08:48 +0200 Subject: [PATCH 10/10] Relativize the paths inside collected data too Collected data is opaque to the result cache - only the collector that produced it knows where paths sit inside its value - so the cache rewrote the file-path keys and left every path inside the values absolute. The constant-condition collectors carry the reported Error in their collected value, so the cache still embedded the absolute checkout path: 284 occurrences when analysing phpstan-src itself. Collectors that carry paths now say so by implementing CollectorWithPaths, which the cache calls when it saves and loads. The method is static because the cache only ever holds the collector's class name, never an instance. Also fixes the compound "path (in context of class X)" form of Error::getFile(): it was relativized as if the whole string were a path, so the forward-slash normalisation that makes the cache portable between Windows and Linux rewrote the backslashes in the class name too, and an error reported in a trait came back from the cache with a mangled class name in that field. The transformer already had compound-aware helpers for linesToIgnore's keys; Error's paths now go through them. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-tests.yml | 12 +++- e2e/result-cache-relative-path/phpstan.neon | 6 ++ .../src/TraitWithConstantCondition.php | 34 +++++++++++ .../ResultCache/ResultCacheManager.php | 6 +- .../ResultCachePathTransformer.php | 60 ++++++++++++++++++- src/Collectors/CollectorWithPaths.php | 37 ++++++++++++ .../CollectedConstantConditionError.php | 29 +++++++++ .../ConstantConditionInTraitCollector.php | 23 ++++++- ...FunctionCallConstantConditionCollector.php | 23 ++++++- 9 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 e2e/result-cache-relative-path/src/TraitWithConstantCondition.php create mode 100644 src/Collectors/CollectorWithPaths.php create mode 100644 src/Rules/Comparison/CollectedConstantConditionError.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c4888fb183f..32f11ad1c1c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -439,10 +439,16 @@ jobs: - script: | cd e2e/result-cache-relative-path # Cold run: paths are stored relative to the phpstan install (the anchor), so the cache - # no longer embeds the absolute checkout path. Assert the analysed file is NOT stored under - # its absolute checkout path (i.e. it was relativized). + # no longer embeds the absolute checkout path anywhere - not in the error/dependency + # sections and not inside collected data either, which is only reachable through the + # collector that produced it (CollectorWithPaths). Assert the whole file is free of the + # checkout prefix rather than just the analysed file's own key. ../../bin/phpstan analyse - if grep -q "'$(pwd)/src/HelloWorld.php'" tmp/resultCache.php; then echo 'cache still holds an absolute analysed path'; exit 1; fi + if grep -q "$(cd ../.. && pwd)/" tmp/resultCache.php; then + echo 'cache still holds absolute paths under the checkout:' + grep -o "$(cd ../.. && pwd)/[^'\"]*" tmp/resultCache.php | sort -u | head -20 + 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" diff --git a/e2e/result-cache-relative-path/phpstan.neon b/e2e/result-cache-relative-path/phpstan.neon index 411ecb266ee..91c38ff8629 100644 --- a/e2e/result-cache-relative-path/phpstan.neon +++ b/e2e/result-cache-relative-path/phpstan.neon @@ -3,3 +3,9 @@ parameters: tmpDir: tmp paths: - src + ignoreErrors: + # the trait fixture exists to make the in-trait constant-condition collector emit + # collected data carrying an Error; the error itself is not what is under test + - + identifier: booleanNot.alwaysFalse + path: src/TraitWithConstantCondition.php diff --git a/e2e/result-cache-relative-path/src/TraitWithConstantCondition.php b/e2e/result-cache-relative-path/src/TraitWithConstantCondition.php new file mode 100644 index 00000000000..d780c77de62 --- /dev/null +++ b/e2e/result-cache-relative-path/src/TraitWithConstantCondition.php @@ -0,0 +1,34 @@ + $transformer->absolutizeErrors($locallyIgnoredErrorsCallback()); $collectedDataCallback = $data['collectedDataCallback']; - $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($collectedDataCallback()); + $data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeCollectedData($collectedDataCallback()); $exportedNodesCallback = $data['exportedNodesCallback']; $data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback()); @@ -1150,7 +1150,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param array> $locallyIgnoredErrors * @param array $linesToIgnore * @param array $unmatchedLineIgnores - * @param array>> $collectedData + * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies * @param array> $packageDependencies @@ -1256,7 +1256,7 @@ private function save( $locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors); $linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore); $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); - $collectedData = $transformer->relativizeFileKeyed($collectedData); + $collectedData = $transformer->relativizeCollectedData($collectedData); $invertedDependencies = $transformer->relativizeDependencies($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); $exportedNodes = $transformer->relativizeFileKeyed($exportedNodes); diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php index 2ec88f06412..24bb36c2e3b 100644 --- a/src/Analyser/ResultCache/ResultCachePathTransformer.php +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -3,9 +3,12 @@ namespace PHPStan\Analyser\ResultCache; use PHPStan\Analyser\Error; +use PHPStan\Collectors\CollectedData; +use PHPStan\Collectors\CollectorWithPaths; use PHPStan\File\FileHelper; use PHPStan\File\ParentDirectoryRelativePathHelper; use function array_key_exists; +use function is_a; use function is_array; use function is_string; use function preg_match; @@ -24,6 +27,8 @@ * Only paths under (or reachable from) the anchor become relative; a path with no shared prefix is * left absolute, following ccache's CCACHE_BASEDIR rule. absolutizePath() is the inverse: an already * absolute path is passed through unchanged, so relative and absolute entries can coexist in one cache. + * + * @phpstan-import-type CollectorData from CollectedData */ final class ResultCachePathTransformer { @@ -68,7 +73,7 @@ public function relativizeErrors(array $errorsByFile): array foreach ($errorsByFile as $file => $errors) { $relativized = []; foreach ($errors as $error) { - $relativized[] = $error->transformPaths(fn (string $path): string => $this->relativizePath($path)); + $relativized[] = $error->transformPaths(fn (string $path): string => $this->relativizeCompoundKey($path)); } $result[$this->relativizePath($file)] = $relativized; } @@ -86,7 +91,7 @@ public function absolutizeErrors(array $errorsByFile): array foreach ($errorsByFile as $file => $errors) { $absolutized = []; foreach ($errors as $error) { - $absolutized[] = $error->transformPaths(fn (string $path): string => $this->absolutizePath($path)); + $absolutized[] = $error->transformPaths(fn (string $path): string => $this->absolutizeCompoundKey($path)); } $result[$this->absolutizePath($file)] = $absolutized; } @@ -94,9 +99,58 @@ public function absolutizeErrors(array $errorsByFile): array return $result; } + /** + * Collected data is opaque to the cache, so only the collector that produced it can rewrite the + * paths inside it - it does so by implementing CollectorWithPaths. Collectors that carry no paths + * need nothing and keep their data unchanged. + * + * @param CollectorData $collectedData + * @return CollectorData + */ + public function relativizeCollectedData(array $collectedData): array + { + return $this->transformCollectedData($collectedData, fn (string $path): string => $this->relativizeCompoundKey($path), true); + } + + /** + * @param CollectorData $collectedData + * @return CollectorData + */ + public function absolutizeCollectedData(array $collectedData): array + { + return $this->transformCollectedData($collectedData, fn (string $path): string => $this->absolutizeCompoundKey($path), false); + } + + /** + * @param CollectorData $collectedData + * @param callable(string): string $transformPath + * @return CollectorData + */ + private function transformCollectedData(array $collectedData, callable $transformPath, bool $relativize): array + { + $result = []; + foreach ($collectedData as $file => $dataPerCollector) { + $newFile = $relativize ? $this->relativizePath($file) : $this->absolutizePath($file); + foreach ($dataPerCollector as $collectorType => $collectedValues) { + if (!is_a($collectorType, CollectorWithPaths::class, true)) { + $result[$newFile][$collectorType] = $collectedValues; + continue; + } + + $transformed = []; + foreach ($collectedValues as $collectedValue) { + $transformed[] = $collectorType::transformCollectedDataPaths($collectedValue, $transformPath); + } + $result[$newFile][$collectorType] = $transformed; + } + } + + 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. + * values carry no paths: packageDependencies, exportedNodes, projectExtensionFiles. * * @param array $byFile * @return array diff --git a/src/Collectors/CollectorWithPaths.php b/src/Collectors/CollectorWithPaths.php new file mode 100644 index 00000000000..48ca89d7a0f --- /dev/null +++ b/src/Collectors/CollectorWithPaths.php @@ -0,0 +1,37 @@ + + */ +interface CollectorWithPaths extends Collector +{ + + /** + * Returns the collected data with every filesystem path in it passed through $transformPath. + * Called once per collected value when the result cache is saved, and again with the inverse + * transformation when it is loaded, so it has to be symmetric. + * + * @param TValue $data + * @param callable(string): string $transformPath + * @return TValue + */ + public static function transformCollectedDataPaths($data, callable $transformPath); + +} diff --git a/src/Rules/Comparison/CollectedConstantConditionError.php b/src/Rules/Comparison/CollectedConstantConditionError.php new file mode 100644 index 00000000000..5d39fbb650f --- /dev/null +++ b/src/Rules/Comparison/CollectedConstantConditionError.php @@ -0,0 +1,29 @@ + $error + * @param callable(string): string $transformPath + * @return Error|array + */ + public static function transformPaths($error, callable $transformPath) + { + if ($error instanceof Error) { + return $error->transformPaths($transformPath); + } + + // an Error that crossed a parallel worker boundary arrives as its JSON form + return Error::decode($error)->transformPaths($transformPath)->jsonSerialize(); + } + +} diff --git a/src/Rules/Comparison/ConstantConditionInTraitCollector.php b/src/Rules/Comparison/ConstantConditionInTraitCollector.php index 7e2f75990d6..b8a7e89f346 100644 --- a/src/Rules/Comparison/ConstantConditionInTraitCollector.php +++ b/src/Rules/Comparison/ConstantConditionInTraitCollector.php @@ -5,14 +5,15 @@ use PhpParser\Node; use PHPStan\Analyser\Error; use PHPStan\Analyser\Scope; -use PHPStan\Collectors\Collector; +use PHPStan\Collectors\CollectorWithPaths; use PHPStan\Rules\Rule; use PHPStan\ShouldNotHappenException; +use function array_key_exists; /** - * @implements Collector>, trait-string, string, null}|array{class-string>, trait-string, string, bool, Error|array}> + * @implements CollectorWithPaths>, trait-string, string, null}|array{class-string>, trait-string, string, bool, Error|array}> */ -final class ConstantConditionInTraitCollector implements Collector +final class ConstantConditionInTraitCollector implements CollectorWithPaths { public function getNodeType(): string @@ -25,4 +26,20 @@ public function processNode(Node $node, Scope $scope) throw new ShouldNotHappenException(); } + /** + * @param array{class-string>, trait-string, string, null}|array{class-string>, trait-string, string, bool, Error|array} $data + * @param callable(string): string $transformPath + * @return array{class-string>, trait-string, string, null}|array{class-string>, trait-string, string, bool, Error|array} + */ + public static function transformCollectedDataPaths($data, callable $transformPath) + { + if (!array_key_exists(4, $data)) { + return $data; + } + + $data[4] = CollectedConstantConditionError::transformPaths($data[4], $transformPath); + + return $data; + } + } diff --git a/src/Rules/Comparison/FunctionCallConstantConditionCollector.php b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php index fa37dab949c..89cf4afd704 100644 --- a/src/Rules/Comparison/FunctionCallConstantConditionCollector.php +++ b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php @@ -5,14 +5,15 @@ use PhpParser\Node; use PHPStan\Analyser\Error; use PHPStan\Analyser\Scope; -use PHPStan\Collectors\Collector; +use PHPStan\Collectors\CollectorWithPaths; use PHPStan\Rules\Rule; use PHPStan\ShouldNotHappenException; +use function array_key_exists; /** - * @implements Collector>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array}> + * @implements CollectorWithPaths>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array}> */ -final class FunctionCallConstantConditionCollector implements Collector +final class FunctionCallConstantConditionCollector implements CollectorWithPaths { public function getNodeType(): string @@ -25,4 +26,20 @@ public function processNode(Node $node, Scope $scope) throw new ShouldNotHappenException(); } + /** + * @param array{class-string>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array} $data + * @param callable(string): string $transformPath + * @return array{class-string>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array} + */ + public static function transformCollectedDataPaths($data, callable $transformPath) + { + if (!array_key_exists(4, $data)) { + return $data; + } + + $data[4] = CollectedConstantConditionError::transformPaths($data[4], $transformPath); + + return $data; + } + }