Replace FAUX function call workarounds#5769
Conversation
| return null; | ||
| } | ||
|
|
||
| if ($specifiedTypes->shouldNotImplyOppositeCase()) { |
There was a problem hiding this comment.
It's unclear reading this early return how it's only about "opposite case".
There was a problem hiding this comment.
Added a comment above the shouldNotImplyOppositeCase() check in ImpossibleCheckTypeHelper.php explaining why this flag causes a full early return: the sureTypes are side effects of the check (not the determining condition), so they can't tell us whether the check is always-true or always-false. All tests and static analysis pass.
There was a problem hiding this comment.
Is the new property well named ?
There was a problem hiding this comment.
Renamed shouldNotImplyOppositeCase to shouldNotDetermineCheckResult across all 5 files. The old name implied the flag only prevented inferring the negated case, but it actually prevents ImpossibleCheckTypeHelper from determining any outcome (both always-true and always-false). The new name accurately describes the flag's effect. All 12155 tests pass and PHPStan self-analysis reports no errors.
VincentLanglet
left a comment
There was a problem hiding this comment.
Should it be something like "specifyOnly" (improve the suggestion)
Also should overwrite imply this ?
|
Pushed. Here's what changed:
|
setShouldNotImplyOppositeCase() on SpecifiedTypes to replace FAUX function call workaroundssetSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds
|
@phpstan-bot describe how https://phpstan.org/writing-php-code/narrowing-types#equality-assertions is implemented (without changing code) |
VincentLanglet
left a comment
There was a problem hiding this comment.
When we use @phpstan-assert =string $foo the AssertTag has isEquality set to true.
Then there was the logic
$newTypes = $this->create(
$assertExpr,
$assertedType,
$assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(),
$scope,
)->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null);
which avoided the always-true/false error
Then seems like some specific case exists in ImpossibleCheckTypeHelper for $rootExpr !== null. Shouldn't we still rely on this logic ?
Also, it would be nice to keep reporting the second condition as always true in
if (str_ends_with($haystack, $needle)) {
if (str_ends_with($haystack, $needle)) {
}
}
|
Pushed. Here's what changed: Equality assertions (
Duplicate call detection: Added test cases showing that nested identical |
VincentLanglet
left a comment
There was a problem hiding this comment.
I want
-
An Unified behavior for Faux function call and Assert with
isEqualityto true. -
Duplicate call to be reported, like how it works for typeSpecifier without RootExpr.
class MondayMorning
{
/**
* @param non-empty-string $needle
*/
public function strEndsWithDuplicate(string $haystack, string $needle): void
{
if (str_ends_with($haystack, $needle)) { // Not reported
if (str_ends_with($haystack, $needle)) { // Reported
}
}
}
public function startDay(string $task): void
{
if ($this->isValid($task)) { // Not reported
if ($this->isValid($task)) { // reported
}
}
}
/** @phpstan-assert-if-true =non-empty-string $foo */
public function isValid(string $foo): bool
{
}
}
Maybe start by explaining the purpose of the rootExpr.
…FAUX function call workarounds - Add `shouldNotImplyOppositeCase` flag to `SpecifiedTypes` with `@api`-tagged setter and getter methods, propagated through all immutable-copy operations (setAlwaysOverwriteTypes, setRootExpr, setNewConditionalExpressionHolders, removeExpr, intersectWith, unionWith, normalize) - Check the flag in `ImpossibleCheckTypeHelper::findSpecifiedType()` to return null early, preventing false "always true/false" reports when sureTypes are side effects of a check rather than its determining condition - Replace `FAUX_FUNCTION` rootExpr in `StrContainingTypeSpecifyingExtension` with `setShouldNotImplyOppositeCase()` - Replace `__PHPSTAN_FAUX_CONSTANT` rootExpr in `ArrayKeyExistsFunctionTypeSpecifyingExtension` with `setShouldNotImplyOppositeCase()` - Use the flag for equality assertions in `TypeSpecifier::specifyTypesFromAsserts()` instead of setting rootExpr to the call expression - Remove unused imports (Arg, BooleanAnd, NotIdentical, String_, Name, Identical, ConstFetch) from the two extension files Closes phpstan/phpstan#14705
…turn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The old name suggested the flag only prevented inferring the negated (opposite) case, but it actually prevents ImpossibleCheckTypeHelper from determining any outcome — both always-true and always-false. The new name accurately describes the flag's effect: the sureTypes should not be used to determine the check result. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The flag means "these SpecifiedTypes only narrow types, don't use them to determine check outcome" — `specifyOnly` captures this concisely using the domain language of the SpecifiedTypes class. `overwrite` remains independent: it controls how types are applied in MutatingScope (assign vs intersect), while `specifyOnly` controls ImpossibleCheckTypeHelper. They already have separate early-return checks and serve different purposes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r check - Revert equality assertions (`@phpstan-assert =`) back to using `rootExpr = $call` instead of `specifyOnly`. The rootExpr mechanism in ImpossibleCheckTypeHelper provides more nuanced detection (constant boolean evaluation via scope) and is the established path for these. `specifyOnly` is reserved for the FAUX replacement cases (str_contains, array_key_exists) where sureTypes are pure side effects. - Move the `specifyOnly` check after the `rootExpr` check in ImpossibleCheckTypeHelper so that rootExpr takes precedence when both flags are set (e.g. via unionWith/intersectWith propagation). - Add duplicate call test cases (str_ends_with, str_contains) to document that nested identical calls are not reported as always-true. This was never detected before — the old FAUX mechanism also returned null for these — and would require a separate mechanism (tracking function call results in scope). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
f8567a4 to
d6e4e48
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
I want
-
An Unified behavior for Faux function call and Assert with
isEqualityto true. -
Duplicate call to be reported, like how it works for typeSpecifier without RootExpr.
class MondayMorning
{
/**
* @param non-empty-string $needle
*/
public function strEndsWithDuplicate(string $haystack, string $needle): void
{
if (str_ends_with($haystack, $needle)) { // Not reported
if (str_ends_with($haystack, $needle)) { // Reported
}
}
}
public function startDay(string $task): void
{
if ($this->isValid($task)) { // Not reported
if ($this->isValid($task)) { // reported
}
}
}
/** @phpstan-assert-if-true =non-empty-string $foo */
public function isValid(string $foo): bool
{
}
}
Maybe start by explaining the purpose of the rootExpr.
|
Pushed successfully. Here's a summary of what changed: Unified
Added duplicate call detection:
Tests: All 12540 tests pass. PHPStan shows only 4 pre-existing errors (none from this change). New test expectations verify duplicate detection for |
f7b6a02 to
b601591
Compare
| new Arg($args[$needleArg]->value), | ||
| ]), | ||
| )); | ||
| )->setRootExpr($node); |
There was a problem hiding this comment.
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` ?
There was a problem hiding this comment.
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
rootExpris set,ImpossibleCheckTypeHelperevaluates 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 reachreturn nullinImpossibleCheckTypeHelper, just via different paths. With$node, theisSpecified()check sees$rootExpr === $node(line 374) and returnstrueimmediately, causing an earlyreturn null. With FAUX_FUNCTION,isSpecified()returnedfalse(fabricated expression ≠ node), then$scope->getType(fabricatedExpr)couldn't resolve to aConstantBooleanType(undefined function), falling through to the samereturn null.
All 12539 tests pass, PHPStan self-analysis reports no errors.
After the Rework commit unified both FAUX extensions and equality assertions through setRootExpr($node), the specifyOnly flag became dead code. Remove it entirely. Add PHPDoc to setRootExpr() explaining the mechanism: when rootExpr is set, ImpossibleCheckTypeHelper evaluates it in scope instead of analysing sureTypes/sureNotTypes. When rootExpr === the check node (as used by str_contains, array_key_exists, and equality assertions), isSpecified() bails out immediately, preventing false always-true reports from side-effect narrowing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| 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 |
There was a problem hiding this comment.
This should be reported, it would certainly require to add
$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy(), $scope),
and a dedicated logic before the early return null if it's specified.
But couldn't the typeSpecifier->create call be imply by the fact we set a rootExpr since it's made for this ?
There was a problem hiding this comment.
Pushed. Here's what changed:
Duplicate call detection for rootExpr-based type specifying:
-
Extensions (
StrContainingTypeSpecifyingExtension,ArrayKeyExistsFunctionTypeSpecifyingExtension): Now add a sureType$node → ConstantBooleanType(true)viaunionWith($this->typeSpecifier->create(...))alongsidesetRootExpr($node). This stores the call's boolean result in scope whenfilterByTruthyValueprocesses the condition. -
TypeSpecifier::specifyTypesFromAsserts: For equality assertions and unresolved templates (where rootExpr is set), adds a sureType for the call expression withConstantBooleanType($context->true())—truein truthy context,falsein falsey context. -
ImpossibleCheckTypeHelper: Before theisSpecifiedearly return in the rootExpr block, checks$scope->hasExpressionType($node). If the call's result type is already known in scope (stored from a previous identical check in an outer branch), reports always-true/false. -
SpecifiedTypes::setRootExprPHPDoc: Updated to document that callers should also add a sureType for the rootExpr expression. -
Test expectations updated: Duplicate
str_ends_with,str_contains, and equality assertion method calls are now reported as always-true.
Dead code fix: The new duplicate detection caught a real duplicate array_key_exists check in MethodCallWithPossiblyRenamedNamedArgumentRule.php:54 — removed the redundant check, reducing PHPStan self-analysis errors from 4 to 3.
All 12540 tests pass. PHPStan self-analysis shows only 3 pre-existing errors.
When setRootExpr($node) is used, also add a sureType for the call expression with ConstantBooleanType(true). This stores the expression result in scope via filterByTruthyValue, enabling ImpossibleCheckTypeHelper to detect duplicate calls (e.g. nested identical str_ends_with inside if(str_ends_with(...))). ImpossibleCheckTypeHelper now checks scope for the expression type before the isSpecified early return. If the call result is already known (stored from a previous identical check), it reports always-true/false. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The same array_key_exists($prototypeParameterName, $prototypeMethodCalls) check was performed twice — the second was dead code. Detected by the new duplicate call detection for rootExpr-based type specifying. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
setSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds
VincentLanglet
left a comment
There was a problem hiding this comment.
Would it be possible to deprecate setRootExpr and use a new property/setter with a dedicated logic in ImpossibleCheckHelper instead which
- Doesn't require to add
$this->create($call, new ConstantBooleanType($context->true()), TypeSpecifierContext::createTrue(), $scope),
as a workaround
- Keep reporting duplicate calls
- Still avoid always-true/always-false false positive
Without any implementation, propose one or multiple strategy in a PR comment.
|
I can't comment directly on the upstream PR due to token permissions. Since the review asks for strategy proposals without implementation, I'll provide the comment content here. The automated pipeline will post it to the PR. Proposed strategies for deprecating
|
| Aspect | Current approach | Proposed |
|---|---|---|
| Extension API | ->unionWith(create($node, ConstantBooleanType...))->setRootExpr($node) |
->setSideEffectOnly() |
| False positive suppression | isSpecified short-circuit via rootExpr === node |
Dedicated early return in ImpossibleCheckTypeHelper |
| Duplicate detection | hasExpressionType using manually-added ConstantBooleanType sureType |
hasExpressionType using automatically-stored result in filterByTruthyValue |
| Infrastructure changes | None | filterByTruthyValue/filterByFalseyValue (~4 lines each) |
No code changes in this iteration — this is a strategy proposal per the reviewer's request. The current PR code remains as-is until a strategy is agreed on.
VincentLanglet
left a comment
There was a problem hiding this comment.
Implement the strategy from #5769 (comment)
Summary
Type-specifying extensions like
StrContainingTypeSpecifyingExtensionandArrayKeyExistsFunctionTypeSpecifyingExtensionnarrow argument types as a side effect of a check being true (e.g.str_contains($haystack, $needle)narrows$haystacktonon-empty-string). Previously, to preventImpossibleCheckTypeHelperfrom incorrectly concluding "always true" when the narrowed types were already satisfied, these extensions fabricated rootExpr AST nodes containingFAUX_FUNCTIONor__PHPSTAN_FAUX_CONSTANT— opaque expressions that could never resolve to a constant boolean.This PR replaces that workaround with a proper
shouldNotImplyOppositeCaseflag onSpecifiedTypes, following the same concept as equality assertions (@phpstan-assert-if-true =Type $param) which also prevent the opposite case from being implied.Changes
src/Analyser/SpecifiedTypes.php: AddedshouldNotImplyOppositeCaseboolean flag with@api-taggedsetShouldNotImplyOppositeCase()setter andshouldNotImplyOppositeCase()getter. Flag is propagated through all immutable-copy methods:setAlwaysOverwriteTypes,setRootExpr,setNewConditionalExpressionHolders,removeExpr,intersectWith(OR propagation),unionWith(OR propagation), andnormalize.src/Rules/Comparison/ImpossibleCheckTypeHelper.php: Check the new flag before sureTypes/sureNotTypes analysis — when set, returnnull(no always-true/false reporting).src/Type/Php/StrContainingTypeSpecifyingExtension.php: ReplacedFAUX_FUNCTIONrootExpr withsetShouldNotImplyOppositeCase(). Removed unused imports.src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php: Replaced__PHPSTAN_FAUX_CONSTANTrootExpr withsetShouldNotImplyOppositeCase(). Removed unused imports.src/Analyser/TypeSpecifier.php: InspecifyTypesFromAsserts(), equality assertions now use the new flag instead of setting rootExpr to the call expression. ThecontainsUnresolvedTemplatecase still uses rootExpr as before.Analogous cases probed
ArraySearchFunctionTypeSpecifyingExtension: Already excluded via explicitreturn nullinImpossibleCheckTypeHelper— no change needed.ClassExistsFunctionTypeSpecifyingExtension,FunctionExistsFunctionTypeSpecifyingExtension,DefinedConstantTypeSpecifyingExtension: Already excluded viain_array($functionName, [...])check inImpossibleCheckTypeHelper— no change needed.MethodExistsTypeSpecifyingExtension,PropertyExistsTypeSpecifyingExtension: Have custom logic inImpossibleCheckTypeHelperthat handles them correctly — no change needed.Root cause
The FAUX mechanism was a workaround for the lack of a proper "don't use sureTypes to determine check outcome" flag on
SpecifiedTypes. When a type-specifying extension narrows an argument type as a side effect of a check (not the check's determining condition),ImpossibleCheckTypeHelperwould incorrectly conclude the check is always-true if those types were already satisfied in scope. The fabricatedFAUX_FUNCTION/__PHPSTAN_FAUX_CONSTANTexpressions in rootExpr made the expression unevaluable, suppressing the false positive. The new flag achieves the same result cleanly.Test
tests/PHPStan/Rules/Comparison/data/bug-14705.phpwith test cases forstr_contains,str_starts_with,str_ends_with,strposwithnon-empty-stringhaystack, andarray_key_existswith non-constant key onnon-empty-array— all verifying no false "always true" reports.Fixes phpstan/phpstan#14705