feat: narrow toBeInstanceOf() subjects across ->and() chains - #7
Conversation
3e25213 to
4aacea0
Compare
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>
…2.5/2.2.6 Upstream 5.x merged a general expectation-narrowing system (pestphp#12) that already covers cross-statement subject narrowing via and()-rebinds for any subject expression, including array/property-access ones. Drop ExpectationInstanceTypeSpecifyingExtension since it duplicated (and partially contradicted, e.g. toBeString narrowing) that system. Keep ExpectationChainSubjectNarrowingExtension: it is the only part covering narrowing that must apply *within* the same statement, to a later argument in the same fluent chain (e.g. ->and($items[0])->toBeInstanceOf(Post::class)->and($items[0]->getName())...) which the upstream TypeSpecifier-based system does not support. Bump phpstan/phpstan to ^2.2.7: 2.2.5/2.2.6 have a real bug that broke intra-chain narrowing under the prefer-lowest CI matrix leg, fixed in 2.2.7.
4aacea0 to
aedc11a
Compare
|
Thanks @maks-oleksyuk — you spotted a real gap, and your arrow-function beforeEach fix just landed via #6! 🎉 Regarding this one (#7): narrowing \ oBeInstanceOf()\ subjects across ->and()\ chains shipped with broader scope in #12 — see \ ests/Type/data/expectation-narrowing.php\ for coverage including negation and rebind edge cases. Your branch now predates and conflicts semantically with that implementation, so closing in its favor rather than maintaining two narrowing paths. Hope to see more from you — your instincts are good! |
|
Thanks for the quick look, and for merging #6! On #7 — I actually rebased this onto expect($components)->toHaveCount(4)
->and($components[0])->toBeInstanceOf(TextInput::class)
->and($components[0]->getName())->toBe('email'); // <- needs $components[0] narrowed here, same statement
I dropped everything from the original PR that duplicated #12 (the old Happy to add a test case demonstrating the exact scenario #12 doesn't cover if that helps the review — let me know if you'd like me to reopen, or if you'd rather this land as a follow-up on top of #12 instead. |
MrPunyapal
left a comment
There was a problem hiding this comment.
Thanks for pointing this out @maks-oleksyuk — you were right, and apologies for the premature close. The rebased scope is exactly right: intra-chain narrowing for a subject used later in the same statement is a genuinely different mechanism than #12's cross-statement \SpecifiedTypes, and your Filament-style case proves it.
I verified the gap on \5.x\ independently and added one more example covering member access on a narrowed dim-fetch subject (\ estMemberAccessOnNarrowedDimFetchSubjectResolves) — all green across the matrix. Merging. Great collaboration on this one! 🙏
|
Closing the loop @maks-oleksyuk — with scope narrowing withdrawn in v5.2.0 (see #12), the intra-chain extension no longer has anything to compose with. Your analysis of same-statement vs cross-statement mechanics was spot-on and shaped how we framed the boundary. Preserved in history if narrowing ever lands properly. Thanks again! |
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.