Skip to content

Report what a function assigns that its @var tag does not accept - #6618

Draft
ondrejmirtes wants to merge 3 commits into
2.3.xfrom
var-tag-accepts-usages
Draft

ondrejmirtes wants to merge 3 commits into
2.3.xfrom
var-tag-accepts-usages

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

A @var tag above a value that doesn't decide the variable's type — $x = null, $x = [], a scalar, new Foo() of a generic class, or static $x — is effectively the declared type of the variable. Every write to it afterwards must keep it within the tag, and a write that doesn't is reported on its own line:

function doFoo(): void
{
	/** @var array<int> $a */
	$a = [];
	$a[] = 1;
	$a[] = 'foo';   // PHPDoc tag @var with type array<int> does not accept type array<int|string> assigned to $a.
	$a[] = 2;       // fine - judged on its own, not coloured by the previous line
}

function writes(): void
{
	/** @var 'a'|'b' $s */
	$s = 'a';
	$s .= 'x';      // ... does not accept type 'ax'|'bx' assigned to $s.

	/** @var list<int> $l */
	$l = [];
	$l[50] = 1;     // ... does not accept type non-empty-array<int<0, max>, int> assigned to $l.

	/** @var int<0, 5> $n */
	$n = 0;
	$n++;           // ... does not accept type int<1, 6> assigned to $n.
}

function say_HELLO(): void
{
	/** @var string $name */
	static $name = 'world';
	$name = [];     // ... with type string does not accept type array assigned to $name.
}

function doBar(): void
{
	/** @var Collection<int|string> $c */
	$c = new Collection([]);  // ... does not accept type Collection<int> assigned to $c.
	$c->add(1);
}

This is the opposite direction of the existing inline @var check. That check makes the tag a subtype of the assigned expression's native type; here the tag has to be a supertype of what is written later. The declared value itself is still left to the existing check.

Bleeding edge only (featureToggles.varTagsReflectUsages). Like #6604 and #6617, it runs inside the two-pass body walk of unresolvedTemplateArguments.

How it works

Find the writes (first commit, engine + turbo). VarTagUsagesInference finds the top-level declarations in a body and every write to their variables after them:

  • assignments;
  • compound assignments (.=, +=, …);
  • ++/--;
  • offset writes ($a[] = …, $a[50] = …, nested dims);
  • destructuring;
  • all of the above inside a closure that uses the variable by reference.

At the end of the body walk, each write is evaluated once more from the variable holding the tag's type. A write after a wrong one, or inside a loop, is therefore judged on its own. VarTagUsagesNode carries the variable's type after the write.

For a generic new, the driver walks the body once more without the tag, re-walking only the statements that read what changed. The node carries the declared value with the template arguments that walk infers (Collection<int> from $c->add(1)), both generalized like inferred ones and precise.

Check it (second commit, VarTagReflectsUsagesRule, level 2, identifier varTag.usages):

  • The type after each write must be a subtype of the tag. The error goes on the write's line, or on the declaration for a generic new.
  • Template arguments count as fine when the tag accepts either the generalized or the precise ones, so Transaction<true> over an inferred bool argument isn't reported. A template argument nothing in the body constrains is unknown.
  • The check follows the rule level through the new RuleLevelHelper::isSuperTypeOf(), the accepts() counterpart for this direction:
    • a type the tag is only maybe a supertype of is reported from level 7 on (array<int> vs array<int|string>);
    • a type holding mixed or a benevolent union that the level does not check yet is judged like accepts() judges it: mixed fits anywhere, and a benevolent union fits where one of its members does. So $list[] = $key with an (int|string) key is fine, and array<mixed> into array<string, Foo> is reported only from level 9.
  • A tag wider than what the function assigns (an interface over its implementation, list<T> over non-empty lists) is not reported.
  • Self-analysis found one true positive: the @var list<…> over the two-pass driver's statement entries, which are keyed by the statement list's keys. It is now array<int, …>, matching the functions it's passed to.

Verification

  • make tests is green with the extension off and loaded.
  • Walk-trace (PHP vs native) is identical on the full corpus and on the rule fixtures.
  • Side-by-side, smoke and signature parity pass. make phpstan and cs are clean.
  • VarTagReflectsUsagesRuleTest runs one fixture in three configurations: level-7+ behaviour, without union checks, and with mixed checked. The fixture covers:
    • wrong elements, compound assignments, increments and decrements;
    • offset writes on a list, destructuring;
    • writes in loops and after a wrong write;
    • generic new with inferred template arguments;
    • by-ref closures, static caches;
    • literal element types, untyped sources, unconstrained template arguments and benevolent keys.
  • bug-14198 covers the issue's snippet verbatim.

Closes phpstan/phpstan#14198

🤖 Generated with Claude Code

https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv

@ondrejmirtes
ondrejmirtes force-pushed the var-tag-accepts-usages branch 2 times, most recently from 72f6678 to 02120c3 Compare September 27, 2026 14:15
ondrejmirtes and others added 3 commits September 27, 2026 16:38
A `@var` tag above `$x = null`, `$x = []`, a scalar, `new Foo()` or
`static $x` declares what the variable holds for the rest of the function.
VarTagUsagesInference finds these declarations among the top-level
statements of a body and the writes to their variables after them -
assignments, compound assignments like `.=`, increments and decrements,
offset writes like `$a[50] = ...` and destructuring, also in a closure that
uses the variable by reference.

At the end of the two-pass walk each write is evaluated again with the
variable narrowed to the tag's type, so a write is judged on its own - one
after an earlier wrong write, or inside a loop, is not coloured by it -
while what the body knows about the variable's offsets (an element
initialised by `??=` before its fields are written) stands.
VarTagUsagesNode carries the variable's type after the write for a rule to
compare. For a generic `new`, the driver walks the body once more without
the tag - from the first such declaration on, re-walking only the
statements that read what changed - and the node carries the declared
value with the template arguments that walk infers (Collection<int> from
`$c->add(1)`), both generalized like inferred ones and precise. A statement
invoking a closure whose by-ref uses hold a changed variable reads that
variable too - the closure runs there.

Behind the varTagsReflectUsages bleeding edge toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validate @var declaration of static variables

1 participant