From a7f0fce50145b3a9d509a4d5952bacf79470e899 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:02:53 +0000 Subject: [PATCH] Match custom extension files to analysed paths by realpath so symlinked packages are not reported as outside analysed paths - ResultCacheManager::getProjectExtensionFiles() looks up the class file reported by reflection in the analysed dependency graph. Reflection returns the file's realpath, while analysed paths may reach the same file through a symlink (e.g. a Composer path repository symlinked into vendor), so the direct lookup missed it and the file was wrongly treated as not analysed. - Add findAnalysedFileNameByRealpath() which resolves the reflection file name to the analysed dependency key by comparing realpaths, and use it as a fallback before concluding an extension is outside of analysed paths. - The realpath map over the dependency graph is built lazily and only when a service class is neither found directly nor located under a vendor dir, so normal (non-symlinked) projects pay no extra cost. - Add e2e/result-cache-symlink covering a custom rule provided by a package symlinked into vendor via a Composer path repository; modifying the rule must not print "Result cache might not behave correctly". --- .github/workflows/e2e-tests.yml | 12 +++++ e2e/result-cache-symlink/app/.gitignore | 1 + e2e/result-cache-symlink/app/composer.json | 19 ++++++++ e2e/result-cache-symlink/app/phpstan.neon | 8 ++++ e2e/result-cache-symlink/app/src/Foo.php | 8 ++++ e2e/result-cache-symlink/ext/composer.json | 6 +++ .../ext/src/CustomRule.php | 24 ++++++++++ .../ResultCache/ResultCacheManager.php | 44 +++++++++++++++++++ 8 files changed, 122 insertions(+) create mode 100644 e2e/result-cache-symlink/app/.gitignore create mode 100644 e2e/result-cache-symlink/app/composer.json create mode 100644 e2e/result-cache-symlink/app/phpstan.neon create mode 100644 e2e/result-cache-symlink/app/src/Foo.php create mode 100644 e2e/result-cache-symlink/ext/composer.json create mode 100644 e2e/result-cache-symlink/ext/src/CustomRule.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 208bb0e5069..2fbc1339898 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -180,6 +180,18 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'Result cache might not behave correctly' "$OUTPUT" ../bashunit -a contains 'ResultCache8E2E\CustomRule' "$OUTPUT" + - script: | + cd e2e/result-cache-symlink/app + # https://github.com/phpstan/phpstan/issues/14924 + # A custom rule provided by a package symlinked into vendor (composer path + # repository) and added to the analysed paths must NOT be reported as being + # outside of analysed paths, even though reflection reports its realpath. + composer install + ../../../bin/phpstan + echo -en '\n' >> ../ext/src/CustomRule.php + OUTPUT=$(../../../bin/phpstan analyze 2>&1 || true) + echo "$OUTPUT" + ../../bashunit -a not_contains 'Result cache might not behave correctly' "$OUTPUT" - script: | cd e2e/env-int-key env 1=1 ../../bin/phpstan analyse test.php diff --git a/e2e/result-cache-symlink/app/.gitignore b/e2e/result-cache-symlink/app/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-symlink/app/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-symlink/app/composer.json b/e2e/result-cache-symlink/app/composer.json new file mode 100644 index 00000000000..cd04f58606a --- /dev/null +++ b/e2e/result-cache-symlink/app/composer.json @@ -0,0 +1,19 @@ +{ + "require-dev": { + "phpstan/phpstan": "^1.10" + }, + "require": { + "resultcachesymlink/ext": "*" + }, + "repositories": [ + { + "type": "path", + "url": "../ext" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "classmap": ["src"] + } +} diff --git a/e2e/result-cache-symlink/app/phpstan.neon b/e2e/result-cache-symlink/app/phpstan.neon new file mode 100644 index 00000000000..1b61c467d09 --- /dev/null +++ b/e2e/result-cache-symlink/app/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + paths: + - src + - vendor/resultcachesymlink/ext/src + level: 8 + +rules: + - ResultCacheSymlinkE2E\CustomRule diff --git a/e2e/result-cache-symlink/app/src/Foo.php b/e2e/result-cache-symlink/app/src/Foo.php new file mode 100644 index 00000000000..77a87919ee7 --- /dev/null +++ b/e2e/result-cache-symlink/app/src/Foo.php @@ -0,0 +1,8 @@ + + */ +class CustomRule implements Rule +{ + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index a732a37c39b..43f125b898f 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -50,6 +50,7 @@ use function is_file; use function ksort; use function microtime; +use function realpath; use function sort; use function sprintf; use function str_starts_with; @@ -75,6 +76,9 @@ final class ResultCacheManager /** @var array */ private array $alreadyProcessed = []; + /** @var array|null */ + private ?array $analysedFileNamesByRealpath = null; + /** * @param string[] $analysedPaths * @param string[] $analysedPathsFromConfig @@ -1251,6 +1255,7 @@ private function streamArrayVarExportToHandle($handle, string $file, array $valu private function getProjectExtensionFiles(?array $projectConfig, array $dependencies): array { $this->alreadyProcessed = []; + $this->analysedFileNamesByRealpath = null; $projectExtensionFiles = []; if ($projectConfig !== null) { $vendorDirs = []; @@ -1289,6 +1294,17 @@ private function getProjectExtensionFiles(?array $projectConfig, array $dependen continue 2; } } + + // Reflection reports the realpath of the class file, but analysed paths + // may reach it through a symlink (e.g. a package symlinked into vendor). + // Match the analysed file by realpath before treating it as not analysed. + $analysedFileName = $this->findAnalysedFileNameByRealpath($fileName, $dependencies); + if ($analysedFileName !== null) { + $allServiceFiles = $this->getAllDependencies($analysedFileName, $dependencies); + } + } + + if (count($allServiceFiles) === 0) { $projectExtensionFiles[$fileName] = [$this->getFileHash($fileName), false, $class]; continue; } @@ -1335,6 +1351,34 @@ private function getAllDependencies(string $fileName, array $dependencies): arra return $files; } + /** + * Resolves a class file name (a realpath reported by reflection) to the analysed + * file it was reached through, matching by realpath so symlinked paths still line up. + * + * @param array> $dependencies + */ + private function findAnalysedFileNameByRealpath(string $fileName, array $dependencies): ?string + { + $realFileName = realpath($fileName); + if ($realFileName === false) { + return null; + } + $realFileName = $this->fileHelper->normalizePath($realFileName); + + if ($this->analysedFileNamesByRealpath === null) { + $this->analysedFileNamesByRealpath = []; + foreach (array_keys($dependencies) as $dependencyFile) { + $realDependencyFile = realpath($dependencyFile); + if ($realDependencyFile === false) { + continue; + } + $this->analysedFileNamesByRealpath[$this->fileHelper->normalizePath($realDependencyFile)] = $dependencyFile; + } + } + + return $this->analysedFileNamesByRealpath[$realFileName] ?? null; + } + /** * @param string[] $allAnalysedFiles * @param mixed[]|null $projectConfigArray