Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
156 changes: 76 additions & 80 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\StaticPropertyFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\NodeFinder;
use PHPStan\Analyser\ExprHandler\BooleanAndHandler;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\Expr\AlwaysRememberedExpr;
Expand Down Expand Up @@ -2539,91 +2541,17 @@
if (
$expr instanceof FuncCall
&& $expr->name instanceof Name
&& !$this->reflectionProvider->hasFunction($expr->name, $scope)
) {
$has = $this->reflectionProvider->hasFunction($expr->name, $scope);
if (!$has) {
// backwards compatibility with previous behaviour
return new SpecifiedTypes([], []);
}

$functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope);
$hasSideEffects = $functionReflection->hasSideEffects();
if ($hasSideEffects->yes()) {
return new SpecifiedTypes([], []);
}

if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) {
return new SpecifiedTypes([], []);
}
return new SpecifiedTypes([], []);
}

if (
$expr instanceof FuncCall
&& !$expr->name instanceof Name
) {
$nameType = $scope->getType($expr->name);
if ($nameType->isCallable()->yes()) {
$isPure = null;
foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) {
$variantIsPure = $variant->isPure();
$isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure);
}

if ($isPure !== null) {
if ($isPure->no()) {
return new SpecifiedTypes([], []);
}

if (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()) {
return new SpecifiedTypes([], []);
}
}
}
}

if (
$expr instanceof MethodCall
&& $expr->name instanceof Node\Identifier
) {
$methodName = $expr->name->toString();
$calledOnType = $scope->getType($expr->var);
$methodReflection = $scope->getMethodReflection($calledOnType, $methodName);
if (
$methodReflection === null
|| $methodReflection->hasSideEffects()->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
) {
if (isset($containsNull) && !$containsNull) {
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
}

return new SpecifiedTypes([], []);
}
}

if (
$expr instanceof StaticCall
&& $expr->name instanceof Node\Identifier
) {
$methodName = $expr->name->toString();
if ($expr->class instanceof Name) {
$calledOnType = $scope->resolveTypeByName($expr->class);
} else {
$calledOnType = $scope->getType($expr->class);
if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) {
if (isset($containsNull) && !$containsNull) {
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
}

$methodReflection = $scope->getMethodReflection($calledOnType, $methodName);
if (
$methodReflection === null
|| $methodReflection->hasSideEffects()->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
) {
if (isset($containsNull) && !$containsNull) {
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
}

return new SpecifiedTypes([], []);
}
return new SpecifiedTypes([], []);
}

$sureTypes = [];
Expand Down Expand Up @@ -2654,6 +2582,74 @@
return $types;
}

private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool
{
$nodeFinder = new NodeFinder();
$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool {
Copy link
Copy Markdown
Contributor

@staabm staabm May 15, 2026

Choose a reason for hiding this comment

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

do we have the same bug on impure ArrowFunctions and impure Closures?
if so, add tests and expand the callable to cover it

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 a summary of the changes:

Investigation: The reviewer asked whether impure ArrowFunctions and Closures are handled by expressionContainsNonPureCall. The answer is yes, the current code handles them correctly through two mechanisms:

  1. NodeFinder descends into closure/arrow function bodies — impure FuncCall/MethodCall/StaticCall nodes inside closure bodies are found and flagged (e.g., (fn() => MyRecord::find())())
  2. The FuncCall dynamic callable path checks ClosureType::isPure() via getCallableParametersAcceptors(), which catches impurity from non-call sources like echo

Tests added (7 new test cases):

  • Arrow function IIFE with impure static call in body
  • Closure IIFE with impure static call in body
  • strlen wrapping impure arrow function IIFE
  • Impure closure called via variable
  • Closure IIFE with echo + impure calls in body
  • Pure closure IIFE stays narrowed (positive case)
  • Pure function wrapping impure function with coalesce (covers #12734 pattern)

All 12071 tests pass and PHPStan self-analysis reports no errors.

if ($node instanceof FuncCall) {
if ($node->name instanceof Name) {
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return false;
}
$hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects();
return $hasSideEffects->yes()

Check warning on line 2595 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ return false; } $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); - return $hasSideEffects->yes() + return !$hasSideEffects->no() || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); }

