Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -606,10 +606,27 @@ 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-dots-in-path
# scanFiles/bootstrapFiles/stubFiles built out of %rootDir% with a '..' segment in the
# middle: the placeholder keeps NeonAdapter from normalizing them when the Neon file is
# read, so nothing may compare that spelling against the normalized one.
../../bin/phpstan analyse
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
../../bin/phpstan analyse
# Nothing changed at all: the cache must be reused without even reaching the
# changed-packages fallback. Composer spells install_path in installed.php with a '..'
# segment ('vendor/composer/../test/logger'), so a meta that is not canonicalized the
# same way it is restored reports composerInstalled as changed on every single run.
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a not_contains 'Composer metadata changed' "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
# Update a single Composer package by bumping its version with a patch and letting Composer
# reinstall it, which changes its recorded reference in vendor/composer/installed.php. The
# cache must be invalidated only for files depending on that package (src/Foo.php uses
Expand Down
1 change: 1 addition & 0 deletions e2e/result-cache-dots-in-path/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/tmp
1 change: 1 addition & 0 deletions e2e/result-cache-dots-in-path/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php declare(strict_types = 1);
13 changes: 13 additions & 0 deletions e2e/result-cache-dots-in-path/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
parameters:
level: 8
tmpDir: tmp
paths:
- src
# every one of these spells an existing file through a '..' segment - the placeholder keeps
# NeonAdapter from normalizing them when the file is read
scanFiles:
- %rootDir%/e2e/result-cache-dots-in-path/src/../scanned/ScannedGreeter.php
bootstrapFiles:
- %rootDir%/e2e/result-cache-dots-in-path/src/../bootstrap.php
stubFiles:
- %rootDir%/e2e/result-cache-dots-in-path/src/../stubs/ScannedGreeter.stub
13 changes: 13 additions & 0 deletions e2e/result-cache-dots-in-path/scanned/ScannedGreeter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace DotsInPathResultCache;

class ScannedGreeter
{

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

}
14 changes: 14 additions & 0 deletions e2e/result-cache-dots-in-path/src/HelloWorld.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);

namespace DotsInPathResultCache;

class HelloWorld
{

/** @return non-empty-string */
public function sayHello(): string
{
return (new ScannedGreeter())->greet('PHPStan');
}

}
11 changes: 11 additions & 0 deletions e2e/result-cache-dots-in-path/stubs/ScannedGreeter.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace DotsInPathResultCache;

class ScannedGreeter
{

/** @return non-empty-string */
public function greet(string $name): string;

}
14 changes: 9 additions & 5 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
Expand Down Expand Up @@ -70,7 +69,7 @@
final class ResultCacheManager
{

private const CACHE_VERSION = 'v14-relativePaths';
private const CACHE_VERSION = 'v15-canonicalPaths';

/** @var array<string, string> */
private array $fileHashes = [];
Expand Down Expand Up @@ -1588,7 +1587,12 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a
ksort($projectConfigArray);
}

return [
// The meta is stored relativized and comes back absolutized, which also normalizes every path
// in it ('a/b/../c' becomes 'a/c'). Not every path here is normalized to begin with - Composer
// records install_path as 'vendor/composer/../foo/bar' in installed.php - so the freshly computed
// meta is put through the very same transformation. Otherwise the restored meta could never equal
// it and the cache would be discarded on every single run.
return $this->getPathTransformer()->absolutizeMeta([
'cacheVersion' => self::CACHE_VERSION,
'phpstanVersion' => ComposerHelper::getPhpStanVersion(),
'metaExtensions' => $this->getMetaFromPhpStanExtensions(),
Expand All @@ -1605,9 +1609,9 @@ private function getMeta(array $allAnalysedFiles, ?array $projectConfigArray): a
// extensions may only run after the bootstrapFiles. This entry catches a changed
// list in any config file, including the included ones that are not part of the
// projectConfig entry above.
'configStubFiles' => array_map(fn (string $stubFile): string => $this->fileHelper->normalizePath($stubFile), $this->configStubFiles),
'configStubFiles' => $this->configStubFiles,
'level' => $this->usedLevel,
];
]);
}

private function getFileHash(string $path): string
Expand Down
4 changes: 2 additions & 2 deletions src/Command/CommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public static function begin(
$autoloadFunctionsBefore = spl_autoload_functions();

if ($autoloadFile !== null) {
$autoloadFile = $currentWorkingDirectoryFileHelper->absolutizePath($autoloadFile);
$autoloadFile = $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($autoloadFile));
if (!is_file($autoloadFile)) {
$errorOutput->writeLineFormatted(sprintf('Autoload file "%s" not found.', $autoloadFile));
throw new InceptionNotSuccessfulException();
Expand Down Expand Up @@ -209,7 +209,7 @@ public static function begin(
}
}
} else {
$projectConfigFile = $currentWorkingDirectoryFileHelper->absolutizePath($projectConfigFile);
$projectConfigFile = $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($projectConfigFile));
}

