Skip to content

Commit 897ba24

Browse files
ondrejmirtesclaude
andcommitted
Merge branch 2.2.x into 2.3.x
getReturnTypeWithUnresolvedTemplateArguments(), which only exists on 2.3.x, narrows the template types in conditional types for parameters too, the same way getReturnTypeWithUnresolvableTemplateTypes() does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QU5puXLGJ7aMwLbyhvFkCu
2 parents e9379e6 + e88417e commit 897ba24

14 files changed

Lines changed: 846 additions & 57 deletions

.github/workflows/e2e-tests.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,6 +1749,16 @@ jobs:
17491749
# the two dumps are the only errors: count the file:line:message lines
17501750
../bashunit -a equals '2' "$(echo "$OUTPUT" | grep -Ec '^/.+:[0-9]+:')"
17511751
1752+
- name: "Verify conditional types leave memoized types equal to the argument alone"
1753+
# With the extension active TypeCombinator's results are memoized, so every
1754+
# `int|false` resolved from a PHPDoc is the same object. ConditionalType
1755+
# narrowed the types in its branches identical to the argument, which made
1756+
# `($priority is int ? bool : int|false)` lose the `int` for an `int|false`
1757+
# argument - https://github.com/phpstan/phpstan/issues/15268
1758+
run: |
1759+
cd e2e/bug-15268
1760+
php ../../bin/phpstan analyse --no-progress --error-format=raw
1761+
17521762
- name: "Test"
17531763
# The arena is a per-run POSIX shared-memory object: shm_open() plus an
17541764
# ftruncate() to 256 MB that tmpfs honours sparsely, committing pages on

e2e/bug-15268/phpstan.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
level: 10
3+
paths:
4+
- .

e2e/bug-15268/test.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
use function PHPStan\Testing\assertType;
4+
5+
/**
6+
* @param int|false $priority
7+
* @return ($priority is int ? bool : int|false)
8+
*/
9+
function leaf($priority = false) {
10+
return is_int($priority) ? true : 5;
11+
}
12+
13+
/**
14+
* @param int|false $priority
15+
* @return ($priority is int ? bool : int|false)
16+
*/
17+
function wrapper($priority = false) {
18+
// $priority is int|false, so the condition selects neither branch and the result
19+
// should be their union. This assertion passes when PHPStan runs on PHP 8.2, and
20+
// fails with "Expected type bool|int, actual: bool" on PHP 8.3, 8.4 and 8.5.
21+
assertType('bool|int', leaf($priority));
22+
23+
// Because the type above lost its int, PHP 8.3+ additionally reports
24+
// "Function wrapper() never returns int so it can be removed from the return type."
25+
// It does return int: leaf(false) returns 5. No such error on PHP 8.2.
26+
return leaf($priority);
27+
}
28+
29+
// These hold on every PHP version: when the argument type selects a branch, the
30+
// conditional resolves correctly.
31+
assertType('int|false', leaf());
32+
assertType('bool', leaf(10));
33+
assertType('int|false', wrapper());
34+
35+
// Playground result on PHP 8.2 (its own runtime): No errors.
36+
// Run locally on PHP 8.3+: two errors, the failed assertion and return.unusedType.

src/Analyser/Traverser/GenericTypeTemplateTraverser.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Type\Generic\TemplateTypeHelper;
1010
use PHPStan\Type\Generic\TemplateTypeMap;
1111
use PHPStan\Type\Generic\UnresolvedTemplateArgumentType;
12+
use PHPStan\Type\NarrowedSubjectType;
1213
use PHPStan\Type\Type;
1314
use PHPStan\Type\TypeTraverserCallable;
1415