Check warning on line 2595 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ return false; } $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); - return $hasSideEffects->yes() + return !$hasSideEffects->no() || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); }
|| (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no());
}

$nameType = $scope->getType($node->name);
if ($nameType->isCallable()->yes()) {

Check warning on line 2600 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $nameType = $scope->getType($node->name); - if ($nameType->isCallable()->yes()) { + if (!$nameType->isCallable()->no()) { $isPure = null; foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { $variantIsPure = $variant->isPure();

Check warning on line 2600 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $nameType = $scope->getType($node->name); - if ($nameType->isCallable()->yes()) { + if (!$nameType->isCallable()->no()) { $isPure = null; foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { $variantIsPure = $variant->isPure();
$isPure = null;
foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) {
$variantIsPure = $variant->isPure();
$isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure);
}
if ($isPure !== null) {
return $isPure->no()

Check warning on line 2607 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); } if ($isPure !== null) { - return $isPure->no() + return !$isPure->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()); } }

Check warning on line 2607 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); } if ($isPure !== null) { - return $isPure->no() + return !$isPure->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()); } }
|| (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes());
}
}

return false;
}

if ($node instanceof MethodCall) {
if ($node->name instanceof Identifier) {
$calledOnType = $scope->getType($node->var);
$methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name);
if ($methodReflection === null) {
return true;
}
$hasSideEffects = $methodReflection->hasSideEffects();
return $hasSideEffects->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no());
}
return true;
Comment thread
staabm marked this conversation as resolved.
}

if ($node instanceof StaticCall) {
if ($node->name instanceof Identifier) {
if ($node->class instanceof Name) {
$calledOnType = $scope->resolveTypeByName($node->class);
} else {
$calledOnType = $scope->getType($node->class);
}
$methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name);
if ($methodReflection === null) {
return true;
}
$hasSideEffects = $methodReflection->hasSideEffects();
return $hasSideEffects->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no());
}
return true;
}

return false;
});

return $found !== null;
}

