Skip to content

Commit ad9c528

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 Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-4619-76d7-89f1-3dc38b076ad6
1 parent 7f336d5 commit ad9c528

8 files changed

Lines changed: 754 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
@@ -813,7 +813,7 @@ private function processBodyStmtNodesTwoPass(
813813
$scope = $scope->withTemplateArgumentFrame($frame)->withTemplateArgumentConstraints(null);
814814
$recording = new RecordingNodeCallback();
815815
$state = new StatementListWalkState($scope);
816-
/** @var list<array{StatementListWalkState, int}> $entries the state and recording offset before each statement, plus the final ones */
816+
/** @var array<int, array{StatementListWalkState, int}> $entries the state and recording offset before each statement, plus the final ones */
817817
$entries = [];
818818
$observationContext = $context->withoutTemplateArgumentResolution();
819819
$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
@@ -253,6 +253,56 @@ public function accepts(Type $acceptingType, Type $acceptedType, bool $strictTyp
253253
);
254254
}
255255

256+
/**
257+
* accepts() for a relationship accepts() does not describe - whether the
258+
* values of $subType fit into $superType, e.g. what a declared type must
259+
* hold - with the same rule level: nullables and unions are checked from
260+
* their levels on, and `mixed` and benevolent unions the level does not
261+
* check yet fit.
262+
*
263+
* @api
264+
*/
265+
public function isSuperTypeOf(Type $superType, Type $subType): RuleLevelHelperAcceptsResult
266+
{
267+
[$subType, $checkForUnion] = $this->transformAcceptedType($superType, $subType);
268+
$superType = $this->transformCommonType($superType);
269+
270+
$isSuperType = $superType->isSuperTypeOf($subType);
271+
if (!$isSuperType->yes() && $this->containsUncheckedType($subType)) {
272+
// the level lets them in like accepts() does - `mixed` fits
273+
// anywhere, a benevolent union where one of its members does
274+
$accepts = $superType->accepts($subType, true);
275+
276+
return new RuleLevelHelperAcceptsResult(
277+
$checkForUnion ? $accepts->yes() : !$accepts->no(),
278+
$accepts->reasons,
279+
);
280+
}
281+
282+
return new RuleLevelHelperAcceptsResult(
283+
$checkForUnion ? $isSuperType->yes() : !$isSuperType->no(),
284+
$isSuperType->reasons,
285+
);
286+
}
287+
288+
/**
289+
* The `mixed` transformCommonType() left as it is (checked mixed became
290+
* StrictMixedType) and the benevolent unions transformAcceptedType() did.
291+
*/
292+
private function containsUncheckedType(Type $type): bool
293+
{
294+
$contains = false;
295+
TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$contains): Type {
296+
if ($type instanceof MixedType || ($type instanceof BenevolentUnionType && !$this->checkBenevolentUnionTypes)) {
297+
$contains = true;
298+
}
299+
300+
return $contains ? $type : $traverse($type);
301+
});
302+
303+
return $contains;
304+
}
305+
256306
/**
257307
* @api
258308
* @param callable(Type $type): bool $unionTypeCriteriaCallback

0 commit comments

Comments
 (0)