From b65a1f649d2bac49a7bf8d31868febb5f33ed1c1 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:02:00 +0000 Subject: [PATCH 01/13] Report continue targeting switch Since PHP 7.3, continue targeting a switch emits a compile-time warning because it behaves like break. Outside of an enclosing loop the error is fixable by replacing it with break. Inside a loop the tip suggests the continue level that reaches the nearest loop. Amp-Thread-ID: https://ampcode.com/threads/T-01a19c16-abb2-754f-b615-8cf680c7b5a2 --- src/Php/PhpVersion.php | 5 + .../Keywords/ContinueTargetingSwitchRule.php | 119 ++++++++++++++++++ .../ContinueTargetingSwitchRuleTest.php | 66 ++++++++++ .../data/continue-targeting-switch.php | 69 ++++++++++ .../data/continue-targeting-switch.php.fixed | 69 ++++++++++ 5 files changed, 328 insertions(+) create mode 100644 src/Rules/Keywords/ContinueTargetingSwitchRule.php create mode 100644 tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php create mode 100644 tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php create mode 100644 tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6667a5916d9..7875f31c0a7 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -543,4 +543,9 @@ public function throwsOnStringCast(): bool return $this->versionId >= 70400; } + public function warnsOnContinueTargetingSwitch(): bool + { + return $this->versionId >= 70300; + } + } diff --git a/src/Rules/Keywords/ContinueTargetingSwitchRule.php b/src/Rules/Keywords/ContinueTargetingSwitchRule.php new file mode 100644 index 00000000000..a60e9ac19ec --- /dev/null +++ b/src/Rules/Keywords/ContinueTargetingSwitchRule.php @@ -0,0 +1,119 @@ + + */ +#[RegisteredRule(level: 0)] +final class ContinueTargetingSwitchRule implements Rule +{ + + private const LOOP_TYPES = [ + Stmt\For_::class, + Stmt\Foreach_::class, + Stmt\Do_::class, + Stmt\While_::class, + ]; + + private const SCOPE_BOUNDARY_TYPES = [ + Node\Expr\Closure::class, + Stmt\ClassMethod::class, + Stmt\Function_::class, + ]; + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return Stmt\Continue_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->warnsOnContinueTargetingSwitch()) { + return []; + } + + $depth = $node->num instanceof Node\Scalar\Int_ ? $node->num->value : 1; + + $remaining = $depth; + $targetsSwitch = false; + $nearestLoopDepth = null; + foreach (array_reverse($node->getAttribute(ParentStmtTypesVisitor::ATTRIBUTE_NAME, [])) as $parentStmtType) { + if (in_array($parentStmtType, self::SCOPE_BOUNDARY_TYPES, true)) { + break; + } + + $isLoop = in_array($parentStmtType, self::LOOP_TYPES, true); + if (!$isLoop && $parentStmtType !== Stmt\Switch_::class) { + continue; + } + + $remaining--; + if ($remaining === 0) { + if (!$isLoop) { + $targetsSwitch = true; + continue; + } + + return []; + } + + if ($remaining >= 0 || !$isLoop) { + continue; + } + + $nearestLoopDepth = $depth - $remaining; + break; + } + + if (!$targetsSwitch) { + return []; + } + + $continueKeyword = $depth > 1 ? sprintf('continue %d', $depth) : 'continue'; + $breakKeyword = $depth > 1 ? sprintf('break %d', $depth) : 'break'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Keyword %s targeting switch is equivalent to %s.', + $continueKeyword, + $breakKeyword, + ))->identifier('continue.switch'); + + if ($nearestLoopDepth !== null) { + // the intention was most likely to continue the enclosing loop, so the fix is not obvious + return [ + $errorBuilder + ->tip(sprintf( + 'Did you mean to use continue %d to continue the enclosing loop? Use %s to keep the current behaviour.', + $nearestLoopDepth, + $breakKeyword, + )) + ->build(), + ]; + } + + return [ + $errorBuilder + ->fixNode($node, static fn (Stmt\Continue_ $node) => new Stmt\Break_($node->num, ['comments' => $node->getComments()])) + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php b/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php new file mode 100644 index 00000000000..5bba08d5e02 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php @@ -0,0 +1,66 @@ + + */ +class ContinueTargetingSwitchRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 70300; + + protected function getRule(): Rule + { + return new ContinueTargetingSwitchRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/continue-targeting-switch.php'], [ + [ + 'Keyword continue targeting switch is equivalent to break.', + 10, + ], + [ + 'Keyword continue 2 targeting switch is equivalent to break 2.', + 14, + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 27, + 'Did you mean to use continue 2 to continue the enclosing loop? Use break to keep the current behaviour.', + ], + [ + 'Keyword continue 2 targeting switch is equivalent to break 2.', + 32, + 'Did you mean to use continue 3 to continue the enclosing loop? Use break 2 to keep the current behaviour.', + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 46, + 'Did you mean to use continue 3 to continue the enclosing loop? Use break to keep the current behaviour.', + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 64, + ], + ]); + } + + public function testOlderPhp(): void + { + $this->phpVersionId = 70200; + $this->analyse([__DIR__ . '/data/continue-targeting-switch.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/continue-targeting-switch.php', __DIR__ . '/data/continue-targeting-switch.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php new file mode 100644 index 00000000000..137ab3666b7 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php @@ -0,0 +1,69 @@ + $items + */ +function insideLoop(array $items): void +{ + foreach ($items as $item) { + switch ($item) { + case 1: + continue; + case 2: + continue 2; + case 3: + foreach ($items as $inner) { + continue 2; + } + break; + case 4: + while (true) { + continue; + } + } + + for ($i = 0; $i < $item; $i++) { + switch ($i) { + case 0: + switch ($item) { + case 0: + continue; + } + } + } + + echo $item; + } +} + +/** + * @param list $items + */ +function closureIsBoundary(array $items): void +{ + foreach ($items as $item) { + $f = static function () use ($item): void { + switch ($item) { + case 1: + continue; + } + }; + $f(); + } +} diff --git a/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed new file mode 100644 index 00000000000..b13626821a4 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed @@ -0,0 +1,69 @@ + $items + */ +function insideLoop(array $items): void +{ + foreach ($items as $item) { + switch ($item) { + case 1: + continue; + case 2: + continue 2; + case 3: + foreach ($items as $inner) { + continue 2; + } + break; + case 4: + while (true) { + continue; + } + } + + for ($i = 0; $i < $item; $i++) { + switch ($i) { + case 0: + switch ($item) { + case 0: + continue; + } + } + } + + echo $item; + } +} + +/** + * @param list $items + */ +function closureIsBoundary(array $items): void +{ + foreach ($items as $item) { + $f = static function () use ($item): void { + switch ($item) { + case 1: + break; + } + }; + $f(); + } +} From 106c68dbf1873e58a32c6215b3c0411995da9e87 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:05:07 +0000 Subject: [PATCH 02/13] Report modification of $GLOBALS as a whole Since PHP 8.1, assigning to, unsetting, incrementing, destructuring into or taking a reference of the $GLOBALS variable itself is a compile-time error. Only its elements can be modified. --- Makefile | 1 + src/Php/PhpVersion.php | 5 + .../Variables/GlobalsModificationRule.php | 125 ++++++++++++++++++ .../Variables/GlobalsModificationRuleTest.php | 47 +++++++ .../Variables/data/globals-modification.php | 42 ++++++ 5 files changed, 220 insertions(+) create mode 100644 src/Rules/Variables/GlobalsModificationRule.php create mode 100644 tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php create mode 100644 tests/PHPStan/Rules/Variables/data/globals-modification.php diff --git a/Makefile b/Makefile index d996514c8b7..9748dea055a 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ lint: --exclude tests/PHPStan/Rules/Variables/data/bug-14349.php \ --exclude tests/PHPStan/Rules/Variables/data/bug-14352.php \ --exclude tests/PHPStan/Rules/Variables/data/bug-14351.php \ + --exclude tests/PHPStan/Rules/Variables/data/globals-modification.php \ --exclude tests/PHPStan/Rules/Properties/data/bug-14457.php \ src tests diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 7875f31c0a7..768007baebd 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -548,4 +548,9 @@ public function warnsOnContinueTargetingSwitch(): bool return $this->versionId >= 70300; } + public function restrictsGlobalsModification(): bool + { + return $this->versionId >= 80100; + } + } diff --git a/src/Rules/Variables/GlobalsModificationRule.php b/src/Rules/Variables/GlobalsModificationRule.php new file mode 100644 index 00000000000..66be06631e6 --- /dev/null +++ b/src/Rules/Variables/GlobalsModificationRule.php @@ -0,0 +1,125 @@ + + */ +#[RegisteredRule(level: 0)] +final class GlobalsModificationRule implements MultipleNodeTypesRule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [ + Expr\Assign::class, + Expr\AssignOp::class, + Expr\AssignRef::class, + Expr\PreInc::class, + Expr\PostInc::class, + Expr\PreDec::class, + Expr\PostDec::class, + Stmt\Foreach_::class, + Stmt\Unset_::class, + ]; + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->restrictsGlobalsModification()) { + return []; + } + + if ($node instanceof Stmt\Unset_) { + $targets = $node->vars; + } elseif ($node instanceof Stmt\Foreach_) { + $targets = [$node->valueVar]; + if ($node->keyVar !== null) { + $targets[] = $node->keyVar; + } + } elseif ( + $node instanceof Expr\Assign + || $node instanceof Expr\AssignOp + || $node instanceof Expr\AssignRef + || $node instanceof Expr\PreInc + || $node instanceof Expr\PostInc + || $node instanceof Expr\PreDec + || $node instanceof Expr\PostDec + ) { + $targets = [$node->var]; + } else { + return []; + } + + foreach ($targets as $target) { + if ($this->containsGlobals($target)) { + return [ + RuleErrorBuilder::message('$GLOBALS can only be modified using the $GLOBALS[$name] = $value syntax.') + ->identifier('globals.modification') + ->nonIgnorable() + ->build(), + ]; + } + } + + if ($node instanceof Expr\AssignRef && $this->isGlobals($node->expr)) { + return [ + RuleErrorBuilder::message('Cannot acquire reference to $GLOBALS.') + ->identifier('globals.reference') + ->nonIgnorable() + ->build(), + ]; + } + + return []; + } + + private function containsGlobals(Expr $expr): bool + { + if ($this->isGlobals($expr)) { + return true; + } + + if (!$expr instanceof Expr\List_ && !$expr instanceof Expr\Array_) { + return false; + } + + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + if ($this->containsGlobals($item->value)) { + return true; + } + } + + return false; + } + + private function isGlobals(Expr $expr): bool + { + return $expr instanceof Expr\Variable && $expr->name === 'GLOBALS'; + } + +} diff --git a/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php b/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php new file mode 100644 index 00000000000..86f4b23e418 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php @@ -0,0 +1,47 @@ + + */ +class GlobalsModificationRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + protected function getRule(): Rule + { + return new GlobalsModificationRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = '$GLOBALS can only be modified using the $GLOBALS[$name] = $value syntax.'; + $this->analyse([__DIR__ . '/data/globals-modification.php'], [ + [$message, 10], + [$message, 11], + [$message, 12], + [$message, 13], + [$message, 14], + [$message, 15], + [$message, 17], + [$message, 19], + [$message, 20], + [$message, 21], + [$message, 22], + ['Cannot acquire reference to $GLOBALS.', 23], + ]); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/globals-modification.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/globals-modification.php b/tests/PHPStan/Rules/Variables/data/globals-modification.php new file mode 100644 index 00000000000..1afb10f33d6 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/globals-modification.php @@ -0,0 +1,42 @@ + $values + */ +function doFoo(array $values): void +{ + $GLOBALS = $values; + $GLOBALS += $values; + $GLOBALS ??= []; + [$GLOBALS] = [$values]; + ['a' => [, $GLOBALS]] = ['a' => [1, $values]]; + foreach ($values as $GLOBALS) { + } + foreach ($values as $GLOBALS => $value) { + } + $GLOBALS++; + --$GLOBALS; + unset($values, $GLOBALS); + $GLOBALS = &$values; + $reference = &$GLOBALS; +} + +/** + * @param array $values + */ +function allowed(array $values): void +{ + $GLOBALS['foo'] = $values; + $GLOBALS['foo']['bar'] = 1; + $GLOBALS['foo'] ??= 1; + $GLOBALS['foo']++; + [$GLOBALS['foo']] = [1]; + unset($GLOBALS['foo']); + foreach ($GLOBALS as $name => &$value) { + } + $copy = $GLOBALS; + $reference = &$GLOBALS['foo']; + var_dump($copy, $reference); +} From 254083ebeef6c7951cb795adde7bed21a31df067 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:06:58 +0000 Subject: [PATCH 03/13] Report array pointer functions called on objects Since PHP 8.1, calling current(), pos(), key(), next(), prev(), reset() and end() on an object is deprecated. Arguments that may be objects are reported according to the rule level, the same as argument types. --- src/Php/PhpVersion.php | 5 ++ .../ArrayPointerFunctionOnObjectRule.php | 89 +++++++++++++++++++ .../ArrayPointerFunctionOnObjectRuleTest.php | 85 ++++++++++++++++++ .../data/array-pointer-function-on-object.php | 44 +++++++++ 4 files changed, 223 insertions(+) create mode 100644 src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php create mode 100644 tests/PHPStan/Rules/Functions/ArrayPointerFunctionOnObjectRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 768007baebd..048ce4ff988 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -553,4 +553,9 @@ public function restrictsGlobalsModification(): bool return $this->versionId >= 80100; } + public function deprecatesArrayPointerFunctionsOnObjects(): bool + { + return $this->versionId >= 80100; + } + } diff --git a/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php b/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php new file mode 100644 index 00000000000..ce644f64c76 --- /dev/null +++ b/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php @@ -0,0 +1,89 @@ + + */ +#[RegisteredRule(level: 5)] +final class ArrayPointerFunctionOnObjectRule implements Rule +{ + + private const FUNCTIONS = ['current', 'pos', 'key', 'next', 'prev', 'reset', 'end']; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuleLevelHelper $ruleLevelHelper, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesArrayPointerFunctionsOnObjects()) { + return []; + } + + if (!$node->name instanceof Node\Name) { + return []; + } + + $args = $node->getArgs(); + if (count($args) !== 1 || $args[0]->unpack) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionName = $this->reflectionProvider->getFunction($node->name, $scope)->getName(); + if (!in_array($functionName, self::FUNCTIONS, true)) { + return []; + } + + $type = $scope->getType($args[0]->value); + $arrayType = new ArrayType(new MixedType(), new MixedType()); + if ( + !TypeCombinator::union($arrayType, new ObjectWithoutClassType())->isSuperTypeOf($type)->yes() + || $this->ruleLevelHelper->accepts($arrayType, $type, $scope->isDeclareStrictTypes())->result + ) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling %s() on %s is deprecated in PHP 8.1.', + $functionName, + $type->describe(VerbosityLevel::typeOnly()), + )) + ->identifier('argument.deprecatedObject') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/ArrayPointerFunctionOnObjectRuleTest.php b/tests/PHPStan/Rules/Functions/ArrayPointerFunctionOnObjectRuleTest.php new file mode 100644 index 00000000000..b8a52afc4bc --- /dev/null +++ b/tests/PHPStan/Rules/Functions/ArrayPointerFunctionOnObjectRuleTest.php @@ -0,0 +1,85 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class ArrayPointerFunctionOnObjectRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + private bool $checkUnionTypes = false; + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + return new ArrayPointerFunctionOnObjectRule( + $reflectionProvider, + new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: $this->checkUnionTypes, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + new PhpVersion($this->phpVersionId), + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], $this->getObjectErrors()); + } + + public function testRuleWithUnionTypes(): void + { + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], array_merge($this->getObjectErrors(), [ + [ + 'Calling current() on array|stdClass is deprecated in PHP 8.1.', + 36, + ], + [ + 'Calling current() on iterable is deprecated in PHP 8.1.', + 37, + ], + ])); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], []); + } + + /** + * @return list + */ + private function getObjectErrors(): array + { + return [ + ['Calling current() on stdClass is deprecated in PHP 8.1.', 27], + ['Calling pos() on stdClass is deprecated in PHP 8.1.', 28], + ['Calling key() on ArrayObject is deprecated in PHP 8.1.', 29], + ['Calling next() on stdClass is deprecated in PHP 8.1.', 30], + ['Calling prev() on stdClass is deprecated in PHP 8.1.', 31], + ['Calling reset() on stdClass is deprecated in PHP 8.1.', 32], + ['Calling end() on stdClass is deprecated in PHP 8.1.', 33], + ['Calling current() on stdClass is deprecated in PHP 8.1.', 34], + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php b/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php new file mode 100644 index 00000000000..e26f2746a23 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php @@ -0,0 +1,44 @@ += 8.0 + +namespace ArrayPointerFunctionOnObject; + +use ArrayObject; +use stdClass; + +/** + * @param array $array + * @param ArrayObject $arrayObject + * @param array|stdClass $arrayOrObject + * @param iterable $iterable + */ +function doFoo( + array $array, + stdClass $object, + ArrayObject $arrayObject, + array|stdClass $arrayOrObject, + iterable $iterable, + mixed $mixed, +): void +{ + current($array); + key($array); + next($array); + + current($object); + pos($object); + key($arrayObject); + next($object); + prev($object); + reset($object); + end($object); + \current(array: $object); + + current($arrayOrObject); + current($iterable); + current($mixed); +} + +function intOrObject(int|stdClass $intOrObject): void +{ + current($intOrObject); +} From 40e2b72f1e93265a2e7014ec00d07784492e486a Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:08:15 +0000 Subject: [PATCH 04/13] Report static method calls and static property fetches on traits Since PHP 8.1, calling a static method or accessing a static property directly on a trait is deprecated. They should only be accessed on a class using the trait. --- src/Php/PhpVersion.php | 5 + .../Classes/AccessStaticTraitMemberRule.php | 122 ++++++++++++++++++ .../AccessStaticTraitMemberRuleTest.php | 46 +++++++ .../data/access-static-trait-member.php | 61 +++++++++ 4 files changed, 234 insertions(+) create mode 100644 src/Rules/Classes/AccessStaticTraitMemberRule.php create mode 100644 tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/access-static-trait-member.php diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 048ce4ff988..aeb051a7457 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -558,4 +558,9 @@ public function deprecatesArrayPointerFunctionsOnObjects(): bool return $this->versionId >= 80100; } + public function deprecatesStaticTraitMemberAccess(): bool + { + return $this->versionId >= 80100; + } + } diff --git a/src/Rules/Classes/AccessStaticTraitMemberRule.php b/src/Rules/Classes/AccessStaticTraitMemberRule.php new file mode 100644 index 00000000000..57894c8e8ef --- /dev/null +++ b/src/Rules/Classes/AccessStaticTraitMemberRule.php @@ -0,0 +1,122 @@ + + */ +#[RegisteredRule(level: 0)] +final class AccessStaticTraitMemberRule implements MultipleNodeTypesRule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\StaticCall::class, StaticMethodCallableNode::class, Expr\StaticPropertyFetch::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesStaticTraitMemberAccess()) { + return []; + } + + if ($node instanceof Expr\StaticCall) { + if ($node->isFirstClassCallable()) { + return []; + } + $class = $node->class; + $name = $node->name; + } elseif ($node instanceof StaticMethodCallableNode) { + $class = $node->getClass(); + $name = $node->getName(); + } elseif ($node instanceof Expr\StaticPropertyFetch) { + $class = $node->class; + $name = $node->name; + } else { + return []; + } + + if (!$name instanceof Node\Identifier) { + return []; + } + + $trait = $this->findTrait($class, $scope); + if ($trait === null) { + return []; + } + + if ($node instanceof Expr\StaticPropertyFetch) { + if (!$trait->hasStaticProperty($name->toString())) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Accessing static trait property %s::$%s is deprecated in PHP 8.1, it should only be accessed on a class using the trait.', + $trait->getDisplayName(), + $name->toString(), + )) + ->identifier('staticProperty.onTrait') + ->build(), + ]; + } + + if (!$trait->hasNativeMethod($name->toString())) { + return []; + } + + $method = $trait->getNativeMethod($name->toString()); + if (!$method->isStatic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling static trait method %s::%s() is deprecated in PHP 8.1, it should only be called on a class using the trait.', + $trait->getDisplayName(), + $method->getName(), + )) + ->identifier('staticMethod.onTrait') + ->build(), + ]; + } + + private function findTrait(Name|Expr $class, Scope $scope): ?ClassReflection + { + $classType = $class instanceof Name + ? $scope->resolveTypeByName($class) + : $scope->getType($class)->getObjectTypeOrClassStringObjectType(); + + $classReflections = $classType->getObjectClassReflections(); + if (count($classReflections) !== 1 || !$classReflections[0]->isTrait()) { + return null; + } + + return $classReflections[0]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php b/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php new file mode 100644 index 00000000000..3ff90f92470 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php @@ -0,0 +1,46 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class AccessStaticTraitMemberRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + protected function getRule(): Rule + { + return new AccessStaticTraitMemberRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $method = 'Calling static trait method AccessStaticTraitMember\FooTrait::create() is deprecated in PHP 8.1, it should only be called on a class using the trait.'; + $property = 'Accessing static trait property AccessStaticTraitMember\FooTrait::$counter is deprecated in PHP 8.1, it should only be accessed on a class using the trait.'; + $this->analyse([__DIR__ . '/data/access-static-trait-member.php'], [ + [$method, 16], + [$property, 17], + [$method, 49], + [$property, 50], + [$property, 51], + [$method, 52], + [$method, 54], + [$property, 55], + ]); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/access-static-trait-member.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php b/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php new file mode 100644 index 00000000000..8c34ce934e8 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php @@ -0,0 +1,61 @@ += 8.1 + +namespace AccessStaticTraitMember; + +trait FooTrait +{ + + public static int $counter = 0; + + public static function create(): void + { + } + + public function instanceMethod(): void + { + FooTrait::create(); + FooTrait::$counter++; + self::create(); + static::create(); + self::$counter++; + static::$counter++; + } + +} + +class Foo +{ + + use FooTrait; + +} + +class Bar extends Foo +{ + + public function doBar(): void + { + parent::create(); + parent::$counter++; + } + +} + +/** + * @param class-string $className + */ +function doFoo(string $className): void +{ + FooTrait::create(); + echo FooTrait::$counter; + FooTrait::$counter = 5; + $callable = FooTrait::create(...); + $traitName = 'AccessStaticTraitMember\FooTrait'; + $traitName::create(); + $traitName::$counter++; + + Foo::create(); + Foo::$counter++; + $className::create(); + $className::$counter++; +} From 98c8450380b4ac6fcfdc7b6972859ad767ac1c17 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:10:32 +0000 Subject: [PATCH 05/13] Report deprecated mbstring pseudo-encodings Since PHP 8.2, passing HTML-ENTITIES, BASE64, UUENCODE or Quoted-Printable (or their aliases) as the encoding of an mbstring function is deprecated. The tip names the dedicated function to use instead. Amp-Thread-ID: https://ampcode.com/threads/T-01a19c16-abb2-754f-b615-8cf680c7b5a2 --- src/Php/PhpVersion.php | 5 + .../MbstringDeprecatedEncodingRule.php | 140 ++++++++++++++++++ .../MbstringDeprecatedEncodingRuleTest.php | 47 ++++++ .../data/mbstring-deprecated-encoding.php | 26 ++++ 4 files changed, 218 insertions(+) create mode 100644 src/Rules/Functions/MbstringDeprecatedEncodingRule.php create mode 100644 tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index aeb051a7457..6e7b1023e0c 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -563,4 +563,9 @@ public function deprecatesStaticTraitMemberAccess(): bool return $this->versionId >= 80100; } + public function deprecatesMbstringPseudoEncodings(): bool + { + return $this->versionId >= 80200; + } + } diff --git a/src/Rules/Functions/MbstringDeprecatedEncodingRule.php b/src/Rules/Functions/MbstringDeprecatedEncodingRule.php new file mode 100644 index 00000000000..3ca6a9f11f1 --- /dev/null +++ b/src/Rules/Functions/MbstringDeprecatedEncodingRule.php @@ -0,0 +1,140 @@ + + */ +#[RegisteredRule(level: 0)] +final class MbstringDeprecatedEncodingRule implements Rule +{ + + /** Functions configuring mbstring do not report the deprecation. */ + private const IGNORED_FUNCTIONS = [ + 'mb_internal_encoding', + 'mb_http_output', + 'mb_detect_order', + 'mb_preferred_mime_name', + 'mb_regex_encoding', + ]; + + private const ENCODINGS = [ + 'HTML-ENTITIES' => 'HTML entities', + 'HTML' => 'HTML entities', + 'BASE64' => 'Base64', + 'UUENCODE' => 'Uuencode', + 'QUOTED-PRINTABLE' => 'QPrint', + 'QPRINT' => 'QPrint', + ]; + + private const REPLACEMENTS = [ + 'HTML entities' => 'htmlspecialchars(), htmlentities(), or mb_encode_numericentity()/mb_decode_numericentity()', + 'Base64' => 'base64_encode()/base64_decode()', + 'Uuencode' => 'convert_uuencode()/convert_uudecode()', + 'QPrint' => 'quoted_printable_encode()/quoted_printable_decode()', + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesMbstringPseudoEncodings()) { + return []; + } + + if (!$node->name instanceof Node\Name || count($node->getArgs()) === 0) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + if (!str_starts_with($functionName, 'mb_') || in_array($functionName, self::IGNORED_FUNCTIONS, true)) { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + $errors = []; + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + if (!in_array($parameter->getName(), ['encoding', 'to_encoding'], true) || !array_key_exists($i, $args)) { + continue; + } + + foreach ($this->getDeprecatedEncodings($scope->getType($args[$i]->value)->getConstantStrings()) as $encoding) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Handling %s via mbstring in %s() is deprecated in PHP 8.2.', + $encoding, + $functionName, + )) + ->tip(sprintf('Use %s instead.', self::REPLACEMENTS[$encoding])) + ->identifier('argument.deprecatedEncoding') + ->build(); + } + } + + return $errors; + } + + /** + * @param list $constantStrings + * @return list> + */ + private function getDeprecatedEncodings(array $constantStrings): array + { + $encodings = []; + foreach ($constantStrings as $constantString) { + $encoding = self::ENCODINGS[strtoupper($constantString->getValue())] ?? null; + if ($encoding === null) { + return []; + } + + $encodings[$encoding] = $encoding; + } + + return array_values($encodings); + } + +} diff --git a/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php b/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php new file mode 100644 index 00000000000..eb6c56225c0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php @@ -0,0 +1,47 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class MbstringDeprecatedEncodingRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80200; + + protected function getRule(): Rule + { + return new MbstringDeprecatedEncodingRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $htmlTip = 'Use htmlspecialchars(), htmlentities(), or mb_encode_numericentity()/mb_decode_numericentity() instead.'; + $this->analyse([__DIR__ . '/data/mbstring-deprecated-encoding.php'], [ + ['Handling HTML entities via mbstring in mb_convert_encoding() is deprecated in PHP 8.2.', 7, $htmlTip], + ['Handling HTML entities via mbstring in mb_convert_encoding() is deprecated in PHP 8.2.', 8, $htmlTip], + ['Handling Base64 via mbstring in mb_strlen() is deprecated in PHP 8.2.', 9, 'Use base64_encode()/base64_decode() instead.'], + ['Handling Uuencode via mbstring in mb_substr() is deprecated in PHP 8.2.', 10, 'Use convert_uuencode()/convert_uudecode() instead.'], + ['Handling QPrint via mbstring in mb_strtoupper() is deprecated in PHP 8.2.', 11, 'Use quoted_printable_encode()/quoted_printable_decode() instead.'], + ['Handling QPrint via mbstring in mb_check_encoding() is deprecated in PHP 8.2.', 12, 'Use quoted_printable_encode()/quoted_printable_decode() instead.'], + ['Handling HTML entities via mbstring in mb_str_pad() is deprecated in PHP 8.2.', 13, $htmlTip], + ['Handling HTML entities via mbstring in mb_strwidth() is deprecated in PHP 8.2.', 14, $htmlTip], + ['Handling HTML entities via mbstring in mb_strlen() is deprecated in PHP 8.2.', 19, $htmlTip], + ['Handling Base64 via mbstring in mb_strlen() is deprecated in PHP 8.2.', 19, 'Use base64_encode()/base64_decode() instead.'], + ]); + } + + public function testBeforePhp82(): void + { + $this->phpVersionId = 80100; + $this->analyse([__DIR__ . '/data/mbstring-deprecated-encoding.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php b/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php new file mode 100644 index 00000000000..02dd83209ac --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php @@ -0,0 +1,26 @@ += 8.0 + +namespace MbstringDeprecatedEncoding; + +function doFoo(string $s, bool $b, string $encoding): void +{ + mb_convert_encoding($s, 'HTML-ENTITIES', 'UTF-8'); + mb_convert_encoding($s, 'html'); + mb_strlen($s, 'base64'); + mb_substr($s, 0, 1, 'UUENCODE'); + mb_strtoupper($s, 'Quoted-Printable'); + mb_check_encoding($s, 'qprint'); + mb_str_pad($s, 10, encoding: 'HTML-ENTITIES'); + \mb_strwidth($s, $b ? 'HTML' : 'HTML-ENTITIES'); + + mb_convert_encoding($s, 'UTF-8', 'HTML-ENTITIES'); + mb_convert_encoding($s, 'UTF-8', ['UTF-8', 'BASE64']); + mb_strlen($s, $b ? 'UTF-8' : 'BASE64'); + mb_strlen($s, $b ? 'HTML' : 'BASE64'); + mb_strlen($s, 'UTF-8'); + mb_strlen($s, $encoding); + mb_strlen($s); + mb_internal_encoding('HTML-ENTITIES'); + mb_http_output('BASE64'); + htmlspecialchars($s, ENT_QUOTES, 'HTML-ENTITIES'); +} From 9154ee50e0087a3d59549bb53d969925e84bfb0f Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:11:41 +0000 Subject: [PATCH 06/13] Report get_class() and get_parent_class() called without arguments Since PHP 8.3, calling them without arguments is deprecated. The error is fixable by replacing the call with self::class, or with parent::class when the class is known to have a parent. Calling get_class() without arguments outside of a class throws an Error and is reported regardless of the PHP version. --- src/Php/PhpVersion.php | 5 + .../GetClassWithoutArgumentsRule.php | 92 +++++++++++++++++++ .../GetClassWithoutArgumentsRuleTest.php | 90 ++++++++++++++++++ .../data/get-class-without-arguments.php | 55 +++++++++++ .../get-class-without-arguments.php.fixed | 55 +++++++++++ 5 files changed, 297 insertions(+) create mode 100644 src/Rules/Functions/GetClassWithoutArgumentsRule.php create mode 100644 tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php create mode 100644 tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6e7b1023e0c..1b69bf83eda 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -568,4 +568,9 @@ public function deprecatesMbstringPseudoEncodings(): bool return $this->versionId >= 80200; } + public function deprecatesGetClassWithoutArguments(): bool + { + return $this->versionId >= 80300; + } + } diff --git a/src/Rules/Functions/GetClassWithoutArgumentsRule.php b/src/Rules/Functions/GetClassWithoutArgumentsRule.php new file mode 100644 index 00000000000..9c747e6c1a7 --- /dev/null +++ b/src/Rules/Functions/GetClassWithoutArgumentsRule.php @@ -0,0 +1,92 @@ + + */ +#[RegisteredRule(level: 0)] +final class GetClassWithoutArgumentsRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable() || count($node->getArgs()) > 0) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionName = $this->reflectionProvider->getFunction($node->name, $scope)->getName(); + if (!in_array($functionName, ['get_class', 'get_parent_class'], true)) { + return []; + } + + if ($functionName === 'get_class' && !$scope->isInClass()) { + return [ + RuleErrorBuilder::message('Function get_class() cannot be called without arguments outside of a class.') + ->identifier('outOfClass.getClass') + ->build(), + ]; + } + + if (!$this->phpVersion->deprecatesGetClassWithoutArguments()) { + return []; + } + + $keyword = $functionName === 'get_class' ? 'self' : 'parent'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Calling %s() without arguments is deprecated in PHP 8.3.', + $functionName, + ))->identifier('function.deprecatedWithoutArguments'); + + if ($keyword === 'parent') { + if (!$scope->isInClass() || $scope->isInTrait()) { + return [$errorBuilder->tip('Pass the object or class name to get_parent_class().')->build()]; + } + + if ($scope->getClassReflection()->getParentClass() === null) { + return [ + $errorBuilder + ->tip(sprintf('Class %s does not extend any class, so this call always returns false.', $scope->getClassReflection()->getDisplayName())) + ->build(), + ]; + } + } + + return [ + $errorBuilder + ->tip(sprintf('Use %s::class instead.', $keyword)) + ->fixNode($node, static fn () => new ClassConstFetch(new Node\Name($keyword), 'class')) + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php b/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php new file mode 100644 index 00000000000..cc3927e90a2 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php @@ -0,0 +1,90 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class GetClassWithoutArgumentsRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80300; + + protected function getRule(): Rule + { + return new GetClassWithoutArgumentsRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/get-class-without-arguments.php'], [ + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 15, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 16, + 'Pass the object or class name to get_parent_class().', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 28, + 'Use self::class instead.', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 29, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 30, + 'Use parent::class instead.', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 31, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 46, + 'Class GetClassWithoutArguments\WithoutParent does not extend any class, so this call always returns false.', + ], + [ + 'Function get_class() cannot be called without arguments outside of a class.', + 53, + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 54, + 'Pass the object or class name to get_parent_class().', + ], + ]); + } + + public function testBeforePhp83(): void + { + $this->phpVersionId = 80200; + $this->analyse([__DIR__ . '/data/get-class-without-arguments.php'], [ + [ + 'Function get_class() cannot be called without arguments outside of a class.', + 53, + ], + ]); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/get-class-without-arguments.php', __DIR__ . '/data/get-class-without-arguments.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php new file mode 100644 index 00000000000..fedb09c82dd --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php @@ -0,0 +1,55 @@ += 8.1 + +namespace GetClassWithoutArguments; + +class ParentClass +{ + +} + +trait FooTrait +{ + + public function traitMethod(): void + { + echo get_class(); + echo get_parent_class(); + } + +} + +class Foo extends ParentClass +{ + + use FooTrait; + + public function doFoo(object $object): void + { + echo get_class(); + echo \get_class(); + echo get_parent_class(); + $closure = static fn (): string => get_class(); + + echo get_class($object); + echo get_class($this); + echo get_parent_class($this); + $callable = get_class(...); + } + +} + +class WithoutParent +{ + + public function doFoo(): void + { + echo get_parent_class(); + } + +} + +function doFoo(): void +{ + echo get_class(); + echo get_parent_class(); +} diff --git a/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed new file mode 100644 index 00000000000..9e3d7d05011 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed @@ -0,0 +1,55 @@ += 8.1 + +namespace GetClassWithoutArguments; + +class ParentClass +{ + +} + +trait FooTrait +{ + + public function traitMethod(): void + { + echo self::class; + echo get_parent_class(); + } + +} + +class Foo extends ParentClass +{ + + use FooTrait; + + public function doFoo(object $object): void + { + echo self::class; + echo self::class; + echo parent::class; + $closure = static fn (): string => self::class; + + echo get_class($object); + echo get_class($this); + echo get_parent_class($this); + $callable = get_class(...); + } + +} + +class WithoutParent +{ + + public function doFoo(): void + { + echo get_parent_class(); + } + +} + +function doFoo(): void +{ + echo get_class(); + echo get_parent_class(); +} From 7c489ea6fd000a18af36acc3f8e68cf0b930a7bb Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:12:27 +0000 Subject: [PATCH 07/13] Report trigger_error() called with E_USER_ERROR Since PHP 8.4, passing E_USER_ERROR to trigger_error() or user_error() is deprecated in favour of throwing an exception or calling exit(). --- src/Php/PhpVersion.php | 5 ++ .../Functions/TriggerErrorUserErrorRule.php | 88 +++++++++++++++++++ .../TriggerErrorUserErrorRuleTest.php | 41 +++++++++ .../data/trigger-error-user-error.php | 25 ++++++ 4 files changed, 159 insertions(+) create mode 100644 src/Rules/Functions/TriggerErrorUserErrorRule.php create mode 100644 tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/trigger-error-user-error.php diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 1b69bf83eda..214c41c95f6 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -573,4 +573,9 @@ public function deprecatesGetClassWithoutArguments(): bool return $this->versionId >= 80300; } + public function deprecatesTriggerErrorWithUserError(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/Functions/TriggerErrorUserErrorRule.php b/src/Rules/Functions/TriggerErrorUserErrorRule.php new file mode 100644 index 00000000000..6b41e9ec2f0 --- /dev/null +++ b/src/Rules/Functions/TriggerErrorUserErrorRule.php @@ -0,0 +1,88 @@ + + */ +#[RegisteredRule(level: 0)] +final class TriggerErrorUserErrorRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesTriggerErrorWithUserError()) { + return []; + } + + if (!$node->name instanceof Node\Name || count($node->getArgs()) < 2) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if (!in_array($functionReflection->getName(), ['trigger_error', 'user_error'], true)) { + return []; + } + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ), + $node, + ); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (!isset($args[1]) || !(new ConstantIntegerType(E_USER_ERROR))->isSuperTypeOf($scope->getType($args[1]->value))->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Passing E_USER_ERROR to %s() is deprecated in PHP 8.4.', + $functionReflection->getName(), + )) + ->tip('Throw an exception or call exit() with a string message instead.') + ->identifier('argument.deprecatedUserError') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php b/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php new file mode 100644 index 00000000000..14c5b94cae8 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php @@ -0,0 +1,41 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class TriggerErrorUserErrorRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new TriggerErrorUserErrorRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Throw an exception or call exit() with a string message instead.'; + $this->analyse([__DIR__ . '/data/trigger-error-user-error.php'], [ + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 8, $tip], + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 11, $tip], + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 14, $tip], + ['Passing E_USER_ERROR to user_error() is deprecated in PHP 8.4.', 17, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/trigger-error-user-error.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/trigger-error-user-error.php b/tests/PHPStan/Rules/Functions/data/trigger-error-user-error.php new file mode 100644 index 00000000000..8518c743271 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/trigger-error-user-error.php @@ -0,0 +1,25 @@ += 8.0 + +namespace TriggerErrorUserError; + +function doFoo(int $level, bool $b): void +{ + if ($b) { + trigger_error('Something went wrong', E_USER_ERROR); + } + if ($b) { + \trigger_error('Something went wrong', 256); + } + if ($b) { + trigger_error(error_level: E_USER_ERROR, message: 'Something went wrong'); + } + if ($b) { + user_error('Something went wrong', E_USER_ERROR); + } + + trigger_error('Something went wrong'); + trigger_error('Something went wrong', E_USER_WARNING); + trigger_error('Something went wrong', E_USER_DEPRECATED); + trigger_error('Something went wrong', $b ? E_USER_ERROR : E_USER_WARNING); + trigger_error('Something went wrong', $level); +} From 6dd679d2c734d54a712ddaa599bfa09cf0b00261 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:14:04 +0000 Subject: [PATCH 08/13] Report power of zero with negative exponent Since PHP 8.4, raising zero to a negative power with the ** operator or pow() is deprecated. For int and float operands the error is fixable by calling fpow(), which returns the same IEEE 754 result. --- src/Php/PhpVersion.php | 5 + .../PowerOfZeroWithNegativeExponentRule.php | 148 ++++++++++++++++++ ...owerOfZeroWithNegativeExponentRuleTest.php | 51 ++++++ .../power-of-zero-with-negative-exponent.php | 31 ++++ ...r-of-zero-with-negative-exponent.php.fixed | 31 ++++ 5 files changed, 266 insertions(+) create mode 100644 src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php create mode 100644 tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php create mode 100644 tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php create mode 100644 tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 214c41c95f6..0835cc0f2d1 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -578,4 +578,9 @@ public function deprecatesTriggerErrorWithUserError(): bool return $this->versionId >= 80400; } + public function deprecatesPowerOfZeroWithNegativeExponent(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php b/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php new file mode 100644 index 00000000000..bfae8666e19 --- /dev/null +++ b/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php @@ -0,0 +1,148 @@ + + */ +#[RegisteredRule(level: 2)] +final class PowerOfZeroWithNegativeExponentRule implements MultipleNodeTypesRule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\BinaryOp\Pow::class, Expr\AssignOp\Pow::class, Expr\FuncCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesPowerOfZeroWithNegativeExponent()) { + return []; + } + + if ($node instanceof Expr\BinaryOp\Pow) { + $base = $node->left; + $exponent = $node->right; + } elseif ($node instanceof Expr\AssignOp\Pow) { + $base = $node->var; + $exponent = $node->expr; + } elseif ($node instanceof Expr\FuncCall) { + $args = $this->getPowArgs($node, $scope); + if ($args === null) { + return []; + } + [$base, $exponent] = $args; + } else { + return []; + } + + $baseType = $scope->getType($base); + $exponentType = $scope->getType($exponent); + if (!$baseType->isObject()->no() || !$exponentType->isObject()->no()) { + return []; + } + + if ( + !$scope->getScopeType(new Expr\BinaryOp\Equal(new TypeExpr($baseType), new Node\Scalar\Int_(0)))->isTrue()->yes() + || !$scope->getScopeType(new Expr\BinaryOp\Smaller(new TypeExpr($exponentType), new Node\Scalar\Int_(0)))->isTrue()->yes() + ) { + return []; + } + + $errorBuilder = RuleErrorBuilder::message('Power of base 0 and negative exponent is deprecated in PHP 8.4.') + ->tip('Use fpow() to get the IEEE 754 result.') + ->identifier('pow.zeroBaseNegativeExponent'); + + // fpow() has float parameters, so only int and float operands keep working with strict types + if ( + !$node instanceof Expr\AssignOp\Pow + && $this->isIntOrFloat($baseType) + && $this->isIntOrFloat($exponentType) + ) { + if ($node instanceof Expr\FuncCall) { + $errorBuilder->fixNode($node, static fn (Expr\FuncCall $node) => new Expr\FuncCall(new Node\Name('fpow'), $node->args)); + } else { + $errorBuilder->fixNode($node, static fn (Expr\BinaryOp\Pow $node) => new Expr\FuncCall(new Node\Name('fpow'), [ + new Node\Arg($node->left), + new Node\Arg($node->right), + ])); + } + } + + return [$errorBuilder->build()]; + } + + /** + * @return array{Expr, Expr}|null + */ + private function getPowArgs(Expr\FuncCall $node, Scope $scope): ?array + { + if (!$node->name instanceof Node\Name || count($node->getArgs()) !== 2) { + return null; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return null; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'pow') { + return null; + } + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ), + $node, + ); + if ($normalizedFuncCall === null) { + return null; + } + + $args = $normalizedFuncCall->getArgs(); + if (count($args) !== 2) { + return null; + } + + return [$args[0]->value, $args[1]->value]; + } + + private function isIntOrFloat(Type $type): bool + { + return (new UnionType([new IntegerType(), new FloatType()]))->isSuperTypeOf($type)->yes(); + } + +} diff --git a/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php b/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php new file mode 100644 index 00000000000..4e202608401 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php @@ -0,0 +1,51 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class PowerOfZeroWithNegativeExponentRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new PowerOfZeroWithNegativeExponentRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = 'Power of base 0 and negative exponent is deprecated in PHP 8.4.'; + $tip = 'Use fpow() to get the IEEE 754 result.'; + $this->analyse([__DIR__ . '/data/power-of-zero-with-negative-exponent.php'], [ + [$message, 13, $tip], + [$message, 14, $tip], + [$message, 15, $tip], + [$message, 16, $tip], + [$message, 17, $tip], + [$message, 18, $tip], + [$message, 19, $tip], + [$message, 21, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/power-of-zero-with-negative-exponent.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/power-of-zero-with-negative-exponent.php', __DIR__ . '/data/power-of-zero-with-negative-exponent.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php new file mode 100644 index 00000000000..22b4425c558 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php @@ -0,0 +1,31 @@ += 8.0 + +namespace PowerOfZeroWithNegativeExponent; + +/** + * @param 0|0.0 $zero + * @param int $negative + * @param int<-1, 1> $maybeNegative + * @param int<0, 1> $maybeZero + */ +function doFoo(int|float $zero, int $negative, int $maybeNegative, int $maybeZero, float $float, string $numericZero, ?int $null): void +{ + var_dump(0 ** -1); + var_dump(0.0 ** -0.5); + var_dump($zero ** $negative); + var_dump(pow(0, -2)); + var_dump(\pow(exponent: -2, num: 0)); + var_dump('0' ** -1); + var_dump(null ** -1); + $i = 0; + $i **= -1; + + var_dump(0 ** 1); + var_dump(0 ** 0); + var_dump(0.0 ** -0.0); + var_dump(2 ** -1); + var_dump($zero ** $maybeNegative); + var_dump($maybeZero ** $negative); + var_dump($float ** -1); + var_dump(fpow(0, -1)); +} diff --git a/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed new file mode 100644 index 00000000000..2c79af0dd7d --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed @@ -0,0 +1,31 @@ += 8.0 + +namespace PowerOfZeroWithNegativeExponent; + +/** + * @param 0|0.0 $zero + * @param int $negative + * @param int<-1, 1> $maybeNegative + * @param int<0, 1> $maybeZero + */ +function doFoo(int|float $zero, int $negative, int $maybeNegative, int $maybeZero, float $float, string $numericZero, ?int $null): void +{ + var_dump(fpow(0, -1)); + var_dump(fpow(0.0, -0.5)); + var_dump(fpow($zero, $negative)); + var_dump(fpow(0, -2)); + var_dump(fpow(exponent: -2, num: 0)); + var_dump('0' ** -1); + var_dump(null ** -1); + $i = 0; + $i **= -1; + + var_dump(0 ** 1); + var_dump(0 ** 0); + var_dump(0.0 ** -0.0); + var_dump(2 ** -1); + var_dump($zero ** $maybeNegative); + var_dump($maybeZero ** $negative); + var_dump($float ** -1); + var_dump(fpow(0, -1)); +} From ed366b21b914d8938938ad420d90faabdb5a18a5 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:15:41 +0000 Subject: [PATCH 09/13] Report CSV functions called without the $escape parameter Since PHP 8.4, calling fgetcsv(), fputcsv(), str_getcsv() and SplFileObject::setCsvControl() without passing $escape is deprecated because its default value will change. The error is fixable by passing the current default explicitly. --- src/Php/PhpVersion.php | 5 + .../Functions/CsvEscapeParameterRule.php | 132 ++++++++++++++++++ .../Functions/CsvEscapeParameterRuleTest.php | 49 +++++++ .../Functions/data/csv-escape-parameter.php | 31 ++++ .../data/csv-escape-parameter.php.fixed | 31 ++++ 5 files changed, 248 insertions(+) create mode 100644 src/Rules/Functions/CsvEscapeParameterRule.php create mode 100644 tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php create mode 100644 tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 0835cc0f2d1..e589ca64636 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -583,4 +583,9 @@ public function deprecatesPowerOfZeroWithNegativeExponent(): bool return $this->versionId >= 80400; } + public function deprecatesCsvEscapeParameterDefault(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/Functions/CsvEscapeParameterRule.php b/src/Rules/Functions/CsvEscapeParameterRule.php new file mode 100644 index 00000000000..306fcadb6b6 --- /dev/null +++ b/src/Rules/Functions/CsvEscapeParameterRule.php @@ -0,0 +1,132 @@ + + */ +#[RegisteredRule(level: 0)] +final class CsvEscapeParameterRule implements MultipleNodeTypesRule +{ + + private const FUNCTIONS = ['fgetcsv', 'fputcsv', 'str_getcsv']; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\FuncCall::class, Expr\MethodCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesCsvEscapeParameterDefault()) { + return []; + } + + if ($node instanceof Expr\FuncCall) { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $reflection = $this->reflectionProvider->getFunction($node->name, $scope); + if (!in_array($reflection->getName(), self::FUNCTIONS, true)) { + return []; + } + $callName = sprintf('%s()', $reflection->getName()); + } elseif ($node instanceof Expr\MethodCall) { + // SplFileObject::fgetcsv() and fputcsv() use the escape set by setCsvControl() + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'setcsvcontrol' || $node->isFirstClassCallable()) { + return []; + } + + $reflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + if ($reflection === null || $reflection->getDeclaringClass()->getName() !== SplFileObject::class) { + return []; + } + $callName = sprintf('%s::%s()', SplFileObject::class, $reflection->getName()); + } else { + return []; + } + + $args = $node->getArgs(); + $parameterPositions = []; + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $reflection->getVariants(), $reflection->getNamedArgumentsVariants()); + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + $parameterPositions[$parameter->getName()] = $i; + } + if (!isset($parameterPositions['escape'])) { + return []; + } + $escapePosition = $parameterPositions['escape']; + + $positionalArgsCount = 0; + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + if ($arg->name === null) { + $positionalArgsCount++; + continue; + } + // a named argument after $escape makes PHP pass its default value explicitly, + // an unknown one is an error reported by the argument check + $position = $parameterPositions[$arg->name->toString()] ?? null; + if ($position === null || $position >= $escapePosition) { + return []; + } + } + + if ($positionalArgsCount > $escapePosition) { + return []; + } + + $appendPositionally = $positionalArgsCount === count($args) && $positionalArgsCount === $escapePosition; + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling %s without the $escape parameter is deprecated in PHP 8.4.', + $callName, + )) + ->tip('Pass \'\\\\\' to keep the current behaviour, or an empty string to disable the proprietary escape mechanism.') + ->identifier('argument.missingEscape') + ->fixNode($node, static function (Expr\FuncCall|Expr\MethodCall $node) use ($appendPositionally): Expr\FuncCall|Expr\MethodCall { + $node->args[] = new Node\Arg( + new Node\Scalar\String_('\\'), + name: $appendPositionally ? null : new Node\Identifier('escape'), + ); + + return $node; + }) + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php b/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php new file mode 100644 index 00000000000..64a6e2a499d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php @@ -0,0 +1,49 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class CsvEscapeParameterRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new CsvEscapeParameterRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Pass \'\\\\\' to keep the current behaviour, or an empty string to disable the proprietary escape mechanism.'; + $this->analyse([__DIR__ . '/data/csv-escape-parameter.php'], [ + ['Calling fgetcsv() without the $escape parameter is deprecated in PHP 8.4.', 15, $tip], + ['Calling fgetcsv() without the $escape parameter is deprecated in PHP 8.4.', 16, $tip], + ['Calling fputcsv() without the $escape parameter is deprecated in PHP 8.4.', 17, $tip], + ['Calling str_getcsv() without the $escape parameter is deprecated in PHP 8.4.', 18, $tip], + ['Calling str_getcsv() without the $escape parameter is deprecated in PHP 8.4.', 19, $tip], + ['Calling SplFileObject::setCsvControl() without the $escape parameter is deprecated in PHP 8.4.', 20, $tip], + ['Calling SplFileObject::setCsvControl() without the $escape parameter is deprecated in PHP 8.4.', 21, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/csv-escape-parameter.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/csv-escape-parameter.php', __DIR__ . '/data/csv-escape-parameter.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php new file mode 100644 index 00000000000..3e85725a72a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php @@ -0,0 +1,31 @@ += 8.0 + +namespace CsvEscapeParameter; + +use SplFileObject; +use SplTempFileObject; + +/** + * @param resource $stream + * @param list $fields + * @param list $args + */ +function doFoo($stream, array $fields, string $line, SplFileObject $file, SplTempFileObject $tempFile, array $args): void +{ + fgetcsv($stream); + fgetcsv($stream, null, ';', '"'); + fputcsv($stream, $fields, enclosure: "'"); + \str_getcsv($line); + str_getcsv($line, ';', '"'); + $file->setCsvControl(); + $tempFile->setCsvControl(';'); + + fgetcsv($stream, null, ',', '"', '\\'); + fgetcsv($stream, escape: ''); + fputcsv($stream, $fields, ',', '"', '', "\n"); + fputcsv($stream, $fields, eol: "\r\n"); + str_getcsv($line, ...$args); + $file->setCsvControl(';', '"', ''); + $file->fgetcsv(); + $file->fputcsv($fields); +} diff --git a/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed new file mode 100644 index 00000000000..f40c9a43e7a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed @@ -0,0 +1,31 @@ += 8.0 + +namespace CsvEscapeParameter; + +use SplFileObject; +use SplTempFileObject; + +/** + * @param resource $stream + * @param list $fields + * @param list $args + */ +function doFoo($stream, array $fields, string $line, SplFileObject $file, SplTempFileObject $tempFile, array $args): void +{ + fgetcsv($stream, escape: '\\'); + fgetcsv($stream, null, ';', '"', '\\'); + fputcsv($stream, $fields, enclosure: "'", escape: '\\'); + \str_getcsv($line, escape: '\\'); + str_getcsv($line, ';', '"', '\\'); + $file->setCsvControl(escape: '\\'); + $tempFile->setCsvControl(';', escape: '\\'); + + fgetcsv($stream, null, ',', '"', '\\'); + fgetcsv($stream, escape: ''); + fputcsv($stream, $fields, ',', '"', '', "\n"); + fputcsv($stream, $fields, eol: "\r\n"); + str_getcsv($line, ...$args); + $file->setCsvControl(';', '"', ''); + $file->fgetcsv(); + $file->fputcsv($fields); +} From 9172ee305422b43cbe8e6cfa11efbbff3ac96339 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:16:15 +0000 Subject: [PATCH 10/13] Report "_" used as a class name Since PHP 8.4, declaring a class, interface, trait or enum named "_" is deprecated. --- src/Php/PhpVersion.php | 5 ++ src/Rules/Classes/UnderscoreClassNameRule.php | 59 +++++++++++++++++++ .../Classes/UnderscoreClassNameRuleTest.php | 54 +++++++++++++++++ .../data/underscore-class-name/anonymous.php | 10 ++++ .../data/underscore-class-name/class.php | 13 ++++ .../data/underscore-class-name/enum.php | 13 ++++ .../data/underscore-class-name/interface.php | 13 ++++ .../data/underscore-class-name/trait.php | 13 ++++ 8 files changed, 180 insertions(+) create mode 100644 src/Rules/Classes/UnderscoreClassNameRule.php create mode 100644 tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php create mode 100644 tests/PHPStan/Rules/Classes/data/underscore-class-name/class.php create mode 100644 tests/PHPStan/Rules/Classes/data/underscore-class-name/enum.php create mode 100644 tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php create mode 100644 tests/PHPStan/Rules/Classes/data/underscore-class-name/trait.php diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index e589ca64636..f80daecb943 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -588,4 +588,9 @@ public function deprecatesCsvEscapeParameterDefault(): bool return $this->versionId >= 80400; } + public function deprecatesUnderscoreClassName(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/Classes/UnderscoreClassNameRule.php b/src/Rules/Classes/UnderscoreClassNameRule.php new file mode 100644 index 00000000000..acc2ced2cbe --- /dev/null +++ b/src/Rules/Classes/UnderscoreClassNameRule.php @@ -0,0 +1,59 @@ + + */ +#[RegisteredRule(level: 0)] +final class UnderscoreClassNameRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return Stmt\ClassLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesUnderscoreClassName()) { + return []; + } + + if ($node->name === null || $node->name->toString() !== '_') { + return []; + } + + if ($node instanceof Stmt\Interface_) { + $kind = 'an interface'; + } elseif ($node instanceof Stmt\Trait_) { + $kind = 'a trait'; + } elseif ($node instanceof Stmt\Enum_) { + $kind = 'an enum'; + } else { + $kind = 'a class'; + } + + return [ + RuleErrorBuilder::message(sprintf('Using "_" as %s name is deprecated in PHP 8.4.', $kind)) + ->identifier('classLike.underscoreName') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php b/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php new file mode 100644 index 00000000000..379cf65ba90 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php @@ -0,0 +1,54 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class UnderscoreClassNameRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new UnderscoreClassNameRule(new PhpVersion($this->phpVersionId)); + } + + /** + * @return list + */ + private function getFiles(): array + { + return [ + __DIR__ . '/data/underscore-class-name/class.php', + __DIR__ . '/data/underscore-class-name/interface.php', + __DIR__ . '/data/underscore-class-name/trait.php', + __DIR__ . '/data/underscore-class-name/enum.php', + __DIR__ . '/data/underscore-class-name/anonymous.php', + ]; + } + + public function testRule(): void + { + $this->analyse($this->getFiles(), [ + ['Using "_" as a class name is deprecated in PHP 8.4.', 5], + ['Using "_" as an interface name is deprecated in PHP 8.4.', 5], + ['Using "_" as a trait name is deprecated in PHP 8.4.', 5], + ['Using "_" as an enum name is deprecated in PHP 8.4.', 5], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse($this->getFiles(), []); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php b/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php new file mode 100644 index 00000000000..be2cf3d03a7 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php @@ -0,0 +1,10 @@ += 8.1 + +namespace UnderscoreClassName\EnumDeclaration; + +enum _ +{ + +} + +enum __ +{ + +} diff --git a/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php b/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php new file mode 100644 index 00000000000..f1e6107f8e9 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php @@ -0,0 +1,13 @@ + Date: Mon, 2 Nov 2026 12:18:02 +0000 Subject: [PATCH 11/13] Report __debugInfo() returning null Since PHP 8.5, returning null from __debugInfo(), explicitly or implicitly, is deprecated. Returning null, a variable or nothing is fixable by returning an empty array. Amp-Thread-ID: https://ampcode.com/threads/T-01a19c16-abb2-754f-b615-8cf680c7b5a2 --- src/Php/PhpVersion.php | 5 + src/Rules/Methods/DebugInfoReturnNullRule.php | 96 +++++++++++++++ .../Methods/DebugInfoReturnNullRuleTest.php | 48 ++++++++ .../Methods/data/debug-info-return-null.php | 109 ++++++++++++++++++ .../data/debug-info-return-null.php.fixed | 109 ++++++++++++++++++ 5 files changed, 367 insertions(+) create mode 100644 src/Rules/Methods/DebugInfoReturnNullRule.php create mode 100644 tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php create mode 100644 tests/PHPStan/Rules/Methods/data/debug-info-return-null.php create mode 100644 tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index f80daecb943..105221bb18e 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -593,4 +593,9 @@ public function deprecatesUnderscoreClassName(): bool return $this->versionId >= 80400; } + public function deprecatesDebugInfoReturningNull(): bool + { + return $this->versionId >= 80500; + } + } diff --git a/src/Rules/Methods/DebugInfoReturnNullRule.php b/src/Rules/Methods/DebugInfoReturnNullRule.php new file mode 100644 index 00000000000..9241c4815d9 --- /dev/null +++ b/src/Rules/Methods/DebugInfoReturnNullRule.php @@ -0,0 +1,96 @@ + + */ +#[RegisteredRule(level: 0)] +final class DebugInfoReturnNullRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesDebugInfoReturningNull()) { + return []; + } + + if (strtolower($node->getMethodName()) !== '__debuginfo' || $node->isGenerator()) { + return []; + } + + $method = $node->getMethodReflection(); + if ($method->isAbstract()->yes()) { + return []; + } + + $message = sprintf( + 'Returning null from %s::%s() is deprecated in PHP 8.5.', + $node->getClassReflection()->getDisplayName(), + $method->getName(), + ); + + $errors = []; + foreach ($node->getReturnStatements() as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ( + $returnNode->expr !== null + && !$returnStatement->getScope()->getType($returnNode->expr)->isNull()->yes() + ) { + continue; + } + + $errorBuilder = RuleErrorBuilder::message($message) + ->line($returnNode->getStartLine()) + ->tip('Return an empty array instead.') + ->identifier('method.debugInfoNull'); + + // replacing other expressions would drop their side effects + if ( + $returnNode->expr === null + || $returnNode->expr instanceof Node\Expr\ConstFetch + || $returnNode->expr instanceof Node\Expr\Variable + ) { + $errorBuilder->fixNode($returnNode, static function (Return_ $returnNode): Return_ { + $returnNode->expr = new Node\Expr\Array_(); + + return $returnNode; + }); + } + + $errors[] = $errorBuilder->build(); + } + + if (!$node->getStatementResult()->isAlwaysTerminating()) { + $errors[] = RuleErrorBuilder::message($message) + ->tip('The method returns null implicitly. Return an empty array instead.') + ->identifier('method.debugInfoNull') + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php b/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php new file mode 100644 index 00000000000..a531353720c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php @@ -0,0 +1,48 @@ + + */ +#[RequiresPhp('>= 8.2.0')] +class DebugInfoReturnNullRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new DebugInfoReturnNullRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Return an empty array instead.'; + $this->analyse([__DIR__ . '/data/debug-info-return-null.php'], [ + ['Returning null from DebugInfoReturnNull\ReturnsNull::__debugInfo() is deprecated in PHP 8.5.', 10, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullConditionally::__DebugInfo() is deprecated in PHP 8.5.', 26, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullConditionally::__DebugInfo() is deprecated in PHP 8.5.', 31, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullFromCall::__debugInfo() is deprecated in PHP 8.5.', 44, $tip], + ['Returning null from DebugInfoReturnNull\EmptyReturn::__debugInfo() is deprecated in PHP 8.5.', 62, $tip], + ['Returning null from DebugInfoReturnNull\ImplicitReturn::__debugInfo() is deprecated in PHP 8.5.', 73, 'The method returns null implicitly. Return an empty array instead.'], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/debug-info-return-null.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/debug-info-return-null.php', __DIR__ . '/data/debug-info-return-null.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php new file mode 100644 index 00000000000..65ae721c600 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php @@ -0,0 +1,109 @@ += 8.2 + +namespace DebugInfoReturnNull; + +class ReturnsNull +{ + + public function __debugInfo() + { + return null; + } + +} + +class ReturnsNullConditionally +{ + + private bool $debug = false; + + /** + * @return array|null + */ + public function __DebugInfo(): ?array + { + if (!$this->debug) { + return null; + } + + $value = null; + if ($this->debug) { + return $value; + } + + return ['debug' => $this->debug]; + } + +} + +class ReturnsNullFromCall +{ + + public function __debugInfo() + { + return $this->log(); + } + + private function log(): null + { + echo 'debugging'; + + return null; + } + +} + +class EmptyReturn +{ + + public function __debugInfo() + { + if (rand(0, 1) === 0) { + return; + } + + return []; + } + +} + +class ImplicitReturn +{ + + public function __debugInfo() + { + echo 'debugging'; + } + +} + +class MaybeReturnsNull +{ + + /** @var array|null */ + private ?array $info = null; + + /** + * @return array|null + */ + public function __debugInfo(): ?array + { + return $this->info; + } + + public function getInfo(): ?string + { + return null; + } + +} + +interface HasDebugInfo +{ + + /** + * @return array + */ + public function __debugInfo(): array; + +} diff --git a/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed new file mode 100644 index 00000000000..431bb32ac13 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed @@ -0,0 +1,109 @@ += 8.2 + +namespace DebugInfoReturnNull; + +class ReturnsNull +{ + + public function __debugInfo() + { + return []; + } + +} + +class ReturnsNullConditionally +{ + + private bool $debug = false; + + /** + * @return array|null + */ + public function __DebugInfo(): ?array + { + if (!$this->debug) { + return []; + } + + $value = null; + if ($this->debug) { + return []; + } + + return ['debug' => $this->debug]; + } + +} + +class ReturnsNullFromCall +{ + + public function __debugInfo() + { + return $this->log(); + } + + private function log(): null + { + echo 'debugging'; + + return null; + } + +} + +class EmptyReturn +{ + + public function __debugInfo() + { + if (rand(0, 1) === 0) { + return []; + } + + return []; + } + +} + +class ImplicitReturn +{ + + public function __debugInfo() + { + echo 'debugging'; + } + +} + +class MaybeReturnsNull +{ + + /** @var array|null */ + private ?array $info = null; + + /** + * @return array|null + */ + public function __debugInfo(): ?array + { + return $this->info; + } + + public function getInfo(): ?string + { + return null; + } + +} + +interface HasDebugInfo +{ + + /** + * @return array + */ + public function __debugInfo(): array; + +} From 32f293a74a8ea9ff9683eb0446720f459219a61d Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 2 Nov 2026 12:19:52 +0000 Subject: [PATCH 12/13] Report ord() called with a string that is not one byte long Since PHP 8.5, passing an empty string or a string longer than one byte to ord() is deprecated. When the string is known to be longer, the error is fixable by passing its first byte explicitly. --- src/Php/PhpVersion.php | 5 + src/Rules/Functions/OrdSingleByteRule.php | 113 ++++++++++++++++++ .../Rules/Functions/OrdSingleByteRuleTest.php | 54 +++++++++ .../data/ord-single-byte-coercion.php | 11 ++ .../Rules/Functions/data/ord-single-byte.php | 31 +++++ .../Functions/data/ord-single-byte.php.fixed | 31 +++++ 6 files changed, 245 insertions(+) create mode 100644 src/Rules/Functions/OrdSingleByteRule.php create mode 100644 tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php create mode 100644 tests/PHPStan/Rules/Functions/data/ord-single-byte.php create mode 100644 tests/PHPStan/Rules/Functions/data/ord-single-byte.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 105221bb18e..231e3afbe6b 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -598,4 +598,9 @@ public function deprecatesDebugInfoReturningNull(): bool return $this->versionId >= 80500; } + public function deprecatesOrdWithoutSingleByte(): bool + { + return $this->versionId >= 80500; + } + } diff --git a/src/Rules/Functions/OrdSingleByteRule.php b/src/Rules/Functions/OrdSingleByteRule.php new file mode 100644 index 00000000000..222937ecc8a --- /dev/null +++ b/src/Rules/Functions/OrdSingleByteRule.php @@ -0,0 +1,113 @@ + + */ +#[RegisteredRule(level: 5)] +final class OrdSingleByteRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesOrdWithoutSingleByte()) { + return []; + } + + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + + $args = $node->getArgs(); + if (count($args) !== 1 || $args[0]->unpack) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + if ($this->reflectionProvider->getFunction($node->name, $scope)->getName() !== 'ord') { + return []; + } + + $argType = $scope->getType($args[0]->value); + if (!$argType->isString()->yes() && $scope->isDeclareStrictTypes()) { + // a TypeError reported by the argument type check + return []; + } + + $stringType = $argType->toString(); + if ($stringType instanceof ErrorType) { + return []; + } + + if ($stringType->isNonEmptyString()->no()) { + return [ + RuleErrorBuilder::message('Providing an empty string to ord() is deprecated in PHP 8.5.') + ->identifier('argument.ordEmptyString') + ->build(), + ]; + } + + $constantStrings = $stringType->getConstantStrings(); + if (count($constantStrings) === 0) { + return []; + } + + $minLength = null; + foreach ($constantStrings as $constantString) { + $length = strlen($constantString->getValue()); + if ($length === 1) { + return []; + } + + $minLength = $minLength === null ? $length : min($minLength, $length); + } + + $errorBuilder = RuleErrorBuilder::message('Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.') + ->tip('Use ord($str[0]) instead.') + ->identifier('argument.ordMultiByte'); + + // ord() reads the first byte, which the fix passes explicitly + if ($minLength > 1 && $argType->isString()->yes()) { + $errorBuilder->fixNode($args[0]->value, static function (Node\Expr $expr): Node\Expr { + if ($expr instanceof Node\Scalar\String_ && ord($expr->value[0]) >= 0x20 && ord($expr->value[0]) <= 0x7E) { + return new Node\Scalar\String_($expr->value[0]); + } + + return new Node\Expr\ArrayDimFetch($expr, new Node\Scalar\Int_(0)); + }); + } + + return [$errorBuilder->build()]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php b/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php new file mode 100644 index 00000000000..777d59992b0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php @@ -0,0 +1,54 @@ + + */ +class OrdSingleByteRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new OrdSingleByteRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = 'Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.'; + $tip = 'Use ord($str[0]) instead.'; + $this->analyse([__DIR__ . '/data/ord-single-byte.php'], [ + [$message, 13, $tip], + [$message, 14, $tip], + [$message, 15, $tip], + [$message, 17, $tip], + ['Providing an empty string to ord() is deprecated in PHP 8.5.', 18], + ]); + } + + public function testCoercion(): void + { + $this->analyse([__DIR__ . '/data/ord-single-byte-coercion.php'], [ + ['Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.', 7, 'Use ord($str[0]) instead.'], + ['Providing an empty string to ord() is deprecated in PHP 8.5.', 10], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/ord-single-byte.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/ord-single-byte.php', __DIR__ . '/data/ord-single-byte.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php b/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php new file mode 100644 index 00000000000..4de82ee7de3 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php @@ -0,0 +1,11 @@ + Date: Mon, 2 Nov 2026 12:20:36 +0000 Subject: [PATCH 13/13] Report $context passed to finfo_buffer() Since PHP 8.5, passing the unused $context parameter to finfo_buffer() and finfo::buffer() is deprecated. The error is fixable by removing the argument when doing so does not drop any side effects. --- src/Php/PhpVersion.php | 5 + .../Functions/FinfoBufferContextRule.php | 119 ++++++++++++++++++ .../Functions/FinfoBufferContextRuleTest.php | 47 +++++++ .../Functions/data/finfo-buffer-context.php | 24 ++++ .../data/finfo-buffer-context.php.fixed | 24 ++++ 5 files changed, 219 insertions(+) create mode 100644 src/Rules/Functions/FinfoBufferContextRule.php create mode 100644 tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php create mode 100644 tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 231e3afbe6b..ef154f0f739 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -603,4 +603,9 @@ public function deprecatesOrdWithoutSingleByte(): bool return $this->versionId >= 80500; } + public function deprecatesFinfoBufferContext(): bool + { + return $this->versionId >= 80500; + } + } diff --git a/src/Rules/Functions/FinfoBufferContextRule.php b/src/Rules/Functions/FinfoBufferContextRule.php new file mode 100644 index 00000000000..bd12e76b9f4 --- /dev/null +++ b/src/Rules/Functions/FinfoBufferContextRule.php @@ -0,0 +1,119 @@ + + */ +#[RegisteredRule(level: 0)] +final class FinfoBufferContextRule implements MultipleNodeTypesRule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\FuncCall::class, Expr\MethodCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesFinfoBufferContext()) { + return []; + } + + if ($node instanceof Expr\FuncCall) { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $reflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($reflection->getName() !== 'finfo_buffer') { + return []; + } + $callName = 'finfo_buffer()'; + } elseif ($node instanceof Expr\MethodCall) { + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'buffer' || $node->isFirstClassCallable()) { + return []; + } + + $reflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + if ($reflection === null || $reflection->getDeclaringClass()->getName() !== finfo::class) { + return []; + } + $callName = sprintf('%s::%s()', finfo::class, $reflection->getName()); + } else { + return []; + } + + $args = $node->getArgs(); + $contextPosition = null; + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $reflection->getVariants(), $reflection->getNamedArgumentsVariants()); + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + if ($parameter->getName() === 'context') { + $contextPosition = $i; + break; + } + } + if ($contextPosition === null) { + return []; + } + + foreach ($args as $i => $arg) { + if ($arg->unpack) { + return []; + } + + $isContext = $arg->name === null ? $i === $contextPosition : $arg->name->toString() === 'context'; + if (!$isContext) { + continue; + } + + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Passing the $context parameter to %s is deprecated in PHP 8.5.', + $callName, + )) + ->tip('The parameter has no effect, remove it.') + ->identifier('argument.unused'); + + // removing other expressions would drop their side effects + if ( + $arg->value instanceof Expr\ConstFetch + || $arg->value instanceof Expr\Variable + || $arg->value instanceof Node\Scalar + ) { + $errorBuilder->removeNode($arg); + } + + return [$errorBuilder->build()]; + } + + return []; + } + +} diff --git a/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php b/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php new file mode 100644 index 00000000000..012accd0c65 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php @@ -0,0 +1,47 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class FinfoBufferContextRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new FinfoBufferContextRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'The parameter has no effect, remove it.'; + $this->analyse([__DIR__ . '/data/finfo-buffer-context.php'], [ + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 13, $tip], + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 14, $tip], + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 15, $tip], + ['Passing the $context parameter to finfo::buffer() is deprecated in PHP 8.5.', 16, $tip], + ['Passing the $context parameter to finfo::buffer() is deprecated in PHP 8.5.', 17, $tip], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/finfo-buffer-context.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/finfo-buffer-context.php', __DIR__ . '/data/finfo-buffer-context.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php new file mode 100644 index 00000000000..7469d9a6a6a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php @@ -0,0 +1,24 @@ += 8.0 + +namespace FinfoBufferContext; + +use finfo; + +/** + * @param resource $context + * @param list $args + */ +function doFoo(finfo $finfo, string $data, $context, array $args): void +{ + finfo_buffer($finfo, $data, FILEINFO_NONE, null); + \finfo_buffer($finfo, $data, FILEINFO_MIME, $context); + finfo_buffer($finfo, $data, context: null); + $finfo->buffer($data, FILEINFO_NONE, null); + $finfo->buffer($data, context: stream_context_create()); + + finfo_buffer($finfo, $data); + finfo_buffer($finfo, $data, FILEINFO_MIME); + $finfo->buffer($data, FILEINFO_NONE); + finfo_buffer($finfo, ...$args); + finfo_file($finfo, __FILE__, FILEINFO_NONE, $context); +} diff --git a/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed new file mode 100644 index 00000000000..77c374dcbe6 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed @@ -0,0 +1,24 @@ += 8.0 + +namespace FinfoBufferContext; + +use finfo; + +/** + * @param resource $context + * @param list $args + */ +function doFoo(finfo $finfo, string $data, $context, array $args): void +{ + finfo_buffer($finfo, $data, FILEINFO_NONE); + \finfo_buffer($finfo, $data, FILEINFO_MIME); + finfo_buffer($finfo, $data); + $finfo->buffer($data, FILEINFO_NONE); + $finfo->buffer($data, context: stream_context_create()); + + finfo_buffer($finfo, $data); + finfo_buffer($finfo, $data, FILEINFO_MIME); + $finfo->buffer($data, FILEINFO_NONE); + finfo_buffer($finfo, ...$args); + finfo_file($finfo, __FILE__, FILEINFO_NONE, $context); +}