From 2520cf0ccf0d0bbd2c95660aa1bda88e2425e848 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 6 Aug 2026 17:23:54 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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) === ':'; } }