Skip to content

Commit bc36ada

Browse files
phpstan-botondrejmirtes
authored andcommitted
Apply the impure-call purity gate to a call's own key when it also carries asserts, and to nullsafe chain keys
- MethodCallHandler::specifyTypes(): the assert branch unioned the asserts with an ungated specifyDefaultTypes(), so an @phpstan-impure method carrying @phpstan-assert-if-true keyed its own truthiness. It now unions with defaultMethodCallNarrowing(), which applies the same purity gate (and nullsafe receiver fan) as the assert-less path. - StaticCallHandler::specifyTypes(): same fix through defaultStaticCallNarrowing(). - DefaultNarrowingHelper::specifyDefaultTypesWithPlainTwin(): the `?->` node's own key and the deeper chain's fully plain form are rebuilt nodes, so the identity- based impure-point gate in createSubjectTypesFromResultState() never matched them and an impure `$a?->b()` was remembered as true. The gate is now read once off the walked twin node and applied to the whole key family. - Extracted the impure-point gate into DefaultNarrowingHelper::isSubjectValueRemembered() and reused it from createSubjectTypesFromResultState(). - Probed and found already correct: FuncCallHandler (fixed by 3b57b6b), NewHandler and the callable-call assert path (neither unions a default key, matching 2.2.x), conditional return types, type-specifying extensions, @phpstan-assert / @phpstan-assert-if-false, static::/parent::/self:: calls, interface methods, loops, match, empty(), ternary, ?:, first-class callables and closures - with rememberPossiblyImpureFunctionValues both true and false.
1 parent 7583afd commit bc36ada

8 files changed

Lines changed: 331 additions & 32 deletions

File tree

