Skip to content
Open
40 changes: 40 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Comment thread
staabm marked this conversation as resolved.
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"
Comment on lines +436 to +438

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did I understand it right, that we need this copying because this PR is not going to implement the whole feature in a single shot?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. The copy stands in for cache discovery, PHPStan locating the main checkout's warm cache from a fresh worktree, which is the harder part and a deliberate follow-up, not this PR. This PR makes the cache content portable so it can be reused once it is present; getting it present in a bare worktree is the separate step. I noted it in the code comment and the PR description.

# 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; }
Comment thread
staabm marked this conversation as resolved.
# 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
Expand Down
1 change: 1 addition & 0 deletions e2e/result-cache-relative-path/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/tmp
5 changes: 5 additions & 0 deletions e2e/result-cache-relative-path/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 8
tmpDir: tmp
paths:
- src
13 changes: 13 additions & 0 deletions e2e/result-cache-relative-path/src/HelloWorld.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace RelativePathResultCache;

class HelloWorld
{

public function sayHello(string $name): string
{
return sprintf('Hello, %s', $name);
}

}
50 changes: 50 additions & 0 deletions src/Analyser/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
use Nette\Utils\Strings;
use Override;
use PhpParser\Node;
use PHPStan\File\FileHelper;
use PHPStan\File\RelativePathHelper;
use PHPStan\ShouldNotHappenException;
use ReturnTypeWillChange;
use Throwable;
use function is_bool;
use function sprintf;
use function str_replace;

/**
* @api
Expand Down Expand Up @@ -133,6 +136,53 @@ public function getTraitFilePath(): ?string
return $this->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;
Expand Down
56 changes: 54 additions & 2 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@
final class ResultCacheManager
{

private const CACHE_VERSION = 'v13-packageDependencies';
private const CACHE_VERSION = 'v14-relativePaths';

/** @var array<string, string> */
private array $fileHashes = [];

private ?ResultCachePathTransformer $pathTransformer = null;

/** @var array<string, true> */
private array $alreadyProcessed = [];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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']);
}

Expand All @@ -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']);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading