Report values assigned to variables that are never read - #6330
Open
ondrejmirtes wants to merge 101 commits into
Open
Report values assigned to variables that are never read#6330ondrejmirtes wants to merge 101 commits into
ondrejmirtes wants to merge 101 commits into
Conversation
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.
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
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
September 2, 2026 13:15
2aca59b to
00f2b74
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.
Reports local variables whose assigned value is never read afterwards on any path (
variable.unused, level 4, behind the newunusedVariablebleeding-edge toggle).Temporarily enable unusedVariable for everyonebefore 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 andif (c) { $a = 1; } return;reports the write, whilefor ($i = 0; $i < 3; $i++), back-edge reads in loops,$x[] = ...; return $x;etc. do not.list()item,foreachvalue/key, catch variable) becomes an immutableVariableWritewith aVariableWrittenExprmarker 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.VariableHandler) records the reaching writes as read in an immutable, persistentVariableWritesFrame(one per function-like body;NodeScopeResolverholds the stack and swaps the top after eachwith*()transition; arrow functions share the enclosing frame;processNodesisolates the stack).compact(),get_defined_vars(),extract(),eval,includeread everything;gotomakes the frame opaque; by-ref parameters/uses,global,staticand reference aliases are untracked.VariableWritesNodeafter each*ReturnStatementsNode;UnusedVariableRulereports 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 stillnull),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/andtests/.Verification
make testsgreen turbo-off and turbo-on,make phpstanclean in both modes, cs/lint clean.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-falseis_int()guard, impossibleisset(), dead catch) — left as is, consistent with the existing*.alwaysFalse/catch.neverThrowndiagnostics.src/Typealone the tracking is ≈ 3.7 % (reads ≈ 2.5 %, scope markers ≈ 1–2.7 %).Follow-ups (not in this PR)
$b = $b + 1chains that never reach a sink): reads in pure RHS positions become dependency edges between writes, the rule computes a fixpoint.$a = ['a' => 1, 'b' => 2]with only$a['b']read) — bounded per-offset sub-writes.$x = 1; unset($x);(unset()walks the variable as a read).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