Honour the composer.json require.php constraint in Scope::getPhpVersion() - #6476
Conversation
…rsion()`
* `MutatingScope::getPhpVersion()` only looked at a scope-narrowed `PHP_VERSION_ID`
and at the NEON `phpVersion` min/max range. Without either it fell back to the
single analysed `PhpVersion`, so the composer.json `require.php` range - which
`ConstantResolver` already uses to narrow `PHP_VERSION_ID` - was ignored.
* Fall back to `ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID')` so
`Scope::getPhpVersion()` can no longer contradict the `PHP_VERSION_ID` constant.
* Extracted the inline "overall PHP version range" detection into
`MutatingScope::isOverallPhpVersionRange()` and reused it for both lookups.
* Fixes the whole family of `PhpVersions` consumers at once, each covered by its
own regression test: `UnusedVariableRule` (`catch.unusedVariable`),
`NoncapturingCatchRule`, `FunctionCallParametersCheck` (named arguments and
named-argument-after-unpacked-argument), `FinalPrivateMethodRule`,
`TooWideTypeCheck` (native `true`/`false` suggestions),
`IniGetReturnTypeExtension` (`max_memory_limit`) and variadic parameter types
in `MutatingScope::getFunctionType()`.
staabm
left a comment
There was a problem hiding this comment.
tests requiring a composer.json should be moved into e2e
The regression tests for `Scope::getPhpVersion()` honouring the composer.json
`require.php` constraint each needed a composer.json of their own, which they
got by pointing `getComposerAutoloaderProjectPaths()` at fixture directories
under `tests/`. Tests that need a composer.json belong in e2e, where the whole
`composer install` + `bin/phpstan` round trip is exercised.
Four e2e projects, one per `require.php` constraint, cover the same ground:
* `composer-php-version-range-7-and-8` (`^7.4 || ^8.0`): no
`Catch variable $e is never read.` (the reported bug) and no native
`true`/`false` return type suggestion, while non-capturing catch, named
arguments and the PHPDoc-only `bool` return type are still reported.
`PHP_VERSION_ID` is `int<70400, 80699>` and an explicit `PHP_VERSION_ID`
check still narrows the scope further down.
* `composer-php-version-range-7` (`^7.4`): a variadic parameter is a `list`
and a final private method produces no warning.
* `composer-php-version-range-8-0` (`^8.0`): a named argument after an
unpacked argument is reported, because PHP 8.0 does not support it.
* `composer-php-version-range-8-5` (`^8.5`): `ini_get('max_memory_limit')`
returns `string`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the review: all composer.json-dependent tests now live in Removed the 8 unit/integration test classes and the 4 Added four e2e projects (
Error-expecting projects assert via bashunit ( Verification: each of the four projects was run locally with the fix (expected output exactly) and with One thing worth flagging: while building the reproducer I found that merely mentioning |
staabm
left a comment
There was a problem hiding this comment.
could the fix be simplified by using ConfiguredPhpVersionRangeHelper?
…ngeHelper MutatingScope::getPhpVersion() handled the NEON phpVersion min/max config itself and went through ConstantResolver::resolvePredefinedConstant() for the composer.json "require.php" range. ConfiguredPhpVersionRangeHelper already answers both in one call, so ask it directly - the raw "phpVersion" parameter is no longer needed in the scope factories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the review — the fix now goes through What changed
[$minPhpVersion, $maxPhpVersion] = $this->configuredPhpVersionRangeHelper->getVersionRange();
if (
$minPhpVersion !== null
|| ($maxPhpVersion !== null && $maxPhpVersion->getVersionId() !== PhpVersionFactory::MAX_PHP_VERSION)
) {
return new PhpVersions(IntegerRangeType::fromInterval(
$minPhpVersion !== null ? $minPhpVersion->getVersionId() : ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID,
$maxPhpVersion !== null ? $maxPhpVersion->getVersionId() : null,
));
}Since Behaviour is preserved per source, because the helper distinguishes them: NEON int config → Verification
|
|
//cc @SanderMuller please review |
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The change consistently threads Composer/NEON-derived PHP version ranges into Scope::getPhpVersion() and adds targeted E2E coverage to prevent regressions across multiple version-gated consumers.
Review effort: Lite
Findings: None
What changed in this PR
This PR fixes inconsistencies in PHP-version–gated analysis by ensuring Scope::getPhpVersion() honors the project’s Composer require.php constraint (and NEON phpVersion range when configured), aligning scope-level version checks with how PHP_VERSION_ID is resolved elsewhere in analysis.
Changes:
- Updated
MutatingScope::getPhpVersion()to useConfiguredPhpVersionRangeHelper(NEONphpVersionrange or Composerrequire.php) when the scope doesn’t already carry a narrowedPHP_VERSION_ID. - Propagated
ConfiguredPhpVersionRangeHelperthrough internal scope factories (and test scope factory wiring) instead of passing the rawphpVersionparameter. - Added E2E fixtures and workflow steps to verify behavior across Composer PHP constraints (
^7.4,^7.4 || ^8.0,^8.0,^8.5).
| File | Description |
|---|---|
| src/Testing/PHPStanTestCase.php | Updates test scope factory wiring to pass ConfiguredPhpVersionRangeHelper into the scope factory stack. |
| src/Analyser/MutatingScope.php | Implements the new getPhpVersion() fallback via ConfiguredPhpVersionRangeHelper and adds isOverallPhpVersionRange() helper. |
| src/Analyser/LazyInternalScopeFactory.php | Lazily fetches and passes ConfiguredPhpVersionRangeHelper into created scopes. |
| src/Analyser/DirectInternalScopeFactoryFactory.php | Switches constructor dependency from raw phpVersion config to ConfiguredPhpVersionRangeHelper. |
| src/Analyser/DirectInternalScopeFactory.php | Threads ConfiguredPhpVersionRangeHelper through scope creation and factory flavor switching. |
| e2e/composer-php-version-range-8-5/phpstan.neon | Adds E2E config for the ^8.5 Composer constraint scenario. |
| e2e/composer-php-version-range-8-5/no-errors.php | Asserts expected PHP_VERSION_ID range and ini_get() typing under the ^8.5 constraint. |
| e2e/composer-php-version-range-8-5/composer.json | Defines require.php: ^8.5 for the 8.5 E2E fixture. |
| e2e/composer-php-version-range-8-5/.gitignore | Ignores vendor/ and composer.lock for the 8.5 E2E fixture. |
| e2e/composer-php-version-range-8-0/phpstan.neon | Adds E2E config for the ^8.0 Composer constraint scenario. |
| e2e/composer-php-version-range-8-0/no-errors.php | Asserts variadic parameter typing under the ^8.0 constraint. |
| e2e/composer-php-version-range-8-0/errors.php | Introduces a version-gated error case (named-arg-after-unpacked) for the ^8.0 fixture. |
| e2e/composer-php-version-range-8-0/composer.json | Defines require.php: ^8.0 for the 8.0 E2E fixture. |
| e2e/composer-php-version-range-8-0/.gitignore | Ignores vendor/ and composer.lock for the 8.0 E2E fixture. |
| e2e/composer-php-version-range-7/phpstan.neon | Adds E2E config for the ^7.4 Composer constraint scenario. |
| e2e/composer-php-version-range-7/no-errors.php | Asserts expected PHP_VERSION_ID range and behavior under a PHP 7-only constraint. |
| e2e/composer-php-version-range-7/composer.json | Defines require.php: ^7.4 for the 7.x E2E fixture. |
| e2e/composer-php-version-range-7/.gitignore | Ignores vendor/ and composer.lock for the 7.x E2E fixture. |
| e2e/composer-php-version-range-7-and-8/phpstan.neon | Adds E2E config (incl. bleeding edge) for the `^7.4 |
| e2e/composer-php-version-range-7-and-8/php-version-id.php | Asserts base and narrowed PHP_VERSION_ID ranges and non-capturing-catch gating behavior. |
| e2e/composer-php-version-range-7-and-8/no-errors.php | Ensures version-gated rules don’t report “drop catch var” / “can be true” when PHP 7 is allowed. |
| e2e/composer-php-version-range-7-and-8/errors.php | Ensures version-gated errors are still reported when appropriate under mixed 7/8 constraints. |
| e2e/composer-php-version-range-7-and-8/composer.json | Defines `require.php: ^7.4 |
| e2e/composer-php-version-range-7-and-8/.gitignore | Ignores vendor/ and composer.lock for the mixed-range E2E fixture. |
| .github/workflows/e2e-tests.yml | Runs the new E2E scenarios in CI and asserts expected raw output for error/no-error cases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Summary
On a project whose
composer.jsonrequires e.g."php": "^7.4 || ^8.0", PHPStan reportedCatch variable $e is never read.even though non-capturing catches only exist since PHP 8.0 and the variable therefore cannot be dropped.UnusedVariableRulealready guards that report with$scope->getPhpVersion()->supportsNoncapturingCatches()->yes(), butScope::getPhpVersion()did not know about the composer constraint: it only consulted a scope-narrowedPHP_VERSION_IDand the NEONphpVersion: {min, max}range, and otherwise returned the single analysed PHP version (the runtime version, 8.x on CI).ConstantResolveron the other hand does narrowPHP_VERSION_IDfromrequire.php, so the two disagreed.The fix makes
Scope::getPhpVersion()fall back to exactly thePHP_VERSION_IDtype thatConstantResolverproduces, so the scope's PHP version and thePHP_VERSION_IDconstant can no longer contradict each other.Changes
src/Analyser/MutatingScope.phpgetPhpVersion(): when neither the scope nor the NEONphpVersionrange provides information, fall back toConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID'), which folds in the composer.jsonrequire.phprange.isOverallPhpVersionRange()and reused it for both the scope-narrowed and the fallback type. This keeps the pre-existing behaviour for projects without any constraint (fall back to the single analysed version).Every
PhpVersionsconsumer was probed against a composer-derived range. All of the following were broken in the same way and are fixed by this single change; each got its own regression test:PhpVersionsmethodRules/DeadCode/UnusedVariableRule(the reported bug)supportsNoncapturingCatches()UnusedVariableRuleComposerPhpVersionRangeTestRules/Exceptions/NoncapturingCatchRulesupportsNoncapturingCatches()NoncapturingCatchRuleComposerPhpVersionRangeTestRules/FunctionCallParametersChecksupportsNamedArguments()CallToFunctionParametersRuleComposerPhpVersionRangeTestRules/FunctionCallParametersChecksupportsNamedArgumentAfterUnpackedArgument()CallToFunctionParametersRuleComposerPhp80RangeTestRules/Methods/FinalPrivateMethodRuleproducesWarningForFinalPrivateMethods()FinalPrivateMethodRuleComposerPhpVersionRangeTestRules/TooWideTypehints/TooWideTypeChecksupportsTrueAndFalseStandaloneType()TooWideFunctionReturnTypehintRuleComposerPhpVersionRangeTestType/Php/IniGetReturnTypeExtensionsupportsMaxMemoryLimit()ComposerPhpVersionRangeIniGetTestMutatingScope::getFunctionType()/getArrayType()(variadic parameter is alistbefore named arguments exist)supportsNamedArguments()ComposerPhpVersionRangeVariadicTestProbed and found already correct (no test kept):
BetterReflectionProvider::getConstant()only uses the version type as a cache key, and the NEONphpVersion: {min, max}path already worked - it is left untouched and still takes precedence over the composer constraint.New composer fixtures under
tests/PHPStan/Analyser/data/:composer-require-php-7-only(^7.4),composer-require-php-7-and-8(^7.4 || ^8.0),composer-require-php-8-0(^8.0) andcomposer-require-php-8-5(^8.5); tests opt in via the existinggetComposerAutoloaderProjectPaths()hook.Root cause
Two independent code paths answered the question "which PHP version(s) is this project analysed against?", and only one of them knew about composer:
ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID')goes throughConfiguredPhpVersionRangeHelper, which returns the NEONphpVersionrange or the composer.jsonrequire.phprange.MutatingScope::getPhpVersion()read the scope-narrowedPHP_VERSION_ID(only set insideif (PHP_VERSION_ID …)conditions), then the NEON range, then gave up and returned the single analysedPhpVersion.So on a
^7.4 || ^8.0projectPHP_VERSION_IDwasint<70400, 80699>whileScope::getPhpVersion()claimed80425. EveryPhpVersionsquery then answeredyes/nowhere it should have answeredmaybe, which is why version-gated rules behaved as if the project were PHP 8.4 only. Routing the fallback through the very sameConstantResolverentry point removes the second source of truth.Test
tests/PHPStan/Rules/DeadCode/UnusedVariableRuleComposerPhpVersionRangeTest— the reported bug: noCatch variable $e is never read.when composer allows PHP 7. Fails before the fix with exactly the error from the issue.tests/PHPStan/Analyser/ScopePhpVersionComposerRangeTest— pinsScope::getPhpVersion()toint<70400, 80699>for a^7.4 || ^8.0project, and checks that an explicitif (PHP_VERSION_ID < 80000) return;still narrows it further toint<80000, 80699>.make tests), self-analysis (make phpstan) andmake cs-fixare green.make name-collisionfails ontests/PHPStan/Rules/Methods/data/static-call-pipe.phpboth with and without this change (pre-existing, unrelated to this PR).Fixes phpstan/phpstan#15270