Resolve PHP version checks in dynamic return type, throw type and type specifying extensions from Scope::getPhpVersion() - #6519
Closed
phpstan-bot wants to merge 1 commit into
Conversation
…e specifying extensions from `Scope::getPhpVersion()` - Add the missing range-aware predicates to `PhpVersions` (`arrayFunctionsReturnNullWithNonArray()`, `hasDateTimeExceptions()`, `hasStricterRoundFunctions()`, `strSplitReturnsEmptyArray()`, `substrReturnFalseInsteadOfEmptyString()`, `highlightStringDoesNotReturnFalse()`, `throwsOnInvalidMbStringEncoding()`, `supportsPassNoneEncodings()`, `hasFilterThrowOnFailureConstant()`, `hasPDOSubclasses()`, `supportsPregUnmatchedAsNull()`, `supportsPregCaptureOnlyNamedGroups()`, `supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()`, `hasPhp8ReflectionReturnTypes()`). - Drop the injected `PhpVersion` from 40+ extensions under `src/Type/Php/` and read `$scope->getPhpVersion()` instead. Every `Maybe` answer keeps the union of both versions' behaviour, so a narrowed `PHP_VERSION_ID` branch now yields the precise type while an unnarrowed version range stays conservative. - `arrayFunctionsReturnNullWithNonArray()` family: `array_flip()`, `array_values()`, `array_keys()`, `array_slice()`, `array_splice()`, `array_reverse()`, `array_search()`, `array_fill_keys()`, `array_intersect_key()`, `array_chunk()` and `ArrayColumnHelper`. - `throwsValueErrorForInternalFunctions()` / `throwsTypeErrorForInternalFunctions()` family: `array_chunk()`, `array_combine()`, `array_fill()`, `array_filter()`, `array_key_exists()`, `array_column()`, `count_chars()`, `hash*()`, `mb_convert_encoding()`, `min()`/`max()`, `openssl_cipher_*_length()`, `str_split()`/`mb_str_split()`, `trigger_error()` (return + throw type), `version_compare()` (return + throw type), `mb_substitute_character()`, `bcdiv()`/`bcmod()`/`bcpowmod()`/`bcsqrt()` and `filter_input()`. - `hasDateTimeExceptions()` family: the `DateTime`/`DateTimeImmutable`/`DateInterval`/`DateTimeZone` constructor, `modify()`, `sub()` and `DateInterval::createFromDateString()` throw type extensions plus the matching return type extensions. Throw types are now reported when the version range only *may* have DateTime exceptions. - Per-function version quirks: `round()`/`ceil()`/`floor()`, `substr()`/`mb_substr()`, `str_split()`, `highlight_string()`, `mb_strlen()`, `mb_internal_encoding()` and friends (via `MbFunctionsReturnTypeExtensionTrait`, whose encoding list is now cached per `pass`/`none` support), `filter_var()`/`filter_input()` and `PDO::connect()` (the version check moved from `isStaticMethodSupported()` into `getTypeFromStaticMethodCall()`). - `RegexArrayShapeMatcher` passes the scope-resolved `PREG_UNMATCHED_AS_NULL` and `n`-modifier support down its AST walk instead of reading the injected `PhpVersion`; `RegexGroupParser::parseGroups()` takes the flag as an argument. - The `ReflectionEnum`/`ReflectionEnumCase` adapter and `ClassReflection::getNativeReflection()` extensions resolve their PHP 8 check from the scope too. - `FilterFunctionReturnTypeHelper::getType()`/`getInputType()` and `ArrayColumnHelper`/`ArrayFilterFunctionReturnTypeHelper` take (or already took) the `Scope`; `FilterFunctionReturnTypeHelper::getConstant()` no longer explodes on a constant missing from the runtime PHP. - Probed and left alone: `BcMathNumberOperatorTypeSpecifyingExtension` and `BcMathNumberUnaryOperatorTypeSpecifyingExtension` (the `@api` `OperatorTypeSpecifyingExtension` interface has no `Scope`), and `ArrayUnpackingHelper` (called from `AssignHandler` while building array literals, not from an extension). Closes phpstan/phpstan#15287 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Member
|
This should be caught by a new rule so that we don't drift in the future. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #6496, #6497 and #6510: type extensions still read the analysed PHP version from a DI-injected
PhpVersion, so they answered with a single version even inside aif (PHP_VERSION_ID >= 80300) { … }branch, and they could not express "this could go either way" when the analysed range spans a behavioural boundary (phpVersionmin/max in NEON, or a composerrequire.phpconstraint).This PR moves every extension that has a
Scopeover toScope::getPhpVersion(). BecausePhpVersionsanswers withTrinaryLogic, each site now handles three cases:YesandNokeep the previous per-version result, andMaybeyields the union of both versions' behaviour — so aPHP_VERSION_IDguard makes inference more precise while an unnarrowed version range stays conservative instead of silently picking one side.Changes
src/Php/PhpVersions.phpAdded the range-aware counterparts of the
PhpVersionpredicates the extensions needed:arrayFunctionsReturnNullWithNonArray(),hasDateTimeExceptions(),hasStricterRoundFunctions(),strSplitReturnsEmptyArray(),substrReturnFalseInsteadOfEmptyString(),highlightStringDoesNotReturnFalse(),throwsOnInvalidMbStringEncoding(),supportsPassNoneEncodings(),hasFilterThrowOnFailureConstant(),hasPDOSubclasses(),supportsPregUnmatchedAsNull(),supportsPregCaptureOnlyNamedGroups(),supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()andhasPhp8ReflectionReturnTypes().arrayFunctionsReturnNullWithNonArray()(PHP < 8 returnednullinstead of throwing)ArrayFlipFunctionReturnTypeExtension,ArrayValuesFunctionDynamicReturnTypeExtension,ArrayKeysFunctionDynamicReturnTypeExtension,ArraySliceFunctionReturnTypeExtension,ArraySpliceFunctionReturnTypeExtension,ArrayReverseFunctionReturnTypeExtension,ArraySearchFunctionDynamicReturnTypeExtension,ArrayFillKeysFunctionReturnTypeExtension,ArrayIntersectKeyFunctionReturnTypeExtension,ArrayChunkFunctionReturnTypeExtensionandArrayColumnHelper.throwsValueErrorForInternalFunctions()/throwsTypeErrorForInternalFunctions()ArrayChunkFunctionReturnTypeExtension,ArrayCombineFunctionReturnTypeExtension,ArrayFillFunctionReturnTypeExtension,ArrayFilterFunctionReturnTypeHelper,ArrayKeyExistsFunctionTypeSpecifyingExtension,ArrayColumnHelper,CountCharsFunctionDynamicReturnTypeExtension,HashFunctionsReturnTypeExtension,MbConvertEncodingFunctionReturnTypeExtension,MinMaxFunctionReturnTypeExtension,OpensslCipherFunctionsReturnTypeExtension,StrSplitFunctionReturnTypeExtension,TriggerErrorDynamicReturnTypeExtension,TriggerErrorFunctionThrowTypeExtension,VersionCompareFunctionDynamicReturnTypeExtension,VersionCompareFunctionDynamicThrowTypeExtension,MbSubstituteCharacterDynamicReturnTypeExtension,BcMathStringOrNullReturnTypeExtensionandFilterFunctionReturnTypeHelper.hasDateTimeExceptions()(PHP 8.3DateExceptionhierarchy)DateTimeConstructorThrowTypeExtension,DateTimeModifyMethodThrowTypeExtension,DateTimeSubMethodThrowTypeExtension,DateTimeZoneConstructorThrowTypeExtension,DateIntervalConstructorThrowTypeExtension,DateIntervalCreateFromDateStringThrowTypeExtension,DateIntervalDynamicReturnTypeExtensionandDateTimeModifyReturnTypeExtension. The throw type is now reported when the analysed range only may have DateTime exceptions, and the specificDate*Exceptionis only used when it is certain (otherwise the widerExceptioncovers both).Per-function version quirks
RoundFunctionReturnTypeExtension(round()/ceil()/floor()— the PHP 8 and PHP 7 result are now computed separately and unioned),SubstrDynamicReturnTypeExtension,StrSplitFunctionReturnTypeExtension,HighlightStringDynamicReturnTypeExtension,MbStrlenFunctionReturnTypeExtension,MbFunctionsReturnTypeExtension+MbFunctionsReturnTypeExtensionTrait(the supported-encoding list is now cached perpass/nonesupport instead of once),FilterVarThrowTypeExtensionandPDOConnectReturnTypeExtension(the version check moved out ofisStaticMethodSupported(), which has noScope, intogetTypeFromStaticMethodCall()).Regex
RegexArrayShapeMatcherresolvesPREG_UNMATCHED_AS_NULLsupport and the PHP 8.2nmodifier from the scope and threads both down its AST walk;RegexGroupParser::parseGroups()now takes then-modifier support as an argument.Reflection
AdapterReflectionEnumDynamicReturnTypeExtension,AdapterReflectionEnumCaseDynamicReturnTypeExtensionandNativeReflectionEnumReturnDynamicReturnTypeExtensionread their PHP 8 check from the scope.Helper signatures
FilterFunctionReturnTypeHelper::getType()/getInputType()take aScope(updated at all call sites inFilterVarDynamicReturnTypeExtension,FilterVarArrayDynamicReturnTypeExtensionandFilterInputDynamicReturnTypeExtension), andFilterFunctionReturnTypeHelper::getConstant()returnsnullfor a constant the runtime PHP does not define instead of throwing.ArrayColumnHelper::castToArrayKeyType()andMinMaxFunctionReturnTypeExtension::processArrayType()take theScopethey already had available on the caller.Probed, deliberately left alone
BcMathNumberOperatorTypeSpecifyingExtensionandBcMathNumberUnaryOperatorTypeSpecifyingExtension: the@apiOperatorTypeSpecifyingExtensioninterface passes noScope, so this would be a BC break.ArrayUnpackingHelper(supportsArrayUnpackingWithStringKeys()): reached fromAssignHandlerwhile building array literals, not from a type extension — it is scope-machinery, not an extension.No
@apimethod signature changed:PhpVersionsisfinaland only gained methods,RegexArrayShapeMatcher's public API is untouched (only its constructor, which@apiexplicitly does not cover), and the other touched helpers are not@api.Root cause
The pattern is "an extension asks a single
PhpVersiona yes/no question, even though the analysed code can tell it more". Two things go wrong:if (PHP_VERSION_ID >= 8xxxx)guard narrowsPHP_VERSION_IDin the scope, but the extension ignored that and answered for the globally configured version. Inside the guard the result was therefore wrong for one of the two branches.phpVersion: { min, max }or a composerrequire.phpconstraint),PhpVersioncollapses it to one version, so the extension committed to one behaviour instead of returning the union of both.The fix is the same everywhere: ask
Scope::getPhpVersion(), which returns range-awareTrinaryLogic, and makeMaybeproduce the union of the two behaviours. In most cases the narrow result (never) is a subtype of the wide one (null/false), so->yes() ? $narrow : $widealready is the union; where it is not (substr()returningfalsevs'',str_split('')returning[]vs[''],round(),mb_substitute_character()) the two branches are computed separately and combined withTypeCombinator::union().Test
New type inference tests (all fail before the fix):
tests/PHPStan/Analyser/nsrt/scope-php-version-array-functions.php— thearray_*family with a non-array argument,array_chunk()with a non-positive length,array_fill()with a negative count,array_column()with an array index,array_combine()andmin()/max().tests/PHPStan/Analyser/nsrt/scope-php-version-string-functions.php—substr(),str_split(),highlight_string(),count_chars(),hash(),round(),version_compare(),trigger_error(),bcdiv()andmb_substitute_character().tests/PHPStan/Analyser/nsrt/scope-php-version-misc-functions.php—DateTime::modify(),DateInterval::createFromDateString(),filter_input(),mb_strlen(),mb_chr(),openssl_cipher_iv_length(),preg_match()with thenmodifier,ClassReflection::getNativeReflection()andPDO::connect().New throw type tests in
CatchWithUnthrownExceptionRuleTest(both fail before the fix):tests/PHPStan/Rules/Exceptions/data/dead-catch-php-versions.php—version_compare()andtrigger_error()only throwValueErroron PHP 8.tests/PHPStan/Rules/Exceptions/data/dead-catch-date-php-versions.php—DateTime::sub()andDateTime::modify()only throw theDate*Exceptiontypes on PHP 8.3.make tests,make phpstanandmake cs-fixare all green.make name-collisionfails ontests/PHPStan/Rules/Methods/data/static-call-pipe.phpboth with and without this change (the collision detector cannot parse the PHP 8.5 pipe operator on the PHP version used here).Fixes phpstan/phpstan#15287
🤖 Generated with Claude Code