PHPantom — Bug Fixes¶
Every bug below must be fixed at its root cause. "Detect the symptom and suppress the diagnostic" is not an acceptable fix. If the type resolution pipeline produces wrong data, fix the pipeline so it produces correct data. Downstream consumers (diagnostics, hover, completion, definition) should never need to second-guess upstream output.
Each entry below carries an Impact · Complexity rating using the same
scale defined in docs/todo.md; that table is also where
each bug's row lives in the current sprint/backlog.
Crashes¶
No outstanding items.
Type comparison¶
No outstanding items.
Standard-library return types¶
B227. The replace function family's conditional return type never collapses¶
Impact: Medium-High · Complexity: Low-Medium
str_replace, str_ireplace, substr_replace, preg_replace,
preg_replace_callback, preg_replace_callback_array, and preg_filter
are patched in stub_patches.rs (patch_replace_family) with a
conditional return type keyed on $subject: array in, array out;
string in, string out. In practice the conditional never resolves to a
single branch — it always falls back to the full string|array<string>
union, even when $subject is a plain, unambiguous string local or a
string literal:
function relative(string $filename): string
{
return str_replace('\\', '/', $filename); // string|array<string>, not string
}
By contrast, other functions patched the same way but keyed on an
"is string" discriminant (range(), keyed on $start) resolve
correctly. The "is array" discriminant is the common thread across
every failing case (patch_range uses PhpType::named(atom("string"))
as its condition; patch_replace_family uses PhpType::array()), which
points at condition_category/type_category in
type_engine/types/conditional.rs or the conditional-evaluation
dispatch in type_engine/types/narrowing/assertions.rs
(evaluate_conditional_for) mishandling an array discriminant
specifically. Confirmed via minimal repro with a string variable, a
string literal, and a plain array variable as $subject — all three
return the unresolved union instead of picking a branch.
Real-world hits: any str_replace()/str_ireplace() call whose result
feeds a string-typed parameter or return raises a false
type_mismatch_argument/type_mismatch_return.
Narrowing¶
B228. A condition-established narrowing of a method-call expression doesn't survive assignment, elseif siblings, or loop merges¶
Impact: High · Complexity: Very High
@phpstan-assert-if-true/-if-false tags whose subject is a method-call
expression off the receiver ($this->getFoo(), written $this->getFoo()
in the tag) narrow correctly for a direct re-evaluation of that same
call right after the guard:
if (!$scope->isInClass()) {
return;
}
$scope->getClassReflection()->isFinal(); // fine: direct re-evaluation
But the narrowing is lost as soon as the guarded fact has to survive anything beyond that:
- Assigned to a variable, then the variable used elsewhere:
(
if (!$scope->isInClass()) { return; } $classReflection = $scope->getClassReflection(); // stored type is still ?ClassReflection $this->consistentConstructorHelper->findConsistentConstructor($classReflection); // false type_mismatch_argumentsrc/Rules/Classes/InstantiationRule.php:153, and the$declaringClass/$parametersAcceptor/$scopeclusters throughouttype_mismatch_argument.) - A sibling
elseif's condition also checks the same guard method, which corrupts the fact for a later, unrelated branch (src/Analyser/ExprHandler/InstanceofHandler.php:140,142). - A
foreach+continueguard (if (!$scope->isInClass()) { continue; }) loses the fact for the rest of the loop body (src/Rules/Api/NodeConnectingVisitorAttributesRule.php:62,src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php:88,src/Rules/Properties/ReadOnlyPropertyAssignRule.php:78,81). - A plain (non-assert-if-true)
=== null+continueguard on a repeated method call, once nested inside an outer loop with extra loop-scoped state, loses the fact the same way (src/Type/Php/BackedEnumFromMethodDynamicReturnTypeExtension.php:67). - The same shape recurs, unconfirmed in isolation but structurally
identical to the above, in
src/Analyser/NodeScopeResolver.php:1103,1112,1116($finalScope === null→ assign →continue, later!== null→ use).
The mechanism that establishes these facts is the synthetic-key scope
tracking in type_engine/variable/forward_walk/cond_narrowing.rs
(apply_assertion_to_key, seed_synthetic_key_if_needed, keyed by a
string like $scope->getClassReflection()). Direct re-evaluation of the
same call expression clearly consults this key (the first example
works), but the general-purpose RHS-assignment path
(type_engine/variable/rhs_resolution/) computes a method call's type
from the raw signature only and never checks whether the call's printed
form matches a currently-narrowed synthetic key — and whatever merges
scope state at branch/loop boundaries in forward_walk/control_flow.rs
doesn't carry synthetic keys across the merge either. Fixing the
assignment path and the merge path are likely two separate, but
related, changes.
B229. instanceof narrowing is not recognized for several common condition shapes¶
Impact: High · Complexity: High
Plain $var instanceof Foo narrowing works for the simple case (a bare
local variable, tested directly in an if's top-level condition), but
several very common variations on the same idiom are not recognized —
this is the dominant false-positive cluster in unknown_member (60+ of
105 diagnostics):
- A right-hand
&&clause inside anelseifcondition doesn't see theinstanceofon its left: (src/Analyser/ExprHandler/AssignHandler.php:167,src/Analyser/ExprHandler/MatchHandler.php:279,src/PhpDoc/TypeNodeResolver.php:786,src/Type/FileTypeMapper.php:467.) - An array-index expression as the
instanceofsubject ($types[$i] instanceof MixedType && !$types[$i]->isExplicitMixed()), even inside a plainifand across statements in the body (src/Type/TypeCombinator.php:251-297— 6 lines,src/Type/Constant/ConstantArrayType.php:2567,3503,src/Type/Generic/GenericObjectType.php:323). - A ternary condition
(
$paramType instanceof UnionType ? $paramType->getTypes() : [...]) (src/Reflection/GenericParametersAcceptorResolver.php:176). $this, once the enclosing class implements any interface (confirmed: identical code is fine with noimplements, breaks the moment one is added) (src/Type/ValueOfType.php:59,src/Type/ObjectType.php:744).
Likely home: type_engine/variable/forward_walk/ — the condition
narrowing appears to special-case a plain if over a simple local
variable subject and not generalize to ElseIf_/Ternary/array-index/
property-fetch subjects the way real PHPStan's TypeSpecifier does.
B230. $a === $b doesn't narrow the nullable side against a definite-type operand¶
Impact: Medium · Complexity: Medium
$inAssignRightSideVariableName = $context->getInAssignRightSideVariableName(); // ?string
// ...
if ($inAssignRightSideVariableName === $use->var->name) { // $use->var->name is string
$scope->hasVariableType($inAssignRightSideVariableName); // false: expects string, got ?string
(src/Analyser/NodeScopeResolver.php:3080-3098.) For === to hold, both
operands must share a value, so a nullable variable compared identical
to a definite non-null value narrows to that non-null type within the
truthy branch — real PHPStan does this and is silent on this file.
type_engine/variable/forward_walk/cond_narrowing.rs has specific
handling for $x === null/$x == null but nothing for $x === $y
where $y's type is independently known to exclude null.
B231. array_key_exists() doesn't narrow an optional array-shape key the way isset() does¶
Impact: Medium · Complexity: Medium
/** @param array{a?: array<int,string>} $shape */
function get(array $shape): array
{
if (isset($shape['a'])) {
return $shape['a']; // fine: array<int,string>
}
if (array_key_exists('a', $shape)) {
return $shape['a']; // false type_mismatch_return: ?array<int,string> vs array
}
return [];
}
Confirmed against src/File/FileExcluderFactory.php:37,47
(array_key_exists('analyse', $this->excludePaths) guarding
$this->excludePaths['analyse'], an optional key in a promoted
property's docblock shape) and reproduced standalone for both local
variables and properties, so it isn't a property-vs-local-var
distinction — array_key_exists on an optional shape key simply isn't
wired into the same narrowing isset() already gets.
B232. use (&$var) doesn't establish $var as defined when it's new to the enclosing scope¶
Impact: Medium · Complexity: Medium
$linesOfCode = 0; // <- if this line didn't exist:
$counter = function () use (&$linesOfCode): void {
$linesOfCode++; // by-ref use auto-vivifies $linesOfCode as null
};
PHP auto-vivifies a by-reference use capture that doesn't already
exist in the outer scope (as null), which is legal and is exactly
src/Analyser/NodeScopeResolver.php:3666 ($hookImpurePoints) and
src/Command/AnalyseApplication.php:272 ($linesOfCode) — both are
only ever assigned inside the closure body. PHPantom's forward walker
reports "Undefined variable" at the use clause instead of treating it
as a declaration. Home: type_engine/variable/forward_walk/, wherever
closure use clauses are processed.
B233. isset($var) && … doesn't mark a possibly-undefined variable as defined for the rest of the branch¶
Impact: Medium · Complexity: Medium
if (isset($tokenType) && $tokenType !== IgnoreLexer::TOKEN_WHITESPACE) {
$lastTokenTypeLabel = $this->tokenLabel($tokenType); // false "Undefined variable"
}
(src/Parser/RichParser.php:308-309 — $tokenType is only assigned
starting on the second iteration of the enclosing loop, so on the first
pass it's genuinely possibly-undefined outside the guard, but
isset($tokenType) being true in the truthy branch guarantees it's
defined for the rest of that branch, which real PHPStan accounts for
and PHPantom does not.)
B234. is_float()/is_int()'s negative branch doesn't strip a member from a declared union¶
Impact: Medium · Complexity: Medium
/** @var int|float $newAutoIndex */
$newAutoIndex = $offsetValue + 1;
if (is_float($newAutoIndex)) {
// ...
} elseif (!$optional) {
$this->nextAutoIndexes = [$newAutoIndex]; // false: expects list<int>, got array{int|float}
(src/Type/Constant/ConstantArrayTypeBuilder.php:235-242.) Real PHPStan
narrows $newAutoIndex to plain int inside the is_float() check's
negative branch (elseif); PHPantom leaves the full int|float union
in place, which then leaks into the array-literal element type.
B235. A lost chained-call re-narrowing misattributes the member lookup to the enclosing file's own class¶
Impact: Medium-High · Complexity: Medium-High
When a !== null check on a method-call chain
($scope->getParentScope() !== null) is reused later without the
chain's result being cached, PHPantom doesn't just fail to narrow — it
misattributes the member lookup to the current file's own class,
producing a diagnostic that names a completely unrelated class:
for code that never mentions ReproChainReuse at all. Confirmed at
src/Node/ClassPropertiesNode.php:246 and
src/Reflection/BetterReflection/BetterReflectionProvider.php:170. Same
narrowing subsystem as B228, but called out separately because the
symptom actively misleads about which class is missing the member,
not just that one is.
B236. instanceof $variable (a class-string held in a variable) narrowing is unsupported¶
Impact: Medium · Complexity: Medium-High
/** @param class-string<Continue_>|class-string<Break_> $stmtClass */
function f(Node $statement, string $stmtClass): void
{
if (!$statement instanceof $stmtClass) {
continue;
}
$statement->num; // unknown_member: PHPantom never narrowed $statement at all
}
(src/Analyser/StatementResult.php:95,
src/Analyser/InternalStatementResult.php:106.) Only instanceof with
a literal class name appears to be handled; a variable holding a
class-string<T> as the right-hand side isn't matched at all, so the
guard has no effect. Likely home: type_engine/resolver/'s
instanceof handling.
B237. A vendor interface's own @phpstan-assert-if-true Type $this tag isn't honored¶
Impact: Medium · Complexity: Medium
// PHPUnit\Event\Code\Test, declared in the vendor package itself:
// @phpstan-assert-if-true TestMethod $this
public function isTestMethod(): bool;
if (!$test->isTestMethod()) {
return;
}
$test->className(); // unknown_member: $test not narrowed to TestMethod
(src/Testing/PHPUnit/InitContainerBeforeTestSubscriber.php:23.) Unlike
PHPStan\Analyser\Scope::isInClass() (B228's example), this tag isn't
missing from the vendor source — PHPUnit declares it directly on its
own interface — so this isn't the same gap stub_patches.rs's
THIRD_PARTY_ASSERT_IF_TRUE list works around. The general
@phpstan-assert-if-true call-site mechanism (documented in
docs/todo/type-inference.md) doesn't appear to apply to a $this-target
tag declared on a class outside the project/vendor scan root, or
there's a narrower gap in how this specific interface gets its
docblock tags parsed; not fully root-caused.
Arithmetic¶
No outstanding items.
Symbol resolution¶
B238. new self(...)/new static(...) resolves by short name, colliding with a same-named global class¶
Impact: High · Complexity: Medium
namespace PHPStan\Analyser;
final class Error implements JsonSerializable
{
public function __construct(private string $message, private ?int $line = null) {}
public function changeFilePath(string $newFilePath): self
{
return new self($this->message, $this->line); // resolves to the built-in \Error!
}
}
type_engine/variable/rhs_resolution/instantiation.rs
(resolve_rhs_instantiation, around line 26-33) resolves self/static
via ctx.current_class.name.to_string() — the class's short name —
instead of ctx.current_class.fqn(). Every other branch of the same
match resolves a written class name through
crate::util::resolve_source_class_name (namespace-aware). Since
PHPStan\Analyser\Error shares its short name with the built-in
\Error, new self(...) inside it misresolves to the global class,
producing false type_mismatch_argument/type_mismatch_return on
every constructor call and every method that returns new self(...)
(confirmed at src/Analyser/Error.php — 8 call sites × 2 argument
positions, plus 10 type_mismatch_return hits across its fluent
changeXxx() methods). Any user class whose short name collides with a
PHP built-in (or any other loaded class) is affected, not just this one.
B239. $this inside a trait method resolves to the trait itself, not the using class¶
Impact: High · Complexity: Medium-High
trait TemplateTypeTrait
{
public function getTypeWithoutSubtractedType(): Type
{
// ...
return $this; // Return type <TheTrait> is incompatible with declared return type Type
}
}
(src/Type/Generic/TemplateTypeTrait.php:140 and 20+ similar sites
across src/Type/Traits/*.php.) $this inside a trait method that
returns $this typed as an interface/abstract type the trait doesn't
itself implement should resolve to the class the trait is mixed into
(late static binding through traits), not to the trait as a
pseudo-class. This is the classic "trait $this not resolved" gap;
minimal repro confirms it reproduces with a bare trait/use/return
$this with no other machinery involved.
B240. static return type on an intersection-typed receiver drops the non-declaring interface members¶
Impact: Medium · Complexity: Medium
interface IfaceA { /** @return static */ public function filter(): self; }
interface IfaceB { public function ifaceBMethod(): void; }
function needsBoth(IfaceA&IfaceB $x): void {}
function test(IfaceA&IfaceB $scope): void
{
$filtered = $scope->filter();
needsBoth($filtered); // false: expects IfaceA&IfaceB, got IfaceA
}
Confirmed minimal repro, and live in phpstan-src at
src/Rules/Methods/CallMethodsRule.php:55 /
CallStaticMethodsRule.php:56 (Scope&NodeCallbackInvoker&CollectedDataEmitter,
narrowed via $scope->filterByTruthyValue(...) which is @return
static). static should resolve to the receiver's full statically-known
type — here the whole intersection — but instead resolves only to
whichever single interface declares the called method.
B241. An abstract class isn't accepted where a union of exactly its known subclasses is declared¶
Impact: Low · Complexity: High
// PhpParser\Node\Stmt\ClassLike is abstract; Class_/Interface_/Trait_/Enum_
// are its only subclasses in the known universe.
private function createAstClassReflection(Node\Stmt\ClassLike $stmt, ...): ClassReflection
{
// ...
$nodeToReflection->__invoke($this->reflector, $stmt, ...);
// $node param declared: Class_|Interface_|Trait_|Enum_|Function_|Closure|ArrowFunction|Const_|FuncCall
// false: ClassLike does not satisfy that union
}
(src/Analyser/NodeScopeResolver.php:2794 and three
BetterReflection/SourceLocator/*.php sites.) Real PHPStan is silent —
it appears to reason that ClassLike's only subclasses are exactly the
four named in the union, so a ClassLike-typed value is exhaustively
covered. Reproducing this "sealed hierarchy" exhaustiveness check would
need enumerating every known subclass of an abstract type across the
whole project + vendor at check time; rare enough in practice
(PhpParser\Node\Stmt\ClassLike may be the only real-world instance
seen so far) that it's filed for completeness rather than urgency.
B242. Composer\Autoload\ClassLoader is invisible to class resolution¶
Impact: Medium · Complexity: Medium
Every Composer-managed project has this class available via
vendor/composer/ClassLoader.php, but it's loaded through a
hand-written spl_autoload_register bootstrap in
vendor/composer/autoload_real.php (loadClassLoader() →
require __DIR__ . '/ClassLoader.php'), not through
autoload_classmap.php or any package's psr-4/psr-0 map. The vendor
classmap scanner (classmap_scanner/, composer.rs) has no special
case for this universal bootstrap file, so Composer\Autoload\ClassLoader
resolves as unknown_class on any project that references it directly
— a fairly common pattern for code that introspects its own autoloader,
as phpstan-src does at src/Testing/TestCaseSourceLocatorFactory.php:55,56
and src/autoloadFunctions.php:62,73. Since vendor/composer/ClassLoader.php
is present verbatim in every Composer install regardless of declared
dependencies, this would recur on any Composer project, not just this
one.
B243. Two more confirmed unknown_member false positives with an unidentified mechanism¶
Impact: Low-Medium · Complexity: Unknown
Two additional confirmed-real false positives surfaced during the
unknown_member sweep whose exact cause wasn't pinned down:
Nette\Neon\Exception::getMessage()not found (src/DependencyInjection/NeonAdapter.php:57,NeonCachedFileReader.php:45), despite the class extending\Exception— an identical hand-written class in isolation resolves fine, so it's specific to something about this vendor path (possibly a classmap/PSR-4 interaction in a monorepo with many nestedcomposer.jsonfiles), notextends \Exceptionin general.PHPStan\Analyser\Scope::mergeWith()not found (src/Analyser/NodeScopeResolver.php:5404,5412and 6 similar lines).Scopegenuinely has nomergeWith(onlyMutatingScopedoes), and PHPantom's owndumpType-equivalent confirms it believes the expression's static type is bareScope— yet real PHPStan raises no error on the call. No stub, mixin, or reflection extension explaining why real PHPStan accepts this was found; filed as a confirmed false positive without a full explanation of the real-PHPStan side.
B218. new ReflectionProperty(Foo::class, 'bar') forgets what it reflects¶
Impact: Low · Complexity: Medium
A reflection value built by ReflectionClass::getProperty('bar') carries
the class and the property name, so reading it types as the property
declares. Constructing the same value directly does not:
$viaClass = (new \ReflectionClass(Configuration::class))->getProperty('shell');
$viaClass->getValue($config); // ?Shell
$direct = new \ReflectionProperty(Configuration::class, 'shell');
$direct->getValue($config); // mixed
The two spellings are interchangeable in real code, so the second should
resolve like the first. The binding cannot come from the constructor's
docblock: class-string<T>|T $class would bind the class through the
existing machinery, but the $property name is a string literal, and a
literal only binds to a @template whose bound is a type operator
(key-of<…> and friends). Either the two new-expression resolution
paths need the same rule the two call paths got, or literal binding has
to be widened to a @template TName of string, which is what PHPStan
does for literal string types and would want measuring against the whole
corpus first.
B226. A function-static variable's type is not tracked across its own reads¶
Impact: Low-Medium · Complexity: Medium-High
type_engine/variable/forward_walk/ has no handling at all for a
static $var; declaration (there is no StaticVariable case anywhere
under it); the walker treats the name as an ordinary, unassigned local
until it sees an assignment to it in the same top-to-bottom pass. That
loses the one thing a static local actually means: its value can carry
over from an earlier call that assigned it in a branch the current
call never reaches.
function info(?Configuration $config = null) {
static $lastConfig;
if ($config !== null) {
$lastConfig = $config;
return null;
}
$config = $lastConfig ?: new Configuration();
// $shell::VERSION below needs $config resolved to Configuration for
// Sudo::fetchProperty($config, 'shell') to type as ?Shell (the
// pass-through accessor inference already handles that part).
$shell = Sudo::fetchProperty($config, 'shell');
if ($shell) {
$shellInfo = ['PsySH version' => $shell::VERSION];
}
}
On the call that falls through to the second half, $lastConfig is read
without ever having been assigned within this walk, so $config
resolves too conservatively for the accessor pass-through (see the
ReflectionProperty/Sudo::fetchProperty inference above) to carry
Configuration::$shell's declared type through to $shell, and
$shell::VERSION cannot be resolved. Found via
php-typing-conformance's LSP navigation probe against psysh
(Psy\Shell::VERSION, src/functions.php:383): find-references reports
20 of 21 known references, missing exactly this one; Intelephense and
Phpactor miss the same reference, but DEVSENSE resolves it, which is
worth chasing. The same gap also degrades hover and inferred types
wherever code narrows on a static local this way, not just
find-references. A correct fix needs to seed a
static $var's type from the union of every assignment reachable
anywhere in the enclosing function body (not only the ones preceding the
read in this pass), since the assignment that matters can sit in a
branch this call never takes.
Array types¶
B246. array_keys() on an array whose keys the + operator merged¶
Impact: Low · Complexity: Medium
/** @param array<string, Holder> $a
* @param array<string, Holder> $b */
function f(array $a, array $b): void
{
foreach (array_keys($a + $b) as $key) {
// $key: array-key, should be string
}
}
(src/Analyser/VolatileExpressionHelper.php:94-98.) Assigning the same
expression first ($c = $a + $b;) and calling array_keys($c) narrows
correctly, so the array-union operator itself is understood. The gap is in
the text-based argument resolver
(resolve_arg_iterable_raw_type / Backend::resolve_arg_text_to_type in
type_engine/variable/rhs_resolution/calls.rs and
type_engine/call_resolution/): it reads an argument written as source
text, and resolve_operator_type there knows . and ?: but not +,
while the class-walk resolver behind it only reports class-backed results
and so answers nothing for an array. Splitting + in that text resolver
would duplicate what the AST walker already computes correctly, so the
real fix is to give the argument path access to the walker's answer
rather than to teach the text path a second set of operator rules.
B255. An array shape key spelled with a backslash widens the shape's key type to int|string¶
Impact: Low · Complexity: Medium
$replacements = ['~\n~' => '|n', '~\r~' => '|r'];
foreach (array_keys($replacements) as $key) {
// $key: int|string, should be string
}
(src/Command/ErrorFormatter/TeamcityErrorFormatter.php:121-126.)
iterable_key_type in php_type/mod.rs widens any shape key containing a
backslash to int|string, because the parser stores a shape key in its
escape spelling rather than its decoded runtime value, and a
double-quoted "\x38" really does decode to the integer key 8. A
single-quoted key decodes nothing of the sort, so the widening is wrong
for it, but the stored key no longer records which quote style it came
from. The fix is to decode the key at parse time (or record the quote
style alongside it) so is_decimal_int_array_key can be asked about the
runtime key, after which the backslash special case can go entirely.
B256. instanceof does not narrow an array element ($types[$i])¶
Impact: Medium-High · Complexity: Medium
/** @param list<Ty> $types */
function f(array $types): void
{
for ($i = 0; $i < count($types); $i++) {
if ($types[$i] instanceof ShapeTy) {
echo $types[$i]->propName(); // false unknown_member on Ty
}
}
}
The narrowing engine keys a narrowed type by its subject, and an
array-dim-fetch subject is not one of them: the guard is recorded but
never read back, so the element keeps the array's declared element type
inside the branch. Narrowing the same value through a local
($t = $types[$i]; if ($t instanceof ShapeTy)) or a foreach binding
works, which is the shape most code takes and why this stayed hidden.
It surfaced as ~17 false unknown_member reports across
src/Type/TypeCombinator.php:1791-2063 the moment the elements of
$types resolved to a real union instead of to nothing. PHPStan and
Psalm both narrow a constant offset this way.
B257. A closure parameter binds the whole container when the argument is a union of array shapes¶
Impact: Medium · Complexity: Medium
$expected = $cond ? [self::TOKEN_A] : [self::TOKEN_A, self::TOKEN_B];
array_map(fn ($token) => $this->lexer->getLabel($token), $expected);
// $token: array{3}|array{4, 2}, should be int
(src/Parser/RichParser.php:311-316.) The ArrayElement template binding
in build_function_template_subs
(type_engine/variable/rhs_resolution/calls.rs) reads the element type
with extract_value_type, which answers nothing for a union of
array{…} shapes; the !resolved_type.is_array_like() fallback beside it
then binds the template to the container itself, so the callback's
parameter is typed as the array rather than as one of its elements. Two
things need fixing: extract_value_type should join the element types of
a shape union the way iterable_element_type already does, and the
fallback should decline rather than bind a container it could not read an
element out of.
Docblock handling¶
No outstanding items.
Miscellaneous¶
B251. A closure's by-reference use mutation isn't tracked when the closure is invoked indirectly¶
Impact: Medium · Complexity: Medium-High
$constructorResult = null;
$nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, new GatheringNodeCallback(
static function (Node $node, ...) use (&$constructorResult): void {
$constructorResult = $node;
},
$nodeCallback,
), ...);
if ($constructorResult !== null) {
$constructorResult->getStatementResult(); // still typed null past the guard
(src/Analyser/ExprHandler/NewHandler.php:153,154.) A directly-invoked
use (&$x) closure ($callback($arg)) is tracked fine; wrapping the
same closure in an object and invoking it through a separate method
call (mirroring GatheringNodeCallback's invoke()) loses the
reference mutation entirely, so $constructorResult's type never
updates and stays "null" even past the !== null guard.
B252. ??= doesn't route its RHS through the callable-property-invocation resolution path¶
Impact: Medium · Complexity: Medium
/** @var callable(): MutatingScope $leftTruthyScope */
private $leftTruthyScope;
$leftTruthyScope = null;
$leftTruthyScope ??= ($this->leftTruthyScope)();
$leftTruthyScope->getType($targetExpr); // false: method call on null
(src/Analyser/DisjunctionHolderProjectionAugment.php:78,84.) Isolated
down to the exact failing ingredient: a direct call ($this->prop)()
alone resolves fine, $x ??= new Foo() alone resolves fine, only
$x ??= ($this->callableProp)() fails — the ??= handler doesn't
reuse the same resolution path a bare invocation of a callable-typed
property already gets right.
B253. A closure parameter's type isn't inferred from a @template-parameterized callable(T): X signature¶
Impact: Medium-High · Complexity: High
/** @template T @param T $node @param callable(T): void $fn */
function fixNode($node, callable $fn): void { $fn($node); }
fixNode($property, function ($property) {
$property->isReadOnly(); // $property's inferred type is unresolved/mismatched, not the bound T
});
When a closure is passed to a method whose docblock is
@param callable(T): X $fn with T bound from the call-site argument,
real PHPStan infers the closure's own parameter type from T (even
overriding a wider explicit hint); PHPantom leaves it as the literal
template name or the declared hint. Explains every unknown_member
"subject type 'TNode'/'TValue' could not be resolved" hit:
src/Parser/LastConditionVisitor.php:91,
src/Rules/Properties/OverridingPropertyRule.php:68,104,
build/PHPStan/Build/NamedArgumentsRule.php:183,232,
build/PHPStan/Build/FinalClassRule.php:77,
src/Reflection/Type/IntersectionTypeMethodReflection.php:240. Likely
home: type_engine/'s generic/template substitution for
callable-type parameters.
B254. Unused-variable check doesn't account for a closure's use() capture feeding a required file's scope¶
Impact: Low · Complexity: Medium
(static function (string $file) use ($container): void {
require_once $file; // the required file executes in this closure's own variable scope
})($file);
(src/Command/CommandHelper.php:642,
src/Testing/PHPStanTestCaseTrait.php:49 — both flag $container as
unused.) $container is never referenced by name inside the closure
body, but require_once runs the target file's code inside the
closure's own scope, so the required file can legitimately read
$container as a local variable — a known idiom for exposing a
variable to dynamically-included code without leaking the rest of the
enclosing scope. The unused_variable check has no notion of "used via
a same-scope require/include" at all. (Neither bundled bootstrap
file in phpstan-src currently reads $container back, so these two
specific instances may or may not be live in practice — but the
checker's blind spot is real regardless of this instance's outcome.)