From b922f9763f0a4204374139d44c8bac9a4b2b38f3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 30 Sep 2026 20:12:18 +0200 Subject: [PATCH 1/5] Upgrade simple-downgrader --- compiler/composer.json | 2 +- compiler/composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/compiler/composer.json b/compiler/composer.json index 5308eec70a4..5728895533f 100644 --- a/compiler/composer.json +++ b/compiler/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "nette/neon": "^3.0.0", - "ondrejmirtes/simple-downgrader": "^2.2.7", + "ondrejmirtes/simple-downgrader": "^2.2.9", "seld/phar-utils": "^1.2", "symfony/console": "^5.4.43", "symfony/filesystem": "^5.4.43", diff --git a/compiler/composer.lock b/compiler/composer.lock index 1e626ef2c79..8aec64f836a 100644 --- a/compiler/composer.lock +++ b/compiler/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7aa6a7377310ec2e6ee27d299b976e5b", + "content-hash": "09f6ef2c6b1386f0089ec2727990b60d", "packages": [ { "name": "nette/neon", @@ -222,16 +222,16 @@ }, { "name": "ondrejmirtes/simple-downgrader", - "version": "2.2.7", + "version": "2.2.9", "source": { "type": "git", "url": "https://github.com/ondrejmirtes/simple-downgrader.git", - "reference": "140f86600b499e1c460761196a12fab9aeb7ec33" + "reference": "b12a15de3c3aaed7c15bbb6f26fd734557fee0e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ondrejmirtes/simple-downgrader/zipball/140f86600b499e1c460761196a12fab9aeb7ec33", - "reference": "140f86600b499e1c460761196a12fab9aeb7ec33", + "url": "https://api.github.com/repos/ondrejmirtes/simple-downgrader/zipball/b12a15de3c3aaed7c15bbb6f26fd734557fee0e7", + "reference": "b12a15de3c3aaed7c15bbb6f26fd734557fee0e7", "shasum": "" }, "require": { @@ -265,9 +265,9 @@ "description": "Simple Downgrader", "support": { "issues": "https://github.com/ondrejmirtes/simple-downgrader/issues", - "source": "https://github.com/ondrejmirtes/simple-downgrader/tree/2.2.7" + "source": "https://github.com/ondrejmirtes/simple-downgrader/tree/2.2.9" }, - "time": "2026-06-12T17:10:38+00:00" + "time": "2026-09-30T19:26:40+00:00" }, { "name": "phpstan/phpdoc-parser", From c51e3d6356d38a0298a1ef76645eb4f440b4d2b1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 30 Sep 2026 20:50:17 +0200 Subject: [PATCH 2/5] Resolve static::FOO_* and T::FOO_* once the class is known A class constant wildcard on static or on a template type was resolved against the class known at the declaration - an interface declaring none of the constants its implementations do, the bound of the template - and was unresolvable there. Both now resolve once the class is known: static for the class the method is called on, a template once it is resolved. Only a final class, or a template bound to one, is known not to declare more constants than it does now, and only for those an unmatched wildcard is unresolvable. Until then the PHPDoc type cannot be compared with the native type, so it is kept rather than replaced by it, and not reported as incompatible with it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ --- src/PhpDoc/TypeNodeResolver.php | 28 ++- .../Php/PhpClassReflectionExtension.php | 7 + src/Rules/Classes/InstantiationRule.php | 18 +- .../PhpDoc/IncompatiblePhpDocTypeCheck.php | 19 +- .../IncompatiblePropertyPhpDocTypeRule.php | 6 +- src/Type/ClassConstantAccessType.php | 45 +++-- src/Type/ClassConstantPatternResolver.php | 126 ++++++++++++ src/Type/TypehintHelper.php | 5 + tests/PHPStan/Analyser/nsrt/bug-11510.php | 37 ++++ tests/PHPStan/Analyser/nsrt/bug-4548.php | 43 +++++ ...lass-constant-wildcard-static-template.php | 182 ++++++++++++++++++ .../Rules/Classes/InstantiationRuleTest.php | 10 + tests/PHPStan/Rules/Classes/data/bug-4548.php | 26 +++ .../CallToFunctionParametersRuleTest.php | 10 + .../Rules/Functions/data/bug-11510.php | 20 ++ .../Rules/Methods/CallMethodsRuleTest.php | 26 +++ .../class-constant-wildcard-arguments.php | 61 ++++++ .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 27 +++ ...IncompatiblePropertyPhpDocTypeRuleTest.php | 5 + tests/PHPStan/Rules/PhpDoc/data/bug-11510.php | 20 ++ tests/PHPStan/Rules/PhpDoc/data/bug-4548.php | 26 +++ .../class-constant-wildcard-unresolvable.php | 67 +++++++ turbo-ext/src/ClassConstantAccessType.cpp | 119 ++++++++---- turbo-ext/src/PhpClassReflectionExtension.cpp | 7 + turbo-ext/src/TypehintHelper.cpp | 7 + .../src/generated/ClassConstantAccessType.h | 96 ++++----- turbo-ext/src/support.cpp | 1 + turbo-ext/src/support.h | 1 + turbo-ext/tests/type-family.php | 3 + 29 files changed, 946 insertions(+), 102 deletions(-) create mode 100644 src/Type/ClassConstantPatternResolver.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11510.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4548.php create mode 100644 tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-4548.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11510.php create mode 100644 tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-11510.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-4548.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index a2b9062093f..4c52e21fe2a 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1323,6 +1323,24 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc } } + if (!isset($className)) { + $templateType = $nameScope->resolveTemplateTypeName($constExpr->className); + if ($templateType instanceof TemplateType) { + if (strtolower($constExpr->name) === 'class') { + return new GenericClassStringType($templateType); + } + + // A template bound to a final class can only be that class. Any other can be a class + // declaring constants its bound does not - known once the template is resolved. + $boundClassReflections = $templateType->getBound()->getObjectClassReflections(); + if (count($boundClassReflections) !== 1 || !$boundClassReflections[0]->isFinal()) { + return new ClassConstantAccessType($templateType, $constExpr->name); + } + + $className = $boundClassReflections[0]->getName(); + } + } + if (!isset($className)) { $className = $nameScope->resolveStringName($constExpr->className); } @@ -1346,6 +1364,12 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new ConstantStringType($classReflection->getName(), true); } + // A class that is not final can have subclasses declaring constants it does not - what + // static:: stands for is known once static is resolved. + if ($isStatic) { + return new ClassConstantAccessType(new StaticType($classReflection), $constantName); + } + if (Strings::contains($constantName, '*')) { // convert * into .*? and escape everything else so the constants can be matched against the pattern $pattern = '{^' . str_replace('\\*', '.*?', preg_quote($constantName)) . '$}D'; @@ -1389,10 +1413,6 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new EnumCaseObjectType($classReflection->getName(), $constantName); } - if ($isStatic) { - return new ClassConstantAccessType(new StaticType($classReflection), $constantName); - } - $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($constantName); if ($reflectionConstant === false) { return new ErrorType(); diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index a047011fd93..694ae002e76 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -50,6 +50,7 @@ use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Enum\EnumCaseObjectType; @@ -1329,6 +1330,12 @@ private function getPhpDocReturnType(ClassReflection $phpDocBlockClassReflection return $phpDocReturnType; } + if ($phpDocReturnType instanceof ClassConstantAccessType && !$phpDocReturnType->isResolvable()) { + // static::FOO_* or T::* - it can be compared with the native type only once the class is + // known, see TypehintHelper::decideType() + return $phpDocReturnType; + } + if ($nativeReturnType->isSuperTypeOf($phpDocReturnType)->yes()) { return $phpDocReturnType; } diff --git a/src/Rules/Classes/InstantiationRule.php b/src/Rules/Classes/InstantiationRule.php index f0b4f4bf424..f9c19257b04 100644 --- a/src/Rules/Classes/InstantiationRule.php +++ b/src/Rules/Classes/InstantiationRule.php @@ -16,6 +16,7 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\Php\PhpMethodReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Reflection\Type\CalledOnTypeUnresolvedMethodPrototypeReflection; use PHPStan\Rules\ClassNameCheck; use PHPStan\Rules\ClassNameNodePair; use PHPStan\Rules\ClassNameUsageLocation; @@ -29,6 +30,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; +use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -293,12 +295,24 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope&N $classDisplayName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $calledConstructorReflection = $constructorReflection; + if ($constructorReflection->getDeclaringClass()->getName() !== $classReflection->getName()) { + // static in the PHPDoc of an inherited constructor is the class being instantiated, + // as it is the class a method is called on + $calledConstructorReflection = (new CalledOnTypeUnresolvedMethodPrototypeReflection( + $constructorReflection, + $constructorReflection->getDeclaringClass(), + false, + new ObjectType($classReflection->getName(), classReflection: $classReflection), + ))->getTransformedMethod(); + } + return array_merge($messages, $this->check->check( ParametersAcceptorSelector::selectFromArgs( $scope, $node->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), + $calledConstructorReflection->getVariants(), + $calledConstructorReflection->getNamedArgumentsVariants(), ), $scope, $constructorReflection->getDeclaringClass()->isBuiltin(), diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php index 7e2963cf1a0..9280c062350 100644 --- a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php @@ -12,6 +12,8 @@ use PHPStan\Rules\Generics\GenericObjectTypeCheck; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\TrinaryLogic; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\ClosureType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Type; @@ -143,7 +145,9 @@ public function check( } if (in_array($tagName, ['@param', '@param-out'], true)) { - $isParamSuperType = $nativeParamType->isSuperTypeOf($phpDocParamType); + $isParamSuperType = $this->isDeferredClassConstantAccess($phpDocParamType) + ? TrinaryLogic::createYes() + : $nativeParamType->isSuperTypeOf($phpDocParamType); if ($isParamSuperType->no()) { $errors[] = RuleErrorBuilder::message(sprintf( 'PHPDoc tag %s for parameter $%s with type %s is incompatible with native type %s.', @@ -196,7 +200,9 @@ public function check( $errors[] = $errorBuilder->build(); } else { - $isReturnSuperType = $nativeReturnType->isSuperTypeOf($phpDocReturnType); + $isReturnSuperType = $this->isDeferredClassConstantAccess($phpDocReturnType) + ? TrinaryLogic::createYes() + : $nativeReturnType->isSuperTypeOf($phpDocReturnType); $errors = array_merge($errors, $this->genericObjectTypeCheck->check( $phpDocReturnType, 'PHPDoc tag @return contains generic type %s but %s %s is not generic.', @@ -241,4 +247,13 @@ public function check( return $errors; } + /** + * static::FOO_* or T::* - which constants it stands for is known only for the class the method is + * called on, so it cannot be compared with the native type here. + */ + private function isDeferredClassConstantAccess(Type $type): bool + { + return $type instanceof ClassConstantAccessType && !$type->isResolvable(); + } + } diff --git a/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php index 36662057dd2..65c62d48d87 100644 --- a/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php +++ b/src/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRule.php @@ -11,6 +11,7 @@ use PHPStan\Rules\Generics\GenericObjectTypeCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\VerbosityLevel; use function array_merge; @@ -69,7 +70,10 @@ public function processNode(Node $node, Scope $scope): array } $nativeType = $node->getNativeType(); - if ($nativeType !== null) { + // static::FOO_* or T::* - which constants it stands for is known only for the class of the + // object, so it cannot be compared with the native type here + $isDeferred = $phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable(); + if ($nativeType !== null && !$isDeferred) { $isSuperType = $nativeType->isSuperTypeOf($phpDocType); if ($isSuperType->no()) { $messages[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index 991c271295e..ed55e80967b 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Traits\LateResolvableTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; @@ -17,13 +18,27 @@ final class ClassConstantAccessType implements CompoundType, LateResolvableType use LateResolvableTypeTrait; use NonGeneralizableTypeTrait; + /** + * @param Type|null $nativeType The native type next to the PHPDoc type this is - see withNativeType(). + */ public function __construct( private Type $type, private string $constantName, + private ?Type $nativeType = null, ) { } + /** + * The PHPDoc type of a parameter or of a return type with its native type. Which constants + * static:: or T:: stands for is known only once the class is, so the two are combined only + * then - see TypehintHelper::decideType(). + */ + public function withNativeType(Type $nativeType): self + { + return new self($this->type, $this->constantName, $nativeType); + } + public function getReferencedClasses(): array { return $this->type->getReferencedClasses(); @@ -36,9 +51,15 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc public function equals(Type $type): bool { - return $type instanceof self - && $this->constantName === $type->constantName - && $this->type->equals($type->type); + if (!$type instanceof self || $this->constantName !== $type->constantName || !$this->type->equals($type->type)) { + return false; + } + + if ($this->nativeType === null || $type->nativeType === null) { + return $this->nativeType === $type->nativeType; + } + + return $this->nativeType->equals($type->nativeType); } public function describe(VerbosityLevel $level): string @@ -46,18 +67,18 @@ public function describe(VerbosityLevel $level): string return $this->resolve()->describe($level); } + /** + * Not while the class is a template type or static - both can still turn out to be a class + * declaring constants the class known now does not. + */ public function isResolvable(): bool { - return !TypeUtils::containsTemplateType($this->type); + return !TypeUtils::containsTemplateType($this->type) && !$this->type instanceof StaticType; } protected function getResult(): Type { - if ($this->type->hasConstant($this->constantName)->yes()) { - return $this->type->getConstant($this->constantName)->getValueType(); - } - - return new ErrorType(); + return ClassConstantPatternResolver::resolve($this->type, $this->constantName, $this->nativeType); } /** @@ -71,7 +92,7 @@ public function traverse(callable $cb): Type return $this; } - return new self($type, $this->constantName); + return new self($type, $this->constantName, $this->nativeType); } public function traverseSimultaneously(Type $right, callable $cb): Type @@ -86,12 +107,12 @@ public function traverseSimultaneously(Type $right, callable $cb): Type return $this; } - return new self($type, $this->constantName); + return new self($type, $this->constantName, $this->nativeType); } public function toPhpDocNode(): TypeNode { - return new ConstTypeNode(new ConstFetchNode('static', $this->constantName)); + return new ConstTypeNode(new ConstFetchNode($this->type instanceof TemplateType ? $this->type->getName() : 'static', $this->constantName)); } } diff --git a/src/Type/ClassConstantPatternResolver.php b/src/Type/ClassConstantPatternResolver.php new file mode 100644 index 00000000000..f19fcbd1c68 --- /dev/null +++ b/src/Type/ClassConstantPatternResolver.php @@ -0,0 +1,126 @@ +getObjectClassReflections(); + if ($classReflections === []) { + return new ErrorType(); + } + + $isClassKnown = !$type instanceof StaticType && !TypeUtils::containsTemplateType($type); + $constantTypes = []; + foreach ($classReflections as $classReflection) { + $classConstantTypes = str_contains($pattern, '*') + ? self::resolveWildcard($classReflection, $pattern, $isClassKnown) + : self::resolveConstant($classReflection, $pattern); + if ($classConstantTypes instanceof MixedType) { + return $classConstantTypes; + } + + foreach ($classConstantTypes as $constantType) { + $constantTypes[] = $constantType; + } + } + + if ($constantTypes === []) { + return new ErrorType(); + } + + return TypeCombinator::union(...$constantTypes); + } + + /** + * @return list|MixedType + */ + private static function resolveWildcard(ClassReflection $classReflection, string $pattern, bool $isClassKnown): array|MixedType + { + if ( + !$classReflection->isFinal() + && (!$isClassKnown || $classReflection->isInterface() || $classReflection->isAbstract()) + ) { + return new MixedType(); + } + + // convert * into .*? and escape everything else so the constants can be matched against the pattern + $regex = '{^' . str_replace('\\*', '.*?', preg_quote($pattern)) . '$}D'; + $constantTypes = []; + foreach ($classReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { + $constantName = $reflectionConstant->getName(); + if (Strings::match($constantName, $regex) === null) { + continue; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + $constantTypes[] = new EnumCaseObjectType($classReflection->getName(), $constantName); + continue; + } + + $constantTypes[] = $classReflection->getConstant($constantName)->getValueType(); + } + + return $constantTypes; + } + + /** + * A single constant is the one of the class it is looked up on, like `static::FOO` in a return + * type is the value for the class the method is called on. + * + * @return list|MixedType + */ + private static function resolveConstant(ClassReflection $classReflection, string $constantName): array|MixedType + { + if (!$classReflection->hasConstant($constantName)) { + if (!$classReflection->isFinal()) { + return new MixedType(); + } + + return []; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + return [new EnumCaseObjectType($classReflection->getName(), $constantName)]; + } + + return [$classReflection->getConstant($constantName)->getValueType()]; + } + +} diff --git a/src/Type/TypehintHelper.php b/src/Type/TypehintHelper.php index 9254c1c6359..6fdab79212b 100644 --- a/src/Type/TypehintHelper.php +++ b/src/Type/TypehintHelper.php @@ -91,6 +91,11 @@ public static function decideType( if ($phpDocType instanceof NeverType && $phpDocType->isExplicit()) { return $phpDocType; } + if ($phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable()) { + // static::FOO_* or T::* - which constants it stands for is known only for the class the + // method is called on, and cannot be compared with the native type before that + return $phpDocType->withNativeType($type); + } if ( $type instanceof MixedType && !$type->isExplicitMixed() diff --git a/tests/PHPStan/Analyser/nsrt/bug-11510.php b/tests/PHPStan/Analyser/nsrt/bug-11510.php new file mode 100644 index 00000000000..04fcb3e1608 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11510.php @@ -0,0 +1,37 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void +{ + // T is any implementation of FooInterface - only the native type is certain + assertType('string', $classConfig); +} + +/** + * @template T of FooInterface + * @param class-string $class + * @return T::CONFIG_* + */ +function config(string $class): string +{ + return ''; +} + +function (): void { + assertType("'test'", config(Bar::class)); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-4548.php b/tests/PHPStan/Analyser/nsrt/bug-4548.php new file mode 100644 index 00000000000..68aa0cd1825 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4548.php @@ -0,0 +1,43 @@ +value = $value; + } + + /** @return static::* */ + public function getValue(): int + { + assertType('int', $this->value); + + return $this->value; + } + +} + +final class Suit extends Enum +{ + + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; + +} + +function (Suit $suit): void { + assertType('1|2|3|4', $suit->getValue()); +}; diff --git a/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php b/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php new file mode 100644 index 00000000000..71398cdcfc6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-constant-wildcard-static-template.php @@ -0,0 +1,182 @@ += 8.1 + +namespace ClassConstantWildcardStaticTemplate; + +use function PHPStan\Testing\assertType; + +interface Extension +{ + + /** + * @param static::* $type + * @return static::* + */ + public function doFoo(string $type): string; + + /** + * @return static::VERSION + */ + public function getVersion(): string; + +} + +final class FileExtension implements Extension +{ + + public const FILE = 'file'; + public const VERSION = 'file-1'; + + public function doFoo(string $type): string + { + assertType("'file'|'file-1'", $type); + + return $type; + } + + public function getVersion(): string + { + return self::VERSION; + } + +} + +class ContainerExtension implements Extension +{ + + public const SERVICE_HAS = 'hasService'; + public const SERVICE_GET = 'getService'; + public const PARAMETER = 'parameter'; + public const VERSION = 'container-1'; + + public function doFoo(string $type): string + { + // static can be a subclass declaring more constants - only the native type is certain + assertType('string', $type); + + return $type; + } + + public function getVersion(): string + { + return static::VERSION; + } + + /** + * @param static::SERVICE_* $type + */ + public function doService(string $type): void + { + assertType('string', $type); + } + + /** + * @return static::SERVICE_* + */ + public function getService(): string + { + return self::SERVICE_HAS; + } + +} + +final class SymfonyContainerExtension extends ContainerExtension +{ + + public const SERVICE_TAGGED = 'taggedService'; + public const VERSION = 'symfony-1'; + +} + +/** + * @template T of Extension + * @param class-string $extensionClass + * @param T::* $type + * @return T::* + */ +function valueOf(string $extensionClass, string $type): string +{ + assertType('class-string', $extensionClass); + + return $type; +} + +/** + * @template T of Extension + * @param class-string $extensionClass + * @return class-string + */ +function classOf(string $extensionClass): string +{ + /** @var T::class $class */ + $class = $extensionClass; + + return $class; +} + +/** + * @template T of FileExtension + * @param class-string $extensionClass + * @param T::* $type + */ +function finalBound(string $extensionClass, string $type): void +{ + assertType("'file'|'file-1'", $type); +} + +function ( + Extension $extension, + FileExtension $fileExtension, + ContainerExtension $containerExtension, + SymfonyContainerExtension $symfonyContainerExtension, +): void { + // the class the method is called on decides which constants static:: stands for + assertType("'file'|'file-1'", $fileExtension->doFoo(FileExtension::FILE)); + assertType("'container-1'|'getService'|'hasService'|'parameter'", $containerExtension->doFoo(ContainerExtension::PARAMETER)); + assertType("'getService'|'hasService'|'parameter'|'symfony-1'|'taggedService'", $symfonyContainerExtension->doFoo(SymfonyContainerExtension::SERVICE_TAGGED)); + assertType("'getService'|'hasService'", $containerExtension->getService()); + assertType("'getService'|'hasService'|'taggedService'", $symfonyContainerExtension->getService()); + + // an interface declares none of the constants its implementations do - only the native type is certain + assertType('string', $extension->doFoo('anything')); + + // a single constant is the one of the class the method is called on, as with static::FOO elsewhere + assertType("'file-1'", $fileExtension->getVersion()); + assertType("'container-1'", $containerExtension->getVersion()); + assertType("'symfony-1'", $symfonyContainerExtension->getVersion()); + + // T::* stands for the constants of the class T is inferred as + assertType("'file'|'file-1'", valueOf(FileExtension::class, FileExtension::FILE)); + assertType("'getService'|'hasService'|'parameter'|'symfony-1'|'taggedService'", valueOf(SymfonyContainerExtension::class, SymfonyContainerExtension::SERVICE_HAS)); + assertType('class-string', classOf(FileExtension::class)); +}; + +trait HasModes +{ + + /** + * @param static::MODE_* $mode + * @return static::MODE_* + */ + public function withMode(string $mode): string + { + // in the context of the only class using the trait, which is final + assertType("'fast'|'slow'", $mode); + + return $mode; + } + +} + +final class Engine +{ + + use HasModes; + + public const MODE_FAST = 'fast'; + public const MODE_SLOW = 'slow'; + +} + +function (Engine $engine): void { + assertType("'fast'|'slow'", $engine->withMode(Engine::MODE_FAST)); +}; diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index d518365eaf6..e5be65d06a4 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -800,4 +800,14 @@ public function testBug12827(bool $checkImportedClassNameCase): void $this->analyse([__DIR__ . '/data/bug-12827.php'], $expectedErrors); } + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], [ + [ + 'Parameter #1 $value of class Bug4548\\Suit constructor expects 1|2|3|4, 5 given.', + 26, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-4548.php b/tests/PHPStan/Rules/Classes/data/bug-4548.php new file mode 100644 index 00000000000..9c6d1ac61df --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-4548.php @@ -0,0 +1,26 @@ +value = $value; + } +} + +final class Suit extends Enum +{ + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; +} + +new Suit(Suit::HEARTS); +new Suit(5); diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 501b328ff6c..3ac6c660706 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3315,4 +3315,14 @@ public function testOversizedArrayUnionWithEmptyArray(): void ]); } + public function testBug11510(): void + { + $this->analyse([__DIR__ . '/data/bug-11510.php'], [ + [ + 'Parameter #2 $classConfig of function Bug11510\\foo expects \'test\', \'hello\' given.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11510.php b/tests/PHPStan/Rules/Functions/data/bug-11510.php new file mode 100644 index 00000000000..b48108451ea --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11510.php @@ -0,0 +1,20 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void { +} + +foo(Bar::class, 'test'); +foo(Bar::class, 'hello'); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index c7bf350930e..7f5210d5f06 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4560,4 +4560,30 @@ public function testBug15251(): void $this->analyse([__DIR__ . '/data/bug-15251.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testClassConstantWildcardArguments(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/class-constant-wildcard-arguments.php'], [ + [ + 'Parameter #2 $type of method ClassConstantWildcardArguments\\Emitter::valueDependency() expects \'file\', \'hasService\' given.', + 54, + ], + [ + 'Parameter #2 $type of method ClassConstantWildcardArguments\\Emitter::valueDependency() expects \'file\', \'nope\' given.', + 55, + ], + [ + 'Parameter #1 $type of method ClassConstantWildcardArguments\\FileExtension::doFoo() expects \'file\', \'hasService\' given.', + 58, + ], + [ + 'Parameter #1 $type of method ClassConstantWildcardArguments\\ContainerExtension::doFoo() expects \'hasService\', \'nope\' given.', + 59, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php b/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php new file mode 100644 index 00000000000..17b1cc519cc --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/class-constant-wildcard-arguments.php @@ -0,0 +1,61 @@ += 8.1 + +namespace ClassConstantWildcardArguments; + +interface Extension +{ + + /** + * @param static::* $type + */ + public function doFoo(string $type): void; + +} + +final class FileExtension implements Extension +{ + + public const FILE = 'file'; + + public function doFoo(string $type): void + { + } + +} + +class ContainerExtension implements Extension +{ + + public const HAS_SERVICE = 'hasService'; + + public function doFoo(string $type): void + { + } + +} + +final class Emitter +{ + + /** + * @template T of Extension + * @param class-string $extensionClass + * @param T::* $type + */ + public function valueDependency(string $extensionClass, string $type): void + { + } + +} + +function (Emitter $emitter, Extension $extension, FileExtension $fileExtension, ContainerExtension $containerExtension): void { + $emitter->valueDependency(FileExtension::class, FileExtension::FILE); + $emitter->valueDependency(ContainerExtension::class, ContainerExtension::HAS_SERVICE); + $emitter->valueDependency(FileExtension::class, ContainerExtension::HAS_SERVICE); + $emitter->valueDependency(FileExtension::class, 'nope'); + + $fileExtension->doFoo(FileExtension::FILE); + $fileExtension->doFoo(ContainerExtension::HAS_SERVICE); + $containerExtension->doFoo('nope'); + $extension->doFoo('anything'); +}; diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index 808c165f1b2..c284aea9916 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -521,4 +521,31 @@ public function testExplainUnresolvable(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testClassConstantWildcardUnresolvable(): void + { + // static and a template can stand for a class declaring the constants - only a final class + // is known not to + $this->analyse([__DIR__ . '/data/class-constant-wildcard-unresolvable.php'], [ + [ + 'PHPDoc tag @param for parameter $type contains unresolvable type.', + 40, + ], + [ + 'PHPDoc tag @param for parameter $type contains unresolvable type.', + 65, + ], + ]); + } + + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], []); + } + + public function testBug11510(): void + { + $this->analyse([__DIR__ . '/data/bug-11510.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php index 674aa088002..bfe33676515 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php @@ -203,4 +203,9 @@ public function testBug11033(): void $this->analyse([__DIR__ . '/data/bug-11033.php'], []); } + public function testBug4548(): void + { + $this->analyse([__DIR__ . '/data/bug-4548.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php b/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php new file mode 100644 index 00000000000..0563de0b3e8 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-11510.php @@ -0,0 +1,20 @@ + $class + * @param T::CONFIG_* $classConfig + */ +function foo(string $class, string $classConfig): void { +} + +foo(Bar::class, 'test'); +foo(Bar::class, 'hello'); diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php b/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php new file mode 100644 index 00000000000..d6e637a1a31 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-4548.php @@ -0,0 +1,26 @@ +value = $value; + } +} + +final class Suit extends Enum +{ + public const HEARTS = 1; + public const DIAMONDS = 2; + public const SPADES = 3; + public const CLUBS = 4; +} + +new Suit(Suit::HEARTS); +new Suit(5); diff --git a/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php b/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php new file mode 100644 index 00000000000..4868cea3a7a --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/class-constant-wildcard-unresolvable.php @@ -0,0 +1,67 @@ += 8.1 + +namespace ClassConstantWildcardUnresolvable; + +interface Extension +{ + + /** + * @param static::* $type + */ + public function doFoo(string $type): void; + + /** + * @param static::VERSION $type + */ + public function doBar(string $type): void; + +} + +class NotFinal +{ + + /** + * @param static::NOPE_* $type + */ + public function doFoo(string $type): void + { + } + +} + +final class IsFinal +{ + + public const FOO = 'foo'; + + /** + * @param static::NOPE_* $type + */ + public function doFoo(string $type): void + { + } + + /** + * @param static::F* $type + */ + public function doBar(string $type): void + { + } + +} + +/** + * @template T of NotFinal + * @param T::NOPE_* $type + */ +function notFinalBound(string $type): void +{ +} + +/** + * @template T of IsFinal + * @param T::NOPE_* $type + */ +function finalBound(string $type): void +{ +} diff --git a/turbo-ext/src/ClassConstantAccessType.cpp b/turbo-ext/src/ClassConstantAccessType.cpp index aae660864a5..7a8a50245a3 100644 --- a/turbo-ext/src/ClassConstantAccessType.cpp +++ b/turbo-ext/src/ClassConstantAccessType.cpp @@ -2,9 +2,9 @@ * PHPStanTurbo\ClassConstantAccessType — native implementation of * PHPStan\Type\ClassConstantAccessType. * - * State is the twin's two promoted `private Type $type` / `private string - * $constantName` in slots 0 and 1; LateResolvableTypeTrait's `private ?Type - * $result` follows them, declared by the shared registrar in TypeTraits.cpp + * State is the twin's three promoted `private Type $type` / `private string + * $constantName` / `private ?Type $nativeType` in slots 0 to 2; + * LateResolvableTypeTrait's `private ?Type $result` follows them, declared by the shared registrar in TypeTraits.cpp * that also supplies the trait's forwards; NonGeneralizableTypeTrait's * generalize() comes from its registrar. * @@ -31,29 +31,43 @@ class ClassConstantAccessType public: explicit ClassConstantAccessType(zend_object *self) : self(self) {} - /* __construct(private Type $type, private string $constantName); both - * borrowed */ - void construct(zval *type, zend_string *constantName) + /* __construct(private Type $type, private string $constantName, private + * ?Type $nativeType = null); all borrowed, $nativeType NULL for the + * default */ + void construct(zval *type, zend_string *constantName, zval *nativeType) { writeSlot(slots::type, type); zval name; ZVAL_STR(&name, constantName); writeSlot(slots::constantName, &name); + zval null; + ZVAL_NULL(&null); + writeSlot(slots::nativeType, nativeType != NULL ? nativeType : &null); } - /* new self($type, $constantName); UNDEF = pending exception */ - static zv::Val create(zval *type, zend_string *constantName) + /* new self($type, $constantName, $nativeType); UNDEF = pending exception */ + static zv::Val create(zval *type, zend_string *constantName, zval *nativeType) { zval object; if (UNEXPECTED(object_init_ex(&object, pt_ce_class_constant_access_type) != SUCCESS)) return zv::Val(); - ClassConstantAccessType(Z_OBJ(object)).construct(type, constantName); + ClassConstantAccessType(Z_OBJ(object)).construct(type, constantName, nativeType); return zv::Val::adopt(object); } + /* new self($this->type, $this->constantName, $nativeType) */ + zv::Val withNativeType(zval *nativeType) const + { + zval *t = type(); + zval *name = t != NULL ? constantName() : NULL; + if (UNEXPECTED(name == NULL)) return zv::Val(); + return create(t, Z_STR_P(name), nativeType); + } + /* the slots (borrowed); NULL with an Error pending when the constructor * never ran */ [[nodiscard]] zval *type() const { return slot(self, slots::type, "type"); } zval *constantName() const { return slot(self, slots::constantName, "constantName"); } + zval *nativeType() const { return slot(self, slots::nativeType, "nativeType"); } static zval *slot(zend_object *object, uint32_t index, const char *name) { return pt_typed_slot(object, index, pt_ce_class_constant_access_type, name); } @@ -83,7 +97,22 @@ class ClassConstantAccessType if (UNEXPECTED(theirType == NULL)) return false; zv::Val equal = callType(PT_LC("equals"), 1, theirType); if (UNEXPECTED(equal.isUndef())) return false; - out = zend_is_true(equal.raw()); + if (!zend_is_true(equal.raw())) { + out = false; + return true; + } + /* the native types: identical when either is null, equals() otherwise */ + zval *native = nativeType(); + if (UNEXPECTED(native == NULL)) return false; + zval *theirNative = slot(Z_OBJ_P(type), slots::nativeType, "nativeType"); + if (UNEXPECTED(theirNative == NULL)) return false; + if (Z_TYPE_P(native) == IS_NULL || Z_TYPE_P(theirNative) == IS_NULL) { + out = Z_TYPE_P(native) == Z_TYPE_P(theirNative); + return true; + } + zv::Val nativeEqual = pt_type_call(Z_OBJ_P(native), PT_LC("equals"), 1, theirNative); + if (UNEXPECTED(nativeEqual.isUndef())) return false; + out = zend_is_true(nativeEqual.raw()); return true; } @@ -99,41 +128,36 @@ class ClassConstantAccessType return pt_type_op(Z_OBJ_P(resolved.raw()), PT_OP_DESCRIBE, 1, level); } - /* !TypeUtils::containsTemplateType($this->type); false = pending exception */ + /* !TypeUtils::containsTemplateType($this->type) && !$this->type instanceof StaticType; + * false = pending exception */ [[nodiscard]] bool isResolvable(bool &out) const { zval *t = type(); if (UNEXPECTED(t == NULL)) return false; bool contains; if (UNEXPECTED(!pt_type_utils_contains_template_type(t, contains))) return false; - out = !contains; + out = !contains && !(pt_ce_static_type != NULL && instanceof_function(Z_OBJCE_P(t), pt_ce_static_type)); return true; } - /* $this->type->getConstant($this->constantName)->getValueType() when - * $this->type->hasConstant($this->constantName)->yes(), new ErrorType() - * otherwise; UNDEF = pending exception */ + /* ClassConstantPatternResolver::resolve($this->type, $this->constantName, + * $this->nativeType); UNDEF = pending exception */ zv::Val getResult() const { zval *t = type(); zval *name = t != NULL ? constantName() : NULL; /* one Error at a time, as the twin's first read raises */ - if (UNEXPECTED(name == NULL)) return zv::Val(); - zend_long has = pt_type_call_trinary(Z_OBJ_P(t), PT_LC("hasconstant"), 1, name); - if (UNEXPECTED(has < 0)) return zv::Val(); - if (has == PT_TRI_YES) { - zv::Val constant = pt_type_call(Z_OBJ_P(t), PT_LC("getconstant"), 1, name); - if (UNEXPECTED(constant.isUndef())) return zv::Val(); - if (UNEXPECTED(!zv::Ref(constant.raw()).isObject())) { - zend_type_error("phpstan_turbo: getConstant() must return an object"); - return zv::Val(); - } - return pt_type_call(Z_OBJ_P(constant.raw()), PT_LC("getvaluetype"), 0, NULL); - } - return pt_type_new_error_type(); + zval *native = name != NULL ? nativeType() : NULL; + if (UNEXPECTED(native == NULL)) return zv::Val(); + zval args[3]; + ZVAL_COPY_VALUE(&args[0], t); + ZVAL_COPY_VALUE(&args[1], name); + ZVAL_COPY_VALUE(&args[2], native); + return pt_type_call_static(PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER, PT_LC("resolve"), 3, args); } - /* new self($cb($this->type), $this->constantName) when the callback - * changed the type, $this otherwise; UNDEF = pending exception */ + /* new self($cb($this->type), $this->constantName, $this->nativeType) when + * the callback changed the type, $this otherwise; UNDEF = pending + * exception */ zv::Val traverse(zend_fcall_info *fci, zend_fcall_info_cache *fcc) const { zval *t = type(); @@ -153,13 +177,23 @@ class ClassConstantAccessType return traversed(pt_type_traverse_call(fci, fcc, t, theirType)); } - /* new ConstTypeNode(new ConstFetchNode('static', $this->constantName)) */ + /* new ConstTypeNode(new ConstFetchNode($this->type instanceof TemplateType + * ? $this->type->getName() : 'static', $this->constantName)) */ zv::Val toPhpDocNode() const { - zval *name = constantName(); + zval *t = type(); + zval *name = t != NULL ? constantName() : NULL; if (UNEXPECTED(name == NULL)) return zv::Val(); + zend_class_entry *templateType = pt_class(PT_CLASS_TEMPLATE_TYPE); + if (UNEXPECTED(templateType == NULL)) return zv::Val(); zval args[2]; - ZVAL_STRINGL(&args[0], "static", sizeof("static") - 1); + if (instanceof_function(Z_OBJCE_P(t), templateType)) { + zv::Val templateName = pt_type_call(Z_OBJ_P(t), PT_LC("getname"), 0, NULL); + if (UNEXPECTED(templateName.isUndef())) return zv::Val(); + ZVAL_COPY(&args[0], templateName.raw()); + } else { + ZVAL_STRINGL(&args[0], "static", sizeof("static") - 1); + } ZVAL_COPY_VALUE(&args[1], name); zv::Val constFetch = pt_type_new(PT_CLASS_CONST_FETCH_NODE, 2, args); zval_ptr_dtor(&args[0]); @@ -183,7 +217,8 @@ class ClassConstantAccessType } /* the tail of traverse()/traverseSimultaneously(): `$this->type === $type - * ? $this : new self($type, $this->constantName)` (UNDEF in = UNDEF out) */ + * ? $this : new self($type, $this->constantName, $this->nativeType)` + * (UNDEF in = UNDEF out) */ zv::Val traversed(zv::Val type) const { if (UNEXPECTED(type.isUndef())) return zv::Val(); @@ -191,8 +226,9 @@ class ClassConstantAccessType if (UNEXPECTED(t == NULL)) return zv::Val(); if (pt_type_same_object(t, type.raw())) return thisValue(); zval *name = constantName(); - if (UNEXPECTED(name == NULL)) return zv::Val(); - return create(type.raw(), Z_STR_P(name)); + zval *native = name != NULL ? nativeType() : NULL; + if (UNEXPECTED(native == NULL)) return zv::Val(); + return create(type.raw(), Z_STR_P(name), Z_TYPE_P(native) == IS_NULL ? NULL : native); } }; @@ -202,7 +238,7 @@ using phpstanturbo::ClassConstantAccessType; bool pt_class_constant_access_type_new(zval *out, zval *type, zend_string *constantName) { - return pt_val_into(ClassConstantAccessType::create(type, constantName), out); + return pt_val_into(ClassConstantAccessType::create(type, constantName, NULL), out); } /* {{{ engine ABI glue: parameter parsing + registration */ @@ -217,7 +253,14 @@ PT_MINIT_REGISTRATION(pt_register_class_constant_access_type) * declares $result after them */ ptdecl::ClassConstantAccessType::declareProperties(cls); - cls.method<&ClassConstantAccessType::construct, zp::TypeObj, zp::Str>(sigs::__construct); + cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *type, *nativeType = NULL; + zend_string *constantName; + if (!zp::parse>(execute_data, type, constantName, nativeType)) RETURN_THROWS(); + PT_THIS.construct(type, constantName, nativeType); + }); + + cls.method<&ClassConstantAccessType::withNativeType, zp::TypeObj>(sigs::withNativeType); cls.method<&ClassConstantAccessType::getReferencedClasses>(sigs::getReferencedClasses); cls.op(); diff --git a/turbo-ext/src/PhpClassReflectionExtension.cpp b/turbo-ext/src/PhpClassReflectionExtension.cpp index 4660b41a701..1143525d7ee 100644 --- a/turbo-ext/src/PhpClassReflectionExtension.cpp +++ b/turbo-ext/src/PhpClassReflectionExtension.cpp @@ -2494,6 +2494,13 @@ class PhpClassReflectionExtension bool isExplicit = callBool(returnTag.raw(), PT_LC("isexplicit"), 0, NULL, ok); if (UNEXPECTED(!ok)) return zv::Val(); if (isExplicit) return phpDocReturnType; + /* $phpDocReturnType instanceof ClassConstantAccessType && !$phpDocReturnType->isResolvable() - + * static::FOO_* or T::*, compared with the native type once the class is known */ + if (pt_ce_class_constant_access_type != NULL && instanceof_function(Z_OBJCE_P(phpDocReturnType.raw()), pt_ce_class_constant_access_type)) { + bool resolvable = callBool(phpDocReturnType.raw(), PT_LC("isresolvable"), 0, NULL, ok); + if (UNEXPECTED(!ok)) return zv::Val(); + if (!resolvable) return phpDocReturnType; + } bool superType = isSuperTypeOfYes(nativeReturnType, phpDocReturnType.raw(), ok); if (UNEXPECTED(!ok)) return zv::Val(); if (superType) return phpDocReturnType; diff --git a/turbo-ext/src/TypehintHelper.cpp b/turbo-ext/src/TypehintHelper.cpp index 222a3439f5c..bd0b0286049 100644 --- a/turbo-ext/src/TypehintHelper.cpp +++ b/turbo-ext/src/TypehintHelper.cpp @@ -170,6 +170,13 @@ class TypehintHelper if (UNEXPECTED(!pt_never_type_is_explicit(Z_OBJ_P(phpDocType.raw()), isExplicit))) return zv::Val(); if (isExplicit) return phpDocType; } + /* $phpDocType instanceof ClassConstantAccessType && !$phpDocType->isResolvable() - static::FOO_* + * or T::*, known only for the class the method is called on: $phpDocType->withNativeType($type) */ + if (pt_ce_class_constant_access_type != NULL && instanceof_function(Z_OBJCE_P(phpDocType.raw()), pt_ce_class_constant_access_type)) { + zv::Val resolvable = pt_type_call(Z_OBJ_P(phpDocType.raw()), PT_LC("isresolvable"), 0, NULL); + if (UNEXPECTED(resolvable.isUndef())) return zv::Val(); + if (!zend_is_true(resolvable.raw())) return pt_type_call(Z_OBJ_P(phpDocType.raw()), PT_LC("withnativetype"), 1, type.raw()); + } /* $type instanceof MixedType && !$type->isExplicitMixed() && $phpDocType->isVoid()->yes() */ if (instanceof_function(Z_OBJCE_P(type.raw()), pt_ce_mixed_type)) { bool explicitMixed; diff --git a/turbo-ext/src/generated/ClassConstantAccessType.h b/turbo-ext/src/generated/ClassConstantAccessType.h index 53a0d00c6df..ab4465ac63c 100644 --- a/turbo-ext/src/generated/ClassConstantAccessType.h +++ b/turbo-ext/src/generated/ClassConstantAccessType.h @@ -12,7 +12,8 @@ namespace ptdecl::ClassConstantAccessType { namespace slot { inline constexpr uint32_t type = 0; inline constexpr uint32_t constantName = 1; -inline constexpr uint32_t result = 2; +inline constexpr uint32_t nativeType = 2; +inline constexpr uint32_t result = 3; } // namespace slot inline void declareClass(reg::Class &cls) @@ -26,6 +27,7 @@ inline void declareProperties(reg::Class &cls) { cls.property("type", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, 0, "PHPStan\\Type\\Type"); cls.property("constantName", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_STRING); + cls.property("nativeType", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_NULL, "PHPStan\\Type\\Type"); } /* the shared registrars of the traits the twin uses, in its own order (a used trait's own traits after it) */ @@ -41,58 +43,66 @@ inline constexpr char strings[] = "type\0" /* 0 */ "PHPStan\\Type\\Type\0" /* 5 */ "constantName\0" /* 23 */ - "__construct\0" /* 36 */ - "\0" /* 48 */ - "getReferencedClasses\0" /* 49 */ - "positionVariance\0" /* 70 */ - "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 87 */ - "getReferencedTemplateTypes\0" /* 129 */ - "equals\0" /* 156 */ - "level\0" /* 163 */ - "PHPStan\\Type\\VerbosityLevel\0" /* 169 */ - "describe\0" /* 197 */ - "isResolvable\0" /* 206 */ - "getResult\0" /* 219 */ - "cb\0" /* 229 */ - "traverse\0" /* 232 */ - "right\0" /* 241 */ - "traverseSimultaneously\0" /* 247 */ - "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 270 */ - "toPhpDocNode"; /* 309 */ + "nativeType\0" /* 36 */ + "null\0" /* 47 */ + "__construct\0" /* 52 */ + "\0" /* 64 */ + "PHPStan\\Type\\ClassConstantAccessType\0" /* 65 */ + "withNativeType\0" /* 102 */ + "getReferencedClasses\0" /* 117 */ + "positionVariance\0" /* 138 */ + "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 155 */ + "getReferencedTemplateTypes\0" /* 197 */ + "equals\0" /* 224 */ + "level\0" /* 231 */ + "PHPStan\\Type\\VerbosityLevel\0" /* 237 */ + "describe\0" /* 265 */ + "isResolvable\0" /* 274 */ + "getResult\0" /* 287 */ + "cb\0" /* 297 */ + "traverse\0" /* 300 */ + "right\0" /* 309 */ + "traverseSimultaneously\0" /* 315 */ + "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 338 */ + "toPhpDocNode"; /* 377 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 5), /* __construct $type */ reg::packed(23, MAY_BE_STRING), /* __construct $constantName */ - reg::packed(48, MAY_BE_ARRAY), /* getReferencedClasses return */ - reg::packed(70, 0, 87), /* getReferencedTemplateTypes $positionVariance */ - reg::packed(48, MAY_BE_ARRAY), /* getReferencedTemplateTypes return */ + reg::packed(36, MAY_BE_NULL, 5, false, false, 47), /* __construct $nativeType */ + reg::packed(36, 0, 5), /* withNativeType $nativeType */ + reg::packed(64, 0, 65), /* withNativeType return */ + reg::packed(64, MAY_BE_ARRAY), /* getReferencedClasses return */ + reg::packed(138, 0, 155), /* getReferencedTemplateTypes $positionVariance */ + reg::packed(64, MAY_BE_ARRAY), /* getReferencedTemplateTypes return */ reg::packed(0, 0, 5), /* equals $type */ - reg::packed(48, MAY_BE_BOOL), /* equals return */ - reg::packed(163, 0, 169), /* describe $level */ - reg::packed(48, MAY_BE_STRING), /* describe return */ - reg::packed(48, MAY_BE_BOOL), /* isResolvable return */ - reg::packed(48, 0, 5), /* getResult return */ - reg::packed(229, MAY_BE_CALLABLE), /* traverse $cb */ - reg::packed(48, 0, 5), /* traverse return */ - reg::packed(241, 0, 5), /* traverseSimultaneously $right */ - reg::packed(229, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ - reg::packed(48, 0, 5), /* traverseSimultaneously return */ - reg::packed(48, 0, 270), /* toPhpDocNode return */ + reg::packed(64, MAY_BE_BOOL), /* equals return */ + reg::packed(231, 0, 237), /* describe $level */ + reg::packed(64, MAY_BE_STRING), /* describe return */ + reg::packed(64, MAY_BE_BOOL), /* isResolvable return */ + reg::packed(64, 0, 5), /* getResult return */ + reg::packed(297, MAY_BE_CALLABLE), /* traverse $cb */ + reg::packed(64, 0, 5), /* traverse return */ + reg::packed(309, 0, 5), /* traverseSimultaneously $right */ + reg::packed(297, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ + reg::packed(64, 0, 5), /* traverseSimultaneously return */ + reg::packed(64, 0, 338), /* toPhpDocNode return */ }; using Sig = reg::Sig; } // namespace sigtab /* the signatures of the methods the class declares itself (a used trait's are in the trait's header) */ namespace sig { -inline constexpr sigtab::Sig __construct = { { 36 /* __construct */, 2, 0, 2, reg::NoArg, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getReferencedClasses = { { 49 /* getReferencedClasses */, 0, 2, 0, 2, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 129 /* getReferencedTemplateTypes */, 1, 3, 1, 4, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig equals = { { 156 /* equals */, 1, 5, 1, 6, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig describe = { { 197 /* describe */, 1, 7, 1, 8, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig isResolvable = { { 206 /* isResolvable */, 0, 9, 0, 9, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getResult = { { 219 /* getResult */, 0, 10, 0, 10, ZEND_ACC_PROTECTED } }; -inline constexpr sigtab::Sig traverse = { { 232 /* traverse */, 1, 11, 1, 12, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig traverseSimultaneously = { { 247 /* traverseSimultaneously */, 2, 13, 2, 15, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig toPhpDocNode = { { 309 /* toPhpDocNode */, 0, 16, 0, 16, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig __construct = { { 52 /* __construct */, 2, 0, 3, reg::NoArg, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig withNativeType = { { 102 /* withNativeType */, 1, 3, 1, 4, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getReferencedClasses = { { 117 /* getReferencedClasses */, 0, 5, 0, 5, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 197 /* getReferencedTemplateTypes */, 1, 6, 1, 7, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig equals = { { 224 /* equals */, 1, 8, 1, 9, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig describe = { { 265 /* describe */, 1, 10, 1, 11, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig isResolvable = { { 274 /* isResolvable */, 0, 12, 0, 12, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getResult = { { 287 /* getResult */, 0, 13, 0, 13, ZEND_ACC_PROTECTED } }; +inline constexpr sigtab::Sig traverse = { { 300 /* traverse */, 1, 14, 1, 15, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig traverseSimultaneously = { { 315 /* traverseSimultaneously */, 2, 16, 2, 18, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig toPhpDocNode = { { 377 /* toPhpDocNode */, 0, 19, 0, 19, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::ClassConstantAccessType diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 29bc210b316..9ec7cf8ed83 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -411,6 +411,7 @@ static const pt_class_template pt_class_templates[PT_CLASS_COUNT] = { /* PT_CLASS_RELATIVE_NAME */ {"relativeName", "PhpParser\\Node\\Name\\Relative"}, /* PT_CLASS_ITERABLE_HELPER */ {"iterableHelper", "PHPStan\\Internal\\IterableHelper"}, /* PT_CLASS_CLOSURE_CALL_CONTEXT_MATCHER */ {"closureCallContextMatcher", "PHPStan\\Analyser\\ClosureCallContextMatcher"}, + /* PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER */ {"classConstantPatternResolver", "PHPStan\\Type\\ClassConstantPatternResolver"}, }; zend_class_entry *pt_class(int idx) diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index a9928e8412f..061c91a416a 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -469,6 +469,7 @@ enum { PT_CLASS_RELATIVE_NAME, PT_CLASS_ITERABLE_HELPER, PT_CLASS_CLOSURE_CALL_CONTEXT_MATCHER, + PT_CLASS_CLASS_CONSTANT_PATTERN_RESOLVER, PT_CLASS_COUNT }; diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index 2f205bef50d..b314ade2f46 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -4993,6 +4993,9 @@ public function getKeyType(): \PHPStan\Type\Type 'constantStaticKindList' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeStatic, 'KIND_LIST'), 'constantObjectKindArray' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_ARRAY'), 'constantMissing' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_NONEXISTENT'), + 'constantStaticKindListNative' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeStatic, 'KIND_LIST', new \PHPStan\Type\StringType()), + 'constantObjectKindArrayNative' => (new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_ARRAY'))->withNativeType(new \PHPStan\Type\IntegerType()), + 'constantObjectWildcardNative' => new \PHPStan\Type\ClassConstantAccessType($lateShapeNodeObject, 'KIND_*', new \PHPStan\Type\StringType()), 'constantTShapeNode' => new \PHPStan\Type\ClassConstantAccessType($lateTShapeNode, 'KIND_LIST'), 'constantOnInt' => new \PHPStan\Type\ClassConstantAccessType(new \PHPStan\Type\IntegerType(), 'KIND_LIST'), 'newException' => new \PHPStan\Type\NewObjectType(new \PHPStan\Type\ObjectType(\Exception::class)), From dc1410cbcfd0303e1874311b5db3e4f99d39b81f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 30 Sep 2026 21:05:20 +0200 Subject: [PATCH 3/5] Result cache: dependencies on values declared through $scope->valueDependency() A rule or a dynamic return type extension that reads something the result cache does not know about - a DI container, a config file - declares it with $scope->valueDependency(SomeValueExtension::class, $key). The extension, a ResultCacheValueExtension, returns the value for the key. The value is stored in the result cache, and when it's different on the next run, the files that asked for it are re-analysed. The same value declared more times, by one or several rules and extensions, is recorded once. When it's declared outside the walk of the analysed file, like when a private property type is inferred from the constructor, it is the file of the scope that depends on it, together with the files depending on that file. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ --- .github/workflows/e2e-tests.yml | 65 +++++ e2e/result-cache-value-dependency/.gitignore | 1 + .../composer.json | 5 + .../composer.lock | 18 ++ .../container.json | 11 + .../extension/Container.php | 33 +++ .../extension/HasServiceValueExtension.php | 25 ++ .../ParameterReturnTypeExtension.php | 42 +++ .../extension/ParameterValueExtension.php | 25 ++ .../extension/ServiceReturnTypeExtension.php | 39 +++ .../extension/ServiceRule.php | 45 +++ .../phpstan.neon | 28 ++ .../src/Holder.php | 20 ++ .../src/Logger.php | 12 + .../src/LoggerTrait.php | 13 + .../src/Mailer.php | 12 + .../src/UsesCache.php | 8 + .../src/UsesHolder.php | 10 + .../src/UsesLocale.php | 11 + .../src/UsesLocaleToo.php | 11 + .../src/UsesLogger.php | 8 + .../src/UsesLoggerTrait.php | 10 + .../src/UsesMailer.php | 8 + .../src/functions.php | 16 ++ .../tmp/.gitignore | 2 + src/Analyser/Analyser.php | 3 + src/Analyser/AnalyserResult.php | 11 + src/Analyser/AnalyserResultFinalizer.php | 3 + src/Analyser/DependencyEmitter.php | 37 +++ src/Analyser/FileAnalyser.php | 3 + src/Analyser/FileAnalyserCallback.php | 2 +- src/Analyser/FileAnalyserResult.php | 11 + src/Analyser/MutatingScope.php | 8 +- src/Analyser/ResultCache/ResultCache.php | 10 + .../ResultCache/ResultCacheManager.php | 258 +++++++++++++++++- .../ResultCache/ResultCacheValueExtension.php | 57 ++++ src/Analyser/ValueDependencyCollector.php | 171 ++++++++++++ src/Command/AnalyseApplication.php | 23 ++ src/Command/AnalyserRunner.php | 1 + src/Command/FixerWorkerRunner.php | 1 + src/Parallel/ParallelAnalyser.php | 11 +- src/Parallel/WorkerRunner.php | 4 + src/Rules/Methods/OverridingMethodRule.php | 5 +- src/Rules/Playground/PromoteParameterRule.php | 3 +- src/Rules/Rule.php | 3 +- src/Testing/CompositeRule.php | 3 +- src/Testing/DelayedRule.php | 3 +- src/Testing/RuleTestCase.php | 2 + .../DynamicFunctionReturnTypeExtension.php | 3 +- src/Type/DynamicMethodReturnTypeExtension.php | 3 +- ...DynamicStaticMethodReturnTypeExtension.php | 3 +- src/Type/ExpressionTypeResolverExtension.php | 3 +- tests/PHPStan/Analyser/AnalyserTest.php | 1 + .../Analyser/ValueDependencyCollectorTest.php | 115 ++++++++ .../ValueDependencyCollectorTestExtension.php | 29 ++ .../Analyser/value-dependency-collector.neon | 5 + turbo-ext/src/MutatingScope.cpp | 29 ++ turbo-ext/src/generated/MutatingScope.h | 10 +- 58 files changed, 1290 insertions(+), 22 deletions(-) create mode 100644 e2e/result-cache-value-dependency/.gitignore create mode 100644 e2e/result-cache-value-dependency/composer.json create mode 100644 e2e/result-cache-value-dependency/composer.lock create mode 100644 e2e/result-cache-value-dependency/container.json create mode 100644 e2e/result-cache-value-dependency/extension/Container.php create mode 100644 e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php create mode 100644 e2e/result-cache-value-dependency/extension/ParameterReturnTypeExtension.php create mode 100644 e2e/result-cache-value-dependency/extension/ParameterValueExtension.php create mode 100644 e2e/result-cache-value-dependency/extension/ServiceReturnTypeExtension.php create mode 100644 e2e/result-cache-value-dependency/extension/ServiceRule.php create mode 100644 e2e/result-cache-value-dependency/phpstan.neon create mode 100644 e2e/result-cache-value-dependency/src/Holder.php create mode 100644 e2e/result-cache-value-dependency/src/Logger.php create mode 100644 e2e/result-cache-value-dependency/src/LoggerTrait.php create mode 100644 e2e/result-cache-value-dependency/src/Mailer.php create mode 100644 e2e/result-cache-value-dependency/src/UsesCache.php create mode 100644 e2e/result-cache-value-dependency/src/UsesHolder.php create mode 100644 e2e/result-cache-value-dependency/src/UsesLocale.php create mode 100644 e2e/result-cache-value-dependency/src/UsesLocaleToo.php create mode 100644 e2e/result-cache-value-dependency/src/UsesLogger.php create mode 100644 e2e/result-cache-value-dependency/src/UsesLoggerTrait.php create mode 100644 e2e/result-cache-value-dependency/src/UsesMailer.php create mode 100644 e2e/result-cache-value-dependency/src/functions.php create mode 100644 e2e/result-cache-value-dependency/tmp/.gitignore create mode 100644 src/Analyser/DependencyEmitter.php create mode 100644 src/Analyser/ResultCache/ResultCacheValueExtension.php create mode 100644 src/Analyser/ValueDependencyCollector.php create mode 100644 tests/PHPStan/Analyser/ValueDependencyCollectorTest.php create mode 100644 tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php create mode 100644 tests/PHPStan/Analyser/value-dependency-collector.neon diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7f3e22e7019..47f923a7c24 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -442,6 +442,71 @@ jobs: echo "$INFO" ../bashunit -a equals 'true' "$(echo "$INFO" | jq --raw-output '.resultCacheUsed')" ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + - script: | + cd e2e/result-cache-value-dependency + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesCache.php:7:Service cache does not exist.' "$OUTPUT" + ../bashunit -a contains "UsesLocale.php:10:Function ResultCacheE2EValueDependency\usesLocale() should return 'none' but returns 'cs'." "$OUTPUT" + ../bashunit -a contains "UsesLocaleToo.php:10:Function ResultCacheE2EValueDependency\usesLocaleToo() should return 'none' but returns 'cs'." "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EValueDependency\Holder::isDebug() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EValueDependency\usesHolder() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesMailer.php' "$OUTPUT" + # The extensions read container.json, which the result cache knows nothing about. A rule and a + # dynamic return type extension both declare the service they ask about - it is recorded once. + # A change of its value re-analyses only the file asking about it. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["mailer"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesMailer.php:7:Call to an undefined method ResultCacheE2EValueDependency\Logger::send().' "$OUTPUT" + # Values nobody asked about do not re-analyse anything. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["timezone"] = "UTC"; $c["services"]["router"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '0' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + # A value that was missing is watched too. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["cache"] = "ResultCacheE2EValueDependency\\Logger"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Service cache does not exist.' "$OUTPUT" + # Every file asking about the same value is re-analysed. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["locale"] = "en"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesLocale.php:10:Function ResultCacheE2EValueDependency\usesLocale() should return 'none' but returns 'en'." "$OUTPUT" + ../bashunit -a contains "UsesLocaleToo.php:10:Function ResultCacheE2EValueDependency\usesLocaleToo() should return 'none' but returns 'en'." "$OUTPUT" + # Scope::getFile() is not the file to re-analyse when the extension runs outside the walk of the + # analysed file: the type of Holder::$debug is inferred from the constructor on a scope of + # src/Holder.php, whenever the property is first needed, and it is cached. So it is what + # src/Holder.php declares that depends on the value: a change of it re-analyses src/Holder.php and + # the files depending on it. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["parameters"]["debug"] = "1"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php' "$OUTPUT" + # Inside a trait, the value is asked about in the analysis of the class using it - that's the + # file re-analysed, and the error is reported in the trait. + php -r '$c = json_decode(file_get_contents("container.json"), true); $c["services"]["logger"] = "ResultCacheE2EValueDependency\\Mailer"; file_put_contents("container.json", json_encode($c, JSON_PRETTY_PRINT));' + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesLogger.php:7:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" + ../bashunit -a contains 'LoggerTrait.php (in context of class ResultCacheE2EValueDependency\UsesLoggerTrait):10:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" - script: | cd e2e/result-cache-scanned-trait ../../bin/phpstan analyse diff --git a/e2e/result-cache-value-dependency/.gitignore b/e2e/result-cache-value-dependency/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-value-dependency/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-value-dependency/composer.json b/e2e/result-cache-value-dependency/composer.json new file mode 100644 index 00000000000..e23fc929433 --- /dev/null +++ b/e2e/result-cache-value-dependency/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["extension/"] + } +} diff --git a/e2e/result-cache-value-dependency/composer.lock b/e2e/result-cache-value-dependency/composer.lock new file mode 100644 index 00000000000..ba8d41762cc --- /dev/null +++ b/e2e/result-cache-value-dependency/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d751713988987e9331980363e24189ce", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/e2e/result-cache-value-dependency/container.json b/e2e/result-cache-value-dependency/container.json new file mode 100644 index 00000000000..74d2fd10f41 --- /dev/null +++ b/e2e/result-cache-value-dependency/container.json @@ -0,0 +1,11 @@ +{ + "services": { + "mailer": "ResultCacheE2EValueDependency\\Mailer", + "logger": "ResultCacheE2EValueDependency\\Logger" + }, + "parameters": { + "locale": "cs", + "timezone": "Europe/Prague", + "debug": "yes" + } +} diff --git a/e2e/result-cache-value-dependency/extension/Container.php b/e2e/result-cache-value-dependency/extension/Container.php new file mode 100644 index 00000000000..31cf12f0f79 --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/Container.php @@ -0,0 +1,33 @@ +, parameters: array} + */ + private static function read(): array + { + $contents = file_get_contents(dirname(__DIR__) . '/container.json'); + /** @var array{services: array, parameters: array} $container */ + $container = json_decode($contents === false ? '{}' : $contents, true); + + return $container; + } + +} diff --git a/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php b/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php new file mode 100644 index 00000000000..d4b65bc637b --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/HasServiceValueExtension.php @@ -0,0 +1,25 @@ +getName() === 'ResultCacheE2EValueDependency\parameter'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arg = $functionCall->getArgs()[0]->value ?? null; + if (!$arg instanceof String_) { + return null; + } + + $scope->valueDependency(ParameterValueExtension::class, $arg->value); + $value = Container::getParameter($arg->value); + if ($value === null) { + return null; + } + + return is_numeric($value) ? new ConstantIntegerType((int) $value) : new ConstantStringType($value); + } + +} diff --git a/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php b/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php new file mode 100644 index 00000000000..b610dbd03ea --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/ParameterValueExtension.php @@ -0,0 +1,25 @@ +getName() === 'ResultCacheE2EValueDependency\service'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $arg = $functionCall->getArgs()[0]->value ?? null; + if (!$arg instanceof String_) { + return null; + } + + // the same value ServiceRule declares - recorded once + $scope->valueDependency(HasServiceValueExtension::class, $arg->value); + $class = Container::getService($arg->value); + + return $class !== null ? new ObjectType($class) : null; + } + +} diff --git a/e2e/result-cache-value-dependency/extension/ServiceRule.php b/e2e/result-cache-value-dependency/extension/ServiceRule.php new file mode 100644 index 00000000000..10a313ac8d2 --- /dev/null +++ b/e2e/result-cache-value-dependency/extension/ServiceRule.php @@ -0,0 +1,45 @@ + + */ +final class ServiceRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'service' || !isset($node->getArgs()[0]) || !$node->getArgs()[0]->value instanceof String_) { + return []; + } + + $id = $node->getArgs()[0]->value->value; + $scope->valueDependency(HasServiceValueExtension::class, $id); + if (Container::getService($id) !== null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('Service %s does not exist.', $id))->identifier('resultCacheE2E.service')->build(), + ]; + } + +} diff --git a/e2e/result-cache-value-dependency/phpstan.neon b/e2e/result-cache-value-dependency/phpstan.neon new file mode 100644 index 00000000000..107ca730da8 --- /dev/null +++ b/e2e/result-cache-value-dependency/phpstan.neon @@ -0,0 +1,28 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + inferPrivatePropertyTypeFromConstructor: true + +services: + - + class: ResultCacheE2EValueDependency\HasServiceValueExtension + tags: + - phpstan.resultCacheValueExtension + - + class: ResultCacheE2EValueDependency\ParameterValueExtension + tags: + - phpstan.resultCacheValueExtension + - + class: ResultCacheE2EValueDependency\ServiceRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EValueDependency\ServiceReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: ResultCacheE2EValueDependency\ParameterReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/e2e/result-cache-value-dependency/src/Holder.php b/e2e/result-cache-value-dependency/src/Holder.php new file mode 100644 index 00000000000..3db3d1581f9 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Holder.php @@ -0,0 +1,20 @@ +debug = parameter('debug'); + } + + public function isDebug(): int + { + return $this->debug; + } + +} diff --git a/e2e/result-cache-value-dependency/src/Logger.php b/e2e/result-cache-value-dependency/src/Logger.php new file mode 100644 index 00000000000..608bcc9d945 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Logger.php @@ -0,0 +1,12 @@ +log(); + } + +} diff --git a/e2e/result-cache-value-dependency/src/Mailer.php b/e2e/result-cache-value-dependency/src/Mailer.php new file mode 100644 index 00000000000..ba8cdfe536a --- /dev/null +++ b/e2e/result-cache-value-dependency/src/Mailer.php @@ -0,0 +1,12 @@ + $holder->debug, null, Holder::class); + + return $read($holder); +} diff --git a/e2e/result-cache-value-dependency/src/UsesLocale.php b/e2e/result-cache-value-dependency/src/UsesLocale.php new file mode 100644 index 00000000000..42ba9d469a0 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/UsesLocale.php @@ -0,0 +1,11 @@ +log(); +} diff --git a/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php b/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php new file mode 100644 index 00000000000..f1101031234 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/UsesLoggerTrait.php @@ -0,0 +1,10 @@ +send(); +} diff --git a/e2e/result-cache-value-dependency/src/functions.php b/e2e/result-cache-value-dependency/src/functions.php new file mode 100644 index 00000000000..556da1231d6 --- /dev/null +++ b/e2e/result-cache-value-dependency/src/functions.php @@ -0,0 +1,16 @@ + [], 'dependents' => []]; $packageDependencies = []; $exportedNodes = []; $allProcessedFiles = []; @@ -101,6 +102,7 @@ public function analyse( $collectedData = array_merge($collectedData, $fileAnalyserResult->getCollectedData()); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $fileExportedNodes = $fileAnalyserResult->getExportedNodes(); @@ -143,6 +145,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, diff --git a/src/Analyser/AnalyserResult.php b/src/Analyser/AnalyserResult.php index 78a44ae2601..4b814297f14 100644 --- a/src/Analyser/AnalyserResult.php +++ b/src/Analyser/AnalyserResult.php @@ -9,6 +9,7 @@ /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult * @phpstan-import-type CollectorData from CollectedData + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ final class AnalyserResult { @@ -27,6 +28,7 @@ final class AnalyserResult * @param list $internalErrors * @param array>|null $dependencies * @param array>|null $usedTraitDependencies + * @param ValueDependencies|null $valueDependencies see ValueDependencyCollector * @param array>|null $packageDependencies * @param array> $exportedNodes * @param list $processedFiles @@ -42,6 +44,7 @@ public function __construct( private array $collectedData, private ?array $dependencies, private ?array $usedTraitDependencies, + private ?array $valueDependencies, private ?array $packageDependencies, private array $exportedNodes, private bool $reachedInternalErrorsCountLimit, @@ -165,6 +168,14 @@ public function getUsedTraitDependencies(): ?array return $this->usedTraitDependencies; } + /** + * @return ValueDependencies|null + */ + public function getValueDependencies(): ?array + { + return $this->valueDependencies; + } + /** * @return array>|null */ diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index fd883666f87..c40bc1066bb 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -151,6 +151,7 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -173,6 +174,7 @@ private function mergeFilteredPhpErrors(AnalyserResult $analyserResult): Analyse collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -240,6 +242,7 @@ private function addUnmatchedIgnoredErrors( collectedData: $analyserResult->getCollectedData(), dependencies: $analyserResult->getDependencies(), usedTraitDependencies: $analyserResult->getUsedTraitDependencies(), + valueDependencies: $analyserResult->getValueDependencies(), packageDependencies: $analyserResult->getPackageDependencies(), exportedNodes: $analyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), diff --git a/src/Analyser/DependencyEmitter.php b/src/Analyser/DependencyEmitter.php new file mode 100644 index 00000000000..be55a875c42 --- /dev/null +++ b/src/Analyser/DependencyEmitter.php @@ -0,0 +1,37 @@ + $extensionClass + */ + public function valueDependency(string $extensionClass, string $key): void; + +} diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php index f0b72370438..3aac6d4d46a 100644 --- a/src/Analyser/FileAnalyser.php +++ b/src/Analyser/FileAnalyser.php @@ -70,6 +70,7 @@ public function __construct( private LocalIgnoresProcessor $localIgnoresProcessor, #[AutowiredParameter] private bool $reportIgnoresWithoutComments, + private ValueDependencyCollector $valueDependencyCollector, ) { } @@ -104,6 +105,7 @@ public function analyseFile( $exportedNodes = []; $linesToIgnore = []; $unmatchedLineIgnores = []; + $this->valueDependencyCollector->startFile($file); if (is_file($file)) { try { $this->collectErrors($analysedFiles); @@ -255,6 +257,7 @@ public function analyseFile( $fileCollectedData, array_values(array_unique($fileDependencies)), array_values(array_unique($usedTraitFileDependencies)), + $this->valueDependencyCollector->finishFile(), array_values(array_unique($filePackageDependencies)), $exportedNodes, $linesToIgnore, diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 10982cb075e..eb821efdd9c 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -107,7 +107,7 @@ public function __invoke(Node $node, Scope $scope): void $parserNodes = $this->parserNodes; - /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter $scope */ + /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope */ if ($node instanceof Node\Stmt\Trait_) { foreach (array_keys($this->linesToIgnore[$this->file] ?? []) as $lineToIgnore) { if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) { diff --git a/src/Analyser/FileAnalyserResult.php b/src/Analyser/FileAnalyserResult.php index 5d9c5f15226..693a01335fd 100644 --- a/src/Analyser/FileAnalyserResult.php +++ b/src/Analyser/FileAnalyserResult.php @@ -9,6 +9,7 @@ * @phpstan-type Identifier = array{name: string, comment: string|null} * @phpstan-type LinesToIgnore = array|null>> * @phpstan-import-type CollectorData from CollectedData + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ final class FileAnalyserResult { @@ -21,6 +22,7 @@ final class FileAnalyserResult * @param CollectorData $collectedData * @param list $dependencies * @param list $usedTraitDependencies + * @param ValueDependencies $valueDependencies see ValueDependencyCollector * @param list $packageDependencies * @param list $exportedNodes * @param LinesToIgnore $linesToIgnore @@ -35,6 +37,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $valueDependencies, private array $packageDependencies, private array $exportedNodes, private array $linesToIgnore, @@ -100,6 +103,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return ValueDependencies + */ + public function getValueDependencies(): array + { + return $this->valueDependencies; + } + /** * @return list */ diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 20da5ff28ab..043462da76a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -150,7 +150,7 @@ use const PHP_INT_MIN; #[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/MutatingScope.cpp')] -class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter +class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter, DependencyEmitter { private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; @@ -6071,4 +6071,10 @@ public function emitCollectedData(string $collectorType, mixed $data): void $nodeCallback(new EmitCollectedDataNode($collectorType, $data), $this); } + public function valueDependency(string $extensionClass, string $key): void + { + // a scope without a node callback is outside the walk of the analysed file - see ValueDependencyCollector + $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, $this, $this->nodeCallback !== null); + } + } diff --git a/src/Analyser/ResultCache/ResultCache.php b/src/Analyser/ResultCache/ResultCache.php index e7a80d025bc..99889f0a63d 100644 --- a/src/Analyser/ResultCache/ResultCache.php +++ b/src/Analyser/ResultCache/ResultCache.php @@ -24,6 +24,7 @@ final class ResultCache * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param array, list}> $valueDependencies id => [extension class, key, current value, analysis dependents, declarations dependents] - see ValueDependencyCollector * @param array> $packageDependencies * @param array> $exportedNodes the decoded ones - see $cachedExportedNodes for the rest * @param array $projectExtensionFiles @@ -42,6 +43,7 @@ public function __construct( private array $collectedData, private array $dependencies, private array $usedTraitDependencies, + private array $valueDependencies, private array $packageDependencies, private array $exportedNodes, private CachedExportedNodes $cachedExportedNodes, @@ -142,6 +144,14 @@ public function getUsedTraitDependencies(): array return $this->usedTraitDependencies; } + /** + * @return array, list}> + */ + public function getValueDependencies(): array + { + return $this->valueDependencies; + } + /** * @return array> */ diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 3fa5c4807dc..ea9bb477d4a 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\FileAnalyserResult; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\Command\Output; use PHPStan\Dependency\ExportedNode\ExportedTraitNode; @@ -87,6 +88,7 @@ /** * @phpstan-import-type LinesToIgnore from FileAnalyserResult + * @phpstan-import-type ValueDependencies from ValueDependencyCollector * @phpstan-import-type CollectorData from CollectedData */ #[GenerateFactory(interface: ResultCacheManagerFactory::class)] @@ -103,7 +105,7 @@ final class ResultCacheManager */ private const EXTENSIONS_NOT_INVALIDATING_CACHE = ['xdebug', 'blackfire', 'phpstan_turbo']; - private const CACHE_VERSION = 'v21-exportedNodesIndex'; + private const CACHE_VERSION = 'v22-valueDependencies'; /** * The recorded hash of a dependency that does not exist. A rule can depend on a path rather than on @@ -224,6 +226,7 @@ public function __construct( private PhpVersion $phpVersion, private ComposerPhpVersionFactory $composerPhpVersionFactory, private FileStatSignatures $fileStatSignatures, + private ValueDependencyCollector $valueDependencyCollector, ) { } @@ -276,6 +279,7 @@ private function fullAnalysis( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: [], packageDependencies: [], exportedNodes: [], cachedExportedNodes: CachedExportedNodes::createEmpty(), @@ -390,6 +394,19 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? $currentFileHashes = $this->hashAnalysedFiles($analysedFileStats, $data['dependencies']); $fileStatSignaturesChanged = $this->fileStatSignaturesDiffer($data['dependencies']); $data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []); + try { + $cachedValueDependencies = $this->decodeValueDependencies($data['valueDependencies'] ?? ['paths' => [], 'entries' => []], $transformer); + } catch (Throwable $e) { + @unlink($cacheFilePath); + + return $this->fullAnalysis( + sprintf('Result cache not used because an error occurred while loading the cache file: %s', $e->getMessage()), + $allAnalysedFiles, + $this->getMeta($allAnalysedFiles, $projectConfigArray), + $currentFileHashes, + $output, + ); + } $errorsCallback = $data['errorsCallback']; $data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback()); @@ -963,6 +980,35 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? ); } + // The values declared through DependencyEmitter::valueDependency() - see ValueDependencyCollector. + // The files depending on one that is different now are re-analysed, and so are the ones + // depending on one whose extension is no longer registered, which is then forgotten. + $allAnalysedFilesSet = array_fill_keys($allAnalysedFiles, true); + $valueDependenciesToReturn = []; + $valueDependenciesChanged = false; + foreach ($cachedValueDependencies as $cachedValueDependency) { + $extension = $this->valueDependencyCollector->getExtension($cachedValueDependency['extensionClass']); + $key = $extension !== null ? $extension->keyFromResultCache($cachedValueDependency['storedKey']) : null; + $currentValue = $extension !== null && $key !== null ? $extension->getValue($key) : null; + if ($currentValue !== $cachedValueDependency['value']) { + $valueDependenciesChanged = true; + foreach ($this->getValueDependentFilesToAnalyse($cachedValueDependency['analysis'], $cachedValueDependency['declarations'], $invertedDependencies, $allAnalysedFilesSet) as $valueDependentFile) { + $filesToAnalyse[] = $valueDependentFile; + } + } + if ($key === null || $currentValue === null) { + continue; + } + + $valueDependenciesToReturn[ValueDependencyCollector::getId($cachedValueDependency['extensionClass'], $key)] = [ + $cachedValueDependency['extensionClass'], + $key, + $currentValue, + $cachedValueDependency['analysis'], + $cachedValueDependency['declarations'], + ]; + } + if ($newFileAppeared || $notAnalysedFileSymbolsChanged) { foreach (array_keys($filteredErrors) as $fileWithError) { $filesToAnalyse[] = $fileWithError; @@ -1000,7 +1046,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? )); } - $this->restoredCacheUnchanged = !$metaDifferent && !$dependencyFilesChanged && !$fileStatSignaturesChanged; + $this->restoredCacheUnchanged = !$metaDifferent && !$dependencyFilesChanged && !$fileStatSignaturesChanged && !$valueDependenciesChanged; $this->restoredStubFiles = $cachedStubFiles; return new ResultCache( @@ -1016,6 +1062,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? collectedData: $filteredCollectedData, dependencies: $invertedDependenciesToReturn, usedTraitDependencies: $invertedUsedTraitDependenciesToReturn, + valueDependencies: $valueDependenciesToReturn, packageDependencies: $packageDependencies, exportedNodes: $filteredExportedNodes, cachedExportedNodes: $cachedExportedNodes->only($keptCachedExportedNodes), @@ -1167,7 +1214,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $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, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { + $doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $valueDependencies, ?array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool { if ($onlyFiles) { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because only files were passed as analysed paths.'); @@ -1192,6 +1239,12 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache } return false; } + if ($valueDependencies === null) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted('Result cache was not saved because of error in value dependencies.'); + } + return false; + } if (count($internalErrors) > 0) { if ($output->isVeryVerbose()) { @@ -1245,7 +1298,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache return true; } - $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $stubFiles); + $this->save($resultCache->getLastFullAnalysisTime(), $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles, $resultCache->getCurrentFileHashes(), $meta, $stubFiles); if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache is saved.'); @@ -1261,7 +1314,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache if ($analyserResult->getDependencies() !== null) { $projectExtensionFiles = $this->getProjectExtensionFiles($projectConfigArray, $analyserResult->getDependencies()); } - $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); + $saved = $doSave($freshErrorsByFile, $freshLocallyIgnoredErrorsByFile, $analyserResult->getLinesToIgnore(), $analyserResult->getUnmatchedLineIgnores(), $freshCollectedDataByFile, $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), $analyserResult->getValueDependencies(), $analyserResult->getPackageDependencies(), $this->addNonAnalysedExportedNodes($analyserResult->getExportedNodes(), $analyserResult->getDependencies(), $analyserResult->getUsedTraitDependencies(), CachedExportedNodes::createEmpty()), CachedExportedNodes::createEmpty(), $projectExtensionFiles); } else { if ($output->isVeryVerbose()) { $output->writeLineFormatted('Result cache was not saved because it was not requested.'); @@ -1276,6 +1329,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $collectedDataByFile = $this->mergeCollectedData($resultCache, $freshCollectedDataByFile); $dependencies = $this->mergeDependencies($resultCache->getDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getDependencies()); $usedTraitDependencies = $this->mergeDependencies($resultCache->getUsedTraitDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getUsedTraitDependencies()); + $valueDependencies = $this->mergeValueDependencies($resultCache->getValueDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getValueDependencies()); $packageDependencies = $this->mergePackageDependencies($resultCache->getPackageDependencies(), $resultCache->getFilesToAnalyse(), $analyserResult->getPackageDependencies()); // the re-analysed files take their fresh nodes, what is left of the cached ones stays undecoded $cachedExportedNodes = $resultCache->getCachedExportedNodes()->without(array_fill_keys($resultCache->getFilesToAnalyse(), true)); @@ -1305,7 +1359,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $projectExtensionFiles[$file] = [$hash, true, $className]; } } - $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles); + $saved = $doSave($errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, $dependencies, $usedTraitDependencies, $valueDependencies, $packageDependencies, $exportedNodes, $cachedExportedNodes, $projectExtensionFiles); } $flatErrors = []; @@ -1333,6 +1387,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache collectedData: $collectedDataByFile, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + valueDependencies: $valueDependencies, packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -1454,6 +1509,193 @@ private function mergeDependencies(array $resultCacheDependencies, array $filesT return $newDependencies; } + /** + * The fresh "analysis" dependents of a re-analysed file replace its cached ones, like in + * mergeDependencies(). The "declarations" ones are kept: the file they are about may have been + * inferred during the analysis of another file that was not re-analysed (see + * ValueDependencyCollector), and one left over only means a file re-analysed needlessly. The + * fresh values are the ones the analysis saw. + * + * @param array, list}> $resultCacheValueDependencies + * @param string[] $filesToAnalyse + * @param ValueDependencies|null $freshValueDependencies + * @return ValueDependencies|null + */ + private function mergeValueDependencies(array $resultCacheValueDependencies, array $filesToAnalyse, ?array $freshValueDependencies): ?array + { + if ($freshValueDependencies === null) { + return null; + } + + $values = []; + $dependents = []; + foreach ($resultCacheValueDependencies as $id => [$extensionClass, $key, $value, $analysis, $declarations]) { + $values[$id] = [$extensionClass, $key, $value]; + foreach (['analysis' => $analysis, 'declarations' => $declarations] as $kind => $dependentFiles) { + foreach ($dependentFiles as $dependentFile) { + $dependents[$dependentFile] ??= ['analysis' => [], 'declarations' => []]; + $dependents[$dependentFile][$kind][] = $id; + } + } + } + + foreach ($filesToAnalyse as $file) { + $files = [$file]; + if (array_key_exists($file, $this->fileReplacements)) { + $files[] = $this->fileReplacements[$file]; + } + foreach ($files as $reanalysedFile) { + if (!array_key_exists($reanalysedFile, $dependents)) { + continue; + } + + $dependents[$reanalysedFile]['analysis'] = []; + } + } + + $merged = ValueDependencyCollector::merge(['values' => [], 'dependents' => $dependents], $freshValueDependencies); + $merged['values'] = $freshValueDependencies['values'] + $values; + + return $merged; + } + + /** + * @param list $analysisDependentFiles + * @param list $declarationDependentFiles + * @param array, usedTraitDependentFiles?: list}> $invertedDependencies + * @param array $allAnalysedFiles + * @return list + */ + private function getValueDependentFilesToAnalyse(array $analysisDependentFiles, array $declarationDependentFiles, array $invertedDependencies, array $allAnalysedFiles): array + { + $files = []; + foreach ($analysisDependentFiles as $file) { + if (!array_key_exists($file, $allAnalysedFiles)) { + continue; + } + $files[] = $file; + } + + // What these files declare was derived from the value, and the files depending on them see it + // without having asked for the value themselves. + foreach ($declarationDependentFiles as $declaringFile) { + if (array_key_exists($declaringFile, $allAnalysedFiles)) { + $files[] = $declaringFile; + } + + $declaringFileData = $invertedDependencies[$declaringFile] ?? null; + if ($declaringFileData === null) { + continue; + } + + foreach (array_merge($declaringFileData['dependentFiles'], $declaringFileData['usedTraitDependentFiles'] ?? []) as $file) { + if (!is_file($file)) { + continue; + } + $files[] = $file; + } + } + + return $files; + } + + /** + * One entry for each value, with the files depending on it in a table of paths like the + * dependency graph's, and the key as its extension stores it. + * + * @param ValueDependencies $valueDependencies + * @return array{paths: list, entries: list, list}>} + */ + private function encodeValueDependencies(array $valueDependencies, ResultCachePathTransformer $transformer): array + { + $dependentsById = []; + foreach ($valueDependencies['dependents'] as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + foreach ($analysis as $id) { + $dependentsById[$id]['analysis'][] = $dependentFile; + } + foreach ($declarations as $id) { + $dependentsById[$id]['declarations'][] = $dependentFile; + } + } + ksort($dependentsById); + + $ids = []; + $paths = []; + $entries = []; + foreach ($dependentsById as $id => $dependents) { + if (!array_key_exists($id, $valueDependencies['values'])) { + continue; + } + + [$extensionClass, $key, $value] = $valueDependencies['values'][$id]; + $extension = $this->valueDependencyCollector->getExtension($extensionClass); + if ($extension === null) { + continue; + } + + $dependentIds = []; + foreach (['analysis', 'declarations'] as $kind) { + $pathIds = []; + $dependentFiles = array_values(array_unique($dependents[$kind] ?? [])); + sort($dependentFiles); + foreach ($dependentFiles as $dependentFile) { + if (!isset($ids[$dependentFile])) { + $ids[$dependentFile] = count($paths); + $paths[] = $dependentFile; + } + $pathIds[] = $ids[$dependentFile]; + } + $dependentIds[$kind] = $pathIds; + } + + $entries[] = [$extensionClass, $extension->keyToResultCache($key), $value, $dependentIds['analysis'], $dependentIds['declarations']]; + } + + $relativePaths = []; + foreach ($paths as $path) { + $relativePaths[] = $transformer->relativizePath($path); + } + + return ['paths' => $relativePaths, 'entries' => $entries]; + } + + /** + * @param mixed $encoded + * @return list, declarations: list}> + */ + private function decodeValueDependencies($encoded, ResultCachePathTransformer $transformer): array + { + if (!is_array($encoded) || !is_array($encoded['paths'] ?? null) || !is_array($encoded['entries'] ?? null)) { + throw new RuntimeException('The value dependencies are malformed.'); + } + + $paths = []; + foreach ($encoded['paths'] as $path) { + $paths[] = $transformer->absolutizePath($path); + } + + $valueDependencies = []; + foreach ($encoded['entries'] as [$extensionClass, $storedKey, $value, $analysisIds, $declarationIds]) { + $analysis = []; + foreach ($analysisIds as $pathId) { + $analysis[] = $paths[$pathId]; + } + $declarations = []; + foreach ($declarationIds as $pathId) { + $declarations[] = $paths[$pathId]; + } + $valueDependencies[] = [ + 'extensionClass' => $extensionClass, + 'storedKey' => $storedKey, + 'value' => $value, + 'analysis' => $analysis, + 'declarations' => $declarations, + ]; + } + + return $valueDependencies; + } + /** * @param array> $freshExportedNodes * @return array> @@ -1569,6 +1811,7 @@ private function mergeUnmatchedLineIgnores(ResultCache $resultCache, array $fres * @param CollectorData $collectedData * @param array> $dependencies * @param array> $usedTraitDependencies + * @param ValueDependencies $valueDependencies * @param array> $packageDependencies * @param array> $exportedNodes * @param array $projectExtensionFiles @@ -1585,6 +1828,7 @@ private function save( array $collectedData, array $dependencies, array $usedTraitDependencies, + array $valueDependencies, array $packageDependencies, array $exportedNodes, CachedExportedNodes $cachedExportedNodes, @@ -1672,6 +1916,7 @@ private function save( $unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores); $collectedData = $transformer->relativizeCollectedData($collectedData); $dependencyGraph = $this->encodeDependencyGraph($invertedDependencies, $transformer); + $encodedValueDependencies = $this->encodeValueDependencies($valueDependencies, $transformer); unset($invertedDependencies); $packageDependencies = $transformer->relativizeFileKeyed($packageDependencies); $projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles); @@ -1715,6 +1960,7 @@ private function save( // cacheVersion check that makes it discard the file, and fails on a missing section. $this->writeArrayFrame($handle, $file, 'dependencies', []); $this->writeValueFrame($handle, $file, 'dependencyGraph', $dependencyGraph); + $this->writeValueFrame($handle, $file, 'valueDependencies', $encodedValueDependencies); $this->writeArrayFrame($handle, $file, 'packageDependencies', $packageDependencies); $this->writeExportedNodes($handle, $file, $exportedNodes, $cachedExportedNodes, $transformer); fclose($handle); diff --git a/src/Analyser/ResultCache/ResultCacheValueExtension.php b/src/Analyser/ResultCache/ResultCacheValueExtension.php new file mode 100644 index 00000000000..8ae87e00335 --- /dev/null +++ b/src/Analyser/ResultCache/ResultCacheValueExtension.php @@ -0,0 +1,57 @@ +valueDependency(MyExtension::class, $key); + * ``` + * + * The result cache then records the value, and re-analyses the files that declared it when it + * is different, and only them. Compared to ResultCacheMetaExtension, which discards the whole + * result cache on any change, only what was actually asked about matters, and only for the + * files asking. + * + * To register it in the configuration file use the `phpstan.resultCacheValueExtension` service tag: + * + * ``` + * services: + * - + * class: App\PHPStan\MyExtension + * tags: + * - phpstan.resultCacheValueExtension + * ``` + * + * @api + */ +#[ExtensionInterface(tag: self::EXTENSION_TAG)] +interface ResultCacheValueExtension +{ + + public const EXTENSION_TAG = 'phpstan.resultCacheValueExtension'; + + /** + * The current value for the key - compared with the one recorded by the last analysis. + * A long value is better hashed. + */ + public function getValue(string $key): string; + + /** + * The key as the result cache stores it. A key that is a file path is best stored relative to + * the project, so that the result cache survives a moved checkout. + */ + public function keyToResultCache(string $key): string; + + /** + * The key back from the result cache - the reverse of keyToResultCache(). + */ + public function keyFromResultCache(string $storedKey): string; + +} diff --git a/src/Analyser/ValueDependencyCollector.php b/src/Analyser/ValueDependencyCollector.php new file mode 100644 index 00000000000..0010ae44b75 --- /dev/null +++ b/src/Analyser/ValueDependencyCollector.php @@ -0,0 +1,171 @@ +, + * dependents: array, declarations: list}>, + * } + */ +#[AutowiredService] +final class ValueDependencyCollector +{ + + private ?string $analysedFile = null; + + /** @var array id => [extension class, key, value] */ + private array $values = []; + + /** @var array, declarations: array}> dependent file => ids */ + private array $dependents = []; + + /** + * @param ExtensionsCollection $valueExtensions + */ + public function __construct( + #[AutowiredExtensions(of: ResultCacheValueExtension::class)] + private ExtensionsCollection $valueExtensions, + ) + { + } + + public static function getId(string $extensionClass, string $key): string + { + return $extensionClass . "\0" . $key; + } + + public function startFile(string $analysedFile): void + { + $this->analysedFile = $analysedFile; + $this->values = []; + $this->dependents = [$analysedFile => ['analysis' => [], 'declarations' => []]]; + } + + /** + * @param class-string $extensionClass + */ + public function record(string $extensionClass, string $key, Scope $scope, bool $insideWalk): void + { + if ($this->analysedFile === null) { + return; + } + + $id = self::getId($extensionClass, $key); + if (!array_key_exists($id, $this->values)) { + $this->values[$id] = [$extensionClass, $key, $this->getRegisteredExtension($extensionClass)->getValue($key)]; + } + + $this->dependents[$this->analysedFile]['analysis'][$id] = true; + if ($insideWalk) { + return; + } + + $scopeFile = $scope->getFile(); + if (!array_key_exists($scopeFile, $this->dependents)) { + $this->dependents[$scopeFile] = ['analysis' => [], 'declarations' => []]; + } + $this->dependents[$scopeFile]['declarations'][$id] = true; + } + + /** + * @return ValueDependencies always with an entry for the analysed file + */ + public function finishFile(): array + { + $dependents = []; + foreach ($this->dependents as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + $dependents[$dependentFile] = [ + 'analysis' => array_keys($analysis), + 'declarations' => array_keys($declarations), + ]; + } + + $values = $this->values; + $this->analysedFile = null; + $this->values = []; + $this->dependents = []; + + return ['values' => $values, 'dependents' => $dependents]; + } + + /** + * The dependencies recorded during the analysis of one file can be about another file too, + * so the results of several files are merged, not overwritten. A value seen first stays. + * + * @param ValueDependencies $dependencies + * @param ValueDependencies $newDependencies + * @return ValueDependencies + */ + public static function merge(array $dependencies, array $newDependencies): array + { + $values = $dependencies['values'] + $newDependencies['values']; + $dependents = $dependencies['dependents']; + foreach ($newDependencies['dependents'] as $dependentFile => ['analysis' => $analysis, 'declarations' => $declarations]) { + $dependents[$dependentFile] = [ + 'analysis' => array_values(array_unique(array_merge($dependents[$dependentFile]['analysis'] ?? [], $analysis))), + 'declarations' => array_values(array_unique(array_merge($dependents[$dependentFile]['declarations'] ?? [], $declarations))), + ]; + } + + return ['values' => $values, 'dependents' => $dependents]; + } + + public function getExtension(string $extensionClass): ?ResultCacheValueExtension + { + foreach ($this->valueExtensions->getAll() as $extension) { + if ($extension instanceof $extensionClass) { + return $extension; + } + } + + return null; + } + + private function getRegisteredExtension(string $extensionClass): ResultCacheValueExtension + { + $extension = $this->getExtension($extensionClass); + if ($extension === null) { + throw new ShouldNotHappenException(sprintf( + '%s is not registered as a result cache value extension. Register it with the %s service tag.', + $extensionClass, + ResultCacheValueExtension::EXTENSION_TAG, + )); + } + + return $extension; + } + +} diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index e7d6566c238..f5868057130 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\FileAnalyserResult; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\CollectedData; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Internal\BytesHelper; @@ -29,6 +30,7 @@ /** * @phpstan-import-type CollectorData from CollectedData * @phpstan-import-type LinesToIgnore from FileAnalyserResult + * @phpstan-import-type ValueDependencies from ValueDependencyCollector */ #[AutowiredService] final class AnalyseApplication @@ -120,6 +122,7 @@ public function analyse( collectedData: $intermediateAnalyserResult->getCollectedData(), dependencies: $intermediateAnalyserResult->getDependencies(), usedTraitDependencies: $intermediateAnalyserResult->getUsedTraitDependencies(), + valueDependencies: $intermediateAnalyserResult->getValueDependencies(), packageDependencies: $intermediateAnalyserResult->getPackageDependencies(), exportedNodes: $intermediateAnalyserResult->getExportedNodes(), reachedInternalErrorsCountLimit: $intermediateAnalyserResult->hasReachedInternalErrorsCountLimit(), @@ -226,6 +229,7 @@ private function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, @@ -314,6 +318,10 @@ private function switchTmpFileInAnalyserResult( if ($analyserResult->getUsedTraitDependencies() !== null) { $usedTraitDependencies = $this->switchTmpFileInDependencies($analyserResult->getUsedTraitDependencies(), $insteadOfFile, $tmpFile); } + $valueDependencies = null; + if ($analyserResult->getValueDependencies() !== null) { + $valueDependencies = $this->switchTmpFileInValueDependencies($analyserResult->getValueDependencies(), $insteadOfFile, $tmpFile); + } $packageDependencies = null; if ($analyserResult->getPackageDependencies() !== null) { $packageDependencies = $this->switchTmpFileInDependencies($analyserResult->getPackageDependencies(), $insteadOfFile, $tmpFile); @@ -339,6 +347,7 @@ private function switchTmpFileInAnalyserResult( collectedData: $newCollectedData, dependencies: $dependencies, usedTraitDependencies: $usedTraitDependencies, + valueDependencies: $valueDependencies, packageDependencies: $packageDependencies, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $analyserResult->hasReachedInternalErrorsCountLimit(), @@ -377,6 +386,20 @@ private function switchTmpFileInDependencies(array $dependencies, string $instea return $newDependencies; } + /** + * @param ValueDependencies $dependencies + * @return ValueDependencies + */ + private function switchTmpFileInValueDependencies(array $dependencies, string $insteadOfFile, string $tmpFile): array + { + $dependents = []; + foreach ($dependencies['dependents'] as $dependentFile => $ids) { + $dependents[$dependentFile === $tmpFile ? $insteadOfFile : $dependentFile] = $ids; + } + + return ['values' => $dependencies['values'], 'dependents' => $dependents]; + } + /** * @param list $errors * @return list diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index 59e0dbb2d87..496bdee4c82 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -68,6 +68,7 @@ public function runAnalyser( collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index 3b4ace9a3f8..cb162b221b4 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -345,6 +345,7 @@ private function runAnalyser(LoopInterface $loop, array $files, array $allAnalys collectedData: [], dependencies: [], usedTraitDependencies: [], + valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], exportedNodes: [], reachedInternalErrorsCountLimit: false, diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 936335499de..c1e07733662 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\Error; use PHPStan\Analyser\InternalError; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Cache\ArenaCache; use PHPStan\Command\CommandHelper; use PHPStan\Command\Output; @@ -122,6 +123,7 @@ public function analyse( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $valueDependencies = ['values' => [], 'dependents' => []]; $packageDependencies = []; $reachedInternalErrorsCountLimit = false; $exportedNodes = []; @@ -134,7 +136,7 @@ public function analyse( $useFork = $this->forkParallelChecker->isSupported(); $server = new TcpServer('127.0.0.1:0', $loop); - $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { + $this->processPool = new ProcessPool($server, static function () use ($deferred, &$jobs, &$internalErrors, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$allProcessedFiles, $arenaName): void { if ($arenaName !== null) { ArenaCache::destroy(); } @@ -161,6 +163,7 @@ public function analyse( collectedData: $collectedData, dependencies: $internalErrorsCount === 0 ? $dependencies : null, usedTraitDependencies: $internalErrorsCount === 0 ? $usedTraitDependencies : null, + valueDependencies: $internalErrorsCount === 0 ? $valueDependencies : null, packageDependencies: $internalErrorsCount === 0 ? $packageDependencies : null, exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: $reachedInternalErrorsCountLimit, @@ -284,7 +287,7 @@ public function analyse( $insteadOfFile, $input, ); - $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { + $process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$valueDependencies, &$packageDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void { $fileErrors = []; foreach ($json['errors'] as $jsonError) { $fileErrors[] = Error::decode($jsonError); @@ -342,6 +345,10 @@ public function analyse( $usedTraitDependencies[$file] = $fileUsedTraitDependencies; } + /** @var array{values: array, dependents: array, declarations: list}>} $workerValueDependencies */ + $workerValueDependencies = $json['valueDependencies']; + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $workerValueDependencies); + /** * @var string $file * @var array $filePackageDependencies diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index fa3b5caf2de..3a3f4e466a9 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\InternalError; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Command\BootstrapFilesRunner; use PHPStan\Command\ErrorsConsoleStyle; @@ -207,6 +208,7 @@ private function runWorker( $collectedData = []; $dependencies = []; $usedTraitDependencies = []; + $valueDependencies = ['values' => [], 'dependents' => []]; $packageDependencies = []; $exportedNodes = []; $processedFiles = []; @@ -223,6 +225,7 @@ private function runWorker( $unmatchedLineIgnores[$file] = $fileAnalyserResult->getUnmatchedLineIgnores(); $dependencies[$file] = $fileAnalyserResult->getDependencies(); $usedTraitDependencies[$file] = $fileAnalyserResult->getUsedTraitDependencies(); + $valueDependencies = ValueDependencyCollector::merge($valueDependencies, $fileAnalyserResult->getValueDependencies()); $packageDependencies[$file] = $fileAnalyserResult->getPackageDependencies(); $exportedNodes[$file] = $fileAnalyserResult->getExportedNodes(); $processedFiles = array_merge($processedFiles, $fileAnalyserResult->getProcessedFiles()); @@ -265,6 +268,7 @@ private function runWorker( 'memoryUsage' => memory_get_peak_usage(true), 'dependencies' => $dependencies, 'usedTraitDependencies' => $usedTraitDependencies, + 'valueDependencies' => $valueDependencies, 'packageDependencies' => $packageDependencies, 'exportedNodes' => $exportedNodes, 'files' => $files, diff --git a/src/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php index b08e81dd8bc..5f9447faf09 100644 --- a/src/Rules/Methods/OverridingMethodRule.php +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PhpParser\Node\Attribute; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -52,7 +53,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $method = $node->getMethodReflection(); $prototypeData = $this->methodPrototypeFinder->findPrototype($node->getClassReflection(), $method->getName()); @@ -365,7 +366,7 @@ private function filterOverrideAttribute(array $attrGroups): array private function addErrors( array $errors, InClassMethodNode $classMethod, - Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope, ): array { if (count($errors) > 0) { diff --git a/src/Rules/Playground/PromoteParameterRule.php b/src/Rules/Playground/PromoteParameterRule.php index 22a1c6aeedc..640238ae01f 100644 --- a/src/Rules/Playground/PromoteParameterRule.php +++ b/src/Rules/Playground/PromoteParameterRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Container; @@ -88,7 +89,7 @@ private function getOriginalRule(): ?Rule return $this->originalRule = $originalRule; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { if ($this->parameterValue) { return []; diff --git a/src/Rules/Rule.php b/src/Rules/Rule.php index b56cbc92b4c..5421f4d43a5 100644 --- a/src/Rules/Rule.php +++ b/src/Rules/Rule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\ExtensionInterface; @@ -38,6 +39,6 @@ public function getNodeType(): string; * @param TNodeType $node * @return list */ - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array; + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array; } diff --git a/src/Testing/CompositeRule.php b/src/Testing/CompositeRule.php index 269ed259cc4..931cb653062 100644 --- a/src/Testing/CompositeRule.php +++ b/src/Testing/CompositeRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -38,7 +39,7 @@ public function getNodeType(): string return Node::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $errors = []; diff --git a/src/Testing/DelayedRule.php b/src/Testing/DelayedRule.php index 17909e3721b..a13dfb06fac 100644 --- a/src/Testing/DelayedRule.php +++ b/src/Testing/DelayedRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\CollectedDataEmitter; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -44,7 +45,7 @@ public function getDelayedErrors(): array return $this->errors; } - public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter&DependencyEmitter $scope): array { $nodeType = get_class($node); foreach ($this->registry->getRules($nodeType) as $rule) { diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 82f16583c30..345fa7af591 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\RuleErrorTransformer; use PHPStan\Analyser\StatementsHandler; use PHPStan\Analyser\TypeSpecifier; +use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Collectors\Collector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; @@ -117,6 +118,7 @@ private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser self::getContainer()->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), false, + self::getContainer()->getByType(ValueDependencyCollector::class), ); $this->analyser = new Analyser( $fileAnalyser, diff --git a/src/Type/DynamicFunctionReturnTypeExtension.php b/src/Type/DynamicFunctionReturnTypeExtension.php index 757c14f0a13..fac50bbd442 100644 --- a/src/Type/DynamicFunctionReturnTypeExtension.php +++ b/src/Type/DynamicFunctionReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\FuncCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -31,6 +32,6 @@ interface DynamicFunctionReturnTypeExtension public function isFunctionSupported(FunctionReflection $functionReflection): bool; - public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type; + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/DynamicMethodReturnTypeExtension.php b/src/Type/DynamicMethodReturnTypeExtension.php index 557a2ea536f..a3c723907f4 100644 --- a/src/Type/DynamicMethodReturnTypeExtension.php +++ b/src/Type/DynamicMethodReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\MethodCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -34,6 +35,6 @@ public function getClass(): string; public function isMethodSupported(MethodReflection $methodReflection): bool; - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type; + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/DynamicStaticMethodReturnTypeExtension.php b/src/Type/DynamicStaticMethodReturnTypeExtension.php index c450ce0b7b0..d798b493539 100644 --- a/src/Type/DynamicStaticMethodReturnTypeExtension.php +++ b/src/Type/DynamicStaticMethodReturnTypeExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr\StaticCall; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -34,6 +35,6 @@ public function getClass(): string; public function isStaticMethodSupported(MethodReflection $methodReflection): bool; - public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type; + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope&DependencyEmitter $scope): ?Type; } diff --git a/src/Type/ExpressionTypeResolverExtension.php b/src/Type/ExpressionTypeResolverExtension.php index efaa35cc738..20c78da2131 100644 --- a/src/Type/ExpressionTypeResolverExtension.php +++ b/src/Type/ExpressionTypeResolverExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PhpParser\Node\Expr; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\Broker\BrokerFactory; use PHPStan\DependencyInjection\ExtensionInterface; @@ -26,6 +27,6 @@ interface ExpressionTypeResolverExtension { - public function getType(Expr $expr, Scope $scope): ?Type; + public function getType(Expr $expr, Scope&DependencyEmitter $scope): ?Type; } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index adb02c6e81b..36f53b2a7e2 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -872,6 +872,7 @@ private function createAnalyser(): Analyser $container->getByType(RuleErrorTransformer::class), new LocalIgnoresProcessor(), false, + $container->getByType(ValueDependencyCollector::class), ); return new Analyser( diff --git a/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php new file mode 100644 index 00000000000..7f30e244f6d --- /dev/null +++ b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php @@ -0,0 +1,115 @@ +getByType(ValueDependencyCollector::class); + $extension = self::getContainer()->getByType(TestValueExtension::class); + $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); + $analysedFileScope = $scopeFactory->create(ScopeContext::create('/project/src/Analysed.php')); + $otherFileScope = $scopeFactory->create(ScopeContext::create('/project/src/Other.php')); + + $collector->startFile('/project/src/Analysed.php'); + $collector->record(TestValueExtension::class, 'a', $analysedFileScope, true); + // the same value again - from another rule or extension - is the same dependency + $collector->record(TestValueExtension::class, 'a', $analysedFileScope, true); + $collector->record(TestValueExtension::class, 'b', $otherFileScope, false); + + $a = ValueDependencyCollector::getId(TestValueExtension::class, 'a'); + $b = ValueDependencyCollector::getId(TestValueExtension::class, 'b'); + $this->assertSame([ + 'values' => [ + $a => [TestValueExtension::class, 'a', 'value of a'], + $b => [TestValueExtension::class, 'b', 'value of b'], + ], + 'dependents' => [ + '/project/src/Analysed.php' => [ + 'analysis' => [$a, $b], + 'declarations' => [], + ], + '/project/src/Other.php' => [ + 'analysis' => [], + 'declarations' => [$b], + ], + ], + ], $collector->finishFile()); + $this->assertSame(2, $extension->calls); + } + + public function testNothingOutsideOfAnalysedFile(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('/project/src/Analysed.php')); + + $collector->record(TestValueExtension::class, 'a', $scope, true); + $collector->startFile('/project/src/Analysed.php'); + + $this->assertSame([ + 'values' => [], + 'dependents' => [ + '/project/src/Analysed.php' => [ + 'analysis' => [], + 'declarations' => [], + ], + ], + ], $collector->finishFile()); + } + + public function testUnregisteredExtension(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('/project/src/Analysed.php')); + + $collector->startFile('/project/src/Analysed.php'); + try { + $this->expectException(ShouldNotHappenException::class); + $this->expectExceptionMessage('stdClass is not registered as a result cache value extension. Register it with the phpstan.resultCacheValueExtension service tag.'); + // @phpstan-ignore argument.type (not an extension on purpose) + $collector->record('stdClass', 'a', $scope, true); + } finally { + $collector->finishFile(); + } + } + + public function testMerge(): void + { + $this->assertSame([ + 'values' => [ + 'x' => ['E', 'x', 'first'], + 'y' => ['E', 'y', 'y'], + ], + 'dependents' => [ + '/a.php' => ['analysis' => ['x', 'y'], 'declarations' => []], + '/b.php' => ['analysis' => [], 'declarations' => ['x']], + ], + ], ValueDependencyCollector::merge([ + 'values' => ['x' => ['E', 'x', 'first']], + 'dependents' => ['/a.php' => ['analysis' => ['x'], 'declarations' => []]], + ], [ + 'values' => ['x' => ['E', 'x', 'second'], 'y' => ['E', 'y', 'y']], + 'dependents' => [ + '/a.php' => ['analysis' => ['x', 'y'], 'declarations' => []], + '/b.php' => ['analysis' => [], 'declarations' => ['x']], + ], + ])); + } + + #[Override] + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [ + __DIR__ . '/value-dependency-collector.neon', + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php b/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php new file mode 100644 index 00000000000..12fda459a98 --- /dev/null +++ b/tests/PHPStan/Analyser/data/ValueDependencyCollectorTestExtension.php @@ -0,0 +1,29 @@ +calls++; + + return 'value of ' . $key; + } + + public function keyToResultCache(string $key): string + { + return $key; + } + + public function keyFromResultCache(string $storedKey): string + { + return $storedKey; + } + +} diff --git a/tests/PHPStan/Analyser/value-dependency-collector.neon b/tests/PHPStan/Analyser/value-dependency-collector.neon new file mode 100644 index 00000000000..4415967fe6a --- /dev/null +++ b/tests/PHPStan/Analyser/value-dependency-collector.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Analyser\ValueDependencyCollectorTest\TestValueExtension + tags: + - phpstan.resultCacheValueExtension diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index d4fa8cec495..bf3efb2dc73 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -10582,6 +10582,29 @@ class MutatingScope return !result.isUndef(); } + /* $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, + * $this, $this->nodeCallback !== null) */ + bool valueDependency(zend_string *extensionClass, zend_string *key) + { + zv::Ref nodeCallback = slot(PT_MS_PROP_NODE_CALLBACK); + if (UNEXPECTED(nodeCallback.isUndef())) { + (void) uninitializedProperty("nodeCallback"); + return false; + } + zval insideWalk; + ZVAL_BOOL(&insideWalk, !nodeCallback.isNull()); + zv::Val collector = containerGetByType(PT_LC("PHPStan\\Analyser\\ValueDependencyCollector")); + if (UNEXPECTED(collector.isUndef())) return false; + zend_object *collectorObject = requireObject(collector, "record"); + if (UNEXPECTED(collectorObject == NULL)) return false; + zval extensionClassZv, keyZv; + ZVAL_STR(&extensionClassZv, extensionClass); + ZVAL_STR(&keyZv, key); + zv::Args args{&extensionClassZv, &keyZv, self, &insideWalk}; + zv::Val result = pt_type_call(collectorObject, PT_LC("record"), 4, args); + return !result.isUndef(); + } + static void throwNodeCallbackMissing() { zend_class_entry *ce = pt_class(PT_CLASS_SHOULD_NOT_HAPPEN); @@ -13349,6 +13372,12 @@ PT_MINIT_REGISTRATION(pt_register_mutating_scope) if (UNEXPECTED(!PT_THIS.emitCollectedData(collectorType, data))) RETURN_THROWS(); }); + cls.method(sigs::valueDependency, [](INTERNAL_FUNCTION_PARAMETERS) { + zend_string *extensionClass, *key; + if (!zp::parse(execute_data, extensionClass, key)) RETURN_THROWS(); + if (UNEXPECTED(!PT_THIS.valueDependency(extensionClass, key))) RETURN_THROWS(); + }); + /* }}} */ /* out of the twin's file order (see the handle class) */ diff --git a/turbo-ext/src/generated/MutatingScope.h b/turbo-ext/src/generated/MutatingScope.h index 18b834da1ce..6f99a964c0e 100644 --- a/turbo-ext/src/generated/MutatingScope.h +++ b/turbo-ext/src/generated/MutatingScope.h @@ -51,7 +51,7 @@ inline constexpr uint32_t templateArgumentConstraints = 36; inline void declareClass(reg::Class &cls) { - cls.implements({ "PHPStan\\Analyser\\Scope", "PHPStan\\Analyser\\NodeCallbackInvoker", "PHPStan\\Analyser\\CollectedDataEmitter" }); + cls.implements({ "PHPStan\\Analyser\\Scope", "PHPStan\\Analyser\\NodeCallbackInvoker", "PHPStan\\Analyser\\CollectedDataEmitter", "PHPStan\\Analyser\\DependencyEmitter" }); } /* the properties the class declares itself, in declaration order (a used trait's come from its registrar) */ @@ -531,7 +531,9 @@ inline constexpr char strings[] = "invokeNodeCallback\0" /* 9203 */ "collectorType\0" /* 9222 */ "data\0" /* 9236 */ - "emitCollectedData"; /* 9241 */ + "emitCollectedData\0" /* 9241 */ + "extensionClass\0" /* 9259 */ + "valueDependency"; /* 9274 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 10), /* __construct $container */ reg::packed(48, 0, 61), /* __construct $scopeFactory */ @@ -1064,6 +1066,9 @@ inline constexpr reg::PackedArg args[] = { reg::packed(9222, MAY_BE_STRING), /* emitCollectedData $collectorType */ reg::packed(9236, MAY_BE_ANY), /* emitCollectedData $data */ reg::packed(1476, MAY_BE_VOID), /* emitCollectedData return */ + reg::packed(9259, MAY_BE_STRING), /* valueDependency $extensionClass */ + reg::packed(3280, MAY_BE_STRING), /* valueDependency $key */ + reg::packed(1476, MAY_BE_VOID), /* valueDependency return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -1265,6 +1270,7 @@ inline constexpr sigtab::Sig getPhpVersion = { { 9149 /* getPhpVersion */, 0, 52 inline constexpr sigtab::Sig isOverallPhpVersionRange = { { 9163 /* isOverallPhpVersionRange */, 1, 524, 1, 525, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig invokeNodeCallback = { { 9203 /* invokeNodeCallback */, 1, 526, 1, 527, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig emitCollectedData = { { 9241 /* emitCollectedData */, 2, 528, 2, 530, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig valueDependency = { { 9274 /* valueDependency */, 2, 531, 2, 533, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::MutatingScope From 0d41107d15ab3bef891568fbaf3505ce17b1162f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 30 Sep 2026 21:14:33 +0200 Subject: [PATCH 4/5] $scope->fileDependency() on top of value dependencies $scope->fileDependency($path) declares that the analysis of the current file depends on the contents of another file - a data file, a template, a docblock in a PHP file - that is read without PHPStan knowing about it. It is a value dependency through FileResultCacheValueExtension: the value is the hash of the file, or that the file does not exist, so creating, changing or deleting it re-analyses the files that declared it. The path is stored relative to the same directory as the other paths in the result cache. A dependency of the analysed file on itself is dropped, the file is re-analysed when it changes anyway. RequireFileExistsRule declares the paths on the scope now, and the paths from the deprecated RuleErrorBuilder::fileDependency() are declared on the scope of the rule's node. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ --- .github/workflows/e2e-tests.yml | 128 ++++++++++++++++++ e2e/result-cache-file-dependency/.gitignore | 1 + .../composer.json | 5 + .../composer.lock | 18 +++ .../data/deprecated.txt | 1 + .../data/function.txt | 1 + .../data/holder.txt | 1 + .../data/method.txt | 1 + .../data/static-method.txt | 1 + .../extension/ConfigRule.php | 53 ++++++++ .../extension/DataFile.php | 38 ++++++ .../DataFunctionReturnTypeExtension.php | 28 ++++ .../DataMethodReturnTypeExtension.php | 33 +++++ .../DataStaticMethodReturnTypeExtension.php | 33 +++++ .../extension/DeprecatedConfigRule.php | 44 ++++++ e2e/result-cache-file-dependency/phpstan.neon | 28 ++++ .../src/Config.php | 3 + .../src/ConfigHolder.php | 13 ++ .../src/Holder.php | 20 +++ .../src/Repository.php | 18 +++ .../src/UsesConfig.php | 8 ++ .../src/UsesConfigHolder.php | 8 ++ .../src/UsesDeprecatedConfig.php | 8 ++ .../src/UsesFunction.php | 11 ++ .../src/UsesHolder.php | 10 ++ .../src/UsesMethod.php | 11 ++ .../src/UsesStaticMethod.php | 11 ++ .../src/functions.php | 21 +++ .../tmp/.gitignore | 2 + src/Analyser/DependencyEmitter.php | 9 ++ src/Analyser/FileAnalyserCallback.php | 5 +- src/Analyser/MutatingScope.php | 5 + .../FileResultCacheValueExtension.php | 53 ++++++++ src/Analyser/ValueDependencyCollector.php | 18 +++ src/Rules/Keywords/RequireFileExistsRule.php | 28 ++-- src/Rules/RuleErrorBuilder.php | 2 + .../Analyser/ValueDependencyCollectorTest.php | 43 ++++++ turbo-ext/src/MutatingScope.cpp | 38 +++++- turbo-ext/src/generated/MutatingScope.h | 7 +- 39 files changed, 740 insertions(+), 26 deletions(-) create mode 100644 e2e/result-cache-file-dependency/.gitignore create mode 100644 e2e/result-cache-file-dependency/composer.json create mode 100644 e2e/result-cache-file-dependency/composer.lock create mode 100644 e2e/result-cache-file-dependency/data/deprecated.txt create mode 100644 e2e/result-cache-file-dependency/data/function.txt create mode 100644 e2e/result-cache-file-dependency/data/holder.txt create mode 100644 e2e/result-cache-file-dependency/data/method.txt create mode 100644 e2e/result-cache-file-dependency/data/static-method.txt create mode 100644 e2e/result-cache-file-dependency/extension/ConfigRule.php create mode 100644 e2e/result-cache-file-dependency/extension/DataFile.php create mode 100644 e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php create mode 100644 e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php create mode 100644 e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php create mode 100644 e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php create mode 100644 e2e/result-cache-file-dependency/phpstan.neon create mode 100644 e2e/result-cache-file-dependency/src/Config.php create mode 100644 e2e/result-cache-file-dependency/src/ConfigHolder.php create mode 100644 e2e/result-cache-file-dependency/src/Holder.php create mode 100644 e2e/result-cache-file-dependency/src/Repository.php create mode 100644 e2e/result-cache-file-dependency/src/UsesConfig.php create mode 100644 e2e/result-cache-file-dependency/src/UsesConfigHolder.php create mode 100644 e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php create mode 100644 e2e/result-cache-file-dependency/src/UsesFunction.php create mode 100644 e2e/result-cache-file-dependency/src/UsesHolder.php create mode 100644 e2e/result-cache-file-dependency/src/UsesMethod.php create mode 100644 e2e/result-cache-file-dependency/src/UsesStaticMethod.php create mode 100644 e2e/result-cache-file-dependency/src/functions.php create mode 100644 e2e/result-cache-file-dependency/tmp/.gitignore create mode 100644 src/Analyser/ResultCache/FileResultCacheValueExtension.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 47f923a7c24..dd3631d7759 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -507,6 +507,134 @@ jobs: echo "$OUTPUT" ../bashunit -a contains 'UsesLogger.php:7:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" ../bashunit -a contains 'LoggerTrait.php (in context of class ResultCacheE2EValueDependency\UsesLoggerTrait):10:Call to an undefined method ResultCacheE2EValueDependency\Mailer::log().' "$OUTPUT" + - script: | + cd e2e/result-cache-file-dependency + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-1'." "$OUTPUT" + ../bashunit -a contains "UsesMethod.php:10:Function ResultCacheE2EFileDependency\usesMethod() should return 'none' but returns 'method-1'." "$OUTPUT" + ../bashunit -a contains "UsesStaticMethod.php:10:Function ResultCacheE2EFileDependency\usesStaticMethod() should return 'none' but returns 'static-method-1'." "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesDeprecatedConfig.php:7:Deprecated config asks for an error.' "$OUTPUT" + ../bashunit -a not_contains 'Config asks for an error.' "$OUTPUT" + # The paths declared with the deprecated RuleErrorBuilder::fileDependency() are declared on the + # scope of the rule's node. + printf 'quiet\n' > data/deprecated.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Deprecated config asks for an error.' "$OUTPUT" + # A rule declares its dependency on src/Config.php whether it reports an error or not. The file + # declares no symbols, so no change of it reaches its exported nodes - any change re-analyses the + # files of the rule's nodes anyway, next to the changed file itself: src/UsesConfig.php and + # src/ConfigHolder.php. Not src/UsesConfigHolder.php depending on src/ConfigHolder.php - what + # src/ConfigHolder.php declares did not change. + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '3' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" + ../bashunit -a contains 'ConfigHolder.php:10:Config asks for an error.' "$OUTPUT" + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '3' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" + # Dynamic return type extensions of a function, a method and a static method declared in + # src/functions.php and src/Repository.php read a data file each. A change of one re-analyses the + # file with the call - not the one declaring the function or the method. + printf 'function-2\n' > data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-2'." "$OUTPUT" + printf 'method-2\n' > data/method.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesMethod.php:10:Function ResultCacheE2EFileDependency\usesMethod() should return 'none' but returns 'method-2'." "$OUTPUT" + printf 'static-method-2\n' > data/static-method.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesStaticMethod.php:10:Function ResultCacheE2EFileDependency\usesStaticMethod() should return 'none' but returns 'static-method-2'." "$OUTPUT" + # Scope::getFile() is not the file to re-analyse when the extension runs outside the walk of the + # analysed file: the type of Holder::$value is inferred from the constructor on a scope of + # src/Holder.php, whenever the property is first needed - in the analysis of src/Holder.php, or of + # src/UsesHolder.php reading it - and it is cached. So it is what src/Holder.php declares that + # depends on data/holder.txt: a change of it re-analyses src/Holder.php and the files depending on it. + printf 'int\n' > data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + # Deleting a declared file re-analyses the files depending on it too, and it stays watched while + # it is missing: creating it again re-analyses them again. + rm data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'missing'." "$OUTPUT" + printf 'function-3\n' > data/function.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '1' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains "UsesFunction.php:10:Function ResultCacheE2EFileDependency\usesFunction() should return 'none' but returns 'function-3'." "$OUTPUT" + # The same for what src/Holder.php declares. + rm data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + printf 'int\n' > data/holder.txt + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Holder.php:17:Method ResultCacheE2EFileDependency\Holder::get() should return int but returns string.' "$OUTPUT" + ../bashunit -a not_contains 'UsesHolder.php:9:Function ResultCacheE2EFileDependency\usesHolder() should return int but returns string.' "$OUTPUT" + # And for a declared file that is analysed itself: deleted, it is no longer analysed, and the files + # of the rule's nodes are re-analysed; created again, all three are - and as with any new file, so + # are the three files with errors. + rm src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '2' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a not_contains 'Config asks for an error.' "$OUTPUT" + printf " src/Config.php + INFO=$(../../bin/phpstan result-cache-info --json) + echo "$INFO" + ../bashunit -a equals '6' "$(echo "$INFO" | jq --raw-output '.filesToAnalyseCount')" + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'UsesConfig.php:7:Config asks for an error.' "$OUTPUT" - script: | cd e2e/result-cache-scanned-trait ../../bin/phpstan analyse diff --git a/e2e/result-cache-file-dependency/.gitignore b/e2e/result-cache-file-dependency/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/result-cache-file-dependency/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/result-cache-file-dependency/composer.json b/e2e/result-cache-file-dependency/composer.json new file mode 100644 index 00000000000..e23fc929433 --- /dev/null +++ b/e2e/result-cache-file-dependency/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["extension/"] + } +} diff --git a/e2e/result-cache-file-dependency/composer.lock b/e2e/result-cache-file-dependency/composer.lock new file mode 100644 index 00000000000..ba8d41762cc --- /dev/null +++ b/e2e/result-cache-file-dependency/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d751713988987e9331980363e24189ce", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/e2e/result-cache-file-dependency/data/deprecated.txt b/e2e/result-cache-file-dependency/data/deprecated.txt new file mode 100644 index 00000000000..05c31926438 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/deprecated.txt @@ -0,0 +1 @@ +report diff --git a/e2e/result-cache-file-dependency/data/function.txt b/e2e/result-cache-file-dependency/data/function.txt new file mode 100644 index 00000000000..0500baafb89 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/function.txt @@ -0,0 +1 @@ +function-1 diff --git a/e2e/result-cache-file-dependency/data/holder.txt b/e2e/result-cache-file-dependency/data/holder.txt new file mode 100644 index 00000000000..8e27be7d615 --- /dev/null +++ b/e2e/result-cache-file-dependency/data/holder.txt @@ -0,0 +1 @@ +text diff --git a/e2e/result-cache-file-dependency/data/method.txt b/e2e/result-cache-file-dependency/data/method.txt new file mode 100644 index 00000000000..7326a69525c --- /dev/null +++ b/e2e/result-cache-file-dependency/data/method.txt @@ -0,0 +1 @@ +method-1 diff --git a/e2e/result-cache-file-dependency/data/static-method.txt b/e2e/result-cache-file-dependency/data/static-method.txt new file mode 100644 index 00000000000..f8005c2a28a --- /dev/null +++ b/e2e/result-cache-file-dependency/data/static-method.txt @@ -0,0 +1 @@ +static-method-1 diff --git a/e2e/result-cache-file-dependency/extension/ConfigRule.php b/e2e/result-cache-file-dependency/extension/ConfigRule.php new file mode 100644 index 00000000000..4084bff6f42 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/ConfigRule.php @@ -0,0 +1,53 @@ + + */ +final class ConfigRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'checkConfig') { + return []; + } + + $configFile = dirname(__DIR__) . '/src/Config.php'; + $scope->fileDependency($configFile); + + if (!is_file($configFile)) { + return []; + } + + $contents = file_get_contents($configFile); + if ($contents === false || !str_contains($contents, 'report')) { + return []; + } + + return [ + RuleErrorBuilder::message('Config asks for an error.')->identifier('resultCacheE2E.config')->build(), + ]; + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataFile.php b/e2e/result-cache-file-dependency/extension/DataFile.php new file mode 100644 index 00000000000..bbfd65d75ca --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataFile.php @@ -0,0 +1,38 @@ +fileDependency($file); + + if (!is_file($file)) { + return new ConstantStringType('missing'); + } + + $contents = file_get_contents($file); + $contents = $contents === false ? '' : trim($contents); + if ($contents === 'int') { + return new IntegerType(); + } + + return new ConstantStringType($contents); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..7c1da2957c3 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataFunctionReturnTypeExtension.php @@ -0,0 +1,28 @@ +getName(), ['ResultCacheE2EFileDependency\functionData', 'ResultCacheE2EFileDependency\holderData'], true); + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + return DataFile::type($functionReflection->getName() === 'ResultCacheE2EFileDependency\functionData' ? 'function' : 'holder', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php new file mode 100644 index 00000000000..261ef7975eb --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataMethodReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'methodData'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + return DataFile::type('method', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php b/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php new file mode 100644 index 00000000000..ca24d00c950 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DataStaticMethodReturnTypeExtension.php @@ -0,0 +1,33 @@ +getName() === 'staticMethodData'; + } + + /** + * @param Scope&DependencyEmitter $scope + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + return DataFile::type('static-method', $scope); + } + +} diff --git a/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php b/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php new file mode 100644 index 00000000000..91dd1a584b5 --- /dev/null +++ b/e2e/result-cache-file-dependency/extension/DeprecatedConfigRule.php @@ -0,0 +1,44 @@ + + */ +final class DeprecatedConfigRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Name || $node->name->getLast() !== 'checkDeprecatedConfig') { + return []; + } + + $configFile = dirname(__DIR__) . '/data/deprecated.txt'; + $contents = @file_get_contents($configFile); + if ($contents === false || !str_contains($contents, 'report')) { + return []; + } + + return [ + // @phpstan-ignore method.deprecated + RuleErrorBuilder::message('Deprecated config asks for an error.')->identifier('resultCacheE2E.deprecatedConfig')->fileDependency($configFile)->build(), + ]; + } + +} diff --git a/e2e/result-cache-file-dependency/phpstan.neon b/e2e/result-cache-file-dependency/phpstan.neon new file mode 100644 index 00000000000..b3735e51982 --- /dev/null +++ b/e2e/result-cache-file-dependency/phpstan.neon @@ -0,0 +1,28 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + inferPrivatePropertyTypeFromConstructor: true + +services: + - + class: ResultCacheE2EFileDependency\ConfigRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EFileDependency\DeprecatedConfigRule + tags: + - phpstan.rules.rule + - + class: ResultCacheE2EFileDependency\DataFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: ResultCacheE2EFileDependency\DataMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: ResultCacheE2EFileDependency\DataStaticMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/e2e/result-cache-file-dependency/src/Config.php b/e2e/result-cache-file-dependency/src/Config.php new file mode 100644 index 00000000000..3a97c20de7f --- /dev/null +++ b/e2e/result-cache-file-dependency/src/Config.php @@ -0,0 +1,3 @@ +value = holderData(); + } + + public function get(): int + { + return $this->value; + } + +} diff --git a/e2e/result-cache-file-dependency/src/Repository.php b/e2e/result-cache-file-dependency/src/Repository.php new file mode 100644 index 00000000000..0c2de3048e6 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/Repository.php @@ -0,0 +1,18 @@ +check(); +} diff --git a/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php b/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php new file mode 100644 index 00000000000..294725546df --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesDeprecatedConfig.php @@ -0,0 +1,8 @@ + $holder->value, null, Holder::class); + + return $read($holder); +} diff --git a/e2e/result-cache-file-dependency/src/UsesMethod.php b/e2e/result-cache-file-dependency/src/UsesMethod.php new file mode 100644 index 00000000000..e21699ca8a9 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesMethod.php @@ -0,0 +1,11 @@ +methodData(); +} diff --git a/e2e/result-cache-file-dependency/src/UsesStaticMethod.php b/e2e/result-cache-file-dependency/src/UsesStaticMethod.php new file mode 100644 index 00000000000..de55cb7e7e6 --- /dev/null +++ b/e2e/result-cache-file-dependency/src/UsesStaticMethod.php @@ -0,0 +1,11 @@ +getFileDependencies() as $fileDependency) { - $this->fileDependencies[] = $fileDependency; + $scope->fileDependency($fileDependency); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 043462da76a..b426b23fdd2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -6077,4 +6077,9 @@ public function valueDependency(string $extensionClass, string $key): void $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, $this, $this->nodeCallback !== null); } + public function fileDependency(string $file): void + { + $this->container->getByType(ValueDependencyCollector::class)->recordFile($file, $this, $this->nodeCallback !== null); + } + } diff --git a/src/Analyser/ResultCache/FileResultCacheValueExtension.php b/src/Analyser/ResultCache/FileResultCacheValueExtension.php new file mode 100644 index 00000000000..046a62ee8bc --- /dev/null +++ b/src/Analyser/ResultCache/FileResultCacheValueExtension.php @@ -0,0 +1,53 @@ +pathTransformer = new ResultCachePathTransformer($anchorDirectory); + } + + public function getValue(string $key): string + { + $hash = $this->fileContentHasher->hash($key); + if ($hash === false) { + return self::MISSING_FILE; + } + + return $hash; + } + + public function keyToResultCache(string $key): string + { + return $this->pathTransformer->relativizePath($key); + } + + public function keyFromResultCache(string $storedKey): string + { + return $this->pathTransformer->absolutizePath($storedKey); + } + +} diff --git a/src/Analyser/ValueDependencyCollector.php b/src/Analyser/ValueDependencyCollector.php index 0010ae44b75..c9f600059f1 100644 --- a/src/Analyser/ValueDependencyCollector.php +++ b/src/Analyser/ValueDependencyCollector.php @@ -2,10 +2,12 @@ namespace PHPStan\Analyser; +use PHPStan\Analyser\ResultCache\FileResultCacheValueExtension; use PHPStan\Analyser\ResultCache\ResultCacheValueExtension; use PHPStan\DependencyInjection\AutowiredExtensions; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\ExtensionsCollection; +use PHPStan\File\FileHelper; use PHPStan\ShouldNotHappenException; use function array_key_exists; use function array_keys; @@ -58,6 +60,7 @@ final class ValueDependencyCollector public function __construct( #[AutowiredExtensions(of: ResultCacheValueExtension::class)] private ExtensionsCollection $valueExtensions, + private FileHelper $fileHelper, ) { } @@ -100,6 +103,21 @@ public function record(string $extensionClass, string $key, Scope $scope, bool $ $this->dependents[$scopeFile]['declarations'][$id] = true; } + /** + * DependencyEmitter::fileDependency() - a dependency on the contents of a file, through + * FileResultCacheValueExtension. + */ + public function recordFile(string $file, Scope $scope, bool $insideWalk): void + { + $file = $this->fileHelper->normalizePath($file); + if ($insideWalk && $file === $this->analysedFile) { + // the analysed file is re-analysed when it changes anyway + return; + } + + $this->record(FileResultCacheValueExtension::class, $file, $scope, $insideWalk); + } + /** * @return ValueDependencies always with an entry for the analysed file */ diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index b1c496608b5..cbff161c0b3 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -8,6 +8,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Include_; use PhpParser\Node\Name\FullyQualified; +use PHPStan\Analyser\DependencyEmitter; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; @@ -58,7 +59,7 @@ public function getNodeType(): string return Include_::class; } - public function processNode(Node $node, Scope $scope): array + public function processNode(Node $node, Scope&DependencyEmitter $scope): array { if ($this->isInFileExists($node, $scope)) { return []; @@ -81,7 +82,13 @@ public function processNode(Node $node, Scope $scope): array $pathExpr = '"' . $path . '"'; } - $errors[] = $this->getErrorMessage($node, $pathExpr, $this->includedFilePathResolver->resolve($path, $scope)); + // The error is about a path, and a path is nothing the dependency graph tracks. Declaring the + // paths makes the result cache re-analyse this file when one of them is created. + foreach ($this->includedFilePathResolver->resolve($path, $scope) as $candidatePath) { + $scope->fileDependency($candidatePath); + } + + $errors[] = $this->getErrorMessage($node, $pathExpr); } return $errors; @@ -124,10 +131,7 @@ private function getScopeFile(Scope $scope): string return $scope->getFile(); } - /** - * @param list $candidatePaths - */ - private function getErrorMessage(Include_ $node, string $filePath, array $candidatePaths): IdentifierRuleError + private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError { $message = 'Path in %s() %s is not a file or it does not exist.'; @@ -154,21 +158,13 @@ private function getErrorMessage(Include_ $node, string $filePath, array $candid $identifier = sprintf('%s.fileNotFound', $identifierType); - $builder = RuleErrorBuilder::message( + return RuleErrorBuilder::message( sprintf( $message, $type, $filePath, ), - )->identifier($identifier); - - // The error is about a path, and a path is nothing the dependency graph tracks. Declaring the - // paths makes the result cache re-analyse this file when one of them is created. - foreach ($candidatePaths as $candidatePath) { - $builder->fileDependency($candidatePath); - } - - return $builder->build(); + )->identifier($identifier)->build(); } /** diff --git a/src/Rules/RuleErrorBuilder.php b/src/Rules/RuleErrorBuilder.php index 51efa2b15f4..ff7c90e0073 100644 --- a/src/Rules/RuleErrorBuilder.php +++ b/src/Rules/RuleErrorBuilder.php @@ -187,6 +187,8 @@ public function file(string $file, ?string $fileDescription = null): self * re-analyses the file the error is reported in when that file appears, changes or is deleted. * The path must be absolute; it does not have to exist. * + * @deprecated Call fileDependency() on the Scope instead (Scope&DependencyEmitter) - it declares + * the dependency whether the rule reports an error or not. * @api * @phpstan-this-out self * @return self diff --git a/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php index 7f30e244f6d..55433aea718 100644 --- a/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php +++ b/tests/PHPStan/Analyser/ValueDependencyCollectorTest.php @@ -3,7 +3,9 @@ namespace PHPStan\Analyser; use Override; +use PHPStan\Analyser\ResultCache\FileResultCacheValueExtension; use PHPStan\Analyser\ValueDependencyCollectorTest\TestValueExtension; +use PHPStan\File\FileHelper; use PHPStan\ShouldNotHappenException; use PHPStan\Testing\PHPStanTestCase; use function array_merge; @@ -46,6 +48,47 @@ public function testDedupAndDependents(): void $this->assertSame(2, $extension->calls); } + public function testFile(): void + { + $collector = self::getContainer()->getByType(ValueDependencyCollector::class); + $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); + $fileHelper = self::getContainer()->getByType(FileHelper::class); + // the paths FileAnalyser passes along are normalized - with backslashes on Windows + $analysedFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-analysed.php'); + $dataFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-missing.txt'); + $otherFile = $fileHelper->normalizePath(__DIR__ . '/data/value-dependency-other.php'); + $analysedFileScope = $scopeFactory->create(ScopeContext::create($analysedFile)); + $otherFileScope = $scopeFactory->create(ScopeContext::create($otherFile)); + + $collector->startFile($analysedFile); + // the analysed file is re-analysed when it changes anyway + $collector->recordFile($analysedFile, $analysedFileScope, true); + // the same path, written differently, is the same dependency + $collector->recordFile(__DIR__ . '/data/../data/value-dependency-missing.txt', $analysedFileScope, true); + $collector->recordFile($dataFile, $analysedFileScope, true); + // outside the walk, what the other file declares depends on the analysed file + $collector->recordFile($analysedFile, $otherFileScope, false); + + $dataFileId = ValueDependencyCollector::getId(FileResultCacheValueExtension::class, $dataFile); + $analysedFileId = ValueDependencyCollector::getId(FileResultCacheValueExtension::class, $analysedFile); + $this->assertSame([ + 'values' => [ + $dataFileId => [FileResultCacheValueExtension::class, $dataFile, 'missing'], + $analysedFileId => [FileResultCacheValueExtension::class, $analysedFile, 'missing'], + ], + 'dependents' => [ + $analysedFile => [ + 'analysis' => [$dataFileId, $analysedFileId], + 'declarations' => [], + ], + $otherFile => [ + 'analysis' => [], + 'declarations' => [$analysedFileId], + ], + ], + ], $collector->finishFile()); + } + public function testNothingOutsideOfAnalysedFile(): void { $collector = self::getContainer()->getByType(ValueDependencyCollector::class); diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index bf3efb2dc73..697c72f5bfd 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -10585,6 +10585,24 @@ class MutatingScope /* $this->container->getByType(ValueDependencyCollector::class)->record($extensionClass, $key, * $this, $this->nodeCallback !== null) */ bool valueDependency(zend_string *extensionClass, zend_string *key) + { + zval extensionClassZv, keyZv; + ZVAL_STR(&extensionClassZv, extensionClass); + ZVAL_STR(&keyZv, key); + return callValueDependencyCollector(PT_LC("record"), &extensionClassZv, &keyZv); + } + + /* $this->container->getByType(ValueDependencyCollector::class)->recordFile($file, $this, + * $this->nodeCallback !== null) */ + bool fileDependency(zend_string *file) + { + zval fileZv; + ZVAL_STR(&fileZv, file); + return callValueDependencyCollector(PT_LC("recordfile"), &fileZv, NULL); + } + + /* the collector's method (lowercase name) with the arguments, the scope and whether it is inside the walk */ + bool callValueDependencyCollector(const char *method, size_t methodLength, zval *first, zval *second) { zv::Ref nodeCallback = slot(PT_MS_PROP_NODE_CALLBACK); if (UNEXPECTED(nodeCallback.isUndef())) { @@ -10595,13 +10613,15 @@ class MutatingScope ZVAL_BOOL(&insideWalk, !nodeCallback.isNull()); zv::Val collector = containerGetByType(PT_LC("PHPStan\\Analyser\\ValueDependencyCollector")); if (UNEXPECTED(collector.isUndef())) return false; - zend_object *collectorObject = requireObject(collector, "record"); + zend_object *collectorObject = requireObject(collector, method); if (UNEXPECTED(collectorObject == NULL)) return false; - zval extensionClassZv, keyZv; - ZVAL_STR(&extensionClassZv, extensionClass); - ZVAL_STR(&keyZv, key); - zv::Args args{&extensionClassZv, &keyZv, self, &insideWalk}; - zv::Val result = pt_type_call(collectorObject, PT_LC("record"), 4, args); + if (second == NULL) { + zv::Args args{first, self, &insideWalk}; + zv::Val result = pt_type_call(collectorObject, method, methodLength, 3, args); + return !result.isUndef(); + } + zv::Args args{first, second, self, &insideWalk}; + zv::Val result = pt_type_call(collectorObject, method, methodLength, 4, args); return !result.isUndef(); } @@ -13378,6 +13398,12 @@ PT_MINIT_REGISTRATION(pt_register_mutating_scope) if (UNEXPECTED(!PT_THIS.valueDependency(extensionClass, key))) RETURN_THROWS(); }); + cls.method(sigs::fileDependency, [](INTERNAL_FUNCTION_PARAMETERS) { + zend_string *file; + if (!zp::parse(execute_data, file)) RETURN_THROWS(); + if (UNEXPECTED(!PT_THIS.fileDependency(file))) RETURN_THROWS(); + }); + /* }}} */ /* out of the twin's file order (see the handle class) */ diff --git a/turbo-ext/src/generated/MutatingScope.h b/turbo-ext/src/generated/MutatingScope.h index 6f99a964c0e..cd7266cf0a6 100644 --- a/turbo-ext/src/generated/MutatingScope.h +++ b/turbo-ext/src/generated/MutatingScope.h @@ -533,7 +533,9 @@ inline constexpr char strings[] = "data\0" /* 9236 */ "emitCollectedData\0" /* 9241 */ "extensionClass\0" /* 9259 */ - "valueDependency"; /* 9274 */ + "valueDependency\0" /* 9274 */ + "file\0" /* 9290 */ + "fileDependency"; /* 9295 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 10), /* __construct $container */ reg::packed(48, 0, 61), /* __construct $scopeFactory */ @@ -1069,6 +1071,8 @@ inline constexpr reg::PackedArg args[] = { reg::packed(9259, MAY_BE_STRING), /* valueDependency $extensionClass */ reg::packed(3280, MAY_BE_STRING), /* valueDependency $key */ reg::packed(1476, MAY_BE_VOID), /* valueDependency return */ + reg::packed(9290, MAY_BE_STRING), /* fileDependency $file */ + reg::packed(1476, MAY_BE_VOID), /* fileDependency return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -1271,6 +1275,7 @@ inline constexpr sigtab::Sig isOverallPhpVersionRange = { { 9163 /* isOverallPhp inline constexpr sigtab::Sig invokeNodeCallback = { { 9203 /* invokeNodeCallback */, 1, 526, 1, 527, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig emitCollectedData = { { 9241 /* emitCollectedData */, 2, 528, 2, 530, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig valueDependency = { { 9274 /* valueDependency */, 2, 531, 2, 533, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig fileDependency = { { 9295 /* fileDependency */, 1, 534, 1, 535, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::MutatingScope From 223a01f3b819c37a015c6d404471d37607ad317e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 30 Sep 2026 21:33:35 +0200 Subject: [PATCH 5/5] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 2b54632598c..6aa515b13d6 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '63cd54c'; + public const EXPECTED_EXTENSION_VERSION = '0d41107'; private static bool $active = false;