From fb4d86489eb8f4bf371c0424c38ab1d666f6c68f Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 5 Aug 2026 16:16:57 +0200 Subject: [PATCH 1/2] Collapse ObjectShapeType against HasPropertyType regardless of member order An intersection of an object shape carrying an optional key with a HasPropertyType for that key - as produced by isset()/?? narrowing, e.g. `stdClass&object{u?:int}` narrowed by `isset($x->u)` - is supposed to resolve the optional key to its declared type, so that `$x->u ?? null` is `int|null`. That relied on member order. The collapse `ObjectShapeType & HasPropertyType -> makePropertyRequired()` sat in the reduction loop, reached only after the generic supertype dedup. When the intersection also contains a dynamic-property class such as stdClass, which reports every property as present, HasPropertyType is a supertype of it and the dedup splices HasPropertyType out before it is ever paired with the object shape. Which of the two fires first depends on the order of the members, so the optional key stayed optional whenever stdClass happened to come first and the read fell back to the class's mixed. While intersection members were still sorted in place this was masked - describing the type reordered them so the shape came first; once that mutation was removed the construction order won. Move the collapse into its own pass before the reduction loop so member order no longer decides the result. Guard it with hasInstanceProperty(): when the shape does not have the key it is left untouched, so a sealed shape intersected with a HasPropertyType for a key it cannot have still reduces to never in the loop below, as before. The array analogue (ConstantArrayType & HasOffsetType) is unaffected: there is no universal-offset crate reporting every offset as present, so nothing absorbs the HasOffsetType before the offset is made required. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Type/TypeCombinator.php | 54 +++++++++++++++------ tests/PHPStan/Analyser/nsrt/bug-15047.php | 58 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15047.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 6f36f1ceec..736c9aec7e 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1735,6 +1735,7 @@ public static function doIntersect(Type ...$types): Type $hasOffsetValueTypeCount = 0; $typesCount = count($types); $typesNeedSorting = false; + $hasPropertyType = false; for ($i = 0; $i < $typesCount; $i++) { $type = $types[$i]; @@ -1742,6 +1743,10 @@ public static function doIntersect(Type ...$types): Type $typesNeedSorting = true; } + if ($type instanceof HasPropertyType) { + $hasPropertyType = true; + } + if ($type instanceof IntersectionType && !$type instanceof TemplateType) { // transform A & (B & C) to A & B & C array_splice($types, $i--, 1, $type->getTypes()); @@ -1780,6 +1785,41 @@ public static function doIntersect(Type ...$types): Type }); } + // Resolve object-shape optional keys that a HasPropertyType asserts are present before the + // reduction loop below. In that loop the generic supertype dedup can drop a HasPropertyType + // as redundant against a dynamic-property class such as stdClass (which reports every + // property as present) before it is ever paired with the object shape. Which of the two + // fires first depends on the member order, so the collapse runs here, where order does not + // change the result of what is meant to be an order-independent value. Gated on the presence + // of a HasPropertyType so the common intersection pays only the flag check set above. + if ($hasPropertyType) { + for ($i = 0; $i < $typesCount; $i++) { + for ($j = $i + 1; $j < $typesCount; $j++) { + if ( + $types[$i] instanceof ObjectShapeType + && $types[$j] instanceof HasPropertyType + && !$types[$i]->hasInstanceProperty($types[$j]->getPropertyName())->no() + ) { + $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + + if ( + $types[$j] instanceof ObjectShapeType + && $types[$i] instanceof HasPropertyType + && !$types[$j]->hasInstanceProperty($types[$i]->getPropertyName())->no() + ) { + $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + } + } + // transform IntegerType & ConstantIntegerType to ConstantIntegerType // transform Child & Parent to Child // transform Object & ~null to Object @@ -1944,20 +1984,6 @@ public static function doIntersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ObjectShapeType && $types[$j] instanceof HasPropertyType) { - $types[$i] = $types[$i]->makePropertyRequired($types[$j]->getPropertyName()); - array_splice($types, $j--, 1); - $typesCount--; - continue; - } - - if ($types[$j] instanceof ObjectShapeType && $types[$i] instanceof HasPropertyType) { - $types[$j] = $types[$j]->makePropertyRequired($types[$i]->getPropertyName()); - array_splice($types, $i--, 1); - $typesCount--; - continue 2; - } - $constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType); $constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType); if ($constArrayIsI || $constArrayIsJ) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15047.php b/tests/PHPStan/Analyser/nsrt/bug-15047.php new file mode 100644 index 0000000000..d4782336fd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15047.php @@ -0,0 +1,58 @@ +untilYear = $untilYear; + } + + /** + * @param stdClass&object{since_year:int,until_year?:int} $data + */ + protected static function fromObjectInternal(stdClass $data): self + { + assertType('object{since_year: int, until_year?: int}&stdClass', $data); + assertType('int|null', $data->until_year ?? null); + assertType('int', $data->since_year); + + if (isset($data->until_year)) { + assertType('object{since_year: int, until_year: int}&stdClass', $data); + assertType('int', $data->until_year); + } + + return new self($data->until_year ?? null); + } + +} + +/** + * The member order of the intersection must not change the result: whether the object shape or + * stdClass is written first, isset()/?? narrowing resolves the optional key to its declared type. + * + * @param stdClass&object{u?:int} $stdFirst + * @param object{u?:int}&stdClass $shapeFirst + */ +function orderIndependent($stdFirst, $shapeFirst): void +{ + assertType('int|null', $stdFirst->u ?? null); + assertType('int|null', $shapeFirst->u ?? null); +} From 29b5e27412305f2fc00de1ddd6461cc192fea31b Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Mon, 10 Aug 2026 20:34:21 +0200 Subject: [PATCH 2/2] Add a rule test for the argument.type false positive The nsrt test covers the inferred type; this asserts the reported error is gone at the level that emitted it. The false positive only appears with checkImplicitMixed (level 10), so InstantiationRuleTest gains the same toggle the other rule tests already expose. --- .../Rules/Classes/InstantiationRuleTest.php | 10 ++++- .../PHPStan/Rules/Classes/data/bug-15047.php | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-15047.php diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index f4cc96575b..5b7ccd3be4 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -25,6 +25,8 @@ class InstantiationRuleTest extends RuleTestCase private bool $checkExplicitMixed = false; + private bool $checkImplicitMixed = false; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -35,7 +37,7 @@ protected function getRule(): Rule checkThisOnly: false, checkUnionTypes: true, checkExplicitMixed: $this->checkExplicitMixed, - checkImplicitMixed: false, + checkImplicitMixed: $this->checkImplicitMixed, checkBenevolentUnionTypes: false, discoveringSymbolsTip: true, ); @@ -731,4 +733,10 @@ public function testInstantiationWithNonObjectType(): void ]); } + public function testBug15047(): void + { + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-15047.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-15047.php b/tests/PHPStan/Rules/Classes/data/bug-15047.php new file mode 100644 index 0000000000..ca625b8b0d --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-15047.php @@ -0,0 +1,42 @@ +untilYear = $untilYear; + } + + public function getUntilYear(): ?int + { + return $this->untilYear; + } + + /** + * @param stdClass&object{since_year:int,until_year?:int} $data + */ + protected static function fromObjectInternal(stdClass $data): self + { + // the optional key must keep its declared type through the ?? narrowing, + // otherwise this reports "expects int|null, mixed given" + return new self($data->until_year ?? null); + } + +}