Report what a function assigns that its @var tag does not accept - #6618
Draft
ondrejmirtes wants to merge 3 commits into
Draft
ondrejmirtes wants to merge 3 commits into
ondrejmirtes wants to merge 3 commits into
Conversation
ondrejmirtes
force-pushed
the
var-tag-accepts-usages
branch
2 times, most recently
from
September 27, 2026 14:15
72f6678 to
02120c3
Compare
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
ondrejmirtes
force-pushed
the
var-tag-accepts-usages
branch
from
September 27, 2026 14:41
02120c3 to
50fadd3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A
@vartag above a value that doesn't decide the variable's type —$x = null,$x = [], a scalar,new Foo()of a generic class, orstatic $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:This is the opposite direction of the existing inline
@varcheck. 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 ofunresolvedTemplateArguments.How it works
Find the writes (first commit, engine + turbo).
VarTagUsagesInferencefinds the top-level declarations in a body and every write to their variables after them:.=,+=, …);++/--;$a[] = …,$a[50] = …, nested dims);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.
VarTagUsagesNodecarries 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, identifiervarTag.usages):new.Transaction<true>over an inferredboolargument isn't reported. A template argument nothing in the body constrains is unknown.RuleLevelHelper::isSuperTypeOf(), theaccepts()counterpart for this direction:array<int>vsarray<int|string>);mixedor a benevolent union that the level does not check yet is judged likeaccepts()judges it:mixedfits anywhere, and a benevolent union fits where one of its members does. So$list[] = $keywith an(int|string)key is fine, andarray<mixed>intoarray<string, Foo>is reported only from level 9.list<T>over non-empty lists) is not reported.@var list<…>over the two-pass driver's statement entries, which are keyed by the statement list's keys. It is nowarray<int, …>, matching the functions it's passed to.Verification
make testsis green with the extension off and loaded.make phpstanand cs are clean.VarTagReflectsUsagesRuleTestruns one fixture in three configurations: level-7+ behaviour, without union checks, and withmixedchecked. The fixture covers:newwith inferred template arguments;staticcaches;bug-14198covers the issue's snippet verbatim.Closes phpstan/phpstan#14198
🤖 Generated with Claude Code
https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv