From 7c6d4bdd1ba78b6a6064208bedc1e4329b50ef3b Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 30 Jul 2026 18:13:33 +0530 Subject: [PATCH 1/3] feat: implement ArchExpectationTypeResolver and related tests for 'not' property handling --- src/Type/Pest/ArchExpectationTypeResolver.php | 31 ++++++++++++++++ .../Pest/ExpectationPropertiesExtension.php | 12 +++++++ .../HigherOrderExpectationTypeExtension.php | 25 +++++++++++++ tests/Type/data/arch-expectations.php | 35 +++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 src/Type/Pest/ArchExpectationTypeResolver.php diff --git a/src/Type/Pest/ArchExpectationTypeResolver.php b/src/Type/Pest/ArchExpectationTypeResolver.php new file mode 100644 index 0000000..5916622 --- /dev/null +++ b/src/Type/Pest/ArchExpectationTypeResolver.php @@ -0,0 +1,31 @@ +getResolvedMixinTypes() as $mixinType) { + $mixinClassReflections = $mixinType->getObjectClassReflections(); + foreach ($mixinClassReflections as $mixinClassReflection) { + if ($mixinClassReflection->is(Expectation::class)) { + $tValue = $mixinType->getTemplateType(Expectation::class, 'TValue'); + + return new GenericObjectType(OppositeExpectation::class, [$tValue]); + } + } + } + + return new GenericObjectType(OppositeExpectation::class, [new ObjectType('string')]); + } +} diff --git a/src/Type/Pest/ExpectationPropertiesExtension.php b/src/Type/Pest/ExpectationPropertiesExtension.php index 322c7b2..771dd4f 100644 --- a/src/Type/Pest/ExpectationPropertiesExtension.php +++ b/src/Type/Pest/ExpectationPropertiesExtension.php @@ -4,6 +4,7 @@ namespace Pest\PHPStan\Type\Pest; +use Pest\Arch\Contracts\ArchExpectation; use Pest\Expectation; use Pest\Expectations\HigherOrderExpectation; use PHPStan\Reflection\ClassReflection; @@ -18,6 +19,10 @@ final class ExpectationPropertiesExtension implements PropertiesClassReflectionE public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { + if ($classReflection->is(ArchExpectation::class) && $propertyName === 'not' && ! $classReflection->hasNativeProperty($propertyName)) { + return true; + } + if ($classReflection->is(Expectation::class)) { return ! in_array($propertyName, self::KNOWN_EXPECTATION_PROPERTIES, true) && ! $classReflection->hasNativeProperty($propertyName); @@ -32,6 +37,13 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection { + if ($classReflection->is(ArchExpectation::class) && $propertyName === 'not') { + return new PestTestCaseProperty( + $classReflection, + ArchExpectationTypeResolver::getNotPropertyType($classReflection), + ); + } + return new PestTestCaseProperty($classReflection, new MixedType); } } diff --git a/src/Type/Pest/HigherOrderExpectationTypeExtension.php b/src/Type/Pest/HigherOrderExpectationTypeExtension.php index 2f60a95..d6dd825 100644 --- a/src/Type/Pest/HigherOrderExpectationTypeExtension.php +++ b/src/Type/Pest/HigherOrderExpectationTypeExtension.php @@ -4,6 +4,7 @@ namespace Pest\PHPStan\Type\Pest; +use Pest\Arch\Contracts\ArchExpectation; use Pest\Expectation; use Pest\Expectations\HigherOrderExpectation; use Pest\Expectations\OppositeExpectation; @@ -15,6 +16,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -49,6 +51,15 @@ private function resolvePropertyFetch(PropertyFetch $expr, Scope $scope): ?Type $propertyName = $expr->name->name; $varType = $scope->getType($expr->var); + if ($propertyName === 'not') { + $archType = new ObjectType(ArchExpectation::class); + if ($archType->isSuperTypeOf($varType)->yes()) { + $valueType = $this->extractTValueFromArchExpectationChain($expr, $scope); + + return new GenericObjectType(OppositeExpectation::class, [$valueType]); + } + } + $expectationType = new ObjectType(Expectation::class); if ($expectationType->isSuperTypeOf($varType)->yes()) { return $this->resolveExpectationPropertyFetch($varType, $propertyName, $scope); @@ -62,6 +73,20 @@ private function resolvePropertyFetch(PropertyFetch $expr, Scope $scope): ?Type return null; } + private function extractTValueFromArchExpectationChain(PropertyFetch $expr, Scope $scope): Type + { + if ($expr->var instanceof MethodCall) { + $originalType = $scope->getType($expr->var->var); + $tValue = $originalType->getTemplateType(Expectation::class, 'TValue'); + + if (! $tValue instanceof MixedType) { + return $tValue; + } + } + + return new ObjectType('string'); + } + private function resolveExpectationPropertyFetch(Type $varType, string $propertyName, Scope $scope): ?Type { if ($propertyName === 'not') { diff --git a/tests/Type/data/arch-expectations.php b/tests/Type/data/arch-expectations.php index 88b73dc..d7aae9e 100644 --- a/tests/Type/data/arch-expectations.php +++ b/tests/Type/data/arch-expectations.php @@ -109,3 +109,38 @@ function testToBeInvokable(): void $result = expect('App\Actions')->toBeInvokable(); assertType(ArchExpectation::class, $result); } +function testNotOnArchExpectation(): void +{ + $result = expect('App')->toUseStrictTypes()->not; + assertType("Pest\Expectations\OppositeExpectation<'App'>", $result); +} + +function testNotArchExpectationChainToUse(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toUse(['dd', 'dump']); + assertType(ArchExpectation::class, $result); +} + +function testNotArchExpectationChainToBeFinal(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toBeFinal(); + assertType(ArchExpectation::class, $result); +} + +function testNotArchExpectationChainToImplement(): void +{ + $result = expect('App')->toUseStrictTypes()->not->toImplement('SomeInterface'); + assertType(ArchExpectation::class, $result); +} + +function testNotOnExpectDirectly(): void +{ + $result = expect('App')->not->toUse(['dd', 'dump']); + assertType(ArchExpectation::class, $result); +} + +function testNotOnArchExpectationArrayTarget(): void +{ + $result = expect(['App\Models', 'App\Services'])->toUseStrictTypes()->not; + assertType('Pest\Expectations\OppositeExpectation', $result); +} From eb6231cc9b4ea13196ef529eec9cade603938da4 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 30 Jul 2026 19:12:36 +0530 Subject: [PATCH 2/3] feat: add ArchExpectationNotPropertyIgnoreExtension and related tests for 'not' property handling --- extension.neon | 5 +++ ...hExpectationNotPropertyIgnoreExtension.php | 36 +++++++++++++++++++ ...ectationNotPropertyIgnoreExtensionTest.php | 21 +++++++++++ .../data/arch-expectation-not-property.php | 9 +++++ 4 files changed, 71 insertions(+) create mode 100644 src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php create mode 100644 tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php create mode 100644 tests/Rules/data/arch-expectation-not-property.php diff --git a/extension.neon b/extension.neon index 7808525..c301c63 100644 --- a/extension.neon +++ b/extension.neon @@ -69,6 +69,11 @@ services: tags: - phpstan.ignoreErrorExtension + - + class: Pest\PHPStan\Type\Pest\ArchExpectationNotPropertyIgnoreExtension + tags: + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Type\Pest\TestCallMethodsClassReflectionExtension tags: diff --git a/src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php b/src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php new file mode 100644 index 0000000..f2a0ae5 --- /dev/null +++ b/src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php @@ -0,0 +1,36 @@ +getIdentifier() !== 'property.notFound') { + return false; + } + + if (! $node instanceof PropertyFetch) { + return false; + } + + if (! $node->name instanceof Identifier || $node->name->name !== 'not') { + return false; + } + + return (new ObjectType(ArchExpectation::class)) + ->isSuperTypeOf($scope->getType($node->var)) + ->yes(); + } +} diff --git a/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php b/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php new file mode 100644 index 0000000..3e73911 --- /dev/null +++ b/tests/Rules/ArchExpectationNotPropertyIgnoreExtensionTest.php @@ -0,0 +1,21 @@ +analyse([ + __DIR__.'/data/arch-expectation-not-property.php', + ], []); +}); diff --git a/tests/Rules/data/arch-expectation-not-property.php b/tests/Rules/data/arch-expectation-not-property.php new file mode 100644 index 0000000..802889e --- /dev/null +++ b/tests/Rules/data/arch-expectation-not-property.php @@ -0,0 +1,9 @@ +toUseStrictTypes()->not->toUse(['dd', 'dump']); + expect('App')->toUseStrictTypes()->not->toBeFinal(); + expect(['App\Models', 'App\Services'])->toUseStrictTypes()->not->toUse('Illuminate\Support\Facades\DB'); +}); From 47a813c470248edaaa04e39c9e7610a9ebc5086d Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 30 Jul 2026 19:22:23 +0530 Subject: [PATCH 3/3] update for all available properties --- extension.neon | 2 +- ...tension.php => ArchExpectationPropertyIgnoreExtension.php} | 4 ++-- src/Type/Pest/ExpectationPropertiesExtension.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/Type/Pest/{ArchExpectationNotPropertyIgnoreExtension.php => ArchExpectationPropertyIgnoreExtension.php} (76%) diff --git a/extension.neon b/extension.neon index c301c63..aab2056 100644 --- a/extension.neon +++ b/extension.neon @@ -70,7 +70,7 @@ services: - phpstan.ignoreErrorExtension - - class: Pest\PHPStan\Type\Pest\ArchExpectationNotPropertyIgnoreExtension + class: Pest\PHPStan\Type\Pest\ArchExpectationPropertyIgnoreExtension tags: - phpstan.ignoreErrorExtension diff --git a/src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php b/src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php similarity index 76% rename from src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php rename to src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php index f2a0ae5..f9e3302 100644 --- a/src/Type/Pest/ArchExpectationNotPropertyIgnoreExtension.php +++ b/src/Type/Pest/ArchExpectationPropertyIgnoreExtension.php @@ -13,7 +13,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Type\ObjectType; -final class ArchExpectationNotPropertyIgnoreExtension implements IgnoreErrorExtension +final class ArchExpectationPropertyIgnoreExtension implements IgnoreErrorExtension { public function shouldIgnore(Error $error, Node $node, Scope $scope): bool { @@ -25,7 +25,7 @@ public function shouldIgnore(Error $error, Node $node, Scope $scope): bool return false; } - if (! $node->name instanceof Identifier || $node->name->name !== 'not') { + if (! $node->name instanceof Identifier || ! in_array($node->name->name, ExpectationPropertiesExtension::KNOWN_EXPECTATION_PROPERTIES, true)) { return false; } diff --git a/src/Type/Pest/ExpectationPropertiesExtension.php b/src/Type/Pest/ExpectationPropertiesExtension.php index 771dd4f..a843201 100644 --- a/src/Type/Pest/ExpectationPropertiesExtension.php +++ b/src/Type/Pest/ExpectationPropertiesExtension.php @@ -15,7 +15,7 @@ final class ExpectationPropertiesExtension implements PropertiesClassReflectionExtension { /** @var list */ - private const KNOWN_EXPECTATION_PROPERTIES = ['not', 'each', 'classes', 'traits', 'interfaces', 'enums', 'value']; + public const KNOWN_EXPECTATION_PROPERTIES = ['not', 'each', 'classes', 'traits', 'interfaces', 'enums', 'value']; public function hasProperty(ClassReflection $classReflection, string $propertyName): bool {