‎src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php‎

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,8 @@ public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context):
121121
* specifyDefaultTypes() for a chain containing a nullsafe: a truthy chain
122122
* did not short-circuit, so its fully plain twin ($a?->b()?->c() ->
123123
* $a->b()->c()) holds the same value and the source reads it that way once
124-
* every link is known non-null. The twin is keyed through the create path,
125-
* so the impure gate applies to it exactly as to the chain itself - an
126-
* impure call's value is never remembered, plain or nullsafe.
124+
* every link is known non-null. The impure gate applies to the whole family
125+
* of keys - an impure call's value is never remembered, plain or nullsafe.
127126
*/
128127
public function specifyDefaultTypesWithPlainTwin(Expr $expr, ?ExpressionResult $exprResult, TypeSpecifierContext $context, MutatingScope $s): SpecifiedTypes
129128
{
@@ -134,11 +133,18 @@ public function specifyDefaultTypesWithPlainTwin(Expr $expr, ?ExpressionResult $
134133

135134
// The result is the one-level twin's ($a?->b() walked as $a->b()), and
136135
// its impure point is keyed to that very node - so the twin node it
137-
// carries is the subject, not a rebuilt one (the impure gate compares
138-
// node identity). For a deeper chain the fully plain form differs from
139-
// the twin only in its receiver; it is keyed too, through the same gate.
136+
// carries is the subject, not a rebuilt one (the gate compares node
137+
// identity). The `?->` node and the deeper chain's fully plain form are
138+
// rebuilt, so they cannot be gated by identity - they name the same call
139+
// and share the twin's verdict.
140140
$twinNode = $exprResult->getExpr();
141141
$twin = $this->createSubjectTypesFromResultState($s, $twinNode, $exprResult, StaticTypeFactory::falsey(), TypeSpecifierContext::createFalse());
142+
if (!$this->isSubjectValueRemembered($exprResult, $twinNode)) {
143+
// only the receiver-not-null fan createSubjectTypesFromResultState()
144+
// produced for the impure call survives
145+
return $twin->setRootExpr($expr);
146+
}
147+
142148
$plainChain = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($expr);
143149
if ($plainChain !== $expr && $this->exprPrinter->printExpr($plainChain) !== $this->exprPrinter->printExpr($twinNode)) {
144150
$twin = $twin->unionWith($this->createSubjectTypesFromResultState($s, $plainChain, $exprResult, StaticTypeFactory::falsey(), TypeSpecifierContext::createFalse()));
@@ -147,6 +153,34 @@ public function specifyDefaultTypesWithPlainTwin(Expr $expr, ?ExpressionResult $
147153
return $default->unionWith($twin)->setRootExpr($expr);
148154
}
149155

156+
/**
157+
* The impure gate read off an already-processed result: a call whose own
158+
* execution is (possibly) impure must not get a remembered type. The
159+
* result's impure point is keyed to the very node that was walked, so the
160+
* subject has to be that node and not a rebuilt one.
161+
*/
162+
private function isSubjectValueRemembered(ExpressionResult $subjectResult, Expr $subject): bool
163+
{
164+
if (
165+
!$subject instanceof Expr\FuncCall
166+
&& !$subject instanceof Expr\MethodCall
167+
&& !$subject instanceof Expr\StaticCall
168+
&& !$subject instanceof Expr\NullsafeMethodCall
169+
) {
170+
return true;
171+
}
172+
173+
foreach ($subjectResult->getImpurePoints() as $impurePoint) {
174+
if ($impurePoint->getNode() !== $subject) {
175+
continue;
176+
}
177+
178+
return !$impurePoint->isCertain() && $this->rememberPossiblyImpureFunctionValues;
179+
}
180+
181+
return true;
182+
}
183+
150184
/**
151185
* Converts sure-not entries to sure form against the given evaluation
152186
* scope (position-fixed, captured at compose time) - for the decided
@@ -216,31 +250,17 @@ public function createSubjectTypesFromResultState(MutatingScope $s, Expr $subjec
216250
// a call whose own execution is (possibly) impure must not get a
217251
// remembered type - the gate reads the result's own impure point
218252
// instead of re-asking reflection like the old create() did
219-
if (
220-
$subject instanceof Expr\FuncCall
221-
|| $subject instanceof Expr\MethodCall
222-
|| $subject instanceof Expr\StaticCall
223-
|| $subject instanceof Expr\NullsafeMethodCall
224-
) {
225-
foreach ($subjectResult->getImpurePoints() as $impurePoint) {
226-
if ($impurePoint->getNode() !== $subject) {
227-
continue;
228-
}
229-
if ($impurePoint->isCertain() || !$this->rememberPossiblyImpureFunctionValues) {
230-
// the call's value is not remembered, but a nullsafe
231-
// receiver chain still narrows not-null: the chain must
232-
// have evaluated for the (impure) call to produce any
233-
// non-null value at all (mirrors the old createForExpr()
234-
// returning createNullsafeTypes() from its impure branch)
235-
if ($subjectResult->containsNullsafe() && $this->nullsafeShortCircuitRuledOut($s, $subjectResult, $type, $context)) {
236-
return $this->createFirstNullsafeReceiverTypes($s, $subject) ?? new SpecifiedTypes([], []);
237-
}
238-
239-
return new SpecifiedTypes([], []);
240-
}
241-
242-
break;
253+
if (!$this->isSubjectValueRemembered($subjectResult, $subject)) {
254+
// the call's value is not remembered, but a nullsafe receiver
255+
// chain still narrows not-null: the chain must have evaluated
256+
// for the (impure) call to produce any non-null value at all
257+
// (mirrors the old createForExpr() returning createNullsafeTypes()
258+
// from its impure branch)
259+
if ($subjectResult->containsNullsafe() && $this->nullsafeShortCircuitRuledOut($s, $subjectResult, $type, $context)) {
260+
return $this->createFirstNullsafeReceiverTypes($s, $subject) ?? new SpecifiedTypes([], []);
243261
}
262+
263+
return new SpecifiedTypes([], []);
244264
}
245265

246266
// a chain containing a nullsafe narrows its short-circuited plain

‎src/Analyser/ExprHandler/MethodCallHandler.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,12 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop
536536
));
537537
$specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope);
538538
if ($specifiedTypes !== null) {
539+
// the asserts narrow their subjects regardless; the call's OWN
540+
// key goes through the same purity gate as the assert-less
541+
// default narrowing - an impure method evaluated a second time
542+
// must not read the first call's truthiness
539543
return $specifiedTypes
540-
->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))
544+
->unionWith($this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context))
541545
->setRootExpr($specifiedTypes->getRootExpr());
542546
}
543547
}

‎src/Analyser/ExprHandler/StaticCallHandler.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,8 +604,10 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop
604604
));
605605
$specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope);
606606
if ($specifiedTypes !== null) {
607+
// see MethodCallHandler::specifyTypes() - the call's own key gets
608+
// the purity gate, the asserts narrow their subjects regardless
607609
return $specifiedTypes
608-
->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))
610+
->unionWith($this->defaultStaticCallNarrowing($scope, $expr, $classResult, $nodeScopeResolver, $context))
609611
->setRootExpr($specifiedTypes->getRootExpr());
610612
}
611613
}

‎tests/PHPStan/Analyser/nsrt/impure-call-asserts-not-remembered.php‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,56 @@ function doFoo(string $path): void
1616
assertType('non-empty-string|false', realpath($path));
1717
}
1818
}
19+
20+
class Foo
21+
{
22+
23+
/** @var resource|null */
24+
private $process = null;
25+
26+
/**
27+
* @phpstan-impure
28+
* @phpstan-assert-if-true !null $this->process
29+
*/
30+
private function impureAssert(): bool
31+
{
32+
return $this->process !== null && (bool) microtime(true);
33+
}
34+
35+
/**
36+
* @phpstan-impure
37+
* @phpstan-assert-if-true !null $foo->process
38+
*/
39+
private static function impureStaticAssert(self $foo): bool
40+
{
41+
return $foo->process !== null && (bool) microtime(true);
42+
}
43+
44+
public function doMethod(): void
45+
{
46+
// the assert narrows $this->process, the impure call's own value is
47+
// never remembered
48+
if ($this->impureAssert()) {
49+
assertType('resource', $this->process);
50+
assertType('bool', $this->impureAssert());
51+
}
52+
}
53+
54+
public function doStatic(self $foo): void
55+
{
56+
if (self::impureStaticAssert($foo)) {
57+
assertType('resource', $foo->process);
58+
assertType('bool', self::impureStaticAssert($foo));
59+
}
60+
}
61+
62+
public function doNullsafe(?self $foo): void
63+
{
64+
if ($foo?->impureAssert()) {
65+
assertType('resource', $foo->process);
66+
assertType('bool', $foo?->impureAssert());
67+
assertType('bool', $foo->impureAssert());
68+
}
69+
}
70+
71+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ImpureNullsafeCallNotRemembered;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
10+
/** @phpstan-impure */
11+
public function impure(): bool
12+
{
13+
return (bool) microtime(true);
14+
}
15+
16+
/** @phpstan-pure */
17+
public function pure(): bool
18+
{
19+
return true;
20+
}
21+
22+
/** @phpstan-pure */
23+
public function getSelf(): ?self
24+
{
25+
return $this;
26+
}
27+
28+
public function doImpure(?self $foo): void
29+
{
30+
// neither the `?->` key nor its plain twin remembers an impure value
31+
if ($foo?->impure()) {
32+
assertType('ImpureNullsafeCallNotRemembered\Foo', $foo);
33+
assertType('bool', $foo?->impure());
34+
assertType('bool', $foo->impure());
35+
}
36+
}
37+
38+
public function doImpureChain(?self $foo): void
39+
{
40+
if ($foo?->getSelf()?->impure()) {
41+
assertType('ImpureNullsafeCallNotRemembered\Foo', $foo);
42+
assertType('bool', $foo?->getSelf()?->impure());
43+
assertType('bool', $foo->getSelf()->impure());
44+
}
45+
}
46+
47+
public function doPure(?self $foo): void
48+
{
49+
if ($foo?->pure()) {
50+
assertType('true', $foo?->pure());
51+
assertType('true', $foo->pure());
52+
}
53+
}
54+
55+
public function doPureChain(?self $foo): void
56+
{
57+
if ($foo?->getSelf()?->pure()) {
58+
assertType('true', $foo?->getSelf()?->pure());
59+
assertType('true', $foo->getSelf()->pure());
60+
}
61+
}
62+
63+
}

‎tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,10 @@ public function testInTrait(): void
540540
]);
541541
}
542542

543+
public function testBug15170(): void
544+
{
545+
$this->treatPhpDocTypesAsCertain = true;
546+
$this->analyse([__DIR__ . '/data/bug-15170.php'], []);
547+
}
548+
543549
}

‎tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,4 +423,10 @@ public function testMarkerFromAnotherFileDoesNotSuppress(): void
423423
]);
424424
}
425425

426+
public function testBug15170(): void
427+
{
428+
$this->treatPhpDocTypesAsCertain = true;
429+
$this->analyse([__DIR__ . '/data/bug-15170.php'], []);
430+
}
431+
426432
}

0 commit comments

Comments
 (0)