feat: narrow toBeInstanceOf() subjects across ->and() chains - #7
Open
maks-oleksyuk wants to merge 1 commit into
Open
feat: narrow toBeInstanceOf() subjects across ->and() chains#7maks-oleksyuk wants to merge 1 commit into
maks-oleksyuk wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
expect($x)->toBeInstanceOf(Y::class)only narrows PHPStan's known type for$xwhen that's the entire statement, on its own. Two related, common patterns weren't covered:expect($items[0])->toBeInstanceOf(Post::class)->and($items[0]->title)->toBe('hello');—$items[0]->titleneeds$items[0]narrowed, but that read happens before the statement finishes, and PHPStan's native statement-level narrowing only ever applies to code after a condition/statement — it can't reach into an earlier part of the same expression.expect($response)->toBeInstanceOf(JsonResponse::class)->and($response->getStatusCode())->toBe(200);followed on the next line bydecodeJsonBody($response);— narrowing didn't survive, becausetoBeInstanceOf()wasn't the chain's outermost call.Both are extremely common Pest idioms (asserting a value's type, then immediately reading properties/methods off it, often chained with other assertions via
->and()), and without this, every real-world use forces either splitting the assertion into an awkward standalone statement or falling back to a manualassert($x instanceof Y);.Fix
Two complementary extensions, because they hook into genuinely different PHPStan mechanisms:
ExpectationChainSubjectNarrowingExtension(ExpressionTypeResolverExtension) — narrows a subject reused later in the same chain. Re-parses the file (via the existingPestFileDiscoverer), walks each statement's own->varchain spine (never descending into closures — that would leak a fact from oneit()block into a sibling one), and matches subjects structurally against earliertoBeInstanceOf()steps in the same statement, by source position.ExpectationInstanceTypeSpecifyingExtension(MethodTypeSpecifyingExtension) — walks the entire chain via->varto collect everytoBeInstanceOf()step, not just the outermost call, so narrowing correctly survives for code after the whole statement — using PHPStan's native scope-tracking, which already handles reassignment, branches, and loops correctly, unlike the position-based approach above.These can't be merged into one extension: the "within-chain" one is a text/position-based workaround with no notion of control flow (deliberately scoped to within one statement, where that's safe), while the "past-statement" one needs PHPStan's real scope machinery, which PHPStan only ever invokes for a statement's outermost expression — it structurally cannot see into a sub-expression mid-statement.
Shared
and()/expect()subject-resolution logic lives in oneExpectationChainSubjectResolverservice both extensions inject, rather than being duplicated.Testing
assertTypecoverage:expectation-chain-subject-narrowing.php(8 cases — array/plain-variable subjects, multiple distinct subjects narrowed in one chain, position-ordering guards, no leaking into an unrelatedit()block) andexpectation-instance-narrowing.php(+3 cases — chainedtoBeInstanceOf()narrowing past the statement, multiple follow-up steps,and()-introduced subject narrowing past the statement).pest --parallel: 482 passed.phpstan analyse: no errors.pint/rector --dry-run: clean.expect($x)->toBeInstanceOf(Y)->and(...)->and(...);chains that previously requiredassert()workarounds or splitting across statements now type-check cleanly as a single chain, both mid-chain and for code after.