@@ -42,7 +43,7 @@ public function __construct(
4243
*/
4344
public function traverse(Type $type, callable $traverse): Type
4445
{
45-
if ($type instanceof TemplateType && !$type->isArgument()) {
46+
if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) {
4647
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
4748
if ($this->frame === null) {
4849
if ($newType === null || $newType instanceof ErrorType) {

src/Dependency/DependencyResolver.php

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use PHPStan\Type\Type;
3737
use function array_key_exists;
3838
use function count;
39+
use function get_class;
3940
use function in_array;
4041
use function is_file;
4142
use function spl_object_id;
@@ -44,9 +45,74 @@
4445
final class DependencyResolver
4546
{
4647

48+
private const PROFILE_VAR_TAGS = 1;
49+
50+
private const PROFILE_CHAIN = 2;
51+
52+
private const PROFILE_EXPORT = 4;
53+
54+
private const PROFILE_NAME_SCOPE = 8;
55+
56+
/** Node classes the branch chain in collectNodeDependencies() reacts to */
57+
private const CHAIN_NODE_TYPES = [
58+
Node\Stmt\Class_::class,
59+
Node\Stmt\Interface_::class,
60+
Node\Stmt\Enum_::class,
61+
InClassMethodNode::class,
62+
InPropertyHookNode::class,
63+
ClassPropertyNode::class,
64+
InFunctionNode::class,
65+
Closure::class,
66+
Node\Expr\ArrowFunction::class,
67+
Node\Expr\FuncCall::class,
68+
Node\Expr\MethodCall::class,
69+
Node\Expr\PropertyFetch::class,
70+
Node\Expr\StaticCall::class,
71+
Node\Expr\ClassConstFetch::class,
72+
Node\Expr\ConstFetch::class,
73+
Node\Expr\StaticPropertyFetch::class,
74+
Node\Expr\New_::class,
75+
Node\Stmt\Trait_::class,
76+
Node\Stmt\TraitUse::class,
77+
Node\Expr\Instanceof_::class,
78+
Node\Expr\Include_::class,
79+
Node\Stmt\Catch_::class,
80+
ArrayDimFetch::class,
81+
Foreach_::class,
82+
Array_::class,
83+
StaticMethodCallableNode::class,
84+
MethodCallableNode::class,
85+
FunctionCallableNode::class,
86+
InstantiationCallableNode::class,
87+
];
88+
89+
/**
90+
* Node classes ExportedNodeResolver::resolve() reacts to. A class member is not among them: it is
91+
* exported as part of the class declaring it, through exportClassStatement(), never on its own.
92+
*/
93+
private const EXPORT_NODE_TYPES = [
94+
Node\Stmt\Class_::class,
95+
Node\Stmt\Interface_::class,
96+
Node\Stmt\Enum_::class,
97+
Node\Stmt\Trait_::class,
98+
Node\Stmt\Function_::class,
99+
Node\Stmt\Const_::class,
100+
Node\Expr\FuncCall::class,
101+
];
102+
103+
/** Node classes ExportedNameScopeTracker::enterNode() reacts to */
104+
private const NAME_SCOPE_NODE_TYPES = [
105+
Node\Stmt\Namespace_::class,
106+
Node\Stmt\Use_::class,
107+
Node\Stmt\GroupUse::class,
108+
];
109+
47110
/** @var array<string, array<int, ClassReflection|FunctionReflection|ConstantReflection>> reflections keyed by spl_object_id() */
48111
private array $classDependencies = [];
49112

113+
/** @var array<class-string, int> */
114+
private array $nodeProfiles = [];
115+
50116
private ExportedNameScopeTracker $nameScopeTracker;
51117

52118
private ?string $nameScopeFile = null;
@@ -72,27 +138,42 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
72138
$this->nameScopeFile = $file;
73139
$this->nameScopeTracker->reset();
74140
}
75-
$this->nameScopeTracker->enterNode($node);
141+
$nodeClass = get_class($node);
142+
$nodeProfile = $this->nodeProfiles[$nodeClass] ??= $this->resolveNodeProfile($node);
143+
144+
if (($nodeProfile & self::PROFILE_NAME_SCOPE) !== 0) {
145+
$this->nameScopeTracker->enterNode($node);
146+
}
76147

77148
// Keyed by spl_object_id(), so that a reflection collected again - every level of a class hierarchy
78149
// repeats the interfaces it inherits, and the classes a node references share most of their
79150
// ancestors - is kept only once instead of being resolved to its file and package once more.
80151
$dependenciesReflections = [];
81152
$dependenciesFilePaths = [];
82153

83-
if (
84-
$node instanceof Node\Stmt
85-
&& !$node instanceof VirtualNode
86-
&& !$node instanceof Node\Stmt\ClassLike
87-
&& !$node instanceof Node\Stmt\ClassMethod
88-
&& !$node instanceof Node\Stmt\Function_
89-
&& !$node instanceof Node\Stmt\Property
90-
&& !$node instanceof Node\Stmt\ClassConst
91-
&& !$node instanceof Node\Stmt\Const_
92-
) {
154+
if (($nodeProfile & self::PROFILE_VAR_TAGS) !== 0 && $node instanceof Node\Stmt) {
93155
$this->extractStmtVarTags($node, $scope, $dependenciesReflections);
94156
}
95157

158+
if (($nodeProfile & self::PROFILE_CHAIN) !== 0) {
159+
$this->collectNodeDependencies($node, $scope, $dependenciesReflections, $dependenciesFilePaths);
160+
}
161+
162+
$exportedNode = ($nodeProfile & self::PROFILE_EXPORT) !== 0
163+
? $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope())
164+
: null;
165+
166+
return new NodeDependencies($this->fileHelper, $dependenciesReflections, $exportedNode, $dependenciesFilePaths);
167+
}
168+
169+
/**
170+
* The node-kind branches. Only entered when resolveNodeProfile() says a branch can match.
171+
*
172+
* @param array<ClassReflection|FunctionReflection|ConstantReflection> $dependenciesReflections
173+
* @param list<string> $dependenciesFilePaths
174+
*/
175+
private function collectNodeDependencies(Node $node, Scope $scope, array &$dependenciesReflections, array &$dependenciesFilePaths): void
176+
{
96177
if ($node instanceof Node\Stmt\Class_) {
97178
if (isset($node->namespacedName)) {
98179
$this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections);
@@ -562,8 +643,6 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies
562643
} elseif ($node instanceof InstantiationCallableNode) {
563644
$dependenciesReflections += $this->resolveDependencies(new Node\Expr\New_($node->getClass()), $scope)->getReflections();
564645
}
565-
566-
return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope()), $dependenciesFilePaths);
567646
}
568647

569648
public function resolveUsedTraitDependencies(InClassNode $inClassNode): NodeDependencies
@@ -606,6 +685,45 @@ private function getClassNamesFromClassString(Type $type): array
606685
return $classNames;
607686
}
608687

688+
/**
689+
* Which parts of resolveDependencies() a node of this class can reach. Depends only on the class,
690+
* so it is computed once per class and reused for every node of it.
691+
*/
692+
private function resolveNodeProfile(Node $node): int
693+
{
694+
$profile = 0;
695+
if (
696+
$node instanceof Node\Stmt
697+
&& !$node instanceof VirtualNode
698+
&& !$node instanceof Node\Stmt\ClassLike
699+
&& !$node instanceof Node\Stmt\ClassMethod
700+
&& !$node instanceof Node\Stmt\Function_
701+
&& !$node instanceof Node\Stmt\Property
702+
&& !$node instanceof Node\Stmt\ClassConst
703+
&& !$node instanceof Node\Stmt\Const_
704+
) {
705+
$profile |= self::PROFILE_VAR_TAGS;
706+
}
707+
708+
$lists = [
709+
self::PROFILE_CHAIN => self::CHAIN_NODE_TYPES,
710+
self::PROFILE_EXPORT => self::EXPORT_NODE_TYPES,
711+
self::PROFILE_NAME_SCOPE => self::NAME_SCOPE_NODE_TYPES,
712+
];
713+
foreach ($lists as $bit => $nodeTypes) {
714+
foreach ($nodeTypes as $nodeType) {
715+
if (!$node instanceof $nodeType) {
716+
continue;
717+
}
718+
719+
$profile |= $bit;
720+
break;
721+
}
722+
}
723+
724+
return $profile;
725+
}
726+
609727
/**
610728
* Extracts the classes referenced from a variable-level var-tag PHPDoc attached to a statement.
611729
*

0 commit comments

Comments
 (0)