Skip to content

Report values assigned to variables that are never read - #6330

Open
ondrejmirtes wants to merge 101 commits into
2.3.xfrom
unused-variables
Open

Report values assigned to variables that are never read#6330
ondrejmirtes wants to merge 101 commits into
2.3.xfrom
unused-variables

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 1, 2026

Copy link
Copy Markdown
Member

Reports local variables whose assigned value is never read afterwards on any path (variable.unused, level 4, behind the new unusedVariable bleeding-edge toggle).

⚠️ The last commit enables the toggle for everyone on purpose, so that CI and the downstream dogfooding projects surface false positives — revert Temporarily enable unusedVariable for everyone before merging.

How it works

A write is dead iff no path from it reaches a read of the written value — so $a = 1; $a = 2; echo $a; reports line 1 and if (c) { $a = 1; } return; reports the write, while for ($i = 0; $i < 3; $i++), back-edge reads in loops, $x[] = ...; return $x; etc. do not.

  • Every source-level write site of a local variable (assignment, compound assignment, inc/dec, offset write on an array/string, list() item, foreach value/key, catch variable) becomes an immutable VariableWrite with a VariableWrittenExpr marker in the scope's expression types (__phpstanVariableWritten($x, <id>), listed as a compositional virtual key, PHP + turbo mirror). A new write of the same variable kills the earlier markers explicitly (assignVariable(..., write:, supersededMarkerExprs:) — the scope is told what to kill, it never consults engine state); merges keep markers as Maybe, so at any point they say which writes still reach it.
  • The one place a source-level read is priced (VariableHandler) records the reaching writes as read in an immutable, persistent VariableWritesFrame (one per function-like body; NodeScopeResolver holds the stack and swaps the top after each with*() transition; arrow functions share the enclosing frame; processNodes isolates the stack). compact(), get_defined_vars(), extract(), eval, include read everything; goto makes the frame opaque; by-ref parameters/uses, global, static and reference aliases are untracked.
  • The frame is emitted as a VariableWritesNode after each *ReturnStatementsNode; UnusedVariableRule reports the unread, tracked writes (catch variables only where non-capturing catches exist; $_-prefixed names are exempt).

Engine fixes needed on the way: generalizeWith() now carries markers planted only by a later loop pass (a branch dead while the variable was still null), createConditionalExpressions() no longer records certainty-No conditionals for virtual nodes (a later narrowing could have erased a marker), ClosureTypeResolver's body walk gets its own throwaway frame, and reads walked in consume-stored mode (arguments of a nullsafe call's plain twin) count as reads.

The first commit removes the 30 genuine dead stores the rule found in src/ and tests/.

