diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 11504b44489..7ed1cdde1ce 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -410,6 +410,46 @@ 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: 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). + ../../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 + # 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-relative-path + 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. 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" + 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" + # -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; } + # 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 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..411ecb266ee --- /dev/null +++ b/e2e/result-cache-relative-path/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src 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. 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, + $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(). + */ + 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..166e9b1cec6 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,17 @@ public function __construct( private array $parametersNotInvalidatingCache, #[AutowiredParameter(ref: '%resultCacheSkipIfOlderThanDays%')] private int $skipResultCacheIfOlderThanDays, + #[AutowiredParameter(ref: '%rootDir%')] + private string $anchorDirectory, ) { } + private function getPathTransformer(): ResultCachePathTransformer + { + return $this->pathTransformer ??= new ResultCachePathTransformer($this->anchorDirectory); + } + /** * @param string[] $allAnalysedFiles * @param mixed[]|null $projectConfigArray @@ -263,8 +272,32 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } + // 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); @@ -636,6 +669,7 @@ private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -657,6 +691,7 @@ private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): a if ($projectConfig !== null) { ksort($currentMeta['projectConfig']); + $currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']); $currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']); } @@ -740,6 +775,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $meta = $resultCache->getMeta(); $projectConfigArray = $meta['projectConfig']; if ($projectConfigArray !== null) { + $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 +1246,22 @@ private function save( ksort($exportedNodes); + // 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; // streamed to the file section by section - building the whole diff --git a/src/Analyser/ResultCache/ResultCachePathTransformer.php b/src/Analyser/ResultCache/ResultCachePathTransformer.php new file mode 100644 index 00000000000..7d1bdeb8aca --- /dev/null +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -0,0 +1,376 @@ +relativePathHelper = new ParentDirectoryRelativePathHelper($anchorDirectory); + $this->anchorFileHelper = new FileHelper($anchorDirectory); + } + + public function relativizePath(string $path): string + { + if (!$this->isAbsolutePath($path)) { + 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)); + } + + 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() 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); + } + + /** + * 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 + { + 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->relativizeList($parameters['paths']); + } + if (array_key_exists('tmpDir', $parameters) && is_string($parameters['tmpDir'])) { + $parameters['tmpDir'] = $this->relativizePath($parameters['tmpDir']); + } + $projectConfig['parameters'] = $parameters; + + return $projectConfig; + } + + /** + * @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; + } + + 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 mixed[] $paths + * @return list + */ + private function relativizeList(array $paths): array + { + return $this->transformList($paths, false); + } + + /** + * @param mixed[] $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; + } + +}