Skip to content

Commit 8d02f9a

Browse files
ondrejmirtesclaude
andcommitted
Report what a function assigns that its @var tag does not accept
The tag above `$x = null`, `$x = []`, a scalar, `new Foo()` or `static $x` declares the type of the variable, so every write to it after the declaration must leave it a subtype of the tag. The error is reported on the line of the write - `$a[] = 'foo'`, `$s .= 'x'`, `$n++`, `$l[50] = 1` on a list, `[$d] = ['x']` - and, for a generic `new` whose template arguments the body infers differently, on the declaration. Template arguments are compared both generalized like inferred ones and precise - a tag more precise than the generalization (Collection<true>) is fine - and one nothing in the body constrains is unknown. The check follows the rule level through the new RuleLevelHelper::isSuperTypeOf(), the accepts() counterpart: an assigned type the tag is maybe a supertype of is reported from level 7 on (unions), and a type holding `mixed` or a benevolent union the level does not check yet is judged like accepts() judges it - `mixed` fits anywhere, a benevolent union where one of its members does. The @var tag over the two-pass driver's statement entries declared a list, but the entries are keyed by the statement list's keys. Closes phpstan/phpstan#14198 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
1 parent 765f907 commit 8d02f9a

7 files changed

Lines changed: 731 additions & 1 deletion

File tree

‎conf/config.level2.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ conditionalTags:
1919
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
2020
PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule:
2121
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
22+
PHPStan\Rules\PhpDoc\VarTagReflectsUsagesRule:
23+
phpstan.rules.rule: %featureToggles.varTagsReflectUsages%
2224

2325
services:
2426
-
@@ -32,3 +34,6 @@ services:
3234

3335
-
3436
class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule
37+
38+
-
39+
class: PHPStan\Rules\PhpDoc\VarTagReflectsUsagesRule

‎src/Analyser/StatementsHandler.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ private function processBodyStmtNodesTwoPass(
572572
$scope = $scope->withTemplateArgumentFrame($frame)->withTemplateArgumentConstraints(null);
573573
$recording = new RecordingNodeCallback();
574574
$state = new StatementListWalkState($scope);
575-
/** @var list<array{StatementListWalkState, int}> $entries the state and recording offset before each statement, plus the final ones */
575+
/** @var array<int, array{StatementListWalkState, int}> $entries the state and recording offset before each statement, plus the final ones */
576576
$entries = [];
577577
$observationContext = $context->withoutTemplateArgumentResolution();
578578
$suspendedGatherers = $nodeScopeResolver->suspendNodeGatherers();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PhpDoc;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\VarTagUsagesNode;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPStan\Rules\RuleLevelHelper;
11+
use PHPStan\Type\Type;
12+
use PHPStan\Type\TypeCombinator;
13+
use PHPStan\Type\TypeUtils;
14+
use PHPStan\Type\VerbosityLevel;
15+
use function sprintf;
16+
17+
/**
18+
* A `@var` tag over `$x = null`, `$x = []`, a scalar, a `new` of a generic
19+
* class or `static $x` declares what the variable holds in the rest of the
20+
* function. A write that makes the variable hold something the tag does not
21+
* accept - `$x[] = <element of another type>`, `$x .= ...`, `$x++` - and a
22+
* template argument the body infers differently are reported.
23+
*
24+
* @implements Rule<VarTagUsagesNode>
25+
*/
26+
final class VarTagReflectsUsagesRule implements Rule
27+
{
28+
29+
public function __construct(private RuleLevelHelper $ruleLevelHelper)
30+
{
31+
}
32+
33+
public function getNodeType(): string
34+
{
35+
return VarTagUsagesNode::class;
36+
}
37+
38+
public function processNode(Node $node, Scope $scope): array
39+
{
40+
$varTagType = $node->getVarTagType();
41+
$rejected = $this->getRejectedTypes($varTagType, $node->getAssignedType());
42+
// a tag more precise than the generalized inferred template arguments
43+
// (Collection<true>) is fine when it accepts the precise ones
44+
if ($rejected === [] || $this->getRejectedTypes($varTagType, $node->getPreciseAssignedType()) === []) {
45+
return [];
46+
}
47+
$usagesType = TypeCombinator::union(...$rejected);
48+
49+
$verbosity = VerbosityLevel::getRecommendedLevelByType($varTagType, $usagesType);
50+
51+
return [
52+
RuleErrorBuilder::message(sprintf(
53+
'PHPDoc tag @var with type %s does not accept type %s assigned to $%s.',
54+
$varTagType->describe($verbosity),
55+
$usagesType->describe($verbosity),
56+
$node->getVariableName(),
57+
))->identifier('varTag.usages')->build(),
58+
];
59+
}
60+
61+
/**
62+
* @return list<Type>
63+
*/
64+
private function getRejectedTypes(Type $varTagType, Type $assignedType): array
65+
{
66+
$rejected = [];
67+
foreach (TypeUtils::flattenTypes($assignedType) as $type) {
68+
if ($this->ruleLevelHelper->isSuperTypeOf($varTagType, $type)->result) {
69+
continue;
70+
}
71+
$rejected[] = $type;
72+
}
73+
74+
return $rejected;
75+
}
76+
77+
}

‎src/Rules/RuleLevelHelper.php‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,56 @@ public function accepts(Type $acceptingType, Type $acceptedType, bool $strictTyp
194194
);
195195
}
196196

