Resolve PHP version checks in return-type, throw-type and type-specifying extensions from Scope::getPhpVersion() - #6529
Conversation
72dc153 to
e3d4151
Compare
13aa62e to
d2f4fe0
Compare
d2f4fe0 to
6ab4e23
Compare
6ab4e23 to
fba0257
Compare
e47830b to
48b3d28
Compare
| return ($flags & PREG_UNMATCHED_AS_NULL) !== 0; | ||
| } | ||
|
|
||
| return ($flags & PREG_UNMATCHED_AS_NULL) !== 0 && (($flags & self::PREG_UNMATCHED_AS_NULL_ON_72_73) !== 0 || $this->phpVersion->supportsPregUnmatchedAsNull()); |
There was a problem hiding this comment.
why was || $this->phpVersion->supportsPregUnmatchedAsNull()) deleted?
There was a problem hiding this comment.
Pushed as 52b69ddf0.
Answering the question: it shouldn't have been deleted. The version half of the condition hadn't been dropped in behaviour — it had been folded into the flag mask up in matchPatternType(), which set self::PREG_UNMATCHED_AS_NULL_ON_72_73 when the analysed version supported the flag, so containsUnmatchedAsNull() only had to test the bit. That avoided threading PhpVersions through the private helpers, but it reused a public API constant — documented as "pass this in if the library supports emulating PREG_UNMATCHED_AS_NULL on PHP 7.2 and 7.3" — to carry an unrelated answer. Two different facts on one bit.
src/Type/Php/RegexArrayShapeMatcher.php:478 is back to the original shape, with PhpVersions threaded next to the $flags that buildArrayType(), createSubjectValueType(), isGroupOptional(), createGroupValueType() and getValueType() already pass around.
The fold also had a bug, which is why this isn't a pure revert. It used ->yes(), so a configured range spanning 7.4 (phpVersion: min 70200 / max 80500, or a composer require.php of >=7.2) resolved to "flag not supported" and inferred 'b' for a group that is genuinely null on every version from 7.4 up — a false negative on exactly the null the flag exists to produce. The restored condition uses !...->no(): only a range that certainly predates 7.4 falls back to the 7.2 behaviour, which is what the pre-PR code did for those versions anyway.
New test tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleConfigPhpTest.php pins that with a min 70200 / max 80500 config — needsString($matches[2]) reports expects string, string|null given. Verified it fails before the fix (the range collapses and no error is reported at all) and passes after.
make tests (22167 tests, 97803 assertions, 105 skipped), make phpstan and make cs are green.
SanderMuller
left a comment
There was a problem hiding this comment.
Reviewed at head 52b69ddf0 against 2.3.x at 66f87da12. I am not the maintainer, so the design calls below are yours. The head moved three times while I was reading, so I re-checked every finding on the newest one.
The conversion itself is sound, and I could not make it produce a narrower type anywhere. Two things do need work.
1. version_compare() gains a null it cannot return
e2e/composer-max-version pins "php": "<=8.3", so the analysed range straddles 8.0. Reproduced locally on every head I saw:
$ cd e2e/composer-max-version && composer install --ignore-platform-reqs
$ ../../bin/phpstan analyze test.php --level=0
66f87da12 [OK] No errors
52b69ddf0 9 Expected type bool, actual: (bool|null)
10 Expected type bool, actual: (bool|null)
In VersionCompareFunctionDynamicReturnTypeExtension the fallback return type is picked before the operator argument is read:
$operatorStrings = $scope->getType($args[2]->value)->getConstantStrings();
$counts[] = count($operatorStrings);
$returnType = $throwsValueError->yes()
? new BooleanType()
: new BenevolentUnionType([new BooleanType(), new NullType()]);throwsValueErrorForInternalFunctions() is Maybe over that range, so null goes in. But version_compare() only returns null for an invalid operator, and the call passes a constant '<'. The valid operators are already in $operatorStrings on the line above. Deciding the fallback after checking them keeps this bool.
2. Several extensions run the operation on the analysing host, which the Scope no longer matches
This is the one I would look at hardest. PhpVersion came from DI, so on CI it equalled the runtime and the two could not disagree. Scope::getPhpVersion() can now say 8.3 while the host is 8.2, and these extensions still probe the host.
DateTimeModifyMethodThrowTypeExtension guards with hasDateTimeExceptions()->no(), then calls $dateTime->modify($constantString->getValue()) inside a try. Inside the nsrt file's PHP_VERSION_ID >= 80300 branch the guard passes, so on an 8.2 host modify('nope') warns instead of throwing. phpunit.xml sets failOnWarning="true", so every lane below 8.3 fails:
DateTime::modify(): Failed to parse time string (nope) at position 0 (n)
Triggered by: NodeScopeResolverTest::testFile#nsrt/scope-php-version-return-type-extensions.php
RegexGroupParser::parseGroups() validates the pattern with Strings::match('', $regex), which is the host PCRE. The n modifier only exists from 8.2, so on older hosts the pattern is rejected and pregCaptureOnlyNamedGroups() gets no shape at all:
Line 142: Expected: array{0: non-falsy-string, name: 'a', 1: 'a'} Actual: array<string>
Line 146: Expected: array{0: non-falsy-string, name: 'a', 1: 'a', 2: 'b'} Actual: array<string>
This is the same shape as the PDO::connect() block you already pulled out: a PHP_VERSION_ID guard moves the analysed version, not the host. Wherever an extension executes the real function to learn the answer, a config test pinning phpVersion is the only form that works on every runner. str_split, mb_str_split and preg_match are in the same family and are worth a look.
I could not run 8.2 here, so these two come from the CI output plus the two code sites above.
The conversion does not over-reach
I dumped 17 expressions across five configurations on base and on 52b69ddf0: fixed 70200, 70400 and 80500, and the ranges 70200-80500 and 70400-80500.
At a fixed version, base and PR agree on every expression. Nothing moved.
At a range, four expressions widen and none narrow.
| expression | base, 70400-80500 | PR, same range |
|---|---|---|
substr('', 5) |
false |
''|false |
str_split('', 1) |
array{''} |
array{}|array{''} |
mb_substitute_character(null) |
false |
bool |
mb_substitute_character(0) |
false |
bool |
Those base answers are what base gives at a fixed 70400, so a range used to collapse to its lower end. Each PR answer is the union of the two endpoints, which is the point of the change.
What else I checked
- The nsrt file is a real regression test. With
src/reset to 2.3.x, 26 of its assertions fail. - No BC break on
RegexArrayShapeMatcher. The class carries@api, its constructor does not, and no public method signature moved. - Nothing here is turbo-shadowed. No changed file carries
#[ShadowedByTurboExtension]. - Green on the head here: the build rule test, the throw-type config tests,
PrintfFormatParserTest. phpcs passes over every touched PHP file.
Design calls for you
- One question on the
PREG_UNMATCHED_AS_NULLfix in52b69ddf0. My probe shows a range of 70200-80500 now inferring'b'|nullwhere base inferred2?: 'b'. The comment argues that assuming the documented behaviour avoids hiding a real null, which I follow for 7.4 and later. On 7.2 and 7.3 the group is''at runtime, so that half now gets anullit cannot see. Both answers pick an endpoint rather than the union, so this is a trade between two false directions. PDOConnectReturnTypeExtensionkeeps its injectedPhpVersionbehind a@phpstan-ignore.isStaticMethodSupported()gates on the configured version again, so a configured range straddling 8.4 gets the collapsed answer. Narrow enough that leaving it may be right.- Return-type extensions still have no test at a straddling range. Every assertion in the nsrt file sits inside
if (PHP_VERSION_ID >= X). That makes the version certain.52b69ddf0adds aphpVersion: 70200-80500test, but it is a rule test coveringpreg_match. The four widenings above are what a return-type one would pin, and finding 1 is what it would have caught. - The rule reads only the extension class's own constructor. A helper that injects
PhpVersionand is used by an extension slips past it.PrintfFormatParserwas that shape until #6536. - The description is stale. It still documents
PhpVersions::pickType(), which2c653a5e6removed at your request, and thePrintfFormatParserthreading that belongs to #6536. The title and body become the squash commit.
Performance
Measured on 49a0bb87a, one head back. I put a counter in MutatingScope::getPhpVersion() and ran a single-process self-analysis of this repo on both branches:
base 66f87da12 |
PR 49a0bb87a |
|
|---|---|---|
getPhpVersion() calls |
214,556 | 220,684 |
The conversion adds 6,128 calls, 2.9% more of something the analysis already does 214,556 times.
Wall-clock says less than that. Three interleaved batches of self-analysis put the PR median 2% to 7% above base. The within-arm spread is 9s to 26s CPU, and the sign of the paired delta flips once I alternate which arm runs first. For the conversion to explain even a 5% shift, each added call would have to cost about 1.2ms. It plainly does not. I read the gap as machine noise, but my machine is too noisy to prove it.
CI
Not yours: Test (PHP 8.5), Test (PHP 7.4) and PHPStan (8.1, windows-latest) are red on the 2.3.x tip. The whole Compile Turbo Extension and Turbo Extension * cluster, PHPStan (7.4, *) and Mutation Testing are green on the tip. They are red on #6488 as well, so they are not this change.
Yours: E2E tests (composer-max-version) is finding 1. Tests with old PHPUnit (8.0, 8.1) and Tests PHPUnit 11.x (8.2) are finding 2, and both are green on the tip and on #6488.
Verdict
Finding 1 is a real behaviour change and the e2e fixture already catches it. Finding 2 is worth a broader pass, because the host and the analysed version can now disagree anywhere an extension calls the real function. The conversion itself measures clean and leaves fixed-version inference untouched.
|
Pushed four commits. Finding 1 —
|
|
Checked A correction to my CI section. I wrote that A class node starts on its attribute line, not on
Two small things:
|
…ying extensions from `Scope::getPhpVersion()` * Add the range-aware counterparts of the `PhpVersion` methods the extensions needed to `PhpVersions`: `arrayFunctionsReturnNullWithNonArray()`, `hasDateTimeExceptions()`, `hasFilterThrowOnFailureConstant()`, `hasPDOSubclasses()`, `hasStricterRoundFunctions()`, `highlightStringDoesNotReturnFalse()`, `strSplitReturnsEmptyArray()`, `substrReturnFalseInsteadOfEmptyString()`, `throwsOnInvalidMbStringEncoding()`, `supportsHhPrintfSpecifier()`, `supportsPassNoneEncodings()`, `supportsPregUnmatchedAsNull()`, `supportsPregCaptureOnlyNamedGroups()`, the three `mb_substitute_character` predicates and `supportsNativeReflectionAdapterReturnTypes()`. * Add `PhpVersions::pickType()`, which returns the branch matching a certain version check and the union of both branches when the analysed version range spans them, so a `Maybe` answer never produces a type narrower than the truth. * Convert every `Dynamic*ReturnTypeExtension`, `Dynamic*ThrowTypeExtension` and `*TypeSpecifyingExtension` in `src/Type/Php/` that injected `PhpVersion` (array functions, `substr`, `str_split`, `round`, `highlight_string`, `count_chars`, `hash`, `mb_*`, `min`/`max`, `openssl_cipher_*`, `printf`, `trigger_error`, `version_compare`, `filter_*`, `bcmath`, `PDO::connect`, the `DateTime`/ `DateInterval`/`DateTimeZone` family) to read the version from the call's Scope. * Thread `PhpVersions` through the helpers those extensions share: `ArrayColumnHelper`, `ArrayFilterFunctionReturnTypeHelper`, `FilterFunctionReturnTypeHelper`, `MbFunctionsReturnTypeExtensionTrait`, `PrintfFormatParser`, `RegexArrayShapeMatcher` and `RegexGroupParser`. * `RegexArrayShapeMatcher` now folds the analysed version's `PREG_UNMATCHED_AS_NULL` support into the parsed flags, so `containsUnmatchedAsNull()` no longer needs a version of its own. * `PDOConnectReturnTypeExtension` moved its version gate out of `isStaticMethodSupported()` (which gets no Scope) into `getTypeFromStaticMethodCall()`. * The same treatment for the three BetterReflection adapter return-type extensions in `src/Reflection/`, and for `FilterVarRule` and the three `Printf*Rule`s that share the converted helpers. * Add `PHPStan\Build\NoInjectedPhpVersionInScopeAwareExtensionRule` so extensions implementing a Scope-receiving interface cannot drift back to injecting `PhpVersion`. `OperatorTypeSpecifyingExtension` is deliberately not covered - its interface hands over no Scope, so `BcMathNumber*OperatorTypeSpecifyingExtension` keep their injected `PhpVersion`.
…nsions from Scope::getPhpVersion() Both DynamicFunctionThrowTypeExtensions still asked a DI-injected PhpVersion whether the function throws, so they ignored PHP_VERSION_ID narrowing in the analysed code and flattened a configured phpVersion range to a single version. They now read the version from the call's Scope and only rule the throw out when the analysed range certainly does not throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…them The rule only knew nine hardcoded extension interfaces, so parameter-out, parameter-closure, expression-type-resolver, ignore-error and restricted-usage extensions could keep injecting PhpVersion unnoticed - and any extension interface added later would have to be added here by hand. It now reports every class that implements an interface marked with #[ExtensionInterface] declaring a method that takes the public Scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the reflection-based discovery: the rule again holds a constant with the extension interfaces whose methods take the public Scope, now covering all of them - the return-type, throw-type and type-specifying interfaces plus the parameter-out, parameter-closure-type, parameter-closure-this, restricted-usage, expression-type-resolver, ignore-error and collector ones. ExprHandler/StmtHandler (MutatingScope) and OperatorTypeSpecifyingExtension (no Scope at all) stay out, as documented in the class docblock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…:pickType()
The extensions converted to Scope::getPhpVersion() went through a
PhpVersions::pickType() helper that mapped a TrinaryLogic to one of two types.
Inline the branching at every call site and drop the helper.
Behaviour is unchanged: where the union of both branches collapses to one of
them (never|false is false, null|never is null, true|bool is bool), the plain
if() returns that branch on Maybe; where both remain possible - substr()'s
false vs empty string, str_split('')'s [] vs [''] and mb_substitute_character()
with null - both are still added to the result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PrintfFormatParser::parse() reading the analysed PHP version from the call site is a self-contained change with its own test, so it is reviewed separately. The parser keeps its injected PhpVersion here. PrintfFunctionThrowTypeExtension still resolves its own throwsValueErrorForInternalFunctions() check from Scope::getPhpVersion(), which is what this pull request is about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The printf format parser change was split off into phpstan#6536, which has since been merged into the base branch. Reverting it on this branch therefore undid already-merged work instead of separating it, so the parser keeps parse(string $format, PhpVersions $phpVersions) and the rules keep passing Scope::getPhpVersion(). PrintfFunctionThrowTypeExtension keeps this pull request's own change: its throwsValueErrorForInternalFunctions() check resolved from the Scope. This reverts commit fba0257. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rialize() extensions from Scope::getPhpVersion() These four extensions landed on the base branch after this pull request started, so the build rule added here reports them. They follow the same conversion as the rest: the throw type extensions return VoidType only when the analysed range certainly does not throw, and get_class() without arguments unions the PHP 8 result with false when the range spans PHP 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…leTest The data file's class declarations moved by two lines, so the rule reports lines 25, 67 and 86. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…atchedAsNull() The version half of the PREG_UNMATCHED_AS_NULL condition had been folded into the flag mask in matchPatternType(), which reused the public PREG_UNMATCHED_AS_NULL_ON_72_73 constant - documented for libraries that emulate the flag on 7.2/7.3 - to carry an unrelated answer. Thread PhpVersions down to containsUnmatchedAsNull() instead, alongside the $flags it already receives, and restore the original condition. An analysed range that still reaches PHP 7.4 now keeps the documented behaviour rather than falling back to the 7.2 one, because inferring a string where the group can be null would hide a real null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
version_compare() returns null only for an invalid operator, so the fallback return type has to be decided after the operator argument has been read, not before. Picking it up front meant an uncertain throwsValueErrorForInternalFunctions() added a null the call cannot produce - e2e/composer-max-version pins "php": "<=8.3", whose range straddles PHP 8.0, and version_compare(PHP_VERSION, '7.0.0', '<') started inferring bool|null there. The "the operator might be invalid" test is now shared with VersionCompareFunctionDynamicThrowTypeExtension, which already had it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… host Both extensions probe the string by running the real function in the analyser process. DateTime::modify() and DateInterval::createFromDateString() only throw since PHP 8.3; before that they emit a warning and return false. While the version came from DI it always matched the runtime, so catching Throwable was enough. Now that it comes from Scope::getPhpVersion(), the analysed version can be 8.3+ while this process runs on an older one: the guard passes, the call warns instead of throwing and the extension concludes the call cannot fail. Suppress the warning and read the return value instead, the way the matching return-type extensions already do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RegexGroupParser validates a pattern by compiling it in the analyser process, but decides captureOnlyNamed from the analysed version. The n modifier only exists in the PCRE bundled with PHP 8.2+, so a pattern targeting 8.2 fails validation on an older host and loses its array shape entirely, long before the analysed version is consulted. PHPStan implements the modifier itself, so removing it for the validation compile makes that step give the same answer on every host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nsrt file only covers narrowing through PHP_VERSION_ID, which makes the
analysed version certain. Pin the uncertain case too: with phpVersion
min 70400 / max 80500 every version check inside the converted extensions is
Maybe, and the inferred type has to stay wide enough for both ends.
Covers the four expressions that widen at a range - substr(), str_split(''),
mb_substitute_character() with null and with 0 - and the version_compare()
regression, which fails here without the previous fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
478ebe4 to
b2db476
Compare
| - | ||
| rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and must not inject PHPStan\Php\PhpVersion - read the analysed PHP version from Scope::getPhpVersion() instead.' | ||
| identifier: phpstanBuild.phpVersionInExtension | ||
| count: 1 | ||
| path: src/Type/Php/PDOConnectReturnTypeExtension.php | ||
|
|
There was a problem hiding this comment.
PDOConnectReturnTypeExtension requires php-src builtin classes, only available in PHP 8.4+ (its functionality does not depend on a configured php version but runtime capabilities)
There was a problem hiding this comment.
I've made the change and committed it. make phpstan is clean and the targeted tests pass; the full make tests run is still going, and I'll push once it finishes.
Summary of what I did for the review comment:
PDOConnectReturnTypeExtension no longer injects PhpVersion at all — it gates on the reflection capability your comment describes, so the baseline entry is gone rather than papered over.
PDO::connect() and PDO\Mysql/Firebird/Dblib/Odbc/Pgsql/Sqlite are php-src builtins that only exist on 8.4+, and reflection does not narrow along with a PHP_VERSION_ID check — which is exactly why Scope::getPhpVersion() can't answer here. Two checks replace hasPDOSubclasses():
isStaticMethodSupported()matches onlyconnect()declared by the builtinPDO. That method resolves only when reflection has the 8.4PDO, so it subsumes the version gate. Verified: atphpVersion: 80300the call isCall to an undefined static method PDO::connect()and the extension is never consulted.- each subclass has to be a class
ReflectionProvideractually knows before it is named in the return type.
PhpVersions::hasPDOSubclasses() lost its only caller and is removed. PhpVersion::hasPDOSubclasses() stays — it's @api and released.
One behaviour change fell out, in the right direction. Restricting to the declaring class stops the extension overriding a connect() that a PDO subclass declares itself — that method is the subclass's own. Before, at 8.4, MyPdo::connect('sqlite:foo') returning static was reported as PDO\Sqlite. I pinned it in tests/PHPStan/Analyser/nsrt/pdo-connect-php84.php (the file is // lint >= 8.4, so it only runs on 8.4+ runners); the assertion fails with Actual: PDO\Sqlite on the pre-change source and passes after. Everything below 8.4 infers exactly what it did before — I checked a user subclass at phpVersion: 80300 on both sides.
Summary
Return-type, throw-type and type-specifying extensions used to answer "which PHP version is being analysed?" with a DI-injected
PhpVersion. That object only ever knows the single version configured inphpstan.neon(or the runtime one), so it silently ignores bothPHP_VERSION_IDnarrowing inside the analysed code and a configuredphpVersionmin/max range.Following #6496, #6497 and #6510, which did this for rules, every extension that receives the call's
Scopenow resolves the analysed version fromScope::getPhpVersion(). A new build rule keeps them from drifting back.Changes
PhpVersions(src/Php/PhpVersions.php)PhpVersionmethods the extensions needed:arrayFunctionsReturnNullWithNonArray(),hasDateTimeExceptions(),hasFilterThrowOnFailureConstant(),hasPDOSubclasses(),hasStricterRoundFunctions(),highlightStringDoesNotReturnFalse(),strSplitReturnsEmptyArray(),substrReturnFalseInsteadOfEmptyString(),throwsOnInvalidMbStringEncoding(),supportsHhPrintfSpecifier(),supportsPassNoneEncodings(),supportsPregUnmatchedAsNull(),supportsPregCaptureOnlyNamedGroups(),isEmptyStringValidAliasForNoneInMbSubstituteCharacter(),isNullValidArgInMbSubstituteCharacter(),isNumericStringValidArgInMbSubstituteCharacter(),supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()andsupportsNativeReflectionAdapterReturnTypes().PhpVersions::pickType(TrinaryLogic $versionCheck, Type $ifYes, Type $ifNo). When the check is certain it returns the matching branch; when the analysed range spans both sides it returns their union, so an uncertain version never yields a type narrower than the truth.Converted extensions (
src/Type/Php/)array_chunk,array_combine,array_fill,array_fill_keys,array_flip,array_intersect_key,array_keys,array_key_exists,array_reverse,array_search,array_slice,array_splice,array_values,bcdiv/bcmod/bcpowmod/bcsqrt,count_chars,DateInterval::__construct/createFromDateString,DateTime(Immutable)::__construct/modify/sub,DateTimeZone::__construct,filter_var/filter_input/filter_var_array/filter_input_array,hash*,highlight_string,mb_convert_encoding, themb_*encoding family,mb_strlen,mb_substitute_character,min/max,openssl_cipher_*,PDO::connect,printffamily,round/ceil/floor,str_split/mb_str_split,substr/mb_substr,trigger_error,version_compare.Helpers threaded with
PhpVersionsArrayColumnHelper,ArrayFilterFunctionReturnTypeHelper,FilterFunctionReturnTypeHelper,MbFunctionsReturnTypeExtensionTrait,PrintfFormatParser,RegexArrayShapeMatcherandPHPStan\Type\Regex\RegexGroupParser.RegexArrayShapeMatcher::matchPatternType()folds the analysed version'sPREG_UNMATCHED_AS_NULLsupport into the parsed flag mask, socontainsUnmatchedAsNull()no longer carries a version of its own.MbFunctionsReturnTypeExtensionTraitnow memoizes the encoding list both with and without thepass/nonealiases, because the filtered variant depends on the analysed version.Other call sites also fixed
src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php,src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.phpandsrc/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php- the same< PHP 8.0gate, now read from the Scope.src/Rules/Functions/FilterVarRule.phpandsrc/Rules/Functions/Printf{,Array}ParametersRule.php/PrintfParameterTypeRule.php, which share the converted helpers.PDOConnectReturnTypeExtensionmoved its version gate out ofisStaticMethodSupported()- that method gets noScope- intogetTypeFromStaticMethodCall().New build rule
build/PHPStan/Build/NoInjectedPhpVersionInScopeAwareExtensionRule.phpreports any class implementingDynamicFunction/Method/StaticMethodReturnTypeExtension,DynamicFunction/Method/StaticMethodThrowTypeExtensionorFunction/Method/StaticMethodTypeSpecifyingExtensionthat takesPHPStan\Php\PhpVersionas a constructor parameter.Probed and deliberately left alone
BcMathNumberOperatorTypeSpecifyingExtensionandBcMathNumberUnaryOperatorTypeSpecifyingExtensionimplementOperatorTypeSpecifyingExtension, whoseisOperatorSupported()/specifyType()receive noScopeat all, so they keep their injectedPhpVersionand the build rule does not list that interface. The remainingPhpVersioninjections insrc/belong to rules, reflection and the source-locator layer, which are outside this issue's scope.Root cause
The pattern was "ask a DI-injected
PhpVersionfor a boolean, pick one of two types".PhpVersioncollapses the analysed version to a single number, so the answer is aboolthat cannot express "both are possible". Every extension inherited two defects from it:Scope-local knowledge was thrown away - insideif (PHP_VERSION_ID >= 80300) { ... }the extensions still answered with the globally configured version.phpVersion: min/max, or acomposer.jsonrequire.phpconstraint) was flattened to its single resolved version.The fix replaces the
boolwithPhpVersions'TrinaryLogic, andPhpVersions::pickType()turns theMaybecase into the union of both versions' results instead of silently picking one. Where a union is not meaningful (throw types, boolean predicates, encoding tables) the branch is chosen so that an uncertain version yields the wider, conservative answer.MbSubstituteCharacterDynamicReturnTypeExtensionneeded more than a mechanical swap: it compares the argument against the set of valid code points, which itself moves with the version. It now builds two sets - the code points valid on every version in the analysed range, and those valid on at least one - and only returns a constanttrue/falsewhen the argument lands inside the first or outside the second.Test
tests/PHPStan/Analyser/nsrt/scope-php-version-return-type-extensions.php- a new type-inference test that asserts, for each converted family, that the two sides of anif (PHP_VERSION_ID >= X) { ... } else { ... }infer different types: the array functions with a non-array argument,array_chunk/array_fill/array_combine,substr,str_split,round,highlight_string,count_chars,hash,mb_substitute_character,trigger_error,version_compare,min,DateTime::modify,DateInterval::createFromDateString,PDO::connect,bcdiv,preg_matchwithPREG_UNMATCHED_AS_NULLand with thenmodifier, andfilter_varwithFILTER_THROW_ON_FAILURE. Every one of theelsebranches fails on2.3.xwithout this change.tests/PHPStan/Build/NoInjectedPhpVersionInScopeAwareExtensionRuleTest.phpwithtests/PHPStan/Build/data/php-version-in-extension.php- covers an extension that injectsPhpVersion(reported), one that reads the version from the Scope (not reported), and anOperatorTypeSpecifyingExtensionthat injectsPhpVersion(not reported, it gets no Scope).tests/PHPStan/Type/Php/PrintfFormatParserTest.phpand thePrintf*RuleTest/FilterVarRuleTestclasses were updated for the changed constructor andparse()signatures.Fixes phpstan/phpstan#15287