Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bd43309
Add `setShouldNotImplyOppositeCase()` on `SpecifiedTypes` to replace …
VincentLanglet May 26, 2026
8a3fc6e
Add comment explaining why shouldNotImplyOppositeCase causes early re…
phpstan-bot May 26, 2026
673de07
Rename `shouldNotImplyOppositeCase` to `shouldNotDetermineCheckResult`
phpstan-bot May 26, 2026
184d584
Rename `shouldNotDetermineCheckResult` to `specifyOnly`
phpstan-bot May 26, 2026
d6b3f77
Keep rootExpr for equality assertions, move specifyOnly after rootExp…
phpstan-bot May 27, 2026
2fe0e2c
Rework
VincentLanglet May 27, 2026
7e59a42
Remove unused specifyOnly flag, document setRootExpr
phpstan-bot May 27, 2026
3c6a6df
Add duplicate call detection for rootExpr-based type specifying
phpstan-bot May 27, 2026
ee9d8ef
Remove duplicate array_key_exists check
phpstan-bot May 27, 2026
b4de1d8
Add setSideEffectOnly() flag on SpecifiedTypes, replace rootExpr work…
phpstan-bot May 28, 2026
08421e8
Split bug-14705 test into PHP 7.4-compatible and PHP 8.0+ parts
phpstan-bot May 28, 2026
8277b77
Rename sideEffectOnly to specifyOnly on SpecifiedTypes
phpstan-bot May 28, 2026
5d8e6cd
Revert unrelated duplicate array_key_exists removal
phpstan-bot May 28, 2026
b09209c
Document setSpecifyOnly() for third-party extension migration
phpstan-bot May 28, 2026
980c39a
Correct setSpecifyOnly() PHPDoc: clarify filterByTruthyValue scope
phpstan-bot May 28, 2026
dbec47e
Store specifyOnly boolean marker via overwrite to fix duplicate detec…
phpstan-bot May 29, 2026
b03091a
Fix specifyOnly boolean marker overwriting function return types
phpstan-bot May 29, 2026
df66dbd
Explain specifyOnly expression-statement handling in NodeScopeResolver
phpstan-bot May 29, 2026
341f63d
Move bug-14705 test into nsrt with assertType, merge realpath elvis c…
phpstan-bot May 29, 2026
a3e940b
Rename SpecifiedTypes getter to shouldSpecifyOnly() for consistency
phpstan-bot May 29, 2026
d003b39
Add array_key_exists duplicate-in-loop assertType case to bug-14705
phpstan-bot May 29, 2026
f223e96
Explain why duplicate array_key_exists positive call stays bool
phpstan-bot May 29, 2026
2d8b972
Strip BooleanNot when storing specifyOnly duplicate marker
phpstan-bot May 29, 2026
7081004
Remove duplicate array_key_exists check in MethodCallWithPossiblyRena…
phpstan-bot May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -3265,6 +3265,12 @@ public function filterByTruthyValue(Expr $expr): self
}

$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy());
if ($specifiedTypes->shouldSpecifyOnly()) {
[$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, true);
$specifiedTypes = $specifiedTypes->unionWith(
$this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this),
);
}
$scope = $this->filterBySpecifiedTypes($specifiedTypes);
$this->truthyScopes[$exprString] = $scope;

Expand All @@ -3282,12 +3288,36 @@ public function filterByFalseyValue(Expr $expr): self
}

$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey());
if ($specifiedTypes->shouldSpecifyOnly()) {
[$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, false);
$specifiedTypes = $specifiedTypes->unionWith(
$this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this),
);
}
$scope = $this->filterBySpecifiedTypes($specifiedTypes);
$this->falseyScopes[$exprString] = $scope;

return $scope;
}

/**
* Strips BooleanNot wrappers from a specifyOnly condition so the boolean
* result marker is stored for the underlying call (e.g. `array_key_exists(...)`)
* rather than for the negated form (`!array_key_exists(...)`). The negated form
* is then derived from the inner value instead of being capped at bool.
*
* @return array{Expr, bool}
*/
private function unwrapSpecifyOnlyMarker(Expr $expr, bool $value): array
{
while ($expr instanceof Expr\BooleanNot) {
$expr = $expr->expr;
$value = !$value;
}

return [$expr, $value];
}

/**
* @return static
*/
Expand Down
25 changes: 23 additions & 2 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\FileTypeMapper;
Expand Down Expand Up @@ -1143,11 +1144,31 @@ public function processStmtNode(
$this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage);
}
$scope = $result->getScope();
$scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition(
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
$scope,
$stmt->expr,
TypeSpecifierContext::createNull(),
));
);
$scope = $scope->filterBySpecifiedTypes($specifiedTypes);
if ($specifiedTypes->shouldSpecifyOnly()) {
// This is the expression-statement counterpart of the specifyOnly handling
// in MutatingScope::filterByTruthyValue(). A void assertion method used as a
// statement (e.g. `$this->assertValid($x);` with `@phpstan-assert =non-empty-string $x`)
// only narrows types as a side effect and never determines a check outcome, so
// ImpossibleCheckTypeHelper would otherwise have nothing to detect a duplicate
// call against. Store ConstantBooleanType(true) for the call expression so a
// second identical call is reported as always-true. Unlike filterByTruthyValue,
// we overwrite directly instead of going through TypeSpecifier::create(): void
// calls are not used for their return value, so the purity check there would
// drop the marker for the impure calls that need it most, and intersecting
// `true` with the void return type would produce *NEVER*.
$scope = $scope->filterBySpecifiedTypes(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment to explain this code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I addressed the review comment by adding an explanatory comment to the specifyOnly block in NodeScopeResolver.php (the expression-statement path).

The comment explains:

  • What it is: the expression-statement counterpart to the specifyOnly handling in MutatingScope::filterByTruthyValue().
  • Why it exists: void assertion methods used as statements (e.g. $this->assertValid($x); with @phpstan-assert =non-empty-string $x) only narrow types as a side effect, so ImpossibleCheckTypeHelper would otherwise have no stored result to detect a duplicate call against. Storing ConstantBooleanType(true) for the call makes a second identical call report as always-true.
  • Why it overwrites directly instead of using TypeSpecifier::create(): void calls aren't used for their return value, so the purity check there would drop the marker for the impure calls that need it most, and intersecting true with the void return type would produce *NEVER*.

Verified: make phpstan clean, the two ImpossibleCheckType*RuleTest suites pass (111 tests). Committed separately and pushed.

(new SpecifiedTypes(
[$scope->getNodeKey($stmt->expr) => [$stmt->expr, new ConstantBooleanType(true)]],
[],
))->setAlwaysOverwriteTypes(),
);
}
$hasYield = $result->hasYield();
$throwPoints = $result->getThrowPoints();
$impurePoints = $result->getImpurePoints();
Expand Down
45 changes: 45 additions & 0 deletions src/Analyser/SpecifiedTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ final class SpecifiedTypes

private bool $overwrite = false;

private bool $specifyOnly = false;

/** @var array<string, ConditionalExpressionHolder[]> */
private array $newConditionalExpressionHolders = [];

Expand Down Expand Up @@ -51,19 +53,53 @@ public function setAlwaysOverwriteTypes(): self
{
$self = new self($this->sureTypes, $this->sureNotTypes);
$self->overwrite = true;
$self->specifyOnly = $this->specifyOnly;
$self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders;
$self->rootExpr = $this->rootExpr;

return $self;
}

/**
* Marks these SpecifiedTypes as only narrowing types, not determining
* the check outcome. ImpossibleCheckTypeHelper will not use sureTypes
* to report always-true/false for the check expression.
*
* Duplicate detection: filterByTruthyValue stores the call's boolean
* result via TypeSpecifier::create(), which respects purity checks.
* Pure/possibly-impure calls get duplicate detection; impure calls
* (hasSideEffects=yes) do not, to avoid overwriting the expression's
* real return type in scope. Void assertion methods used as statements
* get duplicate detection via NodeScopeResolver's expression statement
* path, which bypasses purity checks since void calls are not used
* for their return value.
*
* @api
*/
public function setSpecifyOnly(): self
{
$self = new self($this->sureTypes, $this->sureNotTypes);
$self->overwrite = $this->overwrite;
$self->specifyOnly = true;
$self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders;
$self->rootExpr = $this->rootExpr;

return $self;
}

public function shouldSpecifyOnly(): bool
{
return $this->specifyOnly;
}

/**
* @api
*/
public function setRootExpr(?Expr $rootExpr): self
{
$self = new self($this->sureTypes, $this->sureNotTypes);
$self->overwrite = $this->overwrite;
$self->specifyOnly = $this->specifyOnly;
$self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders;
$self->rootExpr = $rootExpr;

Expand All @@ -77,6 +113,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi
{
$self = new self($this->sureTypes, $this->sureNotTypes);
$self->overwrite = $this->overwrite;
$self->specifyOnly = $this->specifyOnly;
$self->newConditionalExpressionHolders = $newConditionalExpressionHolders;
$self->rootExpr = $this->rootExpr;

Expand Down Expand Up @@ -128,6 +165,7 @@ public function removeExpr(string $exprString): self

$self = new self($sureTypes, $sureNotTypes);
$self->overwrite = $this->overwrite;
$self->specifyOnly = $this->specifyOnly;
$self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders;
$self->rootExpr = $this->rootExpr;

Expand Down Expand Up @@ -167,6 +205,9 @@ public function intersectWith(SpecifiedTypes $other): self
if ($this->overwrite && $other->overwrite) {
$result = $result->setAlwaysOverwriteTypes();
}
if ($this->specifyOnly || $other->specifyOnly) {
$result->specifyOnly = true;
}

return $result->setRootExpr($rootExpr);
}
Expand Down Expand Up @@ -204,6 +245,9 @@ public function unionWith(SpecifiedTypes $other): self
if ($this->overwrite || $other->overwrite) {
$result = $result->setAlwaysOverwriteTypes();
}
if ($this->specifyOnly || $other->specifyOnly) {
$result->specifyOnly = true;
}

$conditionalExpressionHolders = $this->newConditionalExpressionHolders;
foreach ($other->newConditionalExpressionHolders as $exprString => $holders) {
Expand Down Expand Up @@ -235,6 +279,7 @@ public function normalize(Scope $scope): self
if ($this->overwrite) {
$result = $result->setAlwaysOverwriteTypes();
}
$result->specifyOnly = $this->specifyOnly;

return $result->setRootExpr($this->rootExpr);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -1856,7 +1856,10 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai
$assertedType,
$assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(),
$scope,
)->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null);
);
if ($containsUnresolvedTemplate || $assert->isEquality()) {
$newTypes = $newTypes->setSpecifyOnly();
}
$types = $types !== null ? $types->unionWith($newTypes) : $newTypes;

