Skip to content

Commit 04d3b6c

Browse files
phpstan-botclaude
authored andcommitted
Pin the native flavour of every call-like handler with tests
Follow-up to the FuncCallHandler native-return shortcut: verify that the sibling handlers resolve the native return type too, and lock it in. MethodCallHandler and StaticCallHandler already branch on $nativeTypesPromoted inside their $resolveMethod/$resolveStaticMethod closure, so both the Identifier and the dynamic-name path answer from getNativeReturnType(). NewHandler passes a null $preResolvedAcceptor when native types are promoted, so a generic class stays unresolved (Generic<mixed>) instead of picking up the phpdoc @param-inferred argument. The first-class callable nodes go through InitializerExprTypeResolver::createFirstClassCallable(), which swaps in getNativeReturnType() per variant. Each of these is now covered by an assertNativeType() assertion that was confirmed to fail when the corresponding native branch is removed: - tests/PHPStan/Analyser/nsrt/bug-15169-calls.php - method call (static and dynamic name), static call (static and dynamic name), plain function call, a call on a callable value (__invoke, callable string, callable array), and `new` on a generic class. - tests/PHPStan/Analyser/nsrt/bug-15169-callables.php - `$c->info(...)`, `C::staticInfo(...)`, `info(...)` and `$c(...)`, plus calling the resulting closure. Needs PHP 8.1 syntax, hence its own file. Rule-level coverage of the same constructs under treatPhpDocTypesAsCertain: false lives in tests/PHPStan/Rules/DeadCode/data/bug-15169c.php (first-class callables, PHP 8.1) and a `new` + generic property case appended to bug-15169b.php. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d84b12a commit 04d3b6c

5 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
// The native flavour of the first-class callable nodes -
6+
// MethodCallableNodeHandler, StaticMethodCallableNodeHandler,
7+
// FunctionCallableNodeHandler - and of calling the resulting closure.
8+
9+
namespace Bug15169Callables;
10+
11+
use function PHPStan\Testing\assertNativeType;
12+
use function PHPStan\Testing\assertType;
13+
14+
class C
15+
{
16+
17+
/** @return array{a: int} */
18+
public function info(): array
19+
{
20+
return ['a' => 1];
21+
}
22+
23+
/** @return array{a: int} */
24+
public static function staticInfo(): array
25+
{
26+
return ['a' => 1];
27+
}
28+
29+
/** @return array{a: int} */
30+
public function __invoke(): array
31+
{
32+
return ['a' => 1];
33+
}
34+
35+
}
36+
37+
/** @return array{a: int} */
38+
function info(): array
39+
{
40+
return ['a' => 1];
41+
}
42+
43+
function firstClassCallables(C $c): void
44+
{
45+
assertType('Closure(): array{a: int}', $c->info(...));
46+
assertNativeType('Closure(): array', $c->info(...));
47+
48+
assertType('Closure(): array{a: int}', C::staticInfo(...));
49+
assertNativeType('Closure(): array', C::staticInfo(...));
50+
51+
assertType('Closure(): array{a: int}', info(...));
52+
assertNativeType('Closure(): array', info(...));
53+
54+
// a first-class callable over a callable value resolves through
55+
// __invoke's own signature
56+
assertType('Closure(): array{a: int}', $c(...));
57+
assertNativeType('Closure(): array', $c(...));
58+
}
59+
60+
function callingAFirstClassCallable(C $c): void
61+
{
62+
$fn = $c->info(...);
63+
assertType('array{a: int}', $fn());
64+
assertNativeType('array', $fn());
65+
66+
assertType('false', isset($fn()['zzz']));
67+
assertNativeType('bool', isset($fn()['zzz']));
68+
69+
$staticFn = C::staticInfo(...);
70+
assertType('array{a: int}', $staticFn());
71+
assertNativeType('array', $staticFn());
72+
73+
$funcFn = info(...);
74+
assertType('array{a: int}', $funcFn());
75+
assertNativeType('array', $funcFn());
76+
77+
$invokeFn = $c(...);
78+
assertType('array{a: int}', $invokeFn());
79+
assertNativeType('array', $invokeFn());
80+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php declare(strict_types = 1);
2+
3+
// The native flavour of every call-like handler: MethodCallHandler,
4+
// StaticCallHandler, FuncCallHandler (named, and a callable value) and
5+
// NewHandler.
6+
7+
namespace Bug15169Calls;
8+
9+
use function PHPStan\Testing\assertNativeType;
10+
use function PHPStan\Testing\assertType;
11+
12+
class C
13+
{
14+
15+
/** @return array{a: int} */
16+
public function info(): array
17+
{
18+
return ['a' => 1];
19+
}
20+
21+
/** @return array{a: int} */
22+
public static function staticInfo(): array
23+
{
24+
return ['a' => 1];
25+
}
26+
27+
/** @return array{a: int} */
28+
public function __invoke(): array
29+
{
30+
return ['a' => 1];
31+
}
32+
33+
}
34+
35+
/** @return array{a: int} */
36+
function info(): array
37+
{
38+
return ['a' => 1];
39+
}
40+
41+
/** @template T */
42+
class Generic
43+
{
44+
45+
/** @var T */
46+
public $value;
47+
48+
/** @param T $value */
49+
public function __construct($value)
50+
{
51+
$this->value = $value;
52+
}
53+
54+
}
55+
56+
function methodCall(C $c): void
57+
{
58+
assertType('array{a: int}', $c->info());
59+
assertNativeType('array', $c->info());
60+
61+
$name = 'info';
62+
assertType('array{a: int}', $c->$name());
63+
assertNativeType('array', $c->$name());
64+
}
65+
66+
function staticCall(): void
67+
{
68+
assertType('array{a: int}', C::staticInfo());
69+
assertNativeType('array', C::staticInfo());
70+
71+
$name = 'staticInfo';
72+
assertType('array{a: int}', C::$name());
73+
assertNativeType('array', C::$name());
74+
}
75+
76+
function funcCall(C $c): void
77+
{
78+
assertType('array{a: int}', info());
79+
assertNativeType('array', info());
80+
81+
// a call on a callable value goes through FuncCallHandler's
82+
// $expr->name instanceof Expr branch: __invoke, a callable string and a
83+
// callable array
84+
assertType('array{a: int}', $c());
85+
assertNativeType('array', $c());
86+
87+
$callableString = 'Bug15169Calls\info';
88+
assertType('array{a: int}', $callableString());
89+
assertNativeType('array', $callableString());
90+
91+
$callableArray = [$c, 'info'];
92+
assertType('array{a: int}', $callableArray());
93+
assertNativeType('array', $callableArray());
94+
}
95+
96+
function instantiation(): void
97+
{
98+
// the template argument is inferred from the phpdoc @param, so it stays
99+
// unresolved in the native flavour
100+
assertType('Bug15169Calls\Generic<int>', new Generic(1));
101+
assertNativeType('Bug15169Calls\Generic<mixed>', new Generic(1));
102+
103+
assertType('int', (new Generic(1))->value);
104+
assertNativeType('mixed', (new Generic(1))->value);
105+
}
106+
107+
function issetOverThem(C $c): void
108+
{
109+
assertType('false', isset($c->info()['zzz']));
110+
assertNativeType('bool', isset($c->info()['zzz']));
111+
112+
assertType('false', isset(C::staticInfo()['zzz']));
113+
assertNativeType('bool', isset(C::staticInfo()['zzz']));
114+
115+
assertType('false', isset(info()['zzz']));
116+
assertNativeType('bool', isset(info()['zzz']));
117+
118+
assertType('false', isset($c()['zzz']));
119+
assertNativeType('bool', isset($c()['zzz']));
120+
}

‎tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,41 @@ public function testBug15169AnalogousTreatPhpDocTypesAsCertain(): void
509509
'Unreachable statement - code above always terminates.',
510510
102,
511511
],
512+
[
513+
'Unreachable statement - code above always terminates.',
514+
127,
515+
],
516+
]);
517+
}
518+
519+
#[RequiresPhp('>= 8.1.0')]
520+
public function testBug15169FirstClassCallables(): void
521+
{
522+
$this->treatPhpDocTypesAsCertain = false;
523+
$this->analyse([__DIR__ . '/data/bug-15169c.php'], []);
524+
}
525+
526+
#[RequiresPhp('>= 8.1.0')]
527+
public function testBug15169FirstClassCallablesTreatPhpDocTypesAsCertain(): void
528+
{
529+
$this->treatPhpDocTypesAsCertain = true;
530+
$this->analyse([__DIR__ . '/data/bug-15169c.php'], [
531+
[
532+
'Unreachable statement - code above always terminates.',
533+
45,
534+
],
535+
[
536+
'Unreachable statement - code above always terminates.',
537+
55,
538+
],
539+
[
540+
'Unreachable statement - code above always terminates.',
541+
65,
542+
],
543+
[
544+
'Unreachable statement - code above always terminates.',
545+
75,
546+
],
512547
]);
513548
}
514549