if ($generateBaselineFile !== null) {
Expand Down
135 changes: 135 additions & 0 deletions src/DependencyInjection/NormalizePathParametersExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php declare(strict_types = 1);

namespace PHPStan\DependencyInjection;

use Nette\DI\CompilerExtension;
use Override;
use PHPStan\DependencyInjection\Neon\OptionalPath;
use PHPStan\File\FileHelper;
use function array_key_exists;
use function array_shift;
use function is_array;
use function is_string;
use function preg_match_all;

/**
* NeonAdapter absolutizes and normalizes the path entries listed in the `expandRelativePaths` section,
* but only the ones that do not contain a `%placeholder%` - those are still unexpanded strings at load
* time. A path like `%rootDir%/../../../vendor/autoload.php` therefore reaches the container with its
* `..` segments intact, and every consumer sees a different spelling of the same file than it would for
* the equivalent placeholder-free entry.
*
* The parameters are fully expanded by the time any extension's loadConfiguration() runs, so this is the
* first point where the same normalization can be applied. Doing it here rather than at each consumer
* keeps a single spelling of every configured path in the container - which is what the result cache
* compares its stored metadata against, see https://github.com/phpstan/phpstan/issues/15125
*/
#[ContainerExtension(name: 'normalizePathParameters')]
final class NormalizePathParametersExtension extends CompilerExtension
{

#[Override]
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$fileHelper = new FileHelper($builder->parameters['currentWorkingDirectory']);

foreach ($this->getExpandRelativePaths() as $configKey) {
if (preg_match_all('~\[([^\]]*)\]~', $configKey, $matches) === 0) {
continue;
}

$segments = $matches[1];
if (array_shift($segments) !== 'parameters') {
continue;
}

$parameters = $builder->parameters;
$this->normalizeAtPath($parameters, $segments, $fileHelper);
$builder->parameters = $parameters;
}
}

/**
* @return list<string>
*/
private function getExpandRelativePaths(): array
{
$configKeys = [];
foreach ($this->compiler->getExtensions(ExpandRelativePathExtension::class) as $extension) {
foreach ($extension->getConfig() as $configKey) {
if (!is_string($configKey)) {
continue;
}

$configKeys[] = $configKey;
}
}

return $configKeys;
}

/**
* @param mixed[] $value
* @param list<string> $segments an empty segment stands for "every element of this list"
*/
private function normalizeAtPath(array &$value, array $segments, FileHelper $fileHelper): void
{
$segment = array_shift($segments);
if ($segment === null) {
return;
}

if ($segment === '') {
foreach ($value as $key => $item) {
$value[$key] = $this->normalizeItem($item, $segments, $fileHelper);
}

return;
}

if (!array_key_exists($segment, $value)) {
return;
}

$value[$segment] = $this->normalizeItem($value[$segment], $segments, $fileHelper);
}

/**
* @param list<string> $segments
*/
private function normalizeItem(mixed $item, array $segments, FileHelper $fileHelper): mixed
{
if ($segments !== []) {
if (is_array($item)) {
$this->normalizeAtPath($item, $segments, $fileHelper);
}

return $item;
}

if ($item instanceof OptionalPath) {
return new OptionalPath($this->normalizePath($item->path, $fileHelper));
}

if (!is_string($item)) {
return $item;
}

return $this->normalizePath($item, $fileHelper);
}

private function normalizePath(string $path, FileHelper $fileHelper): string
{
// A path that is still relative here is either an fnmatch pattern or came from a placeholder that
// expanded to a relative value; there is no config file left to resolve it against, and
// normalizePath() would silently drop its leading '..' segments. absolutizePath() returning the
// path unchanged is what "already absolute" means to the rest of PHPStan, including `scheme://` URLs.
if ($fileHelper->absolutizePath($path) !== $path) {
return $path;
}

return $fileHelper->normalizePath($path);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\ResultCache;

use Override;
use PHPUnit\Framework\TestCase;
use const DIRECTORY_SEPARATOR;

class ResultCachePathTransformerTest extends TestCase
{

private function createTransformer(): ResultCachePathTransformer
{
return new ResultCachePathTransformer('/project/vendor/phpstan/phpstan');
}

#[Override]
protected function setUp(): void
{
if (DIRECTORY_SEPARATOR === '/') {
return;
}

self::markTestSkipped('Test runs only on Unix-like systems.');
}

public function testRoundTripCanonicalizesDotDotSegments(): void
{
$transformer = $this->createTransformer();
$path = '/project/vendor/composer/../nette/neon/src/Neon.php';

$this->assertSame(
'/project/vendor/nette/neon/src/Neon.php',
$transformer->absolutizePath($transformer->relativizePath($path)),
);
}

/**
* Composer records install_path in vendor/composer/installed.php as `vendor/composer/../foo/bar`,
* so the meta the result cache stores is not normalized to begin with. Restoring it collapses those
* `..` segments, which is why ResultCacheManager::getMeta() puts the freshly computed meta through
* the very same transformation - otherwise the restored meta could never equal the computed one and
* the cache would be discarded on every run.
*/
public function testAbsolutizeMetaIsTheCanonicalFormOfTheRoundTrip(): void
{
$transformer = $this->createTransformer();
$meta = [
'analysedPaths' => ['/project/src/../src'],
'scannedFiles' => ['/project/vendor/phpstan/phpstan/../../../vendor/autoload.php' => 'hash'],
'executedFilesHashes' => ['/project/vendor/../vendor/autoload.php' => 'hash'],
'composerLocks' => ['/project/./composer.lock' => 'hash'],
'composerInstalled' => [
'/project/vendor/composer/installed.php' => [
'versions' => [
'nette/neon' => ['install_path' => '/project/vendor/composer/../nette/neon'],
],
],
],
];

$canonical = $transformer->absolutizeMeta($meta);

$this->assertSame([
'analysedPaths' => ['/project/src'],
'scannedFiles' => ['/project/vendor/autoload.php' => 'hash'],
'executedFilesHashes' => ['/project/vendor/autoload.php' => 'hash'],
'composerLocks' => ['/project/composer.lock' => 'hash'],
'composerInstalled' => [
'/project/vendor/composer/installed.php' => [
'versions' => [
'nette/neon' => ['install_path' => '/project/vendor/nette/neon'],
],
],
],
], $canonical);

$this->assertSame($canonical, $transformer->absolutizeMeta($transformer->relativizeMeta($meta)));
$this->assertSame($canonical, $transformer->absolutizeMeta($canonical));
}

}
Loading
Loading