197+
/**
198+
* accepts() for a relationship accepts() does not describe - whether the
199+
* values of $subType fit into $superType, e.g. what a declared type must
200+
* hold - with the same rule level: nullables and unions are checked from
201+
* their levels on, and `mixed` and benevolent unions the level does not
202+
* check yet fit.
203+
*
204+
* @api
205+
*/
206+
public function isSuperTypeOf(Type $superType, Type $subType): RuleLevelHelperAcceptsResult
207+
{
208+
[$subType, $checkForUnion] = $this->transformAcceptedType($superType, $subType);
209+
$superType = $this->transformCommonType($superType);
210+
211+
$isSuperType = $superType->isSuperTypeOf($subType);
212+
if (!$isSuperType->yes() && $this->containsUncheckedType($subType)) {
213+
// the level lets them in like accepts() does - `mixed` fits
214+
// anywhere, a benevolent union where one of its members does
215+
$accepts = $superType->accepts($subType, true);
216+
217+
return new RuleLevelHelperAcceptsResult(
218+
$checkForUnion ? $accepts->yes() : !$accepts->no(),
219+
$accepts->reasons,
220+
);
221+
}
222+
223+
return new RuleLevelHelperAcceptsResult(
224+
$checkForUnion ? $isSuperType->yes() : !$isSuperType->no(),
225+
$isSuperType->reasons,
226+
);
227+
}
228+
229+
/**
230+
* The `mixed` transformCommonType() left as it is (checked mixed became
231+
* StrictMixedType) and the benevolent unions transformAcceptedType() did.
232+
*/
233+
private function containsUncheckedType(Type $type): bool
234+
{
235+
$contains = false;
236+
TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$contains): Type {
237+
if ($type instanceof MixedType || ($type instanceof BenevolentUnionType && !$this->checkBenevolentUnionTypes)) {
238+
$contains = true;
239+
}
240+
241+
return $contains ? $type : $traverse($type);
242+
});
243+
244+
return $contains;
245+
}
246+
197247
/**
198248
* @api
199249
* @param callable(Type $type): bool $unionTypeCriteriaCallback
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PhpDoc;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Rules\RuleLevelHelper;
7+
use PHPStan\Testing\RuleTestCase;
8+
9+
/**
10+
* @extends RuleTestCase<VarTagReflectsUsagesRule>
11+
*/
12+
class VarTagReflectsUsagesRuleTest extends RuleTestCase
13+
{
14+
15+
private bool $checkUnionTypes = true;
16+
17+
private bool $checkMixed = false;
18+
19+
protected function getRule(): Rule
20+
{
21+
return new VarTagReflectsUsagesRule(new RuleLevelHelper(
22+
self::createReflectionProvider(),
23+
checkNullables: true,
24+
checkThisOnly: false,
25+
checkUnionTypes: $this->checkUnionTypes,
26+
checkExplicitMixed: $this->checkMixed,
27+
checkImplicitMixed: $this->checkMixed,
28+
checkBenevolentUnionTypes: false,
29+
discoveringSymbolsTip: true,
30+
));
31+
}
32+
33+
public function testRule(): void
34+
{
35+
$this->analyse([__DIR__ . '/data/var-tag-reflects-usages.php'], [
36+
[
37+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
38+
56,
39+
],
40+
[
41+
'PHPDoc tag @var with type VarTagReflectsUsages\\Collection<int|string> does not accept type VarTagReflectsUsages\\Collection<int> assigned to $c.',
42+
83,
43+
],
44+
[
45+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
46+
99,
47+
],
48+
[
49+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
50+
109,
51+
],
52+
[
53+
'PHPDoc tag @var with type VarTagReflectsUsages\\Foo|null does not accept type VarTagReflectsUsages\\Bar assigned to $a.',
54+
156,
55+
],
56+
[
57+
'PHPDoc tag @var with type array<string, int> does not accept type array<string, int|string> assigned to $cache.',
58+
179,
59+
],
60+
[
61+
'PHPDoc tag @var with type int|null does not accept type string assigned to $instance.',
62+
200,
63+
],
64+
[
65+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
66+
251,
67+
],
68+
[
69+
'PHPDoc tag @var with type array<1|2> does not accept type non-empty-array<1|2|3> assigned to $a.',
70+
269,
71+
],
72+
[
73+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
74+
288,
75+
],
76+
[
77+
'PHPDoc tag @var with type array<int> does not accept type array<float|int> assigned to $a.',
78+
290,
79+
],
80+
[
81+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
82+
293,
83+
],
84+
[
85+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ax\'|\'bx\' assigned to $s.',
86+
302,
87+
],
88+
[
89+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ay\'|\'by\' assigned to $s.',
90+
303,
91+
],
92+
[
93+
'PHPDoc tag @var with type int<0, 5> does not accept type int<1, 6> assigned to $n.',
94+
311,
95+
],
96+
[
97+
'PHPDoc tag @var with type list<int> does not accept type non-empty-array<int<0, max>, int> assigned to $l.',
98+
320,
99+
],
100+
[
101+
'PHPDoc tag @var with type int does not accept type string assigned to $d.',
102+
329,
103+
],
104+
]);
105+
}
106+
107+
public function testRuleWithoutUnionTypes(): void
108+
{
109+
$this->checkUnionTypes = false;
110+
$this->analyse([__DIR__ . '/data/var-tag-reflects-usages.php'], [
111+
[
112+
'PHPDoc tag @var with type VarTagReflectsUsages\\Collection<int|string> does not accept type VarTagReflectsUsages\\Collection<int> assigned to $c.',
113+
83,
114+
],
115+
[
116+
'PHPDoc tag @var with type VarTagReflectsUsages\\Foo|null does not accept type VarTagReflectsUsages\\Bar assigned to $a.',
117+
156,
118+
],
119+
[
120+
'PHPDoc tag @var with type int|null does not accept type string assigned to $instance.',
121+
200,
122+
],
123+
[
124+
'PHPDoc tag @var with type array<int> does not accept type array<float|int> assigned to $a.',
125+
290,
126+
],
127+
[
128+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ax\'|\'bx\' assigned to $s.',
129+
302,
130+
],
131+
[
132+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ay\'|\'by\' assigned to $s.',
133+
303,
134+
],
135+
[
136+
'PHPDoc tag @var with type int does not accept type string assigned to $d.',
137+
329,
138+
],
139+
]);
140+
}
141+
142+
public function testRuleWithMixed(): void
143+
{
144+
$this->checkMixed = true;
145+
$this->analyse([__DIR__ . '/data/var-tag-reflects-usages.php'], [
146+
[
147+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
148+
56,
149+
],
150+
[
151+
'PHPDoc tag @var with type VarTagReflectsUsages\\Collection<int|string> does not accept type VarTagReflectsUsages\\Collection<int> assigned to $c.',
152+
83,
153+
],
154+
[
155+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
156+
99,
157+
],
158+
[
159+
'PHPDoc tag @var with type list<int> does not accept type non-empty-list<\'x\'|int> assigned to $a.',
160+
109,
161+
],
162+
[
163+
'PHPDoc tag @var with type VarTagReflectsUsages\\Foo|null does not accept type VarTagReflectsUsages\\Bar assigned to $a.',
164+
156,
165+
],
166+
[
167+
'PHPDoc tag @var with type array<string, VarTagReflectsUsages\\Foo> does not accept type array<mixed> assigned to $map.',
168+
167,
169+
],
170+
[
171+
'PHPDoc tag @var with type array<string, int> does not accept type array<string, int|string> assigned to $cache.',
172+
179,
173+
],
174+
[
175+
'PHPDoc tag @var with type int|null does not accept type string assigned to $instance.',
176+
200,
177+
],
178+
[
179+
'PHPDoc tag @var with type VarTagReflectsUsages\\Entity<int> does not accept type VarTagReflectsUsages\\Entity<mixed> assigned to $entity.',
180+
242,
181+
],
182+
[
183+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
184+
251,
185+
],
186+
[
187+
'PHPDoc tag @var with type array<1|2> does not accept type non-empty-array<1|2|3> assigned to $a.',
188+
269,
189+
],
190+
[
191+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
192+
288,
193+
],
194+
[
195+
'PHPDoc tag @var with type array<int> does not accept type array<float|int> assigned to $a.',
196+
290,
197+
],
198+
[
199+
'PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.',
200+
293,
201+
],
202+
[
203+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ax\'|\'bx\' assigned to $s.',
204+
302,
205+
],
206+
[
207+
'PHPDoc tag @var with type \'a\'|\'b\' does not accept type \'ay\'|\'by\' assigned to $s.',
208+
303,
209+
],
210+
[
211+
'PHPDoc tag @var with type int<0, 5> does not accept type int<1, 6> assigned to $n.',
212+
311,
213+
],
214+
[
215+
'PHPDoc tag @var with type list<int> does not accept type non-empty-array<int<0, max>, int> assigned to $l.',
216+
320,
217+
],
218+
[
219+
'PHPDoc tag @var with type int does not accept type string assigned to $d.',
220+
329,
221+
],
222+
]);
223+
}
224+
225+
public function testBug14198(): void
226+
{
227+
$this->analyse([__DIR__ . '/data/bug-14198.php'], [
228+
[
229+
'PHPDoc tag @var with type string does not accept type array assigned to $name.',
230+
9,
231+
],
232+
]);
233+
}
234+
235+
}

0 commit comments

Comments
 (0)