Skip to content

Commit 99453bb

Browse files
committed
Collect template argument constraints through object shape properties in TemplateArgumentObserver
- TemplateArgumentObserver::observeSend() now matches a declared `object{...}` shape property by property against the actual object's public non-static properties (new observeObjectShapeSend()). Before, a declared shape names no class, so `(object) ['x' => new Collection([...])]` returned or passed as `object{x: Collection<int, string>}` never constrained the inner `new` site, and its invariant TKey stayed at the exact `0|1|...` literal. - A declared intersection `object{...}&Foo` sends to each shape member too. - Ported the same logic to the native mirror in turbo-ext/src/TemplateArgumentObserver.cpp and regenerated its declarations header. - Covers every place that sends to a declared type: return, typed property assignment, call argument and `yield` (Generator value type), plus nested shapes, nullable unions and optional shape properties. - Probed and left alone: closures returned as `Closure(): object{...}` do not constrain their body's `new` sites even with plain generic or array shape return types. That limitation is separate from object shapes.
1 parent acd0511 commit 99453bb

13 files changed

Lines changed: 353 additions & 27 deletions

‎phpstan-baseline.neon‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ parameters:
5454
count: 1
5555
path: src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php
5656

57+
-
58+
rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated.
59+
identifier: phpstanApi.instanceofType
60+
count: 1
61+
path: src/Analyser/Generics/TemplateArgumentObserver.php
62+
63+
-
64+
rawMessage: 'Doing instanceof PHPStan\Type\ObjectShapeType is error-prone and deprecated. Use Type::isObject() and Type::hasProperty() instead.'
65+
identifier: phpstanApi.instanceofType
66+
count: 2
67+
path: src/Analyser/Generics/TemplateArgumentObserver.php
68+
5769
-
5870
rawMessage: Casting to string something that's already string.
5971
identifier: cast.useless

‎src/Analyser/Generics/TemplateArgumentObserver.php‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PhpParser\Node\Expr;
66
use PHPStan\Analyser\OutOfClassScope;
77
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Reflection\MissingPropertyFromReflectionException;
89
use PHPStan\Reflection\ParametersAcceptor;
910
use PHPStan\Reflection\ResolvedFunctionVariant;
1011
use PHPStan\Turbo\ShadowedByTurboExtension;
@@ -14,8 +15,10 @@
1415
use PHPStan\Type\Generic\TemplateTypeMap;
1516
use PHPStan\Type\Generic\TemplateTypeVariance;
1617
use PHPStan\Type\Generic\UnresolvedTemplateArgumentType;
18+
use PHPStan\Type\IntersectionType;
1719
use PHPStan\Type\MixedType;
1820
use PHPStan\Type\NeverType;
21+
use PHPStan\Type\ObjectShapeType;
1922
use PHPStan\Type\Type;
2023
use PHPStan\Type\TypeCombinator;
2124
use PHPStan\Type\TypeTraverser;
@@ -451,6 +454,18 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec
451454
// never holds no markers, and is its own iterable key and value type
452455
return $constraints;
453456
}
457+
if ($declared instanceof ObjectShapeType) {
458+
return $this->observeObjectShapeSend($constraints, $declared, $actual, $isCallArgument);
459+
}
460+
if ($declared instanceof IntersectionType) {
461+
// object{...}&Foo - the shape's properties are bounds next to Foo's arguments
462+
foreach ($declared->getTypes() as $member) {
463+
if (!$member instanceof ObjectShapeType) {
464+
continue;
465+
}
466+
$constraints = $this->observeObjectShapeSend($constraints, $member, $actual, $isCallArgument);
467+
}
468+
}
454469

455470
$actualReflections = $actual->getObjectClassReflections();
456471
if (count($actualReflections) === 1) {
@@ -506,6 +521,38 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec
506521
return $constraints;
507522
}
508523

524+
/**
525+
* Each property of the object flows into the declared property of the shape
526+
* (`(object) ['x' => new Foo(1)]` returned as `object{x: Foo<int>}`).
527+
*/
528+
private function observeObjectShapeSend(TemplateArgumentConstraints $constraints, ObjectShapeType $declared, Type $actual, bool $isCallArgument): TemplateArgumentConstraints
529+
{
530+
if (!$actual->isObject()->yes()) {
531+
return $constraints;
532+
}
533+
534+
$scope = new OutOfClassScope();
535+
foreach ($declared->getProperties() as $name => $declaredPropertyType) {
536+
$name = (string) $name;
537+
if (!$actual->hasInstanceProperty($name)->yes()) {
538+
continue;
539+
}
540+
541+
try {
542+
$actualProperty = $actual->getInstanceProperty($name, $scope);
543+
} catch (MissingPropertyFromReflectionException) {
544+
continue;
545+
}
546+
if (!$actualProperty->isPublic() || $actualProperty->isStatic()) {
547+
continue;
548+
}
549+
550+
$constraints = $this->observeSend($constraints, $declaredPropertyType, $actualProperty->getReadableType(), $isCallArgument);
551+
}
552+
553+
return $constraints;
554+
}
555+
509556
/**
510557
* A template argument of an object sent to the declared argument at the same
511558
* position. A union of several sites' markers (Box<T|U> of zip(of(1), of(2)))

‎tests/PHPStan/Rules/Generators/YieldTypeRuleTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,9 @@ public function testYieldOversizedSelfRejection(): void
8787
$this->analyse([__DIR__ . '/data/bug-yield-oversized-self-rejection.php'], []);
8888
}
8989

90+
public function testBug15417(): void
91+
{
92+
$this->analyse([__DIR__ . '/data/bug-15417.php'], []);
93+
}
94+
9095
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15417Generator;
4+
5+
/**
6+
* @template TKey of array-key
7+
* @template-covariant TValue
8+
*/
9+
final class Collection
10+
{
11+
/** @param iterable<TKey, TValue> $a */
12+
public function __construct($a) { }
13+
}
14+
15+
final class Repro
16+
{
17+
18+
/** @return \Generator<int, object{x: Collection<int, string>}> */
19+
public static function gen(): \Generator
20+
{
21+
yield (object) ['x' => new Collection(['a', 'b'])];
22+
}
23+
24+
}

