Skip to content

feat: narrow toBeInstanceOf() subjects across ->and() chains - #7

Open
maks-oleksyuk wants to merge 1 commit into
pestphp:5.xfrom
maks-oleksyuk:feat/expectation-instance-narrowing
Open

feat: narrow toBeInstanceOf() subjects across ->and() chains#7
maks-oleksyuk wants to merge 1 commit into
pestphp:5.xfrom
maks-oleksyuk:feat/expectation-instance-narrowing

Conversation

@maks-oleksyuk

Copy link
Copy Markdown

Problem

expect($x)->toBeInstanceOf(Y::class) only narrows PHPStan's known type for $x when that's the entire statement, on its own. Two related, common patterns weren't covered:

  1. Within a chain: expect($items[0])->toBeInstanceOf(Post::class)->and($items[0]->title)->toBe('hello');$items[0]->title needs $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.
  2. Past a chained statement: expect($response)->toBeInstanceOf(JsonResponse::class)->and($response->getStatusCode())->toBe(200); followed on the next line by decodeJsonBody($response); — narrowing didn't survive, because toBeInstanceOf() 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 manual assert($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 existing PestFileDiscoverer), walks each statement's own ->var chain spine (never descending into closures — that would leak a fact from one it() block into a sibling one), and matches subjects structurally against earlier toBeInstanceOf() steps in the same statement, by source position.
  • ExpectationInstanceTypeSpecifyingExtension (MethodTypeSpecifyingExtension) — walks the entire chain via ->var to collect every toBeInstanceOf() 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 one ExpectationChainSubjectResolver service both extensions inject, rather than being duplicated.

Testing

  • New assertType coverage: 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 unrelated it() block) and expectation-instance-narrowing.php (+3 cases — chained toBeInstanceOf() 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.
  • Verified against real-world usage in a consuming app: expect($x)->toBeInstanceOf(Y)->and(...)->and(...); chains that previously required assert() workarounds or splitting across statements now type-check cleanly as a single chain, both mid-chain and for code after.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant