Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,11 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines

### UnionTypeMethodReflection and IntersectionTypeMethodReflection parity

When methods are called on union types (`Foo|Bar`), the resolved method reflection is a `UnionTypeMethodReflection` that wraps the individual method reflections. Similarly, `IntersectionTypeMethodReflection` handles intersection types. These two classes must maintain feature parity for things like `getAsserts()`, `getSelfOutType()`, etc. When one class correctly combines member data (e.g. `IntersectionTypeMethodReflection::getAsserts()` iterating over methods and calling `intersectWith()`), the other should do the same rather than returning empty/null. The `Assertions::intersectWith()` method merges assertion tag lists from multiple sources.
When methods are called on union types (`Foo|Bar`), the resolved method reflection is a `UnionTypeMethodReflection` that wraps the individual method reflections. Similarly, `IntersectionTypeMethodReflection` handles intersection types. These two classes must maintain feature parity for things like `getAsserts()`, `getSelfOutType()`, etc.

**Critical distinction for `getAsserts()`**: Intersection types use `Assertions::intersectWith()` which concatenates all assert tags — this is correct because both assertions must hold simultaneously, so `specifyTypesFromAsserts` intersects the sureTypes (e.g., `list & non-empty-array = non-empty-list`). Union types must use `Assertions::unionWith()` which groups assertions by the same parameter/condition and **unions their types** — this is correct because only one union member's assertion applies at runtime (e.g., `string | int` not `string & int = never`). Assertions that don't have a matching counterpart in the other union member are dropped since they cannot be guaranteed.

The `Assertions::intersectWith()` method merges assertion tag lists by concatenation. The `Assertions::unionWith()` method groups assertions by parameter identity (parameter name + if-condition + negated + equality) and unions the asserted types via `TypeCombinator::union()`.

## Important dependencies

Expand Down
62 changes: 62 additions & 0 deletions src/Reflection/Assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\PhpDoc\Tag\AssertTag;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_filter;
use function array_map;
use function array_merge;
use function count;
use function sprintf;

/**
* Collection of @phpstan-assert annotations on a function or method.
Expand Down Expand Up @@ -99,6 +101,66 @@ public function intersectWith(Assertions $other): self
return new self(array_merge($this->getAll(), $other->getAll()));
}

/**
* Combines assertions from union type members by unioning the asserted types
* for assertions that target the same parameter with the same condition.
*/
public function unionWith(Assertions $other): self
Copy link
Copy Markdown
Contributor

@staabm staabm Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel what is implented here is actual the intersectWith and the intersectWith we had before this PR is more a unionWith.. (so names are inverted - not sure we can fix this because of BC though)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the same impression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we fix it? or should we stay with it as is because BC?

{
$otherAsserts = $other->getAll();
if (count($otherAsserts) === 0) {
return $this;
}

$thisAsserts = $this->getAll();
if (count($thisAsserts) === 0) {
return $other;
}

$merged = [];
$usedOther = [];

foreach ($thisAsserts as $thisAssert) {
$key = self::getAssertKey($thisAssert);
$found = false;

foreach ($otherAsserts as $j => $otherAssert) {
if (isset($usedOther[$j])) {
continue;
}

if (self::getAssertKey($otherAssert) !== $key) {
continue;
}

$merged[] = $thisAssert->withType(TypeCombinator::union($thisAssert->getType(), $otherAssert->getType()));
$usedOther[$j] = true;
$found = true;
break;
}

if ($found) {
continue;
}

// No matching assertion in other — this assertion cannot be guaranteed
// for the union type, so we drop it
}

return new self($merged);
}

private static function getAssertKey(AssertTag $assert): string
{
return sprintf(
'%s-%s-%s-%s',
$assert->getParameter()->describe(),
$assert->getIf(),
$assert->isNegated() ? '1' : '0',
$assert->isEquality() ? '1' : '0',
);
}

public static function createEmpty(): self
{
$empty = self::$empty;
Expand Down
2 changes: 1 addition & 1 deletion src/Reflection/Type/UnionTypeMethodReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public function getAsserts(): Assertions
$assertions = Assertions::createEmpty();

foreach ($this->methods as $method) {
$assertions = $assertions->intersectWith($method->getAsserts());
$assertions = $assertions->unionWith($method->getAsserts());
}

return $assertions;
Expand Down
22 changes: 22 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14107.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);

namespace Bug14107;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/** @param array{0: '1.2'|'a'} $arr */
public function constantArrayCountValues(array $arr): void
{
// '1.2' is a numeric string but not an integer string, so it should stay as string key
assertType("non-empty-array<'1.2'|'a', int<1, max>>", array_count_values($arr));
}

/** @param array{0: '1'|'a'} $arr */
public function intStringArrayCountValues(array $arr): void
{
// '1' is an integer string, so it gets cast to int key
assertType("non-empty-array<1|'a', int<1, max>>", array_count_values($arr));
}
}
52 changes: 52 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14108.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php // lint >= 8.0

namespace Bug14108;

use function PHPStan\Testing\assertType;

class Foo
{
public function __construct(private ?string $param)
{
}

public function getParam(): ?string
{
return $this->param;
}

/**
* @phpstan-assert string $this->getParam()
*/
public function narrowGetParam(): void
{
}
}

class Bar
{
public function __construct(private ?int $param)
{
}

public function getParam(): ?int
{
return $this->param;
}

/**
* @phpstan-assert int $this->getParam()
*/
public function narrowGetParam(): void
{
}
}

function test(Foo|Bar $fooOrBar): void
{
assertType('int|string|null', $fooOrBar->getParam());

$fooOrBar->narrowGetParam();

assertType('int|string', $fooOrBar->getParam());
}
Loading