Skip to content

Implement @pure-unless-parameter-passed - #6018

Draft
zonuexe wants to merge 24 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-parameter-passed
Draft

Implement @pure-unless-parameter-passed#6018
zonuexe wants to merge 24 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-parameter-passed

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Implements the @pure-unless-parameter-passed PHPDoc 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 of 2.2.x, following the same architecture as @pure-unless-callable-is-impure (#3482) — the parameter-level flag is TrinaryLogic from 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() (and str_ireplace/preg_replace) could be treated as pure when the by-ref $count argument 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-passed annotation 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 a TODO to switch to PhpDocNode::getPureUnlessParameterIsPassedTagValues() once the parser PR merges).

How it works

  • The tag (and its @phpstan- prefixed alias) is resolved into per-parameter flags, threaded through the reflection layer (ExtendedParameterReflection::isPureUnlessParameterPassedParameter(): TrinaryLogic), and populated for builtins via functionMetadata (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:
    • the by-ref parameter is omitted (or explicitly null) → the call stays pure;
    • an argument is passed → unchanged (possibly-impure) behavior.
  • combineAcceptors() merges the flag across union method variants with equals-or-Maybe, so a parameter tagged in some members but not others correctly becomes Maybe rather than being silently dropped.
/** @phpstan-pure */
function normalize(string $s): string
{
    return str_replace('_', '-', $s); // no longer "Possibly impure"
}

/** @phpstan-pure */
function normalizeAndCount(string $s, int &$count): string
{
    return str_replace('_', '-', $s, $count); // still "Possibly impure" - $count is written
}
  • Add function map (str_replace, str_ireplace, preg_replace, preg_match, preg_match_all, similar_text)
  • Add tests (userland functions/methods, builtins, union-variant Maybe combine)
  • Add rules (no new rule needed — the impure-point suppression feeds the existing pure-context check)

refs #3482, phpstan/phpdoc-parser#259

Closes phpstan/phpstan#11884

Comment thread src/Reflection/Callables/SimpleImpurePoint.php Outdated
@zonuexe

zonuexe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest 2.2.x. While reworking the tests I found and fixed a real asymmetry in SimpleImpurePoint::createFromVariant(): when an argument was passed for a @pure-unless-parameter-passed by-ref parameter, the call was only reported as possibly impure, whereas passing it is a definite side effect. It now reports certain impurity, matching both the @pure-unless-callable-is-impure block right above it and the constructor path in NewHandler. Added coverage for methods, intersection receivers, inheritance (including a renamed parameter), and first-class callables.

Current CI status — the remaining red checks are all unrelated to this PR's code:

  • Mutation Testing passes on 8.4 (0 escaped; the two NewHandler TrinaryLogicMutator mutants are killed by static analysis). On 8.3 those same two mutants escape and an unrelated mutant times out — a nondeterministic SA-step/coverage artifact on the slower runner, not a genuine test gap. The two mutants are not killable by a test because a Maybe verdict is unreachable at the constructor call site by construction (single-variant constructors; dynamic new bails out on a union of classes).
  • Rector integration fails with undefined method ...getPureUnlessParameterIsPassedTagValues(). This is expected: phpdoc-parser 2.3.3 (which adds that method) was released today, and no PHPStan/Rector release bundling it exists yet, so the integration environment resolves an older phpdoc-parser. It will clear once a release catches up.
  • E2E result-cache-restore-without-reflection fails on 2.2.x itself, from an upstream phpstan-phpunit@dev constructor-parameter change.
  • Benchmark and the old-PHPUnit Windows job failures are pre-existing/flaky (the latter is an unrelated IntersectionTypeTest data set) and reproduce independently of this PR.

@zonuexe
zonuexe marked this pull request as ready for review July 8, 2026 11:25
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@staabm

staabm commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

would it make sense to emit a new error when @pure-unless-parameter-passed is used on a non-optional parameter?

Comment thread src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php Outdated
@zonuexe
zonuexe force-pushed the feature/pure-unless-parameter-passed branch from 52e31bd to 4902e4b Compare July 9, 2026 06:31
Comment thread tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php
@zonuexe
zonuexe marked this pull request as draft July 9, 2026 08:03
@zonuexe
zonuexe marked this pull request as ready for review July 9, 2026 08:41
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@zonuexe

zonuexe commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Update after the latest review round:

  • Non-optional parameter (suggested above): a non-optional parameter is always passed, so the tag can never keep the function/method pure. This is now reported as a misuse (pureFunction.nonOptionalParameterPassed / pureMethod.nonOptionalParameterPassed), mirroring the existing @pure-unless-callable-is-impure redundancy check. Wiring it up surfaced a gap: the declaration-side flag was dropped for functions (enterFunction() never received it, while the method path already did), fixed as well.
  • Function-level map: to find the flagged parameters at a declaration, the check now uses a getPureUnlessParameterPassedParameters() map with array_key_exists, exactly like the callable sibling, instead of the per-parameter ->yes(). This removes an equivalent TrinaryLogicMutator mutant in FunctionPurityCheck (at a declaration site the flag is only ever Yes or No, never Maybe).
  • @phpstan-pure-unless-callable-is-impure was missing from the known-tags list and was reported as an unknown tag; added it there too.

Mutation status: 8.4 is green (0 escaped). 8.3 still reports the two NewHandler constructor-path mutants; these are equivalent ($passedVerdict can only be Yes or No at a new call site: a userland constructor has a single variant, and dynamic new bails out on a union of classes, so Maybe is unreachable) and are killed by static analysis on 8.4. The remaining red checks (Benchmark, Rector integration) are unrelated as noted earlier; E2E now passes after picking up the phpstan-phpunit update.

Comment thread resources/functionMetadata.php
Comment thread src/Reflection/Callables/SimpleImpurePoint.php
@zonuexe
zonuexe marked this pull request as draft July 9, 2026 15:33
@zonuexe
zonuexe marked this pull request as ready for review July 9, 2026 16:01
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@staabm
staabm force-pushed the feature/pure-unless-parameter-passed branch from 915ecf0 to 0b1a724 Compare July 14, 2026 12:55
@staabm
staabm requested a review from VincentLanglet July 14, 2026 12:56
Comment thread bin/functionMetadata_original.php Outdated
@staabm
staabm force-pushed the feature/pure-unless-parameter-passed branch from c8d7c5c to cc28625 Compare July 14, 2026 21:10
@zonuexe
zonuexe force-pushed the feature/pure-unless-parameter-passed branch 2 times, most recently from f04bb56 to 402666d Compare September 2, 2026 01:37
@staabm
staabm force-pushed the feature/pure-unless-parameter-passed branch from 402666d to 4a26563 Compare September 4, 2026 10:41
@staabm
staabm requested a balanced review from Copilot September 4, 2026 10:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, and preg_replace_callback; the new mappings for str_ireplace, preg_replace, preg_match_all, and similar_text are 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.

Comment on lines +248 to +252
if ($arg->name !== null) {
$hasNamedParameter = true;
if ($arg->name->name === $parameter->getName()) {
$matchedArg = $arg;
break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +327 to +331
// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

zonuexe and others added 17 commits September 12, 2026 13:33
…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.
@staabm
staabm force-pushed the feature/pure-unless-parameter-passed branch from b34e654 to 1754fd9 Compare September 12, 2026 11:34
@staabm

staabm commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

@VincentLanglet @SanderMuller please review

Comment thread bin/functionMetadata_original.php Outdated
*/

/** @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>}> */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>}>

Comment thread bin/generate-function-metadata.php Outdated
Comment on lines +130 to +132
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure it's worth keeping the comment

Comment thread bin/generate-function-metadata.php Outdated
Comment on lines +230 to +231
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont think it's worth keeping the comment

Comment on lines +140 to +144
* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this part of the comment is worth, TrinaryLogic is kinda standard in phpstan codebase

Comment on lines +160 to +163
* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be removed ?

Comment on lines +253 to +257
* 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is something to refactor with MergedPureUnlessCallableIsImpureParameters

Both method are the same, except the getPureUnlessParameterPassedParameters call.

@zonuexe
zonuexe marked this pull request as draft September 12, 2026 12:38
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.
@zonuexe
zonuexe changed the base branch from 2.2.x to 2.3.x September 12, 2026 13:00
@zonuexe
zonuexe changed the base branch from 2.3.x to 2.2.x September 12, 2026 13:07
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>
@zonuexe
zonuexe force-pushed the feature/pure-unless-parameter-passed branch from c6bc66e to b2bcda2 Compare September 12, 2026 13:16

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

str_replace considered impure

6 participants