diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b9df7364e26..c4888fb183f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -418,6 +418,64 @@ 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 + # 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-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 @@ +traitFilePath; } + /** + * 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 transformPaths(callable $transformPath): self + { + return new self( + $this->message, + $transformPath($this->file), + $this->line, + $this->canBeIgnored, + $this->filePath === null ? null : $transformPath($this->filePath), + $this->traitFilePath === null ? null : $transformPath($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..2ec88f06412 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCachePathTransformer.php @@ -0,0 +1,398 @@ +relativePathHelper = new ParentDirectoryRelativePathHelper($anchorDirectory); + $this->anchorFileHelper = new FileHelper($anchorDirectory); + } + + public function relativizePath(string $path): string + { + [$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 $scheme . str_replace('\\', '/', $this->relativePathHelper->getRelativePath($filesystemPath)); + } + + public function absolutizePath(string $path): string + { + [$scheme, $filesystemPath] = $this->splitScheme($path); + + return $scheme . $this->anchorFileHelper->normalizePath($this->anchorFileHelper->absolutizePath($filesystemPath)); + } + + /** + * @param array> $errorsByFile + * @return array> + */ + public function relativizeErrors(array $errorsByFile): array + { + $result = []; + foreach ($errorsByFile as $file => $errors) { + $relativized = []; + foreach ($errors as $error) { + $relativized[] = $error->transformPaths(fn (string $path): string => $this->relativizePath($path)); + } + $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->transformPaths(fn (string $path): string => $this->absolutizePath($path)); + } + $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); + } + + /** + * 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 === '/') { + return str_starts_with($path, '/'); + } + + return substr($path, 1, 1) === ':'; + } + +}