private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes
{
if ($expr instanceof Expr\NullsafePropertyFetch) {
Expand Down
17 changes: 17 additions & 0 deletions tests/PHPStan/Analyser/TypeSpecifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use PHPStan\Type\ArrayType;
use PHPStan\Type\ClassStringType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\FloatType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\IntegerType;
Expand Down Expand Up @@ -1354,6 +1355,22 @@ private static function createInstanceOf(string $className, string $variableName
return new Expr\Instanceof_(new Variable($variableName), new Name($className));
}

public function testUnknownFunctionSubExpressionDoesNotPreventNarrowing(): void
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.

could this be tested via StrContainingTypeSpecifyingExtension with a str-containing function instead?

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. Replaced the contrived TypeSpecifierTest::testUnknownFunctionSubExpressionDoesNotPreventNarrowing unit test (which manually built count(FAUX_FUNCTION($foo)) AST nodes) with an NSRT integration test in non-empty-string-str-containing-fns.php that exercises str_contains with a non-empty-string needle — which triggers the FAUX_FUNCTION rootExpr path in StrContainingTypeSpecifyingExtension. The test verifies narrowing works correctly across two successive str_contains calls. All 12071 tests pass, PHPStan self-analysis clean, CS clean.

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.

the newly added test does not fail when reverting aadf81a

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. The fix restores the TypeSpecifierTest::testUnknownFunctionSubExpressionDoesNotPreventNarrowing unit test which directly exercises the unknown-function-in-sub-expression code path by calling TypeSpecifier::create() with count(FAUX_FUNCTION($foo)). I verified that this test correctly fails when reverting commit aadf81a (empty SpecifiedTypes instead of narrowed type) and passes with the fix in place. The NSRT test in non-empty-string-str-containing-fns.php was removed since it only narrows a Variable and never touches expressionContainsNonPureCall with unknown functions.

{
$fauxFuncCall = new FuncCall(new Name('FAUX_FUNCTION'), [new Arg(new Variable('foo'))]);
$countCall = new FuncCall(new Name('count'), [new Arg($fauxFuncCall)]);

$specifiedTypes = $this->typeSpecifier->create(
$countCall,
new ConstantIntegerType(1),
TypeSpecifierContext::createTrue(),
$this->scope,
);

$result = $this->toReadableResult($specifiedTypes);
$this->assertSame(['count(FAUX_FUNCTION($foo))' => '1'], $result);
}

/**
* @param non-empty-string $functionName
*/
Expand Down
147 changes: 147 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13416.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

declare(strict_types = 1);

namespace Bug13416;

use function PHPStan\Testing\assertType;

class MyRecord
{
/** @var list<self> */
private static array $storage = [];

/**
* @return list<self>
* @phpstan-impure
*/
public static function find(): array
{
return self::$storage;
}

/** @phpstan-impure */
public function insert(): void
{
self::$storage[] = $this;
}

/**
* @return non-empty-string
* @phpstan-impure
*/
public function getName(): string
{
return 'test';
}
}

class Repository
{
/**
* @return list<MyRecord>
* @phpstan-impure
*/
public function findAll(): array
{
return [];
}

/** @phpstan-impure */
public function save(MyRecord $record): void
{
}
}

function testImpureStaticCallNotNarrowedByCount(): void
{
assert(count(MyRecord::find()) === 1);
// Impure call result should not be narrowed
assertType('int<0, max>', count(MyRecord::find()));
}

function testImpureMethodCallNotNarrowedByCount(): void
{
$repo = new Repository();

assert(count($repo->findAll()) === 1);
// Impure call result should not be narrowed
assertType('int<0, max>', count($repo->findAll()));
}

function testStrlenOfImpureCallNotNarrowed(): void
{
$record = new MyRecord();

assert(strlen($record->getName()) === 3);
// strlen wrapping an impure call should not be narrowed
assertType('int<1, max>', strlen($record->getName()));
}

function testPureFunctionStaysNarrowed(): void
{
/** @var list<int> $arr */
$arr = [1];
assert(count($arr) === 1);
assertType('1', count($arr));

$x = rand(0, 10);

// Pure expressions stay narrowed
assertType('1', count($arr));
}

function testImpureArrowFunctionIIFE(): void
{
assert(count((fn() => MyRecord::find())()) === 1);
assertType('int<0, max>', count((fn() => MyRecord::find())()));
}

function testImpureClosureIIFE(): void
{
assert(count((function() { return MyRecord::find(); })()) === 1);
assertType('int<0, max>', count((function() { return MyRecord::find(); })()));
}

function testStrlenOfImpureArrowFunctionIIFE(): void
{
$record = new MyRecord();
assert(strlen((fn() => $record->getName())()) === 3);
assertType('int<1, max>', strlen((fn() => $record->getName())()));
}

function testImpureClosureViaVariable(): void
{
$fn = function(): array { return MyRecord::find(); };
assert(count($fn()) === 1);
assertType('int<0, max>', count($fn()));
}

function testImpureClosureWithEchoIIFE(): void
{
assert(strlen((function() { echo 'side-effect'; return MyRecord::find()[0]->getName(); })()) === 5);
assertType('int<1, max>', strlen((function() { echo 'side-effect'; return MyRecord::find()[0]->getName(); })()));
}

function testPureClosureIIFEStaysNarrowed(): void
{
/** @var list<int> $arr */
$arr = [1, 2, 3];
assert(count((fn() => $arr)()) === 3);
assertType('3', count((fn() => $arr)()));
}

/**
* @param string|null $val
* @phpstan-impure
*/
function impureFunction(?string $val): ?string
{
return $val;
}

function testPureOfImpureNotNarrowedByCoalesce(): void
{
$a = strlen(impureFunction('hello') ?? '') > 0;
assertType('bool', strlen(impureFunction('hello') ?? '') > 0);
}
Loading