From 5a5f29e1d06feac6494cba334430d786de9aa0f6 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 8 Jul 2026 14:03:13 +0200 Subject: [PATCH 1/2] Track file dependencies for global constant fetches DependencyResolver recorded dependencies for class constants, functions, methods and properties, but not for global/namespaced constant fetches (Node\Expr\ConstFetch). A file that used only a global constant declared elsewhere therefore recorded no dependency on it, so changing the constant did not re-analyse the consumer from the result cache, leaving a stale result (also surfaced by package-granular cache invalidation when the declaring package is bumped). Resolve the constant and record its declaring file, guarding the ubiquitous true/false/null literals so the common path stays cheap. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Dependency/DependencyResolver.php | 22 ++++++++++++++----- src/Dependency/NodeDependencies.php | 5 +++-- tests/PHPStan/Analyser/AnalyserTest.php | 14 ++++++++++++ .../Analyser/data/const-dependency/def.php | 5 +++++ .../Analyser/data/const-dependency/user.php | 8 +++++++ 5 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/const-dependency/def.php create mode 100644 tests/PHPStan/Analyser/data/const-dependency/user.php diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index 87677b0f7f8..b520416f556 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -23,6 +23,7 @@ use PHPStan\Node\MethodCallableNode; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ConstantReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\FunctionReflection; @@ -38,7 +39,7 @@ final class DependencyResolver { - /** @var array> */ + /** @var array> */ private array $classDependencies = []; public function __construct( @@ -366,6 +367,17 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies } } } + } elseif ($node instanceof Node\Expr\ConstFetch) { + $constantName = $node->name; + $lowercasedConstantName = $constantName->toLowerString(); + if ( + $lowercasedConstantName !== 'true' + && $lowercasedConstantName !== 'false' + && $lowercasedConstantName !== 'null' + && $this->reflectionProvider->hasConstant($constantName, $scope) + ) { + $dependenciesReflections[] = $this->reflectionProvider->getConstant($constantName, $scope); + } } elseif ($node instanceof Node\Expr\StaticPropertyFetch) { if ($node->class instanceof Node\Name) { $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); @@ -521,7 +533,7 @@ private function considerArrayForCallableTest(Scope $scope, Array_ $arrayNode): } /** - * @param array $dependenciesReflections + * @param array $dependenciesReflections */ private function addClassToDependencies(string $className, array &$dependenciesReflections): void { @@ -533,7 +545,7 @@ private function addClassToDependencies(string $className, array &$dependenciesR } /** - * @return list + * @return list */ private function buildClassDependencies(string $className): array { @@ -687,7 +699,7 @@ private function getFunctionReflection(Node\Name $nameNode, ?Scope $scope): Func } /** - * @param array $dependenciesReflections + * @param array $dependenciesReflections */ private function extractFromParametersAcceptor( ExtendedParametersAcceptor $parametersAcceptor, @@ -724,7 +736,7 @@ private function extractFromParametersAcceptor( } /** - * @param array $dependenciesReflections + * @param array $dependenciesReflections */ private function extractThrowType( ?Type $throwType, diff --git a/src/Dependency/NodeDependencies.php b/src/Dependency/NodeDependencies.php index 6c944853d08..840928f8660 100644 --- a/src/Dependency/NodeDependencies.php +++ b/src/Dependency/NodeDependencies.php @@ -4,6 +4,7 @@ use PHPStan\File\FileHelper; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ConstantReflection; use PHPStan\Reflection\FunctionReflection; use function array_values; @@ -11,7 +12,7 @@ final class NodeDependencies { /** - * @param array $reflections + * @param array $reflections */ public function __construct( private FileHelper $fileHelper, @@ -22,7 +23,7 @@ public function __construct( } /** - * @return array + * @return array */ public function getReflections(): array { diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 6f781f28a25..dc148fbb9ad 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -737,6 +737,20 @@ public function testIgnoreErrorExplicitReportUnmatchedEnableMulti(): void $this->assertSame('Ignored error pattern #Fail# was not matched in reported errors.', $result[0]); } + public function testConstantFetchIsTrackedAsDependency(): void + { + $analyser = $this->createAnalyser(); + $defFile = $this->getFileHelper()->normalizePath(__DIR__ . '/data/const-dependency/def.php'); + $userFile = $this->getFileHelper()->normalizePath(__DIR__ . '/data/const-dependency/user.php'); + + $analyserResult = $analyser->analyse([$defFile, $userFile]); + + $dependencies = $analyserResult->getDependencies(); + $this->assertNotNull($dependencies); + $this->assertArrayHasKey($userFile, $dependencies); + $this->assertContains($defFile, $dependencies[$userFile]); + } + /** * @param mixed[] $ignoreErrors * @param string|string[] $filePaths diff --git a/tests/PHPStan/Analyser/data/const-dependency/def.php b/tests/PHPStan/Analyser/data/const-dependency/def.php new file mode 100644 index 00000000000..11532d03c15 --- /dev/null +++ b/tests/PHPStan/Analyser/data/const-dependency/def.php @@ -0,0 +1,5 @@ + Date: Wed, 8 Jul 2026 15:15:40 +0200 Subject: [PATCH 2/2] Track global constants as exported nodes so dependents re-analyse The dependency edge added in the previous commit records that a file reads a global constant, but the result cache only re-analyses a changed file's dependents when that file's exported nodes change, and global constant declarations were not exported nodes. So editing a global constant still left the reading files with stale cached results. Export global constant declarations (name + value) like class constants already are, so renaming or changing a constant re-analyses the files that use it. Covered by a result-cache e2e test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 7 ++ e2e/result-cache-constants/phpstan.neon | 5 + .../renameConstant.patch | 8 ++ .../src/ClassUsingConstant.php | 13 +++ e2e/result-cache-constants/src/Constants.php | 5 + e2e/result-cache-constants/tmp/.gitignore | 2 + .../ExportedNode/ExportedConstantNode.php | 70 ++++++++++++++ .../ExportedNode/ExportedConstantsNode.php | 94 +++++++++++++++++++ src/Dependency/ExportedNodeResolver.php | 14 +++ src/Dependency/RootExportedNode.php | 2 + 10 files changed, 220 insertions(+) create mode 100644 e2e/result-cache-constants/phpstan.neon create mode 100644 e2e/result-cache-constants/renameConstant.patch create mode 100644 e2e/result-cache-constants/src/ClassUsingConstant.php create mode 100644 e2e/result-cache-constants/src/Constants.php create mode 100644 e2e/result-cache-constants/tmp/.gitignore create mode 100644 src/Dependency/ExportedNode/ExportedConstantNode.php create mode 100644 src/Dependency/ExportedNode/ExportedConstantsNode.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 208bb0e5069..54cfaa44f85 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -352,6 +352,13 @@ jobs: composer install ../../bin/phpstan -vvv ../../bin/phpstan -vvv + - script: | + cd e2e/result-cache-constants + ../../bin/phpstan analyse + patch -b src/Constants.php < renameConstant.patch + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'ClassUsingConstant.php:10:Constant SOME_MODE not found. [identifier=constant.notFound]' "$OUTPUT" - script: | cd e2e/bug-12606 export CONFIGTEST=test diff --git a/e2e/result-cache-constants/phpstan.neon b/e2e/result-cache-constants/phpstan.neon new file mode 100644 index 00000000000..1796e7715c7 --- /dev/null +++ b/e2e/result-cache-constants/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + tmpDir: tmp diff --git a/e2e/result-cache-constants/renameConstant.patch b/e2e/result-cache-constants/renameConstant.patch new file mode 100644 index 00000000000..5d4706149b6 --- /dev/null +++ b/e2e/result-cache-constants/renameConstant.patch @@ -0,0 +1,8 @@ +--- src/Constants.php ++++ src/Constants.php +@@ -2,4 +2,4 @@ + + namespace ResultCacheE2EConstants; + +-const SOME_MODE = 1; ++const RENAMED_MODE = 1; diff --git a/e2e/result-cache-constants/src/ClassUsingConstant.php b/e2e/result-cache-constants/src/ClassUsingConstant.php new file mode 100644 index 00000000000..cb1bfc8c540 --- /dev/null +++ b/e2e/result-cache-constants/src/ClassUsingConstant.php @@ -0,0 +1,13 @@ +name; + } + + public function equals(ExportedNode $node): bool + { + if (!$node instanceof self) { + return false; + } + + return $this->name === $node->name + && $this->value === $node->value; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['name'], + $properties['value'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + $data['name'], + $data['value'], + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + #[Override] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'name' => $this->name, + 'value' => $this->value, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNode/ExportedConstantsNode.php b/src/Dependency/ExportedNode/ExportedConstantsNode.php new file mode 100644 index 00000000000..2f9c2ee45c4 --- /dev/null +++ b/src/Dependency/ExportedNode/ExportedConstantsNode.php @@ -0,0 +1,94 @@ +constants[0]->getName(); + } + + public function equals(ExportedNode $node): bool + { + if (!$node instanceof self) { + return false; + } + + if (count($this->constants) !== count($node->constants)) { + return false; + } + + foreach ($this->constants as $i => $constant) { + if (!$constant->equals($node->constants[$i])) { + return false; + } + } + + return true; + } + + /** + * @param mixed[] $properties + */ + public static function __set_state(array $properties): self + { + return new self( + $properties['constants'], + ); + } + + /** + * @param mixed[] $data + */ + public static function decode(array $data): self + { + return new self( + array_map(static function (array $constantData): ExportedConstantNode { + if ($constantData['type'] !== ExportedConstantNode::class) { + throw new ShouldNotHappenException(); + } + + return ExportedConstantNode::decode($constantData['data']); + }, $data['constants']), + ); + } + + /** + * @return mixed + */ + #[ReturnTypeWillChange] + #[Override] + public function jsonSerialize() + { + return [ + 'type' => self::class, + 'data' => [ + 'constants' => $this->constants, + ], + ]; + } + +} diff --git a/src/Dependency/ExportedNodeResolver.php b/src/Dependency/ExportedNodeResolver.php index 678d6e967df..1e230374a0f 100644 --- a/src/Dependency/ExportedNodeResolver.php +++ b/src/Dependency/ExportedNodeResolver.php @@ -12,6 +12,8 @@ use PHPStan\Dependency\ExportedNode\ExportedClassConstantNode; use PHPStan\Dependency\ExportedNode\ExportedClassConstantsNode; use PHPStan\Dependency\ExportedNode\ExportedClassNode; +use PHPStan\Dependency\ExportedNode\ExportedConstantNode; +use PHPStan\Dependency\ExportedNode\ExportedConstantsNode; use PHPStan\Dependency\ExportedNode\ExportedEnumCaseNode; use PHPStan\Dependency\ExportedNode\ExportedEnumNode; use PHPStan\Dependency\ExportedNode\ExportedFunctionNode; @@ -226,6 +228,18 @@ public function resolve(string $fileName, Node $node): ?RootExportedNode ); } + if ($node instanceof Node\Stmt\Const_) { + $constants = []; + foreach ($node->consts as $const) { + $constants[] = new ExportedConstantNode( + $const->name->toString(), + $this->exprPrinter->printExpr($const->value), + ); + } + + return new ExportedConstantsNode($constants); + } + return null; } diff --git a/src/Dependency/RootExportedNode.php b/src/Dependency/RootExportedNode.php index 9434c913372..7a7f93a328f 100644 --- a/src/Dependency/RootExportedNode.php +++ b/src/Dependency/RootExportedNode.php @@ -15,6 +15,8 @@ interface RootExportedNode extends ExportedNode public const TYPE_FUNCTION = 'function'; + public const TYPE_CONSTANT = 'constant'; + /** @return self::TYPE_* */ public function getType(): string;