Skip to content
9 changes: 9 additions & 0 deletions src/Analyser/SpecifiedTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ public function setAlwaysOverwriteTypes(): self
}

/**
* When set, ImpossibleCheckTypeHelper evaluates rootExpr in scope
* instead of analysing sureTypes/sureNotTypes.
*
* If rootExpr === the check node itself, isSpecified() bails out
* immediately and ImpossibleCheckTypeHelper returns null.
* This is used when sureTypes are a side effect of the check
* (e.g. str_contains narrowing haystack to non-empty-string)
* rather than the determining condition.
*
* @api
*/
public function setRootExpr(?Expr $rootExpr): self
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'))));
))->setRootExpr($node);
}

return new SpecifiedTypes();
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),
]),
));
)->setRootExpr($node);
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.

What's the purpose of the setRootExpr method ?

And why setting the $node has exactly the same behavior than the previous code with the FAUX_FUNCTION` ?

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.

Pushed. Here's what changed:

Removed the specifyOnly flag entirely — after the Rework commit unified both FAUX extensions and equality assertions through setRootExpr($node), the flag was dead code. Removed the property, setter, getter, and all propagation in intersectWith/unionWith/normalize/immutable-copy methods.

Documented setRootExpr() with a PHPDoc explaining the mechanism and answering the reviewer's question:

  • Purpose: When rootExpr is set, ImpossibleCheckTypeHelper evaluates it directly in scope instead of analysing sureTypes/sureNotTypes. This separates "side-effect narrowing" from "check outcome determination."
  • Why setRootExpr($node) behaves the same as FAUX_FUNCTION: Both reach return null in ImpossibleCheckTypeHelper, just via different paths. With $node, the isSpecified() check sees $rootExpr === $node (line 374) and returns true immediately, causing an early return null. With FAUX_FUNCTION, isSpecified() returned false (fabricated expression ≠ node), then $scope->getType(fabricatedExpr) couldn't resolve to a ConstantBooleanType (undefined function), falling through to the same return null.

All 12539 tests pass, PHPStan self-analysis reports no errors.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,12 @@ public function testNonEmptySpecifiedString(): void
$this->analyse([__DIR__ . '/data/non-empty-string-impossible-type.php'], []);
}

public function testBug14705(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-14705.php'], []);
}

public function testBug2755(): void
{
$this->treatPhpDocTypesAsCertain = true;
Expand Down
110 changes: 110 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-14705.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Bug14705;

class Foo
{

/**
* str_contains with non-empty-string haystack should not report always-true.
*
* @param non-empty-string $haystack
*/
public function strContainsNonEmpty(string $haystack, string $needle): void
{
if (str_contains($haystack, $needle)) {

}
}

/**
* str_starts_with with non-empty-string haystack should not report always-true.
*
* @param non-empty-string $haystack
*/
public function strStartsWithNonEmpty(string $haystack, string $needle): void
{
if (str_starts_with($haystack, $needle)) {

}
}

/**
* str_ends_with with non-empty-string haystack should not report always-true.
*
* @param non-empty-string $haystack
*/
public function strEndsWithNonEmpty(string $haystack, string $needle): void
{
if (str_ends_with($haystack, $needle)) {

}
}

/**
* strpos with non-empty-string haystack should not report always-true.
*
* @param non-empty-string $haystack
* @param non-empty-string $needle
*/
public function strposNonEmpty(string $haystack, string $needle): void
{
if (strpos($haystack, $needle) !== false) {

}
}

/**
* array_key_exists with non-constant key on a non-empty-array should not report always-true.
*
* @param non-empty-array<string, int> $array
*/
public function arrayKeyExistsNonEmpty(array $array, string $key): void
{
if (array_key_exists($key, $array)) {

}
}

/**
* @param non-empty-string $needle
*/
public function strEndsWithDuplicate(string $haystack, string $needle): void
{
if (str_ends_with($haystack, $needle)) {
if (str_ends_with($haystack, $needle)) { // could be reported as always-true

}
}
}

/**
* @param non-empty-string $needle
*/
public function strContainsDuplicate(string $haystack, string $needle): void
{
if (str_contains($haystack, $needle)) {
if (str_contains($haystack, $needle)) { // could be reported as always-true

}
}
}

/**
* @phpstan-assert-if-true =non-empty-string $foo
*/
public function isValid(string $foo): bool
{
return $foo !== '';
}

public function equalityAssertDuplicate(string $task): void
{
if ($this->isValid($task)) {
if ($this->isValid($task)) { // could be reported as always-true

}
}
}

}
Loading