‎tests/PHPStan/Rules/DeadCode/data/bug-15169b.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,28 @@ function viaStaticPropertyEmpty(): string
101101

102102
return 'G';
103103
}
104+
105+
/** @template T */
106+
class Generic
107+
{
108+
109+
/** @var T */
110+
public $value;
111+
112+
/** @param T $value */
113+
public function __construct($value)
114+
{
115+
$this->value = $value;
116+
}
117+
118+
}
119+
120+
function viaNewGeneric(): string
121+
{
122+
$g = new Generic(['a' => 1]);
123+
if (!isset($g->value['zzz'])) {
124+
return 'early';
125+
}
126+
127+
return 'H';
128+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
// Analyse with treatPhpDocTypesAsCertain: false
6+
7+
namespace Bug15169c;
8+
9+
class C
10+
{
11+
12+
/** @return array{a: int} */
13+
public function info(): array
14+
{
15+
return ['a' => 1];
16+
}
17+
18+
/** @return array{a: int} */
19+
public static function staticInfo(): array
20+
{
21+
return ['a' => 1];
22+
}
23+
24+
/** @return array{a: int} */
25+
public function __invoke(): array
26+
{
27+
return ['a' => 1];
28+
}
29+
30+
}
31+
32+
/** @return array{a: int} */
33+
function info(): array
34+
{
35+
return ['a' => 1];
36+
}
37+
38+
function viaMethodFirstClassCallable(C $c): string
39+
{
40+
$fn = $c->info(...);
41+
if (!isset($fn()['zzz'])) {
42+
return 'early';
43+
}
44+
45+
return 'A';
46+
}
47+
48+
function viaStaticMethodFirstClassCallable(): string
49+
{
50+
$fn = C::staticInfo(...);
51+
if (!isset($fn()['zzz'])) {
52+
return 'early';
53+
}
54+
55+
return 'B';
56+
}
57+
58+
function viaFunctionFirstClassCallable(): string
59+
{
60+
$fn = info(...);
61+
if (!isset($fn()['zzz'])) {
62+
return 'early';
63+
}
64+
65+
return 'C';
66+
}
67+
68+
function viaInvokeFirstClassCallable(C $c): string
69+
{
70+
$fn = $c(...);
71+
if (!isset($fn()['zzz'])) {
72+
return 'early';
73+
}
74+
75+
return 'D';
76+
}

0 commit comments

Comments
 (0)