Implement @pure-unless-parameter-passed - #6018
Conversation
a4f91d3 to
52e31bd
Compare
|
Rebased onto latest 2.2.x. While reworking the tests I found and fixed a real asymmetry in Current CI status — the remaining red checks are all unrelated to this PR's code:
|
|
This pull request has been marked as ready for review. |
|
would it make sense to emit a new error when |
52e31bd to
4902e4b
Compare
|
This pull request has been marked as ready for review. |
|
Update after the latest review round:
Mutation status: 8.4 is green (0 escaped). 8.3 still reports the two |
|
This pull request has been marked as ready for review. |
915ecf0 to
0b1a724
Compare
c8d7c5c to
cc28625
Compare
f04bb56 to
402666d
Compare
402666d to
4a26563
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate correctness issues affect variadics, purity validation, callable propagation, and metadata generation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements @pure-unless-parameter-passed, allowing calls to remain pure when annotated by-reference output parameters are omitted.
Changes:
- Parses and propagates conditional-purity metadata through reflections.
- Evaluates argument presence across calls and combined variants.
- Adds built-in metadata, validation, and regression tests.
File summaries
| File | Review |
|---|---|
tests/PHPStan/Rules/Pure/PureMethodRuleTest.php |
Tests method annotation validation. |
tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php |
Tests userland and built-in behavior. Nit (1 vote): Missing omitted/passed cases for str_ireplace, preg_replace, preg_match_all, and similar_text. |
tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php |
Provides userland fixtures. Moderate (1 vote): First-class callables lose conditional metadata, producing a false positive. |
tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-method.php |
Provides method validation fixtures. |
tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php |
Provides built-in function fixtures. |
tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-doc.php |
Verifies tag recognition. |
tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php |
Extends metadata schema validation. |
tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php |
Tests preservation of the parameter flag. |
src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php |
Delegates conditional-purity metadata. |
src/Rules/Pure/FunctionPurityCheck.php |
Moderate (1 vote): Functions carrying only this tag bypass body-purity validation, allowing unrelated effects to be treated as pure. |
src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php |
Allows the supported purity tags. |
src/Reflection/WrappedExtendedMethodReflection.php |
Supplies default metadata. |
src/Reflection/Type/UnionTypeMethodReflection.php |
Merges union-method flags. |
src/Reflection/Type/MergedPureUnlessParameterPassedParameters.php |
Implements trinary flag merging. |
src/Reflection/Type/IntersectionTypeMethodReflection.php |
Merges intersection-method flags. |
src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php |
Preserves transformed parameter flags. |
src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php |
Preserves callback prototype flags. |
src/Reflection/SignatureMap/SignatureMapProvider.php |
Extends metadata contracts. |
src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php |
Applies built-in parameter metadata. |
src/Reflection/ResolvedMethodReflection.php |
Delegates resolved method metadata. |
src/Reflection/ResolvedFunctionVariantWithOriginal.php |
Preserves flags in resolved variants. |
src/Reflection/Php/PhpParameterReflection.php |
Stores the trinary parameter flag. |
src/Reflection/Php/PhpParameterFromParserNodeReflection.php |
Stores parser-derived flags. |
src/Reflection/Php/PhpMethodReflectionFactory.php |
Extends method factory inputs. |
src/Reflection/Php/PhpMethodReflection.php |
Exposes method parameter metadata. |
src/Reflection/Php/PhpMethodFromParserNodeReflection.php |
Threads parsed method metadata. |
src/Reflection/Php/PhpFunctionReflection.php |
Exposes function parameter metadata. |
src/Reflection/Php/PhpFunctionFromParserNodeReflection.php |
Threads parsed function metadata. |
src/Reflection/Php/PhpClassReflectionExtension.php |
Resolves inherited and native metadata. |
src/Reflection/Php/ExtendedDummyParameter.php |
Carries flags in combined parameters. |
src/Reflection/Php/ExitFunctionReflection.php |
Supplies empty metadata defaults. |
src/Reflection/Php/EnumCasesMethodReflection.php |
Supplies empty metadata defaults. |
src/Reflection/Php/ClosureCallMethodReflection.php |
Propagates closure-call metadata. |
src/Reflection/ParametersAcceptorSelector.php |
Combines flags across variants. |
src/Reflection/Native/NativeMethodReflection.php |
Implements the extended metadata API. |
src/Reflection/Native/NativeFunctionReflection.php |
Implements the extended metadata API. |
src/Reflection/Native/ExtendedNativeParameterReflection.php |
Stores native parameter flags. |
src/Reflection/GenericParametersAcceptorResolver.php |
Initializes flags during generic resolution. |
src/Reflection/FunctionReflectionFactory.php |
Extends function factory inputs. |
src/Reflection/FunctionReflection.php |
Adds the function metadata API. |
src/Reflection/ExtendedParameterReflection.php |
Adds the parameter-level metadata API. |
src/Reflection/ExtendedMethodReflection.php |
Adds the method metadata API. |
src/Reflection/Dummy/DummyMethodReflection.php |
Supplies empty metadata. |
src/Reflection/Dummy/DummyConstructorReflection.php |
Supplies empty constructor metadata. |
src/Reflection/Dummy/ChangedTypeMethodReflection.php |
Delegates changed-type metadata. |
src/Reflection/Callables/SimpleImpurePoint.php |
Critical (1 vote): Named arguments captured by flagged variadic parameters are incorrectly treated as omitted. |
src/Reflection/BetterReflection/BetterReflectionProvider.php |
Merges PHPDoc and built-in metadata. |
src/Reflection/Annotations/AnnotationsMethodParameterReflection.php |
Supplies default parameter flags. |
src/Reflection/Annotations/AnnotationMethodReflection.php |
Supplies empty method metadata. |
src/PhpDoc/ResolvedPhpDocBlock.php |
Caches and inherits resolved tags. |
src/PhpDoc/PhpDocNodeResolver.php |
Resolves both tag aliases. |
src/Analyser/StmtHandler/FunctionHandler.php |
Threads function tags into scope. |
src/Analyser/StmtHandler/ClassMethodHandler.php |
Threads method tags into scope. |
src/Analyser/PhpDocsResolver.php |
Returns resolved parameter flags. |
src/Analyser/MutatingScope.php |
Propagates metadata into reflections. |
src/Analyser/ExprHandler/NewHandler.php |
Applies conditional purity to constructors. |
resources/functionMetadata.php |
Registers affected built-ins. |
bin/generate-function-metadata.php |
Moderate (1 vote): Entries with both purity conditions are rebuilt with only one key, dropping preg_replace_callback’s $count condition at both generation paths. |
bin/functionMetadata_original.php |
Defines source built-in metadata. |
Review details
Suppressed comments (2)
src/Rules/Pure/FunctionPurityCheck.php:99
- Only optionality is validated, so this annotation is also accepted on an ordinary optional value parameter. That lets callers omit the parameter and have an otherwise possibly-impure call classified as pure, even though the tag's contract is specifically for a by-reference output parameter. Reject flagged parameters that are not passed by reference (and cover this invalid declaration in the rule tests).
tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php:450 - This behavioral suite covers only
str_replace,preg_match,preg_filter, andpreg_replace_callback; the new mappings forstr_ireplace,preg_replace,preg_match_all, andsimilar_textare unverified. Because correctness depends on exact version-specific parameter names, add omitted/passed cases for each new mapping so a typo cannot silently leave a builtin possibly impure.
- Files reviewed: 57/59 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if ($arg->name !== null) { | ||
| $hasNamedParameter = true; | ||
| if ($arg->name->name === $parameter->getName()) { | ||
| $matchedArg = $arg; | ||
| break; |
There was a problem hiding this comment.
Fixed in 52d754f. myVariadicOut($s, extra: $x) against myVariadicOut(string $subject, int &...$out) came out pure.
Both verdict resolvers carried a copy of the same matching loop, so I moved it into matchArgForParameter(). A flagged variadic matches positional arguments from its own position onwards, and named arguments that bind to no declared parameter. The shared helper also gives the @pure-unless-callable-is-impure verdict the unpacked-argument handling that only the other resolver had.
| ))->identifier(sprintf('pure%s.redundantUnlessCallable', $identifier))->build(); | ||
| } | ||
|
|
||
| $pureUnlessParameterPassedParameters = $functionReflection->getPureUnlessParameterPassedParameters(); |
There was a problem hiding this comment.
Fixed in 1eca80c. The gap is wider than the comment says: a function carrying only this tag never reached reportImpurePoints(), so it could echo, write a global or call an impure function, and calls omitting the flagged argument still counted as pure.
Writing through a flagged by-ref parameter produces no impure point, so I run the same validation the sibling tag gets over the rest of the body.
The same commit takes the suppressed suggestion below and rejects the tag on a by-value parameter. The two checks depend on each other: passing a by-value parameter cannot introduce a side effect, so such a declaration claims an impurity that does not hang on the argument at all.
| // A first-class callable's purity is evaluated from its ParametersAcceptor | ||
| // alone, without scope/args, so @pure-unless-parameter-passed cannot gate on | ||
| // whether $count is actually passed at this call site; it stays possibly | ||
| // impure even though $count is omitted here. | ||
| return $f($s); |
There was a problem hiding this comment.
Fixed in b34e654. The per-parameter flags do survive into the ClosureType. What gets dropped is the verdict, because a callable value's impure points come from its ParametersAcceptor alone, without scope or arguments. The call site knows the arguments, so narrowByConditionalPurity() settles the verdict there.
This predates the PR. @pure-unless-callable-is-impure has the same gap on plain 2.2.x:
/** @phpstan-pure */
function f(array $a): array
{
$g = array_map(...);
return $g('strtoupper', $a); // Possibly impure call to function array_map()
}The fix covers both tags, with a regression test for each.
…tins str_replace/str_ireplace's count and preg_match/preg_match_all's matches resolve to different parameter names (replace_count/subpatterns) when the target PHP version is below 8.0: NativeFunctionReflectionProvider falls back from php-8-stubs to the legacy functionMap.php names in that case. List both names in the metadata so the by-ref-omitted suppression works regardless of the analysed PHP version. Found via the old-PHPUnit (7.4) CI job, which runs the test suite against phpVersion 7.4 by default.
…d named arguments NewHandler now applies the parameter-passed verdict to 'new' the same way the sibling callable verdict is applied (constructors return void, so createFromVariant cannot be reused). The verdict also treats argument unpacking conservatively (an unpacked argument might cover the flagged by-ref parameter, so the call stays possibly impure) and respects the flag's own TrinaryLogic certainty from union-variant composition (an uncertain flag downgrades a passed argument to Maybe instead of No).
phpstan/phpdoc-parser#259 is merged and released, so replace the generic-tag stopgap with PhpDocNode::getPureUnlessParameterIsPassedTagValues() and bump the dependency.
… is passed createFromVariant suppressed the impure point when the flagged parameter was omitted, but never promoted the verdict to certain when it was passed, unlike its @pure-unless-callable-is-impure sibling right above it and unlike NewHandler's own constructor handling. Passing the by-ref out-parameter is a definite side effect, not a possible one. Also covers intersection types, method inheritance (incl. a renamed parameter), and first-class callables.
InvalidPHPStanDocTagRule matches @phpstan-* tags by name against a hardcoded list, so native phpdoc-parser recognition does not exempt them. @phpstan-pure-unless-callable-is-impure was never added to that list and is currently reported as an unknown tag; add it alongside @phpstan-pure-unless-parameter-passed and drop the stale TODO.
A non-optional parameter is always passed, so the tag can never keep the function or method pure. Report it as a misuse, mirroring the existing @pure-unless-callable-is-impure redundancy check. The declaration-side flag was dropped for functions: enterFunction() was never given the @pure-unless-parameter-passed parameters (the method path already passed them), so FunctionPurityCheck could not see it. Thread it through as well.
FunctionPurityCheck read the per-parameter TrinaryLogic flag via ->yes() to find the flagged parameters, which is an equivalent mutation at a declaration site (the flag is only ever Yes or No there, never Maybe). Mirror the @pure-unless-callable-is-impure sibling instead: expose the flagged parameters as a function/method-level array<string, TrinaryLogic> getter and look them up with array_key_exists.
Cover a function whose flagged by-ref parameter is followed by a trailing variadic: omitting the parameter stays pure, passing it is impure, and the extra variadic arguments do not affect the flagged parameter.
preg_filter() has the same signature as the already-covered preg_replace() (a pure regex substitution with an optional by-ref $count out-parameter), so it stays pure unless $count is passed.
A function can carry both @pure-unless-callable-is-impure and @pure-unless-parameter-passed at once - preg_replace_callback() is pure unless its callback is impure or its $count is passed. The two verdicts were checked in sequence, so a pure callback short-circuited to "pure" and never looked at $count. Combine them with TrinaryLogic::and() in both SimpleImpurePoint::createFromVariant() and NewHandler, and flag preg_replace_callback()'s $count.
Co-authored-by: Markus Staab <maggus.staab@googlemail.com>
A named argument whose name matches no declared parameter is collected by a trailing variadic parameter as a string-keyed element, so for a signature like myVariadicOut(string $subject, int &...$out) the call myVariadicOut($s, extra: $x) does reach the flagged $out. The exact-name check left $matchedArg null and returned a pure verdict. Extract the argument matching shared by both purity verdicts into matchArgForParameter(), and let it treat positional arguments from the variadic's position onwards, and named arguments not bound to any declared parameter, as reaching the variadic. The shared helper also gives the @pure-unless-callable-is-impure verdict the unpacked-argument handling that only the @pure-unless-parameter-passed one had.
The tag alone left isPure() at Maybe and reportImpurePoints() only ran when @pure-unless-callable-is-impure parameters were present, so a function carrying only @pure-unless-parameter-passed could echo, write to a global or call an impure function and every call omitting the flagged argument was still classified as pure. Writing through the flagged by-ref parameter is not an impure point, so the rest of the body can be checked as-is: run the same validation the sibling tag gets. Also reject the tag on a parameter that is not passed by reference - passing a by-value parameter cannot introduce a side effect on its own, so the impurity such a declaration claims would not be conditional on the argument at all.
An entry can declare both pureUnlessCallableIsImpureParameters and pureUnlessParameterPassedParameters - preg_replace_callback() is pure unless its callback is impure or its 'count' is passed - but the merge loop rebuilt such an entry with whichever key it saw first, and the encoder's match() emitted a single key, so regenerating dropped the 'count' condition at both points. Keep every condition the hand-maintained entry declares and encode all the keys an entry has. Also emit the parameter map separator as ', ' so the generated file is valid style and round-trips; the two entries that already had two parameters are reformatted accordingly.
The behavioral suite only exercised str_replace(), preg_match(), preg_filter() and preg_replace_callback(); str_ireplace(), preg_replace(), preg_match_all() and similar_text() were mapped but never called. Since the mapping is keyed by the exact parameter name, a typo there would silently leave the builtin possibly impure - verified by breaking each name in turn and watching these cases fail.
A callable value's impure points are resolved from its ParametersAcceptor
alone, with no scope and no arguments, so a conditionally pure function
reached through a first-class callable arrived at the call site as an
unconditional "possibly impure" point: $f = myReplace(...); $f($s) disagreed
with the equivalent myReplace($s), and so did $f = array_map(...);
$f('strtoupper', $arr).
The per-parameter flags do survive into the Closure type, and the arguments
are known where the callable is invoked, so apply the verdict there:
narrowByConditionalPurity() drops the impure points when the call is pure
and makes them certain when it is not.
b34e654 to
1754fd9
Compare
|
@VincentLanglet @SanderMuller please review |
| */ | ||
|
|
||
| /** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */ | ||
| /** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}|array{pureUnlessCallableIsImpureParameters: array<string, bool>, pureUnlessParameterPassedParameters: array<string, bool>}> */ |
There was a problem hiding this comment.
Should it be just something like
array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>>
Here and other places ?
Or at least
array<string, array{hasSideEffects: bool}|non-empty-array{pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}>
| // An entry can carry both conditions at once (e.g. preg_replace_callback, | ||
| // which is pure unless its callback is impure or its 'count' is passed), | ||
| // so keep every condition the hand-maintained entry declares. |
There was a problem hiding this comment.
Not sure it's worth keeping the comment
| // An entry is either unconditional or carries one or both of the conditional | ||
| // purity keys, so encode every key it has instead of just the first one. |
There was a problem hiding this comment.
Dont think it's worth keeping the comment
| * A function can carry both flags at once (e.g. preg_replace_callback, which is | ||
| * pure unless its callback is impure or its $count is passed). It stays pure only | ||
| * when both verdicts agree it is pure, so they are combined: Yes = pure, | ||
| * No = impure, Maybe = possibly impure. Returns null when the variant declares | ||
| * neither flag, so the caller keeps its current behavior. |
There was a problem hiding this comment.
Not sure this part of the comment is worth, TrinaryLogic is kinda standard in phpstan codebase
| * Applies the conditional purity verdict of this call site to impure points that | ||
| * were resolved without one - a callable value's impure points are computed from | ||
| * its ParametersAcceptor alone, so a first-class callable of a conditionally pure | ||
| * function arrives here as an unconditional "possibly impure" point. |
| * Purity verdict for parameters flagged with @pure-unless-parameter-passed: | ||
| * the call stays pure as long as none of those (by-ref out) parameters | ||
| * received an argument. Returns Yes when no flagged parameter was passed, | ||
| * No when at least one was, and null when the variant has no such parameters | ||
| * (so the caller keeps its current behavior). |
There was a problem hiding this comment.
I think the method is understandable by his name
| * differently across the members - or present in some and absent in others - | ||
| * resolves to Maybe. | ||
| */ | ||
| final class MergedPureUnlessParameterPassedParameters |
There was a problem hiding this comment.
I think there is something to refactor with MergedPureUnlessCallableIsImpureParameters
Both method are the same, except the getPureUnlessParameterPassedParameters call.
The @var spelled the entry type as a union of four array shapes, one per combination of the two conditional purity keys, which does not scale and already repeated itself. A single shape with optional keys says the same thing, and matches how bin/generate-function-metadata.php already annotated the array it loads.
The two comments in the generator described what the loops below them plainly do. In SimpleImpurePoint, resolvePureUnlessParameterPassedVerdict() and narrowByConditionalPurity() read off their names, and the Yes/No/Maybe gloss on resolveConditionalPurityVerdict() explained TrinaryLogic rather than this method. Kept only the parts that are not obvious: that a function can carry both flags at once, and when each verdict returns null.
MergedPureUnlessCallableIsImpureParameters and MergedPureUnlessParameterPassedParameters were identical apart from which getter they read off each method reflection. MergedConditionalPurityParameters keeps that merge once and exposes one entry point per tag. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
c6bc66e to
b2bcda2
Compare
SanderMuller
left a comment
There was a problem hiding this comment.
Reviewed b2bcda2 against its merge base 6457e6d.
With src/ reverted to the base the new tests fail, testPureUnlessCallableIsImpureFirstClassCallable included, so the pre-existing first-class-callable gap really is covered.
I probed the case Copilot raised. A function carrying only @pure-unless-parameter-passed that also echoes is still reported as impure.echo. Omitting the flagged argument stays pure and passing it is reported, for userland functions and for the builtins. preg_replace_callback combines both flags: an impure callback is reported, a pure callback plus $count is reported, a pure callback without $count is clean. @impure still wins over the tag, and a child override that drops the tag and echoes is reported at its own declaration.
No changed class carries #[ShadowedByTurboExtension], so there is no C++ mirror to port.
Gate on the head: full suite green (21420 tests), make phpstan no errors, phpcs clean on the 56 touched files.
Performance: no measurable difference. Self-analysis over 2638 files, result cache cleared every time, base and head interleaved across three rounds. Base median 21.53s wall / 142.04s CPU, head 20.21s / 143.63s, against a base spread of 18.01-21.93s wall and 140.32-146.47s CPU.
Over-reach on real code: on a 4524-file real-world corpus the two error sets are identical, 3265 errors on each side, nothing added and nothing removed.
CI: all eight red checks are also red on #6423, which was updated the same day. Rector fails on the same four fixtures there, and Mutation Testing ends with "The runner has received a shutdown signal" rather than an escaped mutant.
@staabm on 2.3.x: it does not merge cleanly, but only one file conflicts, src/Analyser/ExprHandler/NewHandler.php. 2.3.x moved that block into getConstructorImpurePoints(), and the hunk applies there unchanged. Everything else auto-merges, so a rebase looks cheaper than a second PR.
Two nits, neither blocking. In MergedConditionalPurityParameters::merge() the if ($value === null) { continue; } cannot be reached, because a name only enters $parameterNames from a non-empty map. And @pure-unless-parameter-passed $nameThatDoesNotExist is silently ignored, though @pure-unless-callable-is-impure has the same gap, so that one is not for this PR.
I am not the maintainer, so the merge call is yours.
Implements the
@pure-unless-parameter-passedPHPDoc tag: a parameter-level annotation declaring that the function/method is pure unless an argument is passed for that (by-ref out) parameter. Built on top of2.2.x, following the same architecture as@pure-unless-callable-is-impure(#3482) — the parameter-level flag isTrinaryLogicfrom the start so it composes correctly on union/intersection method variants.Background
This closes @staabm's phpstan/phpstan#11884, where he pointed out that
str_replace()(andstr_ireplace/preg_replace) could be treated as pure when the by-ref$countargument is omitted, and asked for "a solution independent of concrete function signatures" rather than one-off metadata flags. ondrejmirtes suggested the@phpstan-pure-unless-parameter-passedannotation name in response. @staabm then implemented the parser-side syntax support at phpstan/phpdoc-parser#259, which is still open — ondrejmirtes asked there for the phpstan-src implementation to be ready first before merging the parser side. This PR is that implementation.Parser support
Since phpstan/phpdoc-parser#259 is not merged yet, this PR parses the tag from the generic tag value as a stopgap (
PhpDocNodeResolver::resolveParamPureUnlessParameterPassed(), marked with aTODOto switch toPhpDocNode::getPureUnlessParameterIsPassedTagValues()once the parser PR merges).How it works
@phpstan-prefixed alias) is resolved into per-parameter flags, threaded through the reflection layer (ExtendedParameterReflection::isPureUnlessParameterPassedParameter(): TrinaryLogic), and populated for builtins viafunctionMetadata(str_replace,str_ireplace,preg_replace,preg_match,preg_match_all,similar_text).SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict()checks whether an argument was actually passed for each flagged parameter:null) → the call stays pure;combineAcceptors()merges the flag across union method variants with equals-or-Maybe, so a parameter tagged in some members but not others correctly becomesMayberather than being silently dropped.str_replace,str_ireplace,preg_replace,preg_match,preg_match_all,similar_text)refs #3482, phpstan/phpdoc-parser#259
Closes phpstan/phpstan#11884