if (!$context->null() || !$assertedType instanceof ConstantBooleanType) {
Expand Down
14 changes: 14 additions & 0 deletions src/Rules/Comparison/ImpossibleCheckTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,20 @@
return null;
}

if ($specifiedTypes->shouldSpecifyOnly()) {
if ($scope->hasExpressionType($node)->yes()) {

Check warning on line 277 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } if ($specifiedTypes->shouldSpecifyOnly()) { - if ($scope->hasExpressionType($node)->yes()) { + if (!$scope->hasExpressionType($node)->no()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { return true;

Check warning on line 277 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } if ($specifiedTypes->shouldSpecifyOnly()) { - if ($scope->hasExpressionType($node)->yes()) { + if (!$scope->hasExpressionType($node)->no()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { return true;
$nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node);
if ($nodeType->isTrue()->yes()) {

Check warning on line 279 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($specifiedTypes->shouldSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); - if ($nodeType->isTrue()->yes()) { + if (!$nodeType->toBoolean()->isTrue()->no()) { return true; } if ($nodeType->isFalse()->yes()) {

Check warning on line 279 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ if ($specifiedTypes->shouldSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); - if ($nodeType->isTrue()->yes()) { + if ($nodeType->toBoolean()->isTrue()->yes()) { return true; } if ($nodeType->isFalse()->yes()) {

Check warning on line 279 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($specifiedTypes->shouldSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); - if ($nodeType->isTrue()->yes()) { + if (!$nodeType->toBoolean()->isTrue()->no()) { return true; } if ($nodeType->isFalse()->yes()) {

Check warning on line 279 in src/Rules/Comparison/ImpossibleCheckTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\LooseBooleanMutator": @@ @@ if ($specifiedTypes->shouldSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); - if ($nodeType->isTrue()->yes()) { + if ($nodeType->toBoolean()->isTrue()->yes()) { return true; } if ($nodeType->isFalse()->yes()) {
return true;
}
if ($nodeType->isFalse()->yes()) {
return false;
}
}

return null;
}

$sureTypes = $specifiedTypes->getSureTypes();
$sureNotTypes = $specifiedTypes->getSureNotTypes();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataE
continue;
}

if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) {
continue;
}

$callsWithParameter = $prototypeMethodCalls[$prototypeParameterName];
foreach ($callsWithParameter as [$file, $line]) {
$errors[] = RuleErrorBuilder::message(sprintf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\BinaryOp\Identical;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
Expand Down Expand Up @@ -115,7 +112,7 @@ public function specifyTypes(
$arrayType->getIterableValueType(),
$context,
$scope,
))->setRootExpr(new Identical($arrayDimFetch, new ConstFetch(new Name('__PHPSTAN_FAUX_CONSTANT'))));
))->setSpecifyOnly();
}

return new SpecifiedTypes();
Expand Down
2 changes: 1 addition & 1 deletion src/Type/Php/PregMatchTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
$matchedType,
$context,
$scope,
)->setRootExpr($node);
)->setSpecifyOnly();
if ($overwrite) {
$types = $types->setAlwaysOverwriteTypes();
}
Expand Down
15 changes: 1 addition & 14 deletions src/Type/Php/StrContainingTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@

namespace PHPStan\Type\Php;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\BinaryOp\BooleanAnd;
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
Expand Down Expand Up @@ -89,15 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
new IntersectionType($accessories),
$context,
$scope,
)->setRootExpr(new BooleanAnd(
new NotIdentical(
$args[$needleArg]->value,
new String_(''),
),
new FuncCall(new Name('FAUX_FUNCTION'), [
new Arg($args[$needleArg]->value),
]),
));
)->setSpecifyOnly();
}
}

Expand Down
Loading
Loading