Replace FAUX function call workarounds#5769
Conversation
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>
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)
…around Type-specifying extensions that narrow argument types as a side effect (e.g. str_contains narrowing haystack to non-empty-string) previously used setRootExpr($node) plus a manually-added ConstantBooleanType sureType to suppress false always-true/false reports and enable duplicate call detection. Equality assertions used the same workaround. This commit replaces that pattern with a dedicated setSideEffectOnly() flag on SpecifiedTypes: - SpecifiedTypes: new @api setSideEffectOnly() setter, isSideEffectOnly() getter, propagated through all immutable-copy methods (OR semantics in unionWith/intersectWith, preserved through normalize and setRootExpr) - ImpossibleCheckTypeHelper: dedicated sideEffectOnly block before the rootExpr block — checks hasExpressionType for duplicate detection, otherwise returns null (no always-true/false report) - MutatingScope: filterByTruthyValue/filterByFalseyValue automatically store the call's boolean result via TypeSpecifier::create when sideEffectOnly is set, enabling duplicate detection without manual ConstantBooleanType sureTypes in each extension - Extensions simplified: StrContainingTypeSpecifyingExtension: ->setSideEffectOnly() ArrayKeyExistsFunctionTypeSpecifyingExtension: ->setSideEffectOnly() PregMatchTypeSpecifyingExtension: ->setSideEffectOnly() TypeSpecifier::specifyTypesFromAsserts: ->setSideEffectOnly() All 12540 tests pass. PHPStan self-analysis reports no errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed successfully. Here's a summary of what was implemented: Added How it works:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
testBug14705 is failing on PHP 7.4 I think it's because is str_ends_with and str_contains doesn't exists.
Split the test in half, on general and one for PHP 8+
str_contains, str_starts_with, and str_ends_with don't exist in PHP 7.4,
so the test data is split into bug-14705.php (strpos, array_key_exists,
equality assertions) and bug-14705-php8.php (str_* functions). The PHP 8+
test method uses #[RequiresPhp('>= 8.0')] attribute.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All 12541 tests pass, |
VincentLanglet
left a comment
There was a problem hiding this comment.
You said
Side-effect suppression (~5 call sites in extensions + equality assertions): Extensions like StrContaining and ArrayKeyExists set rootExpr = $node (the call itself). Since isSpecified($node, $rootExpr) returns true when $rootExpr === $node (line 380 of ImpossibleCheckTypeHelper), this triggers immediate return null, suppressing false always-true/false reports. The ConstantBooleanType sureType was then added alongside to enable duplicate detection via hasExpressionType (lines 281-289).
But call the option sideEffectOnly ; is it consistent ?
The term "side effect" is overloaded — in programming it refers to function impurity, while here it meant "type narrowings that are a consequence of the check." Rename to specifyOnly which fits the SpecifiedTypes domain: these types only specify (narrow) types, they don't determine the check outcome in ImpossibleCheckTypeHelper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Renamed |
| continue; | ||
| } | ||
|
|
||
| if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { |
There was a problem hiding this comment.
how is this change related?
There was a problem hiding this comment.
Reverted the unrelated array_key_exists removal in MethodCallWithPossiblyRenamedNamedArgumentRule.php. The original file had a genuinely duplicated array_key_exists check that was caught during make phpstan runs with this PR's changes, but it's a pre-existing issue unrelated to the specifyOnly mechanism. All 12541 tests pass and make phpstan is clean.
The duplicate check in MethodCallWithPossiblyRenamedNamedArgumentRule is a pre-existing issue unrelated to this PR. Reverting to keep the PR focused on the specifyOnly mechanism. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
Some extensions like phpstan-webmozart-assert use FauxFunction and rootExpr (for instance https://github.com/phpstan/phpstan-webmozart-assert/blob/c60e8c090c18fd7065c40b18678cf4342f5ab5e5/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php#L649 and https://github.com/phpstan/phpstan-webmozart-assert/blob/c60e8c090c18fd7065c40b18678cf4342f5ab5e5/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php#L168
Given the fact it currently rely on code like
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
$scope,
$expr,
TypeSpecifierContext::createTruthy(),
)->setRootExpr($rootExpr);
where $expr is not the original $node how can we still report always true/false error for duplicated calls if we migrate to specifyOnly ?
Should we update the code in this PR ?
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