🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Commit a01080e

Browse files
ondrejmirtesclaude
andcommitted
Explain unresolvable and impossible types with reasons
IsSuperTypeOfResult already carries reasons; this propagates them from the type layer all the way into rule errors as tips. - NeverType/ErrorType gain an optional reason. ConstantArrayType explains why two sealed array shapes cannot be intersected; the reason flows through TypeCombinator::intersect() and a new UnresolvableTypeResult returned by UnresolvableTypeHelper, so every "contains unresolvable type" rule can show it. - ObjectType/ObjectShapeType::isSuperTypeOf() now explain non-obvious no() results: a trait used as a type, two unrelated classes (single inheritance), a final class that does not implement an interface, and inaccessible object-shape properties. - ImpossibleInstanceofRule and the ImpossibleCheckType{Function,Method, StaticMethod}CallRule family surface these reasons via acceptsReasonsTip(); several existing rules (strict comparison, intersection typehints, etc.) pick them up automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 57ea87c commit a01080e

49 files changed

Lines changed: 475 additions & 126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/PhpDoc/PhpDocNodeResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ private function shouldSkipType(string $tagName, Type $type): bool
733733
return false;
734734
}
735735

736-
return $this->unresolvableTypeHelper->containsUnresolvableType($type);
736+
return $this->unresolvableTypeHelper->getUnresolvableType($type) !== null;
737737
}
738738

739739
public function resolveAllowPrivateMutation(PhpDocNode $phpDocNode): bool

src/Rules/Classes/ImpossibleInstanceOfRule.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
106106
if (!$instanceofType->getValue()) {
107107
$exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node->expr) : $scope->getNativeType($node->expr);
108108

109-
$ruleError = $addTip(RuleErrorBuilder::message(sprintf(
109+
$errorBuilder = RuleErrorBuilder::message(sprintf(
110110
'Instanceof between %s and %s will always evaluate to false.',
111111
$exprType->describe(VerbosityLevel::typeOnly()),
112112
$classType->describe(VerbosityLevel::getRecommendedLevelByType($classType)),
113-
)))->identifier('instanceof.alwaysFalse')->build();
113+
));
114+
$reasons = $classType->isSuperTypeOf($exprType)->reasons;
115+
if ($reasons !== []) {
116+
$errorBuilder = $this->possiblyImpureTipHelper->addTip($scope, $node, $errorBuilder->acceptsReasonsTip($reasons));
117+
} else {
118+
$errorBuilder = $addTip($errorBuilder);
119+
}
120+
$ruleError = $errorBuilder->identifier('instanceof.alwaysFalse')->build();
114121
if ($scope->isInTrait()) {
115122
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError);
116123
return [];

src/Rules/Classes/LocalTypeAliasesCheck.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,14 @@ public function checkInTraitUseContext(
288288
}
289289
}
290290

291-
if ($this->unresolvableTypeHelper->containsUnresolvableType($resolvedType)) {
292-
$errors[] = RuleErrorBuilder::message(sprintf('Type alias %s contains unresolvable type.', $aliasName))
293-
->identifier('typeAlias.unresolvableType')
294-
->build();
291+
$unresolvableType = $this->unresolvableTypeHelper->getUnresolvableType($resolvedType);
292+
if ($unresolvableType !== null) {
293+
$errorBuilder = RuleErrorBuilder::message(sprintf('Type alias %s contains unresolvable type.', $aliasName))
294+
->identifier('typeAlias.unresolvableType');
295+
foreach ($unresolvableType->reasons as $reason) {
296+
$errorBuilder->addTip($reason);
297+
}
298+
$errors[] = $errorBuilder->build();
295299
}
296300

297301
$escapedTypeAlias = SprintfHelper::escapeFormatString($aliasName);

src/Rules/Classes/MethodTagCheck.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,18 @@ private function checkMethodTypeInTraitUseContext(Scope $scope, ClassReflection
250250
}
251251
}
252252

253-
if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) {
254-
$errors[] = RuleErrorBuilder::message(sprintf(
253+
$unresolvableType = $this->unresolvableTypeHelper->getUnresolvableType($type);
254+
if ($unresolvableType !== null) {
255+
$errorBuilder = RuleErrorBuilder::message(sprintf(
255256
'PHPDoc tag @method for method %s::%s() %s contains unresolvable type.',
256257
$classReflection->getDisplayName(),
257258
$methodName,
258259
$description,
259-
))->identifier('methodTag.unresolvableType')->build();
260+
))->identifier('methodTag.unresolvableType');
261+
foreach ($unresolvableType->reasons as $reason) {
262+
$errorBuilder->addTip($reason);
263+
}
264+
$errors[] = $errorBuilder->build();
260265
}
261266

262267
$escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName());

src/Rules/Classes/MixinCheck.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,14 @@ public function checkInTraitUseContext(
133133
$errors = [];
134134
foreach ($phpDoc->getMixinTags() as $mixinTag) {
135135
$type = $mixinTag->getType();
136-
if (
137-
$this->unresolvableTypeHelper->containsUnresolvableType($type)
138-
) {
139-
$errors[] = RuleErrorBuilder::message('PHPDoc tag @mixin contains unresolvable type.')
140-
->identifier('mixin.unresolvableType')
141-
->build();
136+
$unresolvableType = $this->unresolvableTypeHelper->getUnresolvableType($type);
137+
if ($unresolvableType !== null) {
138+
$errorBuilder = RuleErrorBuilder::message('PHPDoc tag @mixin contains unresolvable type.')
139+
->identifier('mixin.unresolvableType');
140+
foreach ($unresolvableType->reasons as $reason) {
141+
$errorBuilder->addTip($reason);
142+
}
143+
$errors[] = $errorBuilder->build();
142144
continue;
143145
}
144146

src/Rules/Classes/PropertyTagCheck.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,18 @@ private function checkPropertyTypeInTraitUseContext(Scope $scope, ClassReflectio
231231
}
232232
}
233233

234-
if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) {
235-
$errors[] = RuleErrorBuilder::message(sprintf(
234+
$unresolvableType = $this->unresolvableTypeHelper->getUnresolvableType($type);
235+
if ($unresolvableType !== null) {
236+
$errorBuilder = RuleErrorBuilder::message(sprintf(
236237
'PHPDoc tag %s for property %s::$%s contains unresolvable type.',
237238
$tagName,
238239
$classReflection->getDisplayName(),
239240
$propertyName,
240-
))->identifier('propertyTag.unresolvableType')->build();
241+
))->identifier('propertyTag.unresolvableType');
242+
foreach ($unresolvableType->reasons as $reason) {
243+
$errorBuilder->addTip($reason);
244+
}
245+
$errors[] = $errorBuilder->build();
241246
}
242247

243248
$escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName());

src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
4646
}
4747

4848
$functionName = (string) $node->name;
49-
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node);
49+
$reasons = [];
50+
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons);
5051
if ($isAlways === null) {
5152
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node);
5253
return [];
@@ -71,11 +72,17 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
7172
};
7273

7374
if (!$isAlways) {
74-
$ruleError = $addTip(RuleErrorBuilder::message(sprintf(
75+
$errorBuilder = RuleErrorBuilder::message(sprintf(
7576
'Call to function %s()%s will always evaluate to false.',
7677
$functionName,
7778
$this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()),
78-
)))->identifier('function.impossibleType')->build();
79+
));
80+
if ($reasons !== []) {
81+
$errorBuilder = $this->possiblyImpureTipHelper->addTip($scope, $node, $errorBuilder->acceptsReasonsTip($reasons));
82+
} else {
83+
$errorBuilder = $addTip($errorBuilder);
84+
}
85+
$ruleError = $errorBuilder->identifier('function.impossibleType')->build();
7986
if ($scope->isInTrait()) {
8087
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError);
8188
return [];

src/Rules/Comparison/ImpossibleCheckTypeHelper.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
use PHPStan\Type\VerbosityLevel;
3434
use function array_map;
3535
use function array_pop;
36+
use function array_unique;
37+
use function array_values;
3638
use function count;
3739
use function implode;
3840
use function in_array;
@@ -53,12 +55,18 @@ public function __construct(
5355
{
5456
}
5557

58+
/**
59+
* @param list<string> $reasons populated with human-readable explanations of why the
60+
* result is "always false" (empty for the "always true" / inconclusive results)
61+
*/
5662
public function findSpecifiedType(
5763
Scope $scope,
5864
Expr $node,
65+
array &$reasons = [],
5966
): ?bool
6067
{
61-
$specifiedValue = $this->getSpecifiedType($scope, $node);
68+
$specifiedValue = $this->getSpecifiedType($scope, $node, $reasons);
69+
$reasons = array_values(array_unique($reasons));
6270

6371
/**
6472
* For class_exists()/interface_exists()/trait_exists()/enum_exists() the "always true"
@@ -83,9 +91,13 @@ public function findSpecifiedType(
8391
return $specifiedValue;
8492
}
8593

94+
/**
95+
* @param list<string> $reasons
96+
*/
8697
private function getSpecifiedType(
8798
Scope $scope,
8899
Expr $node,
100+
array &$reasons = [],
89101
): ?bool
90102
{
91103
if ($node instanceof FuncCall) {
@@ -360,7 +372,15 @@ private function getSpecifiedType(
360372
$argumentType = $scope->getType($assignedInCallVar->expr);
361373
}
362374

363-
$results[] = $resultType->isSuperTypeOf($argumentType)->result;
375+
$isSuperType = $resultType->isSuperTypeOf($argumentType);
376+
$results[] = $isSuperType->result;
377+
if (!$isSuperType->result->no()) {
378+
continue;
379+
}
380+
381+
foreach ($isSuperType->reasons as $reason) {
382+
$reasons[] = $reason;
383+
}
364384
}
365385

366386
foreach ($sureNotTypes as $sureNotType) {
@@ -378,7 +398,15 @@ private function getSpecifiedType(
378398
/** @var Type $resultType */
379399
$resultType = $sureNotType[1];
380400

381-
$results[] = $resultType->isSuperTypeOf($argumentType)->negate()->result;
401+
$isSuperType = $resultType->isSuperTypeOf($argumentType)->negate();
402+
$results[] = $isSuperType->result;
403+
if (!$isSuperType->result->no()) {
404+
continue;
405+
}
406+
407+
foreach ($isSuperType->reasons as $reason) {
408+
$reasons[] = $reason;
409+
}
382410
}
383411

384412
if (count($results) === 0) {

src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
4848
return [];
4949
}
5050

51-
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node);
51+
$reasons = [];
52+
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons);
5253
if ($isAlways === null) {
5354
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node);
5455
return [];
@@ -74,12 +75,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
7475

7576
if (!$isAlways) {
7677
$method = $this->getMethod($node->var, $node->name->name, $scope);
77-
$ruleError = $addTip(RuleErrorBuilder::message(sprintf(
78+
$errorBuilder = RuleErrorBuilder::message(sprintf(
7879
'Call to method %s::%s()%s will always evaluate to false.',
7980
$method->getDeclaringClass()->getDisplayName(),
8081
$method->getName(),
8182
$this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()),
82-
)))->identifier('method.impossibleType')->build();
83+
));
84+
if ($reasons !== []) {
85+
$errorBuilder = $this->possiblyImpureTipHelper->addTip($scope, $node, $errorBuilder->acceptsReasonsTip($reasons));
86+
} else {
87+
$errorBuilder = $addTip($errorBuilder);
88+
}
89+
$ruleError = $errorBuilder->identifier('method.impossibleType')->build();
8390
if ($scope->isInTrait()) {
8491
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError);
8592
return [];

src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
4848
return [];
4949
}
5050

51-
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node);
51+
$reasons = [];
52+
$isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons);
5253
if ($isAlways === null) {
5354
$this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node);
5455
return [];
@@ -75,12 +76,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
7576
if (!$isAlways) {
7677
$method = $this->getMethod($node->class, $node->name->name, $scope);
7778

78-
$ruleError = $addTip(RuleErrorBuilder::message(sprintf(
79+
$errorBuilder = RuleErrorBuilder::message(sprintf(
7980
'Call to static method %s::%s()%s will always evaluate to false.',
8081
$method->getDeclaringClass()->getDisplayName(),
8182
$method->getName(),
8283
$this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()),
83-
)))->identifier('staticMethod.impossibleType')->build();
84+
));
85+
if ($reasons !== []) {
86+
$errorBuilder = $this->possiblyImpureTipHelper->addTip($scope, $node, $errorBuilder->acceptsReasonsTip($reasons));
87+
} else {
88+
$errorBuilder = $addTip($errorBuilder);
89+
}
90+
$ruleError = $errorBuilder->identifier('staticMethod.impossibleType')->build();
8491
if ($scope->isInTrait()) {
8592
$this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError);
8693
return [];

0 commit comments

Comments
 (0)