From 3e252138c12eea6ceabd8801581201cdf5f7a66f Mon Sep 17 00:00:00 2001 From: Maks Oleksyuk Date: Thu, 6 Aug 2026 15:10:27 +0300 Subject: [PATCH] feat: narrow toBeInstanceOf() subjects across ->and() chains Adds ExpectationChainSubjectNarrowingExtension for narrowing within a chain, and teaches ExpectationInstanceTypeSpecifyingExtension to walk the whole chain so narrowing also survives past the statement. Shared and()/expect() subject resolution factored into ExpectationChainSubjectResolver. Co-Authored-By: Claude Sonnet 5 --- extension.neon | 13 ++ .../ExpectationChainSubjectResolver.php | 39 +++++ ...ectationChainSubjectNarrowingExtension.php | 153 ++++++++++++++++++ ...ctationInstanceTypeSpecifyingExtension.php | 98 +++++++++++ tests/Type/ExpectTypeTest.php | 12 ++ .../expectation-chain-subject-narrowing.php | 70 ++++++++ .../data/expectation-instance-narrowing.php | 89 ++++++++++ 7 files changed, 474 insertions(+) create mode 100644 src/Analysis/Expectation/ExpectationChainSubjectResolver.php create mode 100644 src/Type/Pest/ExpectationChainSubjectNarrowingExtension.php create mode 100644 src/Type/Pest/ExpectationInstanceTypeSpecifyingExtension.php create mode 100644 tests/Type/data/expectation-chain-subject-narrowing.php create mode 100644 tests/Type/data/expectation-instance-narrowing.php diff --git a/extension.neon b/extension.neon index aab2056..6a8b9db 100644 --- a/extension.neon +++ b/extension.neon @@ -14,6 +14,9 @@ services: - class: Pest\PHPStan\Type\Pest\PestConfigReader + - + class: Pest\PHPStan\Analysis\Expectation\ExpectationChainSubjectResolver + - class: Pest\PHPStan\Analysis\Expectation\ExpectationMatcherRegistry @@ -36,6 +39,11 @@ services: tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: Pest\PHPStan\Type\Pest\ExpectationInstanceTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - class: Pest\PHPStan\Type\Pest\OppositeExpectationMethodReturnTypeExtension tags: @@ -64,6 +72,11 @@ services: tags: - phpstan.broker.expressionTypeResolverExtension + - + class: Pest\PHPStan\Type\Pest\ExpectationChainSubjectNarrowingExtension + tags: + - phpstan.broker.expressionTypeResolverExtension + - class: Pest\PHPStan\Type\Pest\PestInternalClassAccessIgnoreExtension tags: diff --git a/src/Analysis/Expectation/ExpectationChainSubjectResolver.php b/src/Analysis/Expectation/ExpectationChainSubjectResolver.php new file mode 100644 index 0000000..d7adc84 --- /dev/null +++ b/src/Analysis/Expectation/ExpectationChainSubjectResolver.php @@ -0,0 +1,39 @@ +name instanceof Identifier || $receiver->name->toString() !== 'and') { + return null; + } + + $args = $receiver->getArgs(); + + return $args === [] ? null : $args[0]->value; + } + + if ($receiver instanceof FuncCall) { + if (! $receiver->name instanceof Name || $receiver->name->toString() !== 'expect') { + return null; + } + + $args = $receiver->getArgs(); + + return $args === [] ? null : $args[0]->value; + } + + return null; + } +} diff --git a/src/Type/Pest/ExpectationChainSubjectNarrowingExtension.php b/src/Type/Pest/ExpectationChainSubjectNarrowingExtension.php new file mode 100644 index 0000000..bff255c --- /dev/null +++ b/src/Type/Pest/ExpectationChainSubjectNarrowingExtension.php @@ -0,0 +1,153 @@ +> file path => list of [printed subject, toBeInstanceOf() call, enclosing statement start pos, end pos] */ + private array $chainFactsCache = []; + + public function __construct( + private readonly PestFileDiscoverer $fileDiscoverer, + private readonly ExpectationMatcherRegistry $matcherRegistry, + private readonly ExpectationChainSubjectResolver $subjectResolver, + ) { + $this->printer = new Standard; + } + + public function getType(Expr $expr, Scope $scope): ?Type + { + if (! $expr instanceof Variable && ! $expr instanceof ArrayDimFetch && ! $expr instanceof PropertyFetch) { + return null; + } + + $exprStart = $expr->getStartFilePos(); + if ($exprStart < 0) { + return null; + } + + $facts = $this->chainFactsFor($scope->getFile()); + if ($facts === []) { + return null; + } + + $printedExpr = $this->printer->prettyPrintExpr($expr); + + $narrowedType = null; + + foreach ($facts as [$subjectPrint, $toBeInstanceOfCall, $stmtStart, $stmtEnd]) { + if ($subjectPrint !== $printedExpr) { + continue; + } + + if ($exprStart < $stmtStart) { + continue; + } + + if ($exprStart > $stmtEnd) { + continue; + } + + if ($toBeInstanceOfCall->getEndFilePos() >= $exprStart) { + continue; + } + + $assertedType = $this->matcherRegistry->assertedTypeFor('toBeInstanceOf', $toBeInstanceOfCall, $scope); + if (! $assertedType instanceof Type) { + continue; + } + + $narrowedType = $narrowedType instanceof Type + ? TypeCombinator::intersect($narrowedType, $assertedType) + : $assertedType; + } + + return $narrowedType; + } + + /** + * @return list + */ + private function chainFactsFor(string $filePath): array + { + if (isset($this->chainFactsCache[$filePath])) { + return $this->chainFactsCache[$filePath]; + } + + $parsed = $this->fileDiscoverer->parseFile($filePath); + if ($parsed === null) { + return $this->chainFactsCache[$filePath] = []; + } + + [$stmts] = $parsed; + + $facts = []; + + $nodeFinder = new NodeFinder; + + /** @var ExpressionStmt[] $expressionStmts */ + $expressionStmts = $nodeFinder->findInstanceOf($stmts, ExpressionStmt::class); + + foreach ($expressionStmts as $stmt) { + $stmtStart = $stmt->getStartFilePos(); + $stmtEnd = $stmt->getEndFilePos(); + if ($stmtStart < 0) { + continue; + } + + if ($stmtEnd < 0) { + continue; + } + + $current = $stmt->expr; + + while ($current instanceof MethodCall) { + $fact = $this->factFor($current, $stmtStart, $stmtEnd); + if ($fact !== null) { + $facts[] = $fact; + } + + $current = $current->var; + } + } + + return $this->chainFactsCache[$filePath] = $facts; + } + + /** + * @return array{0: string, 1: MethodCall, 2: int, 3: int}|null + */ + private function factFor(MethodCall $methodCall, int $stmtStart, int $stmtEnd): ?array + { + if (! $methodCall->name instanceof Identifier || $methodCall->name->toString() !== 'toBeInstanceOf') { + return null; + } + + $subjectExpr = $this->subjectResolver->subjectIntroducedBy($methodCall->var); + if (! $subjectExpr instanceof Expr) { + return null; + } + + return [$this->printer->prettyPrintExpr($subjectExpr), $methodCall, $stmtStart, $stmtEnd]; + } +} diff --git a/src/Type/Pest/ExpectationInstanceTypeSpecifyingExtension.php b/src/Type/Pest/ExpectationInstanceTypeSpecifyingExtension.php new file mode 100644 index 0000000..639fe00 --- /dev/null +++ b/src/Type/Pest/ExpectationInstanceTypeSpecifyingExtension.php @@ -0,0 +1,98 @@ +typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return Expectation::class; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + if (! $context->null()) { + return false; + } + + return $this->collectFacts($node) !== []; + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $result = new SpecifiedTypes([], []); + + foreach ($this->collectFacts($node) as [$subjectExpr, $toBeInstanceOfCall]) { + $assertedType = $this->matcherRegistry->assertedTypeFor('toBeInstanceOf', $toBeInstanceOfCall, $scope); + if (! $assertedType instanceof Type) { + continue; + } + + $incomingType = $scope->getType($subjectExpr); + if (! $this->typeNarrower->hasOverlap($incomingType, $assertedType)) { + continue; + } + + $narrowedType = $this->typeNarrower->narrow($incomingType, $assertedType); + + $result = $result->unionWith( + $this->typeSpecifier->create($subjectExpr, $narrowedType, TypeSpecifierContext::createTrue(), $scope) + ); + } + + return $result; + } + + /** + * @return list + */ + private function collectFacts(MethodCall $node): array + { + $facts = []; + $current = $node; + + while ($current instanceof MethodCall) { + if ($current->name instanceof Identifier && $current->name->toString() === 'toBeInstanceOf') { + $subjectExpr = $this->subjectResolver->subjectIntroducedBy($current->var); + if ($subjectExpr instanceof Expr) { + $facts[] = [$subjectExpr, $current]; + } + } + + $current = $current->var; + } + + return $facts; + } +} diff --git a/tests/Type/ExpectTypeTest.php b/tests/Type/ExpectTypeTest.php index f4c8b72..e181775 100644 --- a/tests/Type/ExpectTypeTest.php +++ b/tests/Type/ExpectTypeTest.php @@ -56,6 +56,18 @@ yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-hook-properties.php'); }); +test('expectation instance 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-instance-narrowing.php'); +}); + +test('expectation chain subject 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-chain-subject-narrowing.php'); +}); + test('test call chain method types', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/data/expectation-chain-subject-narrowing.php b/tests/Type/data/expectation-chain-subject-narrowing.php new file mode 100644 index 0000000..210b354 --- /dev/null +++ b/tests/Type/data/expectation-chain-subject-narrowing.php @@ -0,0 +1,70 @@ + + */ +function items(): array +{ + return []; +} + +function testArraySubjectNarrowedLaterInSameChain(): void +{ + $items = items(); + + expect($items)->toHaveCount(1) + ->and($items[0])->toBeInstanceOf(Post::class) + ->and(assertType(Post::class, $items[0])); +} + +function testPlainVariableNarrowedLaterInSameChain(mixed $subject): void +{ + expect($subject)->toBeInstanceOf(Post::class) + ->and(assertType(Post::class, $subject)); +} + +function testMultipleDistinctSubjectsEachNarrowedInSameChain(): void +{ + $items = items(); + + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(Post::class) + ->and($items[1])->toBeInstanceOf(Author::class) + ->and(assertType(Post::class, $items[0])) + ->and(assertType(Author::class, $items[1])); +} + +function testUsageBeforeALaterInstanceofStepStaysUnnarrowed(): void +{ + $items = items(); + + expect(assertType('mixed', $items[0])) + ->and($items[0])->toBeInstanceOf(Post::class); +} + +function testUnrelatedSubjectIsNotNarrowedByAnothersInstanceof(): void +{ + $items = items(); + + expect($items[0])->toBeInstanceOf(Post::class) + ->and(assertType('mixed', $items[1])); +} + +function testNarrowingAlsoPersistsPastTheStatement(): void +{ + $items = items(); + + expect($items[0])->toBeInstanceOf(Post::class) + ->and(assertType(Post::class, $items[0])); + + assertType(Post::class, $items[0]); +} diff --git a/tests/Type/data/expectation-instance-narrowing.php b/tests/Type/data/expectation-instance-narrowing.php new file mode 100644 index 0000000..8208600 --- /dev/null +++ b/tests/Type/data/expectation-instance-narrowing.php @@ -0,0 +1,89 @@ +toBeInstanceOf(Post::class); + + assertType(Post::class, $subject); +} + +function testToBeInstanceOfNarrowsUnionSubject(Post|Author $subject): void +{ + expect($subject)->toBeInstanceOf(Post::class); + + assertType(Post::class, $subject); +} + +function testToBeStringDoesNotNarrowSubject(mixed $subject): void +{ + expect($subject)->toBeString(); + + assertType('mixed', $subject); +} + +function testAndChainedToBeInstanceOfNarrowsPastTheStatement(mixed $subject): void +{ + expect($subject)->toBeString() + ->and($subject)->toBeInt() + ->and($subject)->toBeArray() + ->and($subject)->toBeInstanceOf(Post::class); + + assertType(Post::class, $subject); +} + +function testSequentialUnrelatedStatementsStayIndependent(mixed $subject): void +{ + expect($subject)->toBeString(); + expect($subject)->toBeInt(); + expect($subject)->toBeArray(); + expect($subject)->toBeInstanceOf(Post::class); + + assertType(Post::class, $subject); +} + +function testChainedMatcherDoesNotNarrowSubject(mixed $subject): void +{ + expect($subject)->toBeObject()->toBeInstanceOf(Post::class); + + assertType('mixed', $subject); +} + +function testAndSwitchesSubjectWithoutNarrowingOriginal(mixed $subject, mixed $other): void +{ + expect($subject)->and($other)->toBeInstanceOf(Post::class); + + assertType('mixed', $subject); + assertType(Post::class, $other); +} + +function testChainWithMultipleFollowUpStepsNarrowsPastStatement(mixed $subject): void +{ + expect($subject)->toBeInstanceOf(Post::class) + ->and($subject->title)->toBe('hello') + ->and($subject->content)->toBe('world'); + + assertType(Post::class, $subject); +} + +function testNotDoesNotNarrowSubject(mixed $subject): void +{ + expect($subject)->not()->toBeInstanceOf(Post::class); + + assertType('mixed', $subject); +} + +function testExpressionInsideConditionIsNotNarrowed(mixed $subject): void +{ + if (expect($subject)->toBeInstanceOf(Post::class)) { + assertType('mixed', $subject); + } +}