From bf9a0d0356fb51123428a63dd57d157d8cd1d470 Mon Sep 17 00:00:00 2001 From: Jeppe Knockaert Date: Mon, 17 Aug 2026 11:26:34 +0200 Subject: [PATCH 1/5] feat: narrow expectation subject types in the enclosing scope --- extension.neon | 17 +++ .../Expectation/ExpectationNarrowing.php | 22 ++++ .../ExpectationNarrowingResolver.php | 100 ++++++++++++++++ .../ExpectationTypeSpecifyingExtension.php | 61 ++++++++++ .../impossible-expectation-exhaustive.php | 29 ++++- tests/Type/ExpectTypeTest.php | 6 + tests/Type/data/expectation-narrowing.php | 112 ++++++++++++++++++ 7 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 src/Analysis/Expectation/ExpectationNarrowing.php create mode 100644 src/Analysis/Expectation/ExpectationNarrowingResolver.php create mode 100644 src/Type/Pest/ExpectationTypeSpecifyingExtension.php create mode 100644 tests/Type/data/expectation-narrowing.php diff --git a/extension.neon b/extension.neon index f0f672d..ca1502f 100644 --- a/extension.neon +++ b/extension.neon @@ -26,6 +26,23 @@ services: - class: Pest\PHPStan\Analysis\Expectation\ExpectationSemanticAnalyzer + - + class: Pest\PHPStan\Analysis\Expectation\ExpectationNarrowingResolver + + - + class: Pest\PHPStan\Type\Pest\ExpectationTypeSpecifyingExtension + arguments: + className: Pest\Expectation + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + + - + class: Pest\PHPStan\Type\Pest\ExpectationTypeSpecifyingExtension + arguments: + className: Pest\Mixins\Expectation + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - class: Pest\PHPStan\Type\Pest\PestFunctionReturnTypeExtension tags: diff --git a/src/Analysis/Expectation/ExpectationNarrowing.php b/src/Analysis/Expectation/ExpectationNarrowing.php new file mode 100644 index 0000000..8ac48df --- /dev/null +++ b/src/Analysis/Expectation/ExpectationNarrowing.php @@ -0,0 +1,22 @@ + + */ + public function resolve(MethodCall $methodCall, Scope $scope): array + { + $links = []; + $current = $methodCall; + + while (($current instanceof MethodCall || $current instanceof PropertyFetch) && $current->name instanceof Identifier) { + $links[] = $current; + $current = $current->var; + } + + $subject = $this->resolveSubject($current, $scope); + if (! $subject instanceof Expr) { + return []; + } + + $narrowings = []; + + foreach (array_reverse($links) as $link) { + if ($link instanceof PropertyFetch) { + return $narrowings; + } + + /** @var Identifier $name */ + $name = $link->name; + $methodName = $name->name; + + if ($methodName === self::REBIND_METHOD) { + $subject = $link->getArgs()[0]->value ?? null; + if (! $subject instanceof Expr) { + return $narrowings; + } + + continue; + } + + if (in_array($methodName, self::PASSTHROUGH_METHODS, true)) { + continue; + } + + if (! method_exists(MixinsExpectation::class, $methodName)) { + return $narrowings; + } + + $assertedType = $this->matcherRegistry->assertedTypeFor($methodName, $link, $scope); + if ($assertedType instanceof Type) { + $narrowings[] = ExpectationNarrowing::type($subject, $assertedType); + } + } + + return $narrowings; + } + + private function resolveSubject(Expr $root, Scope $scope): ?Expr + { + if (! $root instanceof FuncCall || ! $root->name instanceof Name) { + return null; + } + + if ($root->name->toLowerString() !== 'expect') { + return null; + } + + if (! new ObjectType(Expectation::class)->isSuperTypeOf($scope->getType($root))->yes()) { + return null; + } + + return $root->getArgs()[0]->value ?? null; + } +} diff --git a/src/Type/Pest/ExpectationTypeSpecifyingExtension.php b/src/Type/Pest/ExpectationTypeSpecifyingExtension.php new file mode 100644 index 0000000..2eee61b --- /dev/null +++ b/src/Type/Pest/ExpectationTypeSpecifyingExtension.php @@ -0,0 +1,61 @@ +className; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $specifiedTypes = new SpecifiedTypes; + + foreach ($this->narrowingResolver->resolve($node, $scope) as $narrowing) { + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $narrowing->subject, + $narrowing->assertedType, + $narrowing->negated + ? TypeSpecifierContext::createTruthy()->negate() + : TypeSpecifierContext::createTruthy(), + $scope, + )); + } + + return $specifiedTypes; + } +} diff --git a/tests/Rules/data/impossible-expectation-exhaustive.php b/tests/Rules/data/impossible-expectation-exhaustive.php index 270acb0..130c1ab 100644 --- a/tests/Rules/data/impossible-expectation-exhaustive.php +++ b/tests/Rules/data/impossible-expectation-exhaustive.php @@ -144,19 +144,39 @@ expect(new Post)->toBeInstanceOf(RuntimeException::class); }); -it('mixed can be anything', function (): void { +it('mixed can be a string', function (): void { /** @var mixed $value */ $value = null; expect($value)->toBeString(); +}); + +it('mixed can be an int', function (): void { + /** @var mixed $value */ + $value = null; expect($value)->toBeInt(); +}); + +it('mixed can be an array', function (): void { + /** @var mixed $value */ + $value = null; expect($value)->toBeArray(); +}); + +it('mixed can be an instance', function (): void { + /** @var mixed $value */ + $value = null; expect($value)->toBeInstanceOf(stdClass::class); }); -it('unions may match either branch', function (): void { +it('union may match the string branch', function (): void { /** @var int|string $value */ $value = 1; expect($value)->toBeString(); +}); + +it('union may match the int branch', function (): void { + /** @var int|string $value */ + $value = 1; expect($value)->toBeInt(); }); @@ -164,6 +184,11 @@ /** @var string|null $value */ $value = null; expect($value)->toBeNull(); +}); + +it('nullable may be a string', function (): void { + /** @var string|null $value */ + $value = null; expect($value)->toBeString(); }); diff --git a/tests/Type/ExpectTypeTest.php b/tests/Type/ExpectTypeTest.php index 583c1a7..914a594 100644 --- a/tests/Type/ExpectTypeTest.php +++ b/tests/Type/ExpectTypeTest.php @@ -97,3 +97,9 @@ })->with(function (): Iterator { yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-hook-properties-exhaustive.php'); }); + +test('expectation narrowing types', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from TestCase::gatherAssertTypes(__DIR__.'/data/expectation-narrowing.php'); +}); diff --git a/tests/Type/data/expectation-narrowing.php b/tests/Type/data/expectation-narrowing.php new file mode 100644 index 0000000..ed00aeb --- /dev/null +++ b/tests/Type/data/expectation-narrowing.php @@ -0,0 +1,112 @@ +toBeInt(); + assertType('int', $value); +} + +function testToBeStringNarrows(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBeString(); + assertType('string', $value); +} + +function testToBeNullNarrows(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->toBeNull(); + assertType('null', $value); +} + +function testToBeInstanceOfNarrows(): void +{ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + expect($value)->toBeInstanceOf(RuntimeException::class); + assertType('RuntimeException', $value); +} + +function testToBeTrueNarrows(): void +{ + $value = random_int(0, 1) === 1; + expect($value)->toBeTrue(); + assertType('true', $value); +} + +function testChainedMatchersAllNarrow(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->toBeString()->toStartWith('a'); + assertType('string', $value); +} + +function testAndRebindsTheSubject(): void +{ + /** @var int|string $first */ + $first = random_int(0, 1) === 1 ? 1 : 'a'; + /** @var int|string $second */ + $second = random_int(0, 1) === 1 ? 1 : 'a'; + expect($first)->toBeInt()->and($second)->toBeString(); + assertType('int', $first); + assertType('string', $second); +} + +function testWhenIsPassthrough(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->when(true, fn ($e) => $e)->toBeInt(); + assertType('int', $value); +} + +function testJsonBreaksTheSubjectLink(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBeString()->json()->toBeArray(); + assertType('string', $value); +} + +function testEachDoesNotNarrowTheSubject(): void +{ + /** @var array $values */ + $values = []; + expect($values)->each->toBeInt(); + assertType('array', $values); +} + +function testHigherOrderPropertyDoesNotNarrow(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBeInt()->foo->toBeString(); + assertType('int|string', $value); +} + +function testExpectWithoutArgumentsIsIgnored(): void +{ + expect()->toBeNull(); +} + +function testAssignedExpectationAlsoNarrowsTheSubject(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + $expectation = expect($value)->toBeInt(); + assertType('Pest\Expectation', $expectation); + assertType('int', $value); +} From 20d68389dabb1be298ed364a90fe42747b1a3625 Mon Sep 17 00:00:00 2001 From: Jeppe Knockaert Date: Mon, 17 Aug 2026 11:43:13 +0200 Subject: [PATCH 2/5] feat: narrow scope types through negated expectations --- extension.neon | 7 ++++ .../ExpectationNarrowingResolver.php | 23 ++++++++++- tests/Type/data/expectation-narrowing.php | 39 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/extension.neon b/extension.neon index ca1502f..d8f4e41 100644 --- a/extension.neon +++ b/extension.neon @@ -43,6 +43,13 @@ services: tags: - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - + class: Pest\PHPStan\Type\Pest\ExpectationTypeSpecifyingExtension + arguments: + className: Pest\Expectations\OppositeExpectation + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - class: Pest\PHPStan\Type\Pest\PestFunctionReturnTypeExtension tags: diff --git a/src/Analysis/Expectation/ExpectationNarrowingResolver.php b/src/Analysis/Expectation/ExpectationNarrowingResolver.php index 7dd6ea6..4f24914 100644 --- a/src/Analysis/Expectation/ExpectationNarrowingResolver.php +++ b/src/Analysis/Expectation/ExpectationNarrowingResolver.php @@ -20,6 +20,8 @@ final class ExpectationNarrowingResolver { private const string REBIND_METHOD = 'and'; + private const string NEGATE_METHOD = 'not'; + private const array PASSTHROUGH_METHODS = ['when', 'unless', 'sequence', 'match', 'ray']; public function __construct( @@ -45,9 +47,18 @@ public function resolve(MethodCall $methodCall, Scope $scope): array } $narrowings = []; + $negated = false; foreach (array_reverse($links) as $link) { if ($link instanceof PropertyFetch) { + /** @var Identifier $name */ + $name = $link->name; + if ($name->name === self::NEGATE_METHOD) { + $negated = ! $negated; + + continue; + } + return $narrowings; } @@ -55,12 +66,20 @@ public function resolve(MethodCall $methodCall, Scope $scope): array $name = $link->name; $methodName = $name->name; + if ($methodName === self::NEGATE_METHOD && $link->getArgs() === []) { + $negated = ! $negated; + + continue; + } + if ($methodName === self::REBIND_METHOD) { $subject = $link->getArgs()[0]->value ?? null; if (! $subject instanceof Expr) { return $narrowings; } + $negated = false; + continue; } @@ -74,8 +93,10 @@ public function resolve(MethodCall $methodCall, Scope $scope): array $assertedType = $this->matcherRegistry->assertedTypeFor($methodName, $link, $scope); if ($assertedType instanceof Type) { - $narrowings[] = ExpectationNarrowing::type($subject, $assertedType); + $narrowings[] = ExpectationNarrowing::type($subject, $assertedType, $negated); } + + $negated = false; } return $narrowings; diff --git a/tests/Type/data/expectation-narrowing.php b/tests/Type/data/expectation-narrowing.php index ed00aeb..1bb884b 100644 --- a/tests/Type/data/expectation-narrowing.php +++ b/tests/Type/data/expectation-narrowing.php @@ -110,3 +110,42 @@ function testAssignedExpectationAlsoNarrowsTheSubject(): void assertType('Pest\Expectation', $expectation); assertType('int', $value); } + +function testNotToBeNullRemovesNull(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->not->toBeNull(); + assertType('string', $value); +} + +function testNotMethodRemovesNull(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->not()->toBeNull(); + assertType('string', $value); +} + +function testNotAppliesToOneMatcherOnly(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->not->toBeNull()->toBeString(); + assertType('string', $value); +} + +function testNotToBeInstanceOfRemovesTheClass(): void +{ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + expect($value)->not->toBeInstanceOf(RuntimeException::class); + assertType("'a'", $value); +} + +function testNotToBeStringOnUnion(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->not->toBeString(); + assertType('int', $value); +} From cfd3d812431ced51fb7618463263091f3d51e41a Mon Sep 17 00:00:00 2001 From: Jeppe Knockaert Date: Mon, 17 Aug 2026 11:53:48 +0200 Subject: [PATCH 3/5] feat: narrow scope types for toBe and toEqual expectations --- .../Expectation/ExpectationNarrowing.php | 11 +++++-- .../ExpectationNarrowingResolver.php | 14 ++++++++ .../ExpectationTypeSpecifyingExtension.php | 28 ++++++++++++++-- tests/Type/data/expectation-narrowing.php | 32 +++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/Analysis/Expectation/ExpectationNarrowing.php b/src/Analysis/Expectation/ExpectationNarrowing.php index 8ac48df..5308a8a 100644 --- a/src/Analysis/Expectation/ExpectationNarrowing.php +++ b/src/Analysis/Expectation/ExpectationNarrowing.php @@ -11,12 +11,19 @@ { private function __construct( public Expr $subject, - public Type $assertedType, + public ?Type $assertedType, + public ?Expr $comparedExpr, + public bool $loose, public bool $negated, ) {} public static function type(Expr $subject, Type $assertedType, bool $negated = false): self { - return new self($subject, $assertedType, $negated); + return new self($subject, $assertedType, null, false, $negated); + } + + public static function comparison(Expr $subject, Expr $comparedExpr, bool $loose, bool $negated): self + { + return new self($subject, null, $comparedExpr, $loose, $negated); } } diff --git a/src/Analysis/Expectation/ExpectationNarrowingResolver.php b/src/Analysis/Expectation/ExpectationNarrowingResolver.php index 4f24914..7f78797 100644 --- a/src/Analysis/Expectation/ExpectationNarrowingResolver.php +++ b/src/Analysis/Expectation/ExpectationNarrowingResolver.php @@ -24,6 +24,9 @@ final class ExpectationNarrowingResolver private const array PASSTHROUGH_METHODS = ['when', 'unless', 'sequence', 'match', 'ray']; + /** @var array Matcher name => uses loose (==) comparison */ + private const array COMPARISON_METHODS = ['toBe' => false, 'toEqual' => true]; + public function __construct( private readonly ExpectationMatcherRegistry $matcherRegistry, ) {} @@ -91,6 +94,17 @@ public function resolve(MethodCall $methodCall, Scope $scope): array return $narrowings; } + if (isset(self::COMPARISON_METHODS[$methodName])) { + $compared = $link->getArgs()[0]->value ?? null; + if ($compared instanceof Expr) { + $narrowings[] = ExpectationNarrowing::comparison($subject, $compared, self::COMPARISON_METHODS[$methodName], $negated); + } + + $negated = false; + + continue; + } + $assertedType = $this->matcherRegistry->assertedTypeFor($methodName, $link, $scope); if ($assertedType instanceof Type) { $narrowings[] = ExpectationNarrowing::type($subject, $assertedType, $negated); diff --git a/src/Type/Pest/ExpectationTypeSpecifyingExtension.php b/src/Type/Pest/ExpectationTypeSpecifyingExtension.php index 2eee61b..3757827 100644 --- a/src/Type/Pest/ExpectationTypeSpecifyingExtension.php +++ b/src/Type/Pest/ExpectationTypeSpecifyingExtension.php @@ -5,6 +5,9 @@ namespace Pest\PHPStan\Type\Pest; use Pest\PHPStan\Analysis\Expectation\ExpectationNarrowingResolver; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\BinaryOp\Equal; +use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -13,6 +16,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\MethodTypeSpecifyingExtension; +use PHPStan\Type\Type; final class ExpectationTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension { @@ -46,12 +50,30 @@ public function specifyTypes(MethodReflection $methodReflection, MethodCall $nod $specifiedTypes = new SpecifiedTypes; foreach ($this->narrowingResolver->resolve($node, $scope) as $narrowing) { + $context = $narrowing->negated + ? TypeSpecifierContext::createTruthy()->negate() + : TypeSpecifierContext::createTruthy(); + + if ($narrowing->comparedExpr instanceof Expr) { + $comparison = $narrowing->loose + ? new Equal($narrowing->subject, $narrowing->comparedExpr) + : new Identical($narrowing->subject, $narrowing->comparedExpr); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->specifyTypesInCondition($scope, $comparison, $context), + ); + + continue; + } + + if (! $narrowing->assertedType instanceof Type) { + continue; + } + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( $narrowing->subject, $narrowing->assertedType, - $narrowing->negated - ? TypeSpecifierContext::createTruthy()->negate() - : TypeSpecifierContext::createTruthy(), + $context, $scope, )); } diff --git a/tests/Type/data/expectation-narrowing.php b/tests/Type/data/expectation-narrowing.php index 1bb884b..b298556 100644 --- a/tests/Type/data/expectation-narrowing.php +++ b/tests/Type/data/expectation-narrowing.php @@ -149,3 +149,35 @@ function testNotToBeStringOnUnion(): void expect($value)->not->toBeString(); assertType('int', $value); } + +function testToBeNarrowsToTheComparedConstant(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBe(1); + assertType('1', $value); +} + +function testToBeNarrowsToTheComparedExpressionType(): void +{ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + $expected = new RuntimeException('x'); + expect($value)->toBe($expected); + assertType('RuntimeException', $value); +} + +function testNotToBeRemovesTheConstant(): void +{ + /** @var 'a'|'b' $value */ + $value = 'a'; + expect($value)->not->toBe('a'); + assertType("'b'", $value); +} + +function testToEqualNarrowsLoosely(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->toEqual(null); + assertType("''|null", $value); +} From 6f5c930211269af43e2ead5c2cb70285ccfe4358 Mon Sep 17 00:00:00 2001 From: Jeppe Knockaert Date: Mon, 17 Aug 2026 12:08:15 +0200 Subject: [PATCH 4/5] test: pin cross-statement redundancy and negation edge cases Covers the rule interplay between scope narrowing and RedundantExpectationRule across separate expect() statements, plus deferred narrowing edge cases: not followed by an and() rebind, double negation, and toBe() called with no arguments. --- tests/Rules/RedundantExpectationRuleTest.php | 12 +++++++++ .../redundant-expectation-cross-statement.php | 9 +++++++ tests/Type/data/expectation-narrowing.php | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 tests/Rules/data/redundant-expectation-cross-statement.php diff --git a/tests/Rules/RedundantExpectationRuleTest.php b/tests/Rules/RedundantExpectationRuleTest.php index d6c0be4..a9c6046 100644 --- a/tests/Rules/RedundantExpectationRuleTest.php +++ b/tests/Rules/RedundantExpectationRuleTest.php @@ -127,6 +127,18 @@ ]); }); +test('redundancy is reported across separate expect() statements', function (): void { + $this->analyse([ + __DIR__.'/data/redundant-expectation-cross-statement.php', + ], [ + [ + 'Calling toBeInt() on Expectation; assertion is redundant.', + 8, + 'The expectation value is already guaranteed to satisfy toBeInt().', + ], + ]); +}); + test('every redundant matcher combination is reported without false positives', function (): void { $this->analyse([ __DIR__.'/data/redundant-expectation-exhaustive.php', diff --git a/tests/Rules/data/redundant-expectation-cross-statement.php b/tests/Rules/data/redundant-expectation-cross-statement.php new file mode 100644 index 0000000..75a9925 --- /dev/null +++ b/tests/Rules/data/redundant-expectation-cross-statement.php @@ -0,0 +1,9 @@ +toBeInt(); + expect($value)->toBeInt(); +}); diff --git a/tests/Type/data/expectation-narrowing.php b/tests/Type/data/expectation-narrowing.php index b298556..b73f6ed 100644 --- a/tests/Type/data/expectation-narrowing.php +++ b/tests/Type/data/expectation-narrowing.php @@ -181,3 +181,30 @@ function testToEqualNarrowsLoosely(): void expect($value)->toEqual(null); assertType("''|null", $value); } + +function testNotFollowedByAndRebindDoesNotLeakNegation(): void +{ + /** @var string|null $a */ + $a = random_int(0, 1) === 1 ? 'a' : null; + /** @var int|string $b */ + $b = random_int(0, 1) === 1 ? 1 : 'x'; + expect($a)->not->toBeNull()->and($b)->toBeInt(); + assertType('string', $a); + assertType('int', $b); +} + +function testDoubleNegationCancelsOut(): void +{ + /** @var string|null $value */ + $value = random_int(0, 1) === 1 ? 'a' : null; + expect($value)->not->not->toBeNull(); + assertType('null', $value); +} + +function testToBeWithNoArgumentsDoesNotNarrow(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBe(); + assertType('int|string', $value); +} From 6f2f0569ae0f16939310fdbe9c0b6c09ddc1372a Mon Sep 17 00:00:00 2001 From: Jeppe Knockaert Date: Mon, 17 Aug 2026 14:23:59 +0200 Subject: [PATCH 5/5] fix: keep negated instanceof and expectation rebinds sound --- .../ExpectationMatcherRegistry.php | 5 + .../ExpectationNarrowingResolver.php | 32 +++++- src/Analysis/Expectation/MatcherArgument.php | 43 ++++++++ .../Expectation/MatcherAssertionRegistry.php | 43 +++++--- tests/Type/data/expectation-narrowing.php | 97 ++++++++++++++++++- 5 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 src/Analysis/Expectation/MatcherArgument.php diff --git a/src/Analysis/Expectation/ExpectationMatcherRegistry.php b/src/Analysis/Expectation/ExpectationMatcherRegistry.php index b2bd271..3c87d3f 100644 --- a/src/Analysis/Expectation/ExpectationMatcherRegistry.php +++ b/src/Analysis/Expectation/ExpectationMatcherRegistry.php @@ -28,6 +28,11 @@ public function assertedTypeFor(string $methodName, MethodCall $methodCall, Scop return $this->assertionRegistry->assertedTypeFor($methodName, $methodCall, $scope); } + public function assertsExactTypeFor(string $methodName, MethodCall $methodCall, Scope $scope): bool + { + return $this->assertionRegistry->assertsExactTypeFor($methodName, $methodCall, $scope); + } + public function metadataFor(string $methodName): ?MatcherSemanticMetadata { if (array_key_exists($methodName, $this->metadataCache)) { diff --git a/src/Analysis/Expectation/ExpectationNarrowingResolver.php b/src/Analysis/Expectation/ExpectationNarrowingResolver.php index 7f78797..621700e 100644 --- a/src/Analysis/Expectation/ExpectationNarrowingResolver.php +++ b/src/Analysis/Expectation/ExpectationNarrowingResolver.php @@ -20,6 +20,8 @@ final class ExpectationNarrowingResolver { private const string REBIND_METHOD = 'and'; + private const string REBIND_PARAMETER = 'value'; + private const string NEGATE_METHOD = 'not'; private const array PASSTHROUGH_METHODS = ['when', 'unless', 'sequence', 'match', 'ray']; @@ -27,6 +29,8 @@ final class ExpectationNarrowingResolver /** @var array Matcher name => uses loose (==) comparison */ private const array COMPARISON_METHODS = ['toBe' => false, 'toEqual' => true]; + private const string COMPARISON_PARAMETER = 'expected'; + public function __construct( private readonly ExpectationMatcherRegistry $matcherRegistry, ) {} @@ -65,6 +69,10 @@ public function resolve(MethodCall $methodCall, Scope $scope): array return $narrowings; } + if ($link->isFirstClassCallable()) { + return $narrowings; + } + /** @var Identifier $name */ $name = $link->name; $methodName = $name->name; @@ -76,11 +84,16 @@ public function resolve(MethodCall $methodCall, Scope $scope): array } if ($methodName === self::REBIND_METHOD) { - $subject = $link->getArgs()[0]->value ?? null; - if (! $subject instanceof Expr) { + $rebound = MatcherArgument::first($link, self::REBIND_PARAMETER); + if (! $rebound instanceof Expr) { return $narrowings; } + if ($this->mayBeExpectation($rebound, $scope)) { + return $narrowings; + } + + $subject = $rebound; $negated = false; continue; @@ -95,7 +108,7 @@ public function resolve(MethodCall $methodCall, Scope $scope): array } if (isset(self::COMPARISON_METHODS[$methodName])) { - $compared = $link->getArgs()[0]->value ?? null; + $compared = MatcherArgument::first($link, self::COMPARISON_PARAMETER); if ($compared instanceof Expr) { $narrowings[] = ExpectationNarrowing::comparison($subject, $compared, self::COMPARISON_METHODS[$methodName], $negated); } @@ -105,7 +118,14 @@ public function resolve(MethodCall $methodCall, Scope $scope): array continue; } + if ($negated && ! $this->matcherRegistry->assertsExactTypeFor($methodName, $link, $scope)) { + $negated = false; + + continue; + } + $assertedType = $this->matcherRegistry->assertedTypeFor($methodName, $link, $scope); + if ($assertedType instanceof Type) { $narrowings[] = ExpectationNarrowing::type($subject, $assertedType, $negated); } @@ -116,6 +136,12 @@ public function resolve(MethodCall $methodCall, Scope $scope): array return $narrowings; } + /** @return bool True when and() may unwrap the argument to an inner value we cannot track */ + private function mayBeExpectation(Expr $expr, Scope $scope): bool + { + return ! new ObjectType(Expectation::class)->isSuperTypeOf($scope->getType($expr))->no(); + } + private function resolveSubject(Expr $root, Scope $scope): ?Expr { if (! $root instanceof FuncCall || ! $root->name instanceof Name) { diff --git a/src/Analysis/Expectation/MatcherArgument.php b/src/Analysis/Expectation/MatcherArgument.php new file mode 100644 index 0000000..f9321d8 --- /dev/null +++ b/src/Analysis/Expectation/MatcherArgument.php @@ -0,0 +1,43 @@ +isFirstClassCallable()) { + return null; + } + + foreach ($methodCall->getArgs() as $position => $argument) { + if ($argument->unpack) { + return null; + } + + if ($argument->name instanceof Identifier) { + if ($argument->name->name === $parameterName) { + return $argument->value; + } + + continue; + } + + if ($position === 0) { + return $argument->value; + } + } + + return null; + } +} diff --git a/src/Analysis/Expectation/MatcherAssertionRegistry.php b/src/Analysis/Expectation/MatcherAssertionRegistry.php index 7800450..ab9d3cf 100644 --- a/src/Analysis/Expectation/MatcherAssertionRegistry.php +++ b/src/Analysis/Expectation/MatcherAssertionRegistry.php @@ -4,6 +4,7 @@ namespace Pest\PHPStan\Analysis\Expectation; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -12,6 +13,7 @@ use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -80,6 +82,8 @@ final class MatcherAssertionRegistry 'toBeResource' => self::RESOURCE, ]; + private const string INSTANCE_OF_PARAMETER = 'class'; + /** @var array */ private array $staticAssertedTypeCache = []; @@ -146,26 +150,43 @@ public function assertedTypeFor(string $methodName, MethodCall $methodCall, Scop return $assertedType; } + /** @return bool True when the asserted type mirrors the matcher exactly, so it may also be removed */ + public function assertsExactTypeFor(string $methodName, MethodCall $methodCall, Scope $scope): bool + { + if ($this->assertionFor($methodName) !== self::INSTANCE_OF) { + return true; + } + + return count($this->constantClassNames($methodCall, $scope)) === 1; + } + private function resolveToBeInstanceOf(MethodCall $methodCall, Scope $scope): Type { - $args = $methodCall->getArgs(); + $classNames = $this->constantClassNames($methodCall, $scope); - if ($args === []) { + if ($classNames === []) { return new ObjectWithoutClassType; } - $classType = $scope->getType($args[0]->value); - $classNames = $classType->getConstantStrings(); + $objectTypes = array_map( + static fn (ConstantStringType $name): ObjectType => new ObjectType($name->getValue()), + $classNames + ); + + return TypeCombinator::union(...$objectTypes); + } - if ($classNames !== []) { - $objectTypes = array_map( - static fn ($name): ObjectType => new ObjectType($name->getValue()), - $classNames - ); + /** + * @return list + */ + private function constantClassNames(MethodCall $methodCall, Scope $scope): array + { + $class = MatcherArgument::first($methodCall, self::INSTANCE_OF_PARAMETER); - return TypeCombinator::union(...$objectTypes); + if (! $class instanceof Expr) { + return []; } - return new ObjectWithoutClassType; + return $scope->getType($class)->getConstantStrings(); } } diff --git a/tests/Type/data/expectation-narrowing.php b/tests/Type/data/expectation-narrowing.php index b73f6ed..ac6c9ee 100644 --- a/tests/Type/data/expectation-narrowing.php +++ b/tests/Type/data/expectation-narrowing.php @@ -4,6 +4,7 @@ namespace ExpectationNarrowing; +use LogicException; use RuntimeException; use function PHPStan\Testing\assertType; @@ -91,7 +92,7 @@ function testEachDoesNotNarrowTheSubject(): void function testHigherOrderPropertyDoesNotNarrow(): void { - /** @var int|string $value */ + /** @var int|string $value Stays wide: the extension never fires on higher order chains */ $value = random_int(0, 1) === 1 ? 1 : 'a'; expect($value)->toBeInt()->foo->toBeString(); assertType('int|string', $value); @@ -104,7 +105,7 @@ function testExpectWithoutArgumentsIsIgnored(): void function testAssignedExpectationAlsoNarrowsTheSubject(): void { - /** @var int|string $value */ + /** @var int|string $value Narrowing also applies when the chain is an assigned expression */ $value = random_int(0, 1) === 1 ? 1 : 'a'; $expectation = expect($value)->toBeInt(); assertType('Pest\Expectation', $expectation); @@ -176,7 +177,7 @@ function testNotToBeRemovesTheConstant(): void function testToEqualNarrowsLoosely(): void { - /** @var string|null $value */ + /** @var string|null $value Loose == against null also matches the empty string */ $value = random_int(0, 1) === 1 ? 'a' : null; expect($value)->toEqual(null); assertType("''|null", $value); @@ -195,7 +196,7 @@ function testNotFollowedByAndRebindDoesNotLeakNegation(): void function testDoubleNegationCancelsOut(): void { - /** @var string|null $value */ + /** @var string|null $value Pest throws on not->not, so the narrowed code never runs; this pins the resolver's bookkeeping */ $value = random_int(0, 1) === 1 ? 'a' : null; expect($value)->not->not->toBeNull(); assertType('null', $value); @@ -208,3 +209,91 @@ function testToBeWithNoArgumentsDoesNotNarrow(): void expect($value)->toBe(); assertType('int|string', $value); } + +function testNotToBeInstanceOfWithClassStringVariableDoesNotNarrow(): void +{ + /** @var RuntimeException|string $value */ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + /** @var class-string $class Unknown class: any object may still pass the negated matcher */ + $class = RuntimeException::class; + expect($value)->not->toBeInstanceOf($class); + assertType('RuntimeException|string', $value); +} + +function testNotToBeInstanceOfWithMultipleClassStringsDoesNotNarrow(): void +{ + /** @var LogicException|RuntimeException|string $value Only one of the two classes is checked at runtime */ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + $class = random_int(0, 1) === 1 ? RuntimeException::class : LogicException::class; + expect($value)->not->toBeInstanceOf($class); + assertType('LogicException|RuntimeException|string', $value); +} + +function testToBeInstanceOfWithClassStringVariableNarrowsToObject(): void +{ + /** @var RuntimeException|string $value */ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + /** @var class-string $class Positive narrowing may over-approximate to any object and stay sound */ + $class = RuntimeException::class; + expect($value)->toBeInstanceOf($class); + assertType('RuntimeException', $value); +} + +function testAndWithExpectationArgumentStopsNarrowing(): void +{ + /** @var int|string $first */ + $first = random_int(0, 1) === 1 ? 1 : 'a'; + /** @var int|string $second and() rebinds to the inner value of the passed expectation */ + $second = random_int(0, 1) === 1 ? 1 : 'a'; + $expectation = expect($second); + expect($first)->toBeInt()->and($expectation)->toBeString(); + assertType('int', $first); + assertType('int|string', $second); + assertType('Pest\Expectation', $expectation); +} + +function testAndWithInlineExpectationStopsNarrowing(): void +{ + /** @var int|string $first */ + $first = random_int(0, 1) === 1 ? 1 : 'a'; + /** @var int|string $second and() rebinds to the inner value, so the printed argument is not the subject */ + $second = random_int(0, 1) === 1 ? 1 : 'a'; + expect($first)->toBeInt()->and(expect($second))->toBeString(); + assertType('int', $first); + assertType('int|string', $second); +} + +function testNamedComparisonArgumentIsReadByName(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBe(message: 'not one', expected: 1); + assertType('1', $value); +} + +function testComparisonWithoutTheComparedValueDoesNotNarrow(): void +{ + /** @var int|string $value */ + $value = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->toBe(message: 'the compared value is missing'); + assertType('int|string', $value); +} + +function testNamedInstanceOfArgumentIsReadByName(): void +{ + /** @var RuntimeException|string $value */ + $value = random_int(0, 1) === 1 ? new RuntimeException('x') : 'a'; + expect($value)->not->toBeInstanceOf(message: 'still an exception', class: RuntimeException::class); + assertType('string', $value); +} + +function testFirstClassCallableChainDoesNotNarrow(): void +{ + /** @var string|null $value PHPStan never offers first-class callable nodes to type-specifying extensions */ + $value = random_int(0, 1) === 1 ? 'a' : null; + /** @var int|string $other */ + $other = random_int(0, 1) === 1 ? 1 : 'a'; + expect($value)->not->toBeNull()->and($other)->toBeInt(...); + assertType('string|null', $value); + assertType('int|string', $other); +}