‎tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,4 +1156,10 @@ public function testBug11421(): void
11561156
]);
11571157
}
11581158

1159+
public function testBug15417(): void
1160+
{
1161+
$this->checkThisOnly = false;
1162+
$this->analyse([__DIR__ . '/data/bug-15417-argument.php'], []);
1163+
}
1164+
11591165
}

‎tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,4 +1420,21 @@ public function testBug13325(): void
14201420
$this->analyse([__DIR__ . '/data/bug-13325.php'], []);
14211421
}
14221422

1423+
#[RequiresPhp('>= 8.2.0')]
1424+
public function testBug15417(): void
1425+
{
1426+
$this->analyse([__DIR__ . '/data/bug-15417.php'], []);
1427+
}
1428+
1429+
public function testBug15417Shapes(): void
1430+
{
1431+
$this->analyse([__DIR__ . '/data/bug-15417-shapes.php'], [
1432+
[
1433+
'Method Bug15417Shapes\\Repro::stillReported() should return object{x: Bug15417Shapes\\Collection<string, string>} but returns object{x: Bug15417Shapes\\Collection<0|1, \'a\'|\'b\'>}&stdClass.',
1434+
51,
1435+
'Property ($x) type Bug15417Shapes\\Collection<string, string> does not accept type Bug15417Shapes\\Collection<0|1, \'a\'|\'b\'>.',
1436+
],
1437+
]);
1438+
}
1439+
14231440
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15417Argument;
4+
5+
/**
6+
* @template TKey of array-key
7+
* @template-covariant TValue
8+
*/
9+
final class Collection
10+
{
11+
/** @param iterable<TKey, TValue> $a */
12+
public function __construct($a) { }
13+
}
14+
15+
final class Repro
16+
{
17+
18+
/** @param object{x: Collection<int, string>} $o */
19+
public static function take(object $o): void
20+
{
21+
}
22+
23+
public static function doFoo(): void
24+
{
25+
self::take((object) ['x' => new Collection(['a', 'b'])]);
26+
}
27+
28+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15417Shapes;
4+
5+
/**
6+
* @template TKey of array-key
7+
* @template-covariant TValue
8+
*/
9+
final class Collection
10+
{
11+
/** @param iterable<TKey, TValue> $a */
12+
public function __construct($a) { }
13+
}
14+
15+
final class Repro
16+
{
17+
18+
/** @return object{x: Collection<int, string>} */
19+
public static function shape(): object
20+
{
21+
return (object) ['x' => new Collection(['a', 'b'])];
22+
}
23+
24+
/** @return object{y: object{x: Collection<int, string>}} */
25+
public static function nested(): object
26+
{
27+
return (object) ['y' => (object) ['x' => new Collection(['a', 'b'])]];
28+
}
29+
30+
/** @return object{x: Collection<int, string>}|null */
31+
public static function nullable(bool $b): ?object
32+
{
33+
return $b ? (object) ['x' => new Collection(['a', 'b'])] : null;
34+
}
35+
36+
/** @return object{x: Collection<int, string>}&\stdClass */
37+
public static function intersection(): object
38+
{
39+
return (object) ['x' => new Collection(['a', 'b'])];
40+
}
41+
42+
/** @return list<object{x: Collection<int, string>, y?: Collection<int, int>}> */
43+
public static function optional(): array
44+
{
45+
return [(object) ['x' => new Collection(['a', 'b'])]];
46+
}
47+
48+
/** @return object{x: Collection<string, string>} */
49+
public static function stillReported(): object
50+
{
51+
return (object) ['x' => new Collection(['a', 'b'])];
52+
}
53+
54+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php // lint >= 8.2
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15417;
6+
7+
class Repro
8+
{
9+
/** @return Collection<int, object{ch: string, x: Collection<int, object{t: string}>}> */
10+
public static function get(string $s): Collection
11+
{
12+
$data = [];
13+
foreach (explode(',', $s) as $char) {
14+
$c = array_map(fn($x) => (object)['t' => $x], ['a', 'b', 'c', 'd']);
15+
if ($s !== 'z') {
16+
$data[] = (object)['ch' => $char, 'x' => new Collection((new \Random\Randomizer)->shuffleArray($c))];
17+
}
18+
}
19+
20+
return new Collection($data);
21+
}
22+
}
23+
24+
/**
25+
* @template TKey of array-key
26+
* @template-covariant TValue (THEA fix by FixLarastanStubs.php)
27+
*/
28+
class Collection
29+
{
30+
/** @param iterable<TKey, TValue> $a */
31+
public function __construct($a) { echo gettype($a); }
32+
}
33+

‎tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,4 +1145,9 @@ public function testIntersectionWithTemplateMixed(): void
11451145
]);
11461146
}
11471147

1148+
public function testBug15417(): void
1149+
{
1150+
$this->analyse([__DIR__ . '/data/bug-15417.php'], []);
1151+
}
1152+
11481153
}

0 commit comments

Comments
 (0)