Verification

  • Rule test with 31 cases (Psalm's valid/invalid catalogue as the trap list; fail-first verified), make tests green turbo-off and turbo-on, make phpstan clean in both modes, cs/lint clean.
  • Dogfood on slevomat (bleeding edge, final build): 48 reports = 41 true positives (two real bugs among them: a duplicate list() target and an offset write on an array the closure had already captured by value) + 7 writes in branches PHPStan itself proves unreachable (always-false is_int() guard, impossible isset(), dead catch) — left as is, consistent with the existing *.alwaysFalse / catch.neverThrown diagnostics.
  • Performance (locally built phars, fork + turbo, cold cache, ABBA pairs): slevomat +0.7 % user CPU (5 pairs, t = 0.8, n.s.); self-analysis +1.55 % (8 pairs, sd 0.82, t = 6.4); on src/Type alone the tracking is ≈ 3.7 % (reads ≈ 2.5 %, scope markers ≈ 1–2.7 %).

Follow-ups (not in this PR)

  • Psalm's value-flow refinement ($b = $b + 1 chains that never reach a sink): reads in pure RHS positions become dependency edges between writes, the rule computes a fixpoint.
  • Unused array-literal offsets ($a = ['a' => 1, 'b' => 2] with only $a['b'] read) — bounded per-offset sub-writes.
  • Known false negative: $x = 1; unset($x); (unset() walks the variable as a read).
  • Whether f($raw = true) (value consumed, variable never read) and writes in PHPStan-proven-dead branches should be reported is a policy call.

Closes phpstan/phpstan#12789
Closes phpstan/phpstan#12012
Closes phpstan/phpstan#11483
Closes phpstan/phpstan#10202

🤖 Generated with Claude Code

https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy

ondrejmirtes and others added 30 commits September 1, 2026 10:51
getIdenticalResult() and getNotIdenticalResult() gain optional
NodeScopeResolver and left/right Type parameters so inside-out narrowing
callbacks can pass the operand types they already computed instead of
having the helper re-price both sides through the scope. Rules keep
calling the two-argument form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ExpressionResult becomes the single carrier of what a walked expression
means: a per-flavour memoized typeCallback, a specifyTypesCallback
memoized per (context, flavour), an optional createTypesCallback (the
inside-out counterpart of TypeSpecifier::create()), and eager
type/nativeType slots for handlers that already built both flavours.
Truthy/falsey scopes are derived from the result's own specified types,
with explicit overrides replacing the scope callbacks.

Void projection moves here too: results keep the raw type and project
void to null at the value-read boundary (getKeepVoidType() is the
opt-out), replacing VoidToNullTypeTransformer and the keepVoid node
attribute. Position awareness (getTypeOnScope(), answersOnScope(),
askScopeVariableStateMatches(), takeReadVariableStateSnapshot()) lets
consumers decide whether a stored result still answers on the asking
scope. Test expectations follow the void change: a phpdoc @return void
read as a value is now null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ExpressionResultStorage now maps expressions to their full results, so
a later consumer can read the type and narrowing of an
already-processed node instead of re-walking it. duplicate() becomes
O(1) through a read-only fallback chain, and mergeResults() unions only
the storage's own entries (the trait-use path needs both).

The new ExpressionResultStorageStack makes the storage of the analysis
currently in progress reachable from any scope: both internal scope
factories thread one shared stack instance into every MutatingScope
they create, across the fiber/non-fiber boundary. The native
ExpressionResultStorage twin mirrors the rework and the smoke test
covers the fallback-chain semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
DefaultNarrowingHelper is the new-world counterpart of TypeSpecifier's
default truthy/falsey handling, create()/createForExpr() and the
assert/conditional-return specification: narrowing is composed from the
already-walked subject's ExpressionResult (impure-call gate, plain-twin
fan for chains containing nullsafe operators, isset chain entries)
instead of re-probing the scope. CountNarrowingHelper receives the
count()/sizeof() size specification that lived in TypeSpecifier.

The helpers get their consumers as the handlers' resolveType() and
specifyTypes() implementations move into result callbacks over the
following commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
…rrowingHelper

The equality narrowing (===, !==, ==, != and the specifying-function
families driven by them) is rebuilt result-first:
IdenticalNarrowingHelper composes the narrowing from the two operands'
ExpressionResults, and specifyIdenticalAgainstType() serves callers
that have no comparison node at all (assign-time conditional holders,
switch cases, foreach exhaustiveness). BinaryOpHandler routes all four
comparison operators through it with context negation instead of
synthetic BooleanNot walks, and CastHandler narrows bool/int/double
casts through a composed comparison against a fabricated literal.

equality-narrowing-new-world.php pins the behaviour of every rewritten
family; the class-name comparison fixtures cover ::class comparisons
against unknown classes and the guard that a non-::class constant
fetch does not narrow the object it is fetched on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
BooleanNarrowingHelper owns the && and || narrowing semantics
parameterised over per-operand closures, so conjunctions and
disjunctions without a real AST node (ternary decomposition, empty(),
multi-subject isset, nullsafe receiver fans) reuse the same logic. The
right side is walked once on the left-truthy scope and its result
consumed, which deletes the flattening machinery and the
BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH cap from BooleanAndHandler and
BooleanOrHandler: deep chains now cost O(n), covered by the and-chain
bench fixture.

The disjunction augments and the conditional-expression holder helper
stop asking the scope to re-price candidates and read scope state or
the composed subject types instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
Mechanical conversion of the handlers with no structural rework:
resolveType() moves into the result's typeCallback and specifyTypes()
into its specifyTypesCallback (default narrowing or the empty
callback), reading operand types from the already-walked child results.
Lexical context that does not depend on the asking scope (initializer
contexts, class and function reflections) is hoisted out of the
callbacks; ArrayHandler keys per-item results by spl_object_id so each
item resolves at its own evaluation point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
VirtualExprResultHelper builds walk-free ExpressionResults for
TypeExpr, NativeTypeExpr and UnsetOffsetExpr, so fabricated and walked
results have the same shape by construction. The offset virtual
handlers now actually walk their sub-expressions and read the results,
and the PossiblyImpureCall marker node gets a dedicated handler.

The four FirstClassCallable*Handlers existed only to carry
resolveType()/specifyTypes() for the *CallableNode virtual nodes; with
those interface methods moving into callbacks, the CallableNode
handlers own their type directly and the extra handlers are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
processArgs() captures every argument's ExpressionResult into
ArgsResult together with the acceptor resolved after all arguments are
walked, so the call handlers select the acceptor from argument results
instead of pre-selecting it before the walk. FuncCall, MethodCall,
StaticCall and New share the preliminary-result pattern: a result
carrying the callbacks is stored before throw points are computed and
finalize()d afterwards, because resolving the return type for throw
points would otherwise recurse into the unfinished call.

Dynamic return type extensions run inside a primed storage
(DynamicReturnTypeStoragePrimer) so Scope::getType() on an argument
inside an extension hits the stored result instead of re-walking the
argument. MethodCallReturnTypeHelper accepts the pre-resolved acceptor
and the ArgsResult; the implicit __toString and method throw point
helpers take the caller's computed result and return type instead of
re-pricing the receiver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ImpossibleCheckTypeHelper stops re-specifying the condition through
TypeSpecifier: the three call virtual nodes carry the call's
ExpressionResult, the rules read the narrowing verdict from it, and
argument types come from the ArgsResult when available. The
TypeSpecifier constructor dependency is gone, which also removes the
argument from the 16 rule test constructors.

TypeSpecifyingFunctionsDynamicReturnTypeExtension is deleted: the
always-true/false collapse for array_key_exists()/key_exists()/
in_array()/is_subclass_of() lives in FuncCallHandler's typeCallback,
reading its own stored result through a weak reference (a strong
backedge would be an uncollectable cycle under gc_disable()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
NullsafeShortCircuitingHelper's recursive chain walk is gone:
expressions process inside-out, so only the nullsafe handlers ever see
a ?-> link, and the other fetch and call handlers short-circuit through
the operand result's containsNullsafe flag. The nullsafe handlers walk
the receiver exactly once, consume the stored result for the plain
twin, and compose the narrowing as receiver !== null && chain-truthy
through the boolean helper, fanned through impure gates and default
narrowing.

NonNullabilityHelper keeps an explicit ensure stack so the handlers can
recover the pre-device nullable receiver type, and resets it per file:
an internal error escaping between an ensure and its revert must not
leak a stale frame into the next file of the worker's batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
PropertyFetch, StaticPropertyFetch, ArrayDimFetch and Variable move
their type resolution into result callbacks over the walked child
results. ArrayDimFetch resolves offsetGet through
MethodCallReturnTypeHelper per flavour on a fabricated, never-walked
MethodCall; dynamic $$name resolution composes name === '...' through
IdenticalNarrowingHelper instead of filtering by a synthetic Identical
walk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The isset/empty/coalesce family stops re-walking its chains: the chain
links' results are captured during the single walk, isset narrowing
entries are built by DefaultNarrowingHelper from those results,
empty($x) becomes an explicit !isset($x) || !$x disjunction through the
boolean helper with IssetabilityResolution::notEmpty() supplying the
type, and ?? composes both type and narrowing from the two sides'
results per flavour (covered by the native-flavour fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
TernaryHandler decomposes c ? a : b into (c && a) || (!c && b) through
the boolean helpers with thunked branch scopes, and caches the three
operand results per node for the assignment handler's conditional
holders. MatchHandler narrows arm conditions through composed
specifyIdentical() with a threaded per-arm subject state and unions the
already-walked arm results; exhaustive matches over nullable enums no
longer produce an UnhandledMatchError throw point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
ClosureHandler and ArrowFunctionHandler build the closure type (both
flavours) from the body walk the handler already performs and pass it
eagerly - a lazy typeCallback would re-walk the body on every ask.
ClosureTypeResolver keeps the resolved types in a per-file
spl_object_id map instead of a node attribute (attributes would leak
onto the parser cache's retained ASTs), keys closure scope caches by
the closure's free variables, and exposes getClosureType() for scope
entry without a body re-walk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
prepareTarget()/applyWrite() carry the walked results of the target
chain and the assigned value on PreparedAssignTarget, so the write path
never re-prices what the walk already computed: chain-link results are
stored read-flavoured for parked rule asks, conditional-holder sentinel
comparisons go through specifyIdenticalAgainstType(), and ??= composes
through CoalesceCompositionHelper without a synthetic Coalesce walk.
The inc/dec handlers share the string/numeric type ladder in
IncDecTypeHelper and hand an explicit value result to the virtual
assign. PropertyReflectionFinder gains a variant taking the
already-known holder type so offset writes do not re-read the receiver,
and the ExistingArrayDimFetch links now reference the original,
already-processed nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The engine switch-over. MutatingScope::getType() routes handler-backed
nodes to the current storage's stored result and falls back to an
on-demand walk for synthetic nodes; specifyTypesInCondition() delegates
the same way, applySpecifiedTypes() reads tracked holders and memoized
on-demand pricings instead of calling getType(), and the scope-state
read family (getStateType()) derives narrowable expressions' types from
tracked state. NodeScopeResolver pushes a storage around every analysis
unit, consumes stored results everywhere it used to ask the scope,
narrows loop/switch/foreach scopes through the composed helpers,
flushes pending fibers only at body boundaries, and resets per-file
state through the tagged resettables. FiberNodeScopeResolver stores
full results and memoizes on-demand flush walks per file; FiberScope
answers settled stored results without a fiber switch.

TypeSpecifier is dropped from the NodeScopeResolver constructor (the
testing harness follows), precisely resolved class constants are no
longer remembered as conditional expressions, and the baseline follows
the moved code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
Every handler now expresses its type and narrowing through the
callbacks on its ExpressionResult; the interface methods have no
implementations or callers left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
The conditional-expression group scan validates the first holder and
re-prints its expression for the invalidation key instead of trusting
the group map key, and nodeKey() loses the keepVoid suffix now that
void projection happens at the value-read boundary. The native ScopeOps
twin mirrors the change and its member order is re-synced with the PHP
side; the keepVoid interned string leaves the native key printer too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f
Rules and DependencyResolver receive a node's callback and immediately ask
about the node or its subexpressions. Under fibers a pre-order callback
parks on its first ask and resumes when the natural walk stores the result
anyway - but a synchronously invoked callback (the plain resolver on
PHP < 8.1) re-walked everything it asked about through the on-demand
bridge: ~380k re-walks during self-analysis, +15% user CPU vs fibers.

Expression nodes now emit their callback right after the handler's result
is stored, and the expression-carrying statements (echo, return,
expression statements) after their expressions are processed - in both
cases with the scope captured at the entry position, so rules observe the
same (scope, answer) pair as before. Self-analysis on the plain resolver
drops from 470k to 107k on-demand walks; fibers are unchanged.
…lts are stored

Continues the previous commit for the remaining synchronous-callback
re-walk clusters: if/elseif/switch emit their statement callback right
after the condition's result is stored (rules like the constant-condition
and boolean-in-condition helpers ask about the condition), and
prepareTarget() emits the raw assignment target's callback after the walk
composed and stored the target's read result (DependencyResolver and the
property rules ask about the target and its receiver). Scopes stay
captured at the entry position. Self-analysis on the plain resolver drops
from 107k to 79k on-demand walks - 14.6k of them on real nodes, down from
380k before the two commits.
…ensions

Extends the argument priming to the two remaining lazily-invoked extension
surfaces: the dynamic static-method return type extensions dispatched for
constructors in NewHandler's exactInstantiation() (runs in the
typeCallback), and the function/method/static-method type-specifying
extensions (run at narrowing-apply time in the specifyTypesCallback). Both
can ask Scope::getType() about the call's arguments after the walk's
storage frame is no longer current; the primed storage answers those asks
from the argument results instead of re-walking on demand.

The eager surfaces (throw-type and parameter-out extensions) run during
the handler with the walk storage current and need no priming.
OutputBufferHelper priced the incremented ob_get_level() type by walking a
synthetic Plus of two TypeExprs through Scope::getType() - a core-engine
synthetic re-walk. It is now a service that calls
InitializerExprTypeResolver::getPlusType() on the operand types directly.
Two more core synthetic re-walks replaced by the logic they were fishing
for: StaticCallHandler priced `new $classExpr` through Scope::getType()
to learn what a class-string receiver instantiates - that is
getObjectTypeOrClassStringObjectType() on the receiver's own result; and
FuncCallHandler's clone-with support walked a synthetic Clone_ although
the object argument was just processed - CloneHandler's type logic is now
an extracted resolveCloneType() both call sites share.
The static-call promoted-properties check priced $this through a synthetic
Variable walk - it is a plain scope-state read. The parent-instantiation
synthetic New_ walk in exactInstantiation() stays: it re-resolves the
parent constructor's template types from the arguments, which a direct
recursion cannot - now documented at the site.
…ider

Resolving an unqualified name probes the namespaced variant first, and a
miss surfaces as a constructed-and-thrown IdentifierNotFound inside the
reflector - repeated for every re-ask of the same name. The single-pass
engine's per-flavour callbacks re-ask the same names many times per file
(2,500 exception throws while analysing ConstantArrayTypeTest alone).
The resolution is now memoized per (namespace, name as written); the key
keeps the asked case because the resolved name preserves it for the
incorrect-case rules.
The processArgs() restructure lost two things the pre-ArgsResult shape
had: the resolved acceptor was selected (and generic-resolved) for every
call although a single template-free acceptor IS the resolved acceptor -
the fast path the original selectFromArgs() took - and the
per-argument type-driven predicate re-traversed the acceptor's parameter
types on every argument instead of once per call. Restoring both cuts
GenericParametersAcceptorResolver::resolve from 5,175 to 648 calls while
analysing ConstantArrayTypeTest.
A rule asking the type of a virtual node itself (BooleanOrNode, ...)
parks its fiber - the node is never stored - and the flush walks it on
demand, hitting processExprNodeInternal()'s unhandled-expr throw and
aborting the whole file's analysis with an internal error.
MutatingScope::resolveType() already answers such nodes with mixed;
processExprOnDemand() now takes the same fallback, keeping the main
walk's throw for real source nodes.
… state

A rule callback may derive the scope it was handed - e.g. assignExpression()
pinning a call-site literal onto a parameter variable, the way callback-
analysing tooling re-analyses a callee body via the public processNodes()
API with more specific argument types. FiberScope's settled-result fast path
and post-suspend read returned the naked walk-position type, ignoring such
derivations. Both now consume through askScopeVariableStateMatches() in a
rule-facing mode: variables unknown to the asking scope and variables
narrower at the evaluation position (the coalesce right side priced on the
left's falsey branch) leave the walk answer standing; an asker-side
refinement re-prices on the asking scope's state.

MutatingScope::toFiberScope() seeds the created scope with its origin (a
WeakReference - a strong back-reference would cycle with the $fiberScope
cache and never free with GC disabled), so toMutatingScope() answers with
the walk scope itself and the guard's beforeScope identity check hits for
same-position asks.
…ad path

Old-world resolveType() ran the extension hook on every ask, both flavours.
The single-pass engine consults it in ExpressionResult::getType() but not in
getTypeOnScope() - the read an assignment fills the target's holder from -
nor in getNativeType(), and eager types short-circuited before the hook. An
extension's override (phpstan-doctrine's ReturnQueryBuilderExpressionType-
ResolverExtension rewriting a method-returned QueryBuilder into its branch
type) never entered the scope state, so every downstream chain read saw the
raw declared type. All three read paths now consult the extensions first,
positioned at the read's scope.
ondrejmirtes and others added 25 commits September 1, 2026 10:51
A for loop narrows its post-loop scope to the condition's falsey branch.
The branch read that narrowing off the condition's stored result, which
was walked BEFORE generalizeWith() widened the counter - so for a nested
loop whose counter is seeded from the enclosing counter the verdict was
stale: `$k <= $d` with the literal `$k = 0` and `$d = 0` reads as
always-true, its falsey branch is unreachable, and every operand was
narrowed to never. That never killed the enclosing loop's `$d++`, the
enclosing counter never widened, and the inner counter stayed literal on
every pass:

    for ($d = 0; $d <= $max; $d++) {
        for ($k = -$d; $k <= $d; $k += 2) { ... }   // $k: 0, should be int
    }

reported by nikic/PHP-Parser (Differ::calculateTrace(): "comparison always
true", "strict comparison between *NEVER* and 0", "unreachable statement").

The condition is now re-priced on the generalized exit scope, exactly as
WhileHandler already does. The myers-diff-loop-widening fixture had been
rewritten by the branch to expect int<0, max> for values that 2.2.x (and
the fixture's own docblock) give as (float|int) - it pinned this bug and is
restored to 2.2.x's expectations.
The scope-read fallback was documented as serving rule-facing bridge asks
whose scope carries no storage. A census over the full suite and a
self-analysis found every ask answered from the stored result (139 hits,
0 fallbacks) - the mode argument is always processed with the call. The
fallback is dead and a miss is now an invariant violation.

Of the three remaining Scope::getType() reads in the handler helpers, this
was the only one whose fallback never fires. ClosureTypeResolver's
readExprType() falls back only from MutatingScope::getType() (the
rule-facing bridge, 390 asks in the suite, none from a walk), and its
immediately-invoked-closure argument read is an ordering seam: those
arguments are walked after the closure they are passed to.
isset()/empty()/?? ensure every link of their operand non-null ahead of the
operand's walk, reading each link's current type from the scope's state.
resolveScopeStateType() had state arms for a property fetch and an
argument-less method call but none for their nullsafe forms, so a ?-> link
fell through to a getType() walk of a node that was not processed yet.

The nullsafe arms answer like the plain ones - reflection on the receiver's
state - with the short-circuit null added. Found by PHPSTAN_GUARD_NW=1
(4 of its 64 remaining violations).
PHPSTAN_GUARD_NW=1 flags every getType() on a node the walk has not stored
yet. This round closes the rule-side sites it found (64 -> 38 violations):

* Impossible-check rules read an argument through the call's ArgsResult,
  carried on the call's ExpressionResult and on the FunctionCall/MethodCall/
  StaticMethodCall expression nodes. A narrowed variable an argument ASSIGNS
  (`is_string($data = json_encode($data))`, chained too) is answered from
  the assignment result's own type - a re-pricing of the variable on the
  callback scope is exactly the read the guard forbids. The
  doNotTreatPhpDocTypesAsCertain() re-check passes the same ArgsResult.
* The @var-changed-type node is emitted AFTER the statement handler walked
  the expression (return / throw), on the scope from before the tag re-typed
  it - the rule compares the tag against the walked type. The type gate that
  used to read the expression ahead of its walk is gone: the rule decides.
* A static variable's constant default and compact()'s literal names are
  position-independent and priced through the initializer resolver;
  a variable-variable's name reads scope state by name.
* declare() values are processed before their callback fires.

The count() mode read and the closure-argument walk of a nullsafe call's
twin landed separately (4bf2a51, 9921952, 49d7f80).
A string-named variable read answers from scope state and a literal is a
constant - neither needs the node walked, so a rule asking about them
ahead of the walk (an assign-op target, a $$name name, a list key) is not
the on-demand pricing the guard exists to catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
An immediately invoked closure's or a pipe's literal operand is walked
only after the callee whose parameters it types. A literal is
position-independent, so the scope prices it without a walk instead of
processing the node ahead of its turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
InvalidKeyInArrayItemRule reads the key's type from the item node; firing
the callback after the key walk lets it consume the stored key result
instead of pricing the node ahead of its walk - the same order the
literal-array handler uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
PHP hands out the spl_object_id() of a freed object again. The file's own
AST nodes live for the whole analysis, but a synthetic node built and
dropped mid-file frees its id, and the next node allocated may get it:
ClosureTypeResolver then answered an arrow function's cached generator
return type for an unrelated closure sharing the id
(ExpressionResultTest #23 under the paratest wrapper, where the arrow
function of an earlier data set had been freed). Each entry now pins the
node it was built for and answers for that very node only; the ternary
and match capture maps keyed the same way get the same check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
…etion

isset()/empty()/?? ensure the links of their operand's chain non-null
before walking it. A link the scope cannot price from its state - a call
with arguments, a ternary, a fetch spine over one - had no type ahead of
the walk, so the ensure priced the node on demand: a second walk of a
real node, and the guard's last non-nullability site.

Such a link is now registered as pending on the ensure frame and deviced
when its own walk completes, from the type that walk produced: the
result's after-scope tracks it as non-null and its value is pinned to the
ensured type, which is what a link walked on an ensured-ahead scope
answered. The late device joins the frame's originals for the nullsafe
handlers and is reverted with the frame. Links the scope does price from
state (variables, fetch spines, argument-less instance calls, constants)
keep the ahead-of-walk device; receivers that are never null (new, array
and closure literals, scalars) skip the ensure altogether.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
An immediately invoked closure's untyped parameters take the types of
its invocation arguments, which the closure type resolver read from the
scope ahead of their walk - the last processSyntheticOnDemand() seam
under the new-world guard (and, for the pipe operator, the callee was
evaluated before its operand, unlike PHP).

The call handler now walks the arguments first, on the closure's
declared signature (ClosureTypeResolver::getDeclaredClosureType(), the
signature without invocation inference or a body walk), then the
closure, whose parameter inference consumes the stored argument results;
the call resolves from the walked closure's acceptor over the same
processed arguments (ArgsResult::withResolvedParametersAcceptor()). The
closure is thereby walked on the post-argument scope: a by-value use of
a variable an argument assigns sees the assigned value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
`$x = $this->foo(function () use (&$x) {...})` types the by-ref use from
the call being assigned - a forward reference read from inside the call's
own arguments, which priced the enclosing call on demand (a nested walk
of the closure itself). The call handlers now hand the context the
declared return type of the acceptor the call was normalized with
(template types resolved to their bounds) before processing the
arguments, and the by-ref use reads it; a closure right side still
resolves through the closure type resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
…e read

resolveScopeStateType() prices an argument-less instance call from the
declared return type of its method - the shape @phpstan-assert subjects
take, synthetic nodes never stored. The arm also caught calls the walk
had processed and stored, and the declared type lacks what the walk
resolved against the arguments: a conditional return type stays a
conditional (`($asResource is true ? resource : string)` narrowed truthy
became `resource|non-falsy-string` instead of `non-falsy-string`, and
getKeepVoidType() no longer saw the `void` a `($callback is null ? void
: TReturn)` call resolves to, losing the method.void report), a template
stays a template, and on the PHP 7.4 downgrade a native `self|false`
union resolved its `false` as a class in the narrowing base.

A call with a stored result now answers through getType(), i.e. from
that result; the reflection route stays for the synthetic subjects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
askScopeVariableStateMatches() traversed the expression's whole subtree
per result - O(depth) per chain link, recreated every loop-convergence
pass. On tests/bench/data/nullsafe-chain-walk.php the traversal was 43%
of the run (NodeTraverser::traverseNode 22x the self-cost of 2.2.x).

The names are pure syntax, so they cache on the node as an attribute
(sharing the node's lifetime) and each link composes its set from its
child's cached set in O(1) amortized. Restores a27ece6, dropped with
the consume/splice arc removal; the read set keeps the current semantics
(variables only, $this excluded, closure use() names only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7pqAkCx4xP6WYxBo2nMJE
Dead stores found by the upcoming unused-variable check: null
initialisations overwritten on every path, unused foreach values and
destructured items, an assignment from the never-returning fail(), and
catch variables that are never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Every source-level write of a local variable (plain and compound
assignment, inc/dec, array-offset write on an array or string, list()
item, foreach value/key, catch variable) becomes a VariableWrite with a
VariableWrittenExpr marker in the scope. A new write of the same
variable kills the earlier markers explicitly; merges keep them as Maybe,
so at any point the markers say which writes still reach it. The one
source-level read of a variable (VariableHandler) records the reaching
writes as read in an immutable per-function-like VariableWritesFrame
held by NodeScopeResolver; compact(), get_defined_vars(), extract(),
eval and include read everything, goto makes the frame opaque, and
by-ref parameters/uses, global, static and reference aliases are
untracked. The frame is emitted as a VariableWritesNode after each
ReturnStatementsNode.

generalizeWith() now carries markers planted only by the newer loop
pass, and createConditionalExpressions() no longer records
certainty-No conditionals for virtual nodes (a later narrowing could
otherwise erase a marker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
…e never read

Level 4, behind the unusedVariable bleeding-edge toggle. A write is
reported when no path from it reaches a read of the written value -
including writes overwritten before being read and writes on only one
branch. Catch variables are reported only where non-capturing catches
exist; $_-prefixed names are exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
A nullsafe call's plain twin re-enters the already-walked receiver in
consume-stored mode; its arguments are walked there for the first time,
so their variable reads are genuine and must not be treated like
on-demand synthetic pricing. Found on slevomat: every variable read only
inside the arguments of $x?->m(...) was reported as never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Reads are recorded on walk scopes, never on a promoted one, so the
marker is only needed in the phpDoc-typed map; keeping it out of the
native map halves its share of every merge, generalization, equality
check and invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
So that CI and the downstream dogfooding projects surface false positives
before the rule ships behind bleeding edge only. Revert before merging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Closes phpstan/phpstan#12789

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Closes phpstan/phpstan#12012

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Closes phpstan/phpstan#11483

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
Closes phpstan/phpstan#10202

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy
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.

1 participant