diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 11504b44489..d7e057f779a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -410,6 +410,38 @@ 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. + ../../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-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" + # -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: | 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. 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..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..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']); + } + +}