From a44be180170fd55b8fa8f71dcb0b6b2496eaaf75 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 3 May 2026 08:29:31 +0000 Subject: [PATCH 1/7] Report invalid `DateInterval` and `DateTimeZone` constructor arguments at analysis time - Add DateIntervalInstantiationRule that validates constant duration strings passed to the DateInterval constructor and reports errors for invalid ones - Add DateTimeZoneInstantiationRule that validates constant timezone strings passed to the DateTimeZone constructor and reports errors for invalid ones - Both rules follow the same pattern as the existing DateTimeInstantiationRule: extract constant strings, attempt construction, report exceptions as errors - Both registered at level 5, matching DateTimeInstantiationRule --- src/Rules/DateIntervalInstantiationRule.php | 62 +++++++++++++++++++ src/Rules/DateTimeZoneInstantiationRule.php | 62 +++++++++++++++++++ .../DateIntervalInstantiationRuleTest.php | 47 ++++++++++++++ .../DateTimeZoneInstantiationRuleTest.php | 43 +++++++++++++ .../data/date-interval-instantiation.php | 38 ++++++++++++ .../data/date-time-zone-instantiation.php | 31 ++++++++++ 6 files changed, 283 insertions(+) create mode 100644 src/Rules/DateIntervalInstantiationRule.php create mode 100644 src/Rules/DateTimeZoneInstantiationRule.php create mode 100644 tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php create mode 100644 tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php create mode 100644 tests/PHPStan/Rules/data/date-interval-instantiation.php create mode 100644 tests/PHPStan/Rules/data/date-time-zone-instantiation.php diff --git a/src/Rules/DateIntervalInstantiationRule.php b/src/Rules/DateIntervalInstantiationRule.php new file mode 100644 index 00000000000..ea94bd500ba --- /dev/null +++ b/src/Rules/DateIntervalInstantiationRule.php @@ -0,0 +1,62 @@ + + */ +#[RegisteredRule(level: 5)] +final class DateIntervalInstantiationRule implements Rule +{ + + public function getNodeType(): string + { + return New_::class; + } + + /** + * @param New_ $node + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + if ( + count($node->getArgs()) === 0 + || strtolower((string) $node->class) !== 'dateinterval' + ) { + return []; + } + + $arg = $scope->getType($node->getArgs()[0]->value); + $errors = []; + + foreach ($arg->getConstantStrings() as $constantString) { + $dateIntervalString = $constantString->getValue(); + try { + new DateInterval($dateIntervalString); + } catch (Throwable $e) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Instantiating DateInterval with %s produces an error: %s', + $dateIntervalString, + $e->getMessage(), + ))->identifier('new.dateInterval')->build(); + } + } + + return $errors; + } + +} diff --git a/src/Rules/DateTimeZoneInstantiationRule.php b/src/Rules/DateTimeZoneInstantiationRule.php new file mode 100644 index 00000000000..7dae67c9bfa --- /dev/null +++ b/src/Rules/DateTimeZoneInstantiationRule.php @@ -0,0 +1,62 @@ + + */ +#[RegisteredRule(level: 5)] +final class DateTimeZoneInstantiationRule implements Rule +{ + + public function getNodeType(): string + { + return New_::class; + } + + /** + * @param New_ $node + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + if ( + count($node->getArgs()) === 0 + || strtolower((string) $node->class) !== 'datetimezone' + ) { + return []; + } + + $arg = $scope->getType($node->getArgs()[0]->value); + $errors = []; + + foreach ($arg->getConstantStrings() as $constantString) { + $timezoneString = $constantString->getValue(); + try { + new DateTimeZone($timezoneString); + } catch (Throwable $e) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Instantiating DateTimeZone with %s produces an error: %s', + $timezoneString, + $e->getMessage(), + ))->identifier('new.dateTimeZone')->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php new file mode 100644 index 00000000000..5025de12fa3 --- /dev/null +++ b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php @@ -0,0 +1,47 @@ + + */ +class DateIntervalInstantiationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new DateIntervalInstantiationRule(); + } + + public function test(): void + { + $this->analyse( + [__DIR__ . '/data/date-interval-instantiation.php'], + [ + [ + 'Instantiating DateInterval with 1M produces an error: Unknown or bad format (1M)', + 5, + ], + [ + 'Instantiating DateInterval with asdfasdf produces an error: Unknown or bad format (asdfasdf)', + 18, + ], + [ + 'Instantiating DateInterval with produces an error: Unknown or bad format ()', + 21, + ], + [ + 'Instantiating DateInterval with 1M produces an error: Unknown or bad format (1M)', + 30, + ], + [ + 'Instantiating DateInterval with invalid produces an error: Unknown or bad format (invalid)', + 37, + ], + ], + ); + } + +} diff --git a/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php b/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php new file mode 100644 index 00000000000..c9cdf6099b7 --- /dev/null +++ b/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php @@ -0,0 +1,43 @@ + + */ +class DateTimeZoneInstantiationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new DateTimeZoneInstantiationRule(); + } + + public function test(): void + { + $this->analyse( + [__DIR__ . '/data/date-time-zone-instantiation.php'], + [ + [ + 'Instantiating DateTimeZone with invalid produces an error: DateTimeZone::__construct(): Unknown or bad timezone (invalid)', + 5, + ], + [ + 'Instantiating DateTimeZone with produces an error: DateTimeZone::__construct(): Unknown or bad timezone ()', + 14, + ], + [ + 'Instantiating DateTimeZone with invalid produces an error: DateTimeZone::__construct(): Unknown or bad timezone (invalid)', + 23, + ], + [ + 'Instantiating DateTimeZone with Not/ATimezone produces an error: DateTimeZone::__construct(): Unknown or bad timezone (Not/ATimezone)', + 30, + ], + ], + ); + } + +} diff --git a/tests/PHPStan/Rules/data/date-interval-instantiation.php b/tests/PHPStan/Rules/data/date-interval-instantiation.php new file mode 100644 index 00000000000..eae4ee12d1c --- /dev/null +++ b/tests/PHPStan/Rules/data/date-interval-instantiation.php @@ -0,0 +1,38 @@ + Date: Sun, 3 May 2026 09:54:09 +0000 Subject: [PATCH 2/7] Remove DateTimeZoneInstantiationRule per review feedback The DateTimeZone validation rule is less valuable than the DateInterval case since DateInterval durations are more commonly known at analysis time. Co-Authored-By: Claude Opus 4.6 --- src/Rules/DateTimeZoneInstantiationRule.php | 62 ------------------- .../DateTimeZoneInstantiationRuleTest.php | 43 ------------- .../data/date-time-zone-instantiation.php | 31 ---------- 3 files changed, 136 deletions(-) delete mode 100644 src/Rules/DateTimeZoneInstantiationRule.php delete mode 100644 tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php delete mode 100644 tests/PHPStan/Rules/data/date-time-zone-instantiation.php diff --git a/src/Rules/DateTimeZoneInstantiationRule.php b/src/Rules/DateTimeZoneInstantiationRule.php deleted file mode 100644 index 7dae67c9bfa..00000000000 --- a/src/Rules/DateTimeZoneInstantiationRule.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ -#[RegisteredRule(level: 5)] -final class DateTimeZoneInstantiationRule implements Rule -{ - - public function getNodeType(): string - { - return New_::class; - } - - /** - * @param New_ $node - */ - public function processNode(Node $node, Scope $scope): array - { - if (!$node->class instanceof Node\Name) { - return []; - } - - if ( - count($node->getArgs()) === 0 - || strtolower((string) $node->class) !== 'datetimezone' - ) { - return []; - } - - $arg = $scope->getType($node->getArgs()[0]->value); - $errors = []; - - foreach ($arg->getConstantStrings() as $constantString) { - $timezoneString = $constantString->getValue(); - try { - new DateTimeZone($timezoneString); - } catch (Throwable $e) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Instantiating DateTimeZone with %s produces an error: %s', - $timezoneString, - $e->getMessage(), - ))->identifier('new.dateTimeZone')->build(); - } - } - - return $errors; - } - -} diff --git a/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php b/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php deleted file mode 100644 index c9cdf6099b7..00000000000 --- a/tests/PHPStan/Rules/DateTimeZoneInstantiationRuleTest.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -class DateTimeZoneInstantiationRuleTest extends RuleTestCase -{ - - protected function getRule(): Rule - { - return new DateTimeZoneInstantiationRule(); - } - - public function test(): void - { - $this->analyse( - [__DIR__ . '/data/date-time-zone-instantiation.php'], - [ - [ - 'Instantiating DateTimeZone with invalid produces an error: DateTimeZone::__construct(): Unknown or bad timezone (invalid)', - 5, - ], - [ - 'Instantiating DateTimeZone with produces an error: DateTimeZone::__construct(): Unknown or bad timezone ()', - 14, - ], - [ - 'Instantiating DateTimeZone with invalid produces an error: DateTimeZone::__construct(): Unknown or bad timezone (invalid)', - 23, - ], - [ - 'Instantiating DateTimeZone with Not/ATimezone produces an error: DateTimeZone::__construct(): Unknown or bad timezone (Not/ATimezone)', - 30, - ], - ], - ); - } - -} diff --git a/tests/PHPStan/Rules/data/date-time-zone-instantiation.php b/tests/PHPStan/Rules/data/date-time-zone-instantiation.php deleted file mode 100644 index e49d0b9883c..00000000000 --- a/tests/PHPStan/Rules/data/date-time-zone-instantiation.php +++ /dev/null @@ -1,31 +0,0 @@ - Date: Sun, 3 May 2026 10:19:19 +0000 Subject: [PATCH 3/7] Split DateInterval test expectations by PHP version On PHP < 8.1, DateInterval::__construct() prefixes the exception message with "DateInterval::__construct(): ", while PHP 8.1+ uses a shorter format. Gate the existing test with RequiresPhp(>= 8.1.0) and add a separate testLegacyPhp method for older PHP versions. Co-Authored-By: Claude Opus 4.6 --- .../DateIntervalInstantiationRuleTest.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php index 5025de12fa3..57ef2ad4062 100644 --- a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php +++ b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -15,6 +16,7 @@ protected function getRule(): Rule return new DateIntervalInstantiationRule(); } + #[RequiresPhp('>= 8.1.0')] public function test(): void { $this->analyse( @@ -44,4 +46,34 @@ public function test(): void ); } + #[RequiresPhp('< 8.1.0')] + public function testLegacyPhp(): void + { + $this->analyse( + [__DIR__ . '/data/date-interval-instantiation.php'], + [ + [ + 'Instantiating DateInterval with 1M produces an error: DateInterval::__construct(): Unknown or bad format (1M)', + 5, + ], + [ + 'Instantiating DateInterval with asdfasdf produces an error: DateInterval::__construct(): Unknown or bad format (asdfasdf)', + 18, + ], + [ + 'Instantiating DateInterval with produces an error: DateInterval::__construct(): Unknown or bad format ()', + 21, + ], + [ + 'Instantiating DateInterval with 1M produces an error: DateInterval::__construct(): Unknown or bad format (1M)', + 30, + ], + [ + 'Instantiating DateInterval with invalid produces an error: DateInterval::__construct(): Unknown or bad format (invalid)', + 37, + ], + ], + ); + } + } From a71fa236cd6d2add4eeb3bb23631c059533aba12 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 3 May 2026 11:28:16 +0000 Subject: [PATCH 4/7] Make DateIntervalInstantiationRule bleeding edge only Co-Authored-By: Claude Opus 4.6 --- conf/bleedingEdge.neon | 1 + conf/config.level5.neon | 4 ++++ conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Rules/DateIntervalInstantiationRule.php | 2 -- 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a68d1a352a2..e8e0152c6a1 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -16,3 +16,4 @@ parameters: reportNestedTooWideType: false # tmp assignToByRefForeachExpr: true curlSetOptArrayTypes: true + checkDateIntervalConstructor: true diff --git a/conf/config.level5.neon b/conf/config.level5.neon index b4518ba7e2c..534d58dde0b 100644 --- a/conf/config.level5.neon +++ b/conf/config.level5.neon @@ -10,6 +10,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.checkParameterCastableToNumberFunctions% PHPStan\Rules\Functions\PrintfParameterTypeRule: phpstan.rules.rule: %featureToggles.checkPrintfParameterTypes% + PHPStan\Rules\DateIntervalInstantiationRule: + phpstan.rules.rule: %featureToggles.checkDateIntervalConstructor% autowiredAttributeServices: # registers rules with #[RegisteredRule] attribute @@ -22,3 +24,5 @@ services: class: PHPStan\Rules\Functions\PrintfParameterTypeRule arguments: checkStrictPrintfPlaceholderTypes: %checkStrictPrintfPlaceholderTypes% + - + class: PHPStan\Rules\DateIntervalInstantiationRule diff --git a/conf/config.neon b/conf/config.neon index 6c71dae9225..fcbb79b1d81 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -43,6 +43,7 @@ parameters: reportNestedTooWideType: false assignToByRefForeachExpr: false curlSetOptArrayTypes: false + checkDateIntervalConstructor: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index bc79fe7c401..7cff420cc3f 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -45,6 +45,7 @@ parametersSchema: reportNestedTooWideType: bool() assignToByRefForeachExpr: bool() curlSetOptArrayTypes: bool() + checkDateIntervalConstructor: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/DateIntervalInstantiationRule.php b/src/Rules/DateIntervalInstantiationRule.php index ea94bd500ba..97e5753aa1d 100644 --- a/src/Rules/DateIntervalInstantiationRule.php +++ b/src/Rules/DateIntervalInstantiationRule.php @@ -6,7 +6,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\New_; use PHPStan\Analyser\Scope; -use PHPStan\DependencyInjection\RegisteredRule; use Throwable; use function count; use function sprintf; @@ -15,7 +14,6 @@ /** * @implements Rule */ -#[RegisteredRule(level: 5)] final class DateIntervalInstantiationRule implements Rule { From 266fa4e8d2027f3f8999dbfc116fb01e8be8e661 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 5 May 2026 19:37:25 +0200 Subject: [PATCH 5/7] normalize errors accross php version --- src/Rules/DateIntervalInstantiationRule.php | 3 +- .../DateIntervalInstantiationRuleTest.php | 32 ------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/Rules/DateIntervalInstantiationRule.php b/src/Rules/DateIntervalInstantiationRule.php index 97e5753aa1d..25109216702 100644 --- a/src/Rules/DateIntervalInstantiationRule.php +++ b/src/Rules/DateIntervalInstantiationRule.php @@ -9,6 +9,7 @@ use Throwable; use function count; use function sprintf; +use function str_replace; use function strtolower; /** @@ -49,7 +50,7 @@ public function processNode(Node $node, Scope $scope): array $errors[] = RuleErrorBuilder::message(sprintf( 'Instantiating DateInterval with %s produces an error: %s', $dateIntervalString, - $e->getMessage(), + str_replace('DateInterval::__construct(): ', '', $e->getMessage()), ))->identifier('new.dateInterval')->build(); } } diff --git a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php index 57ef2ad4062..5025de12fa3 100644 --- a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php +++ b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php @@ -3,7 +3,6 @@ namespace PHPStan\Rules; use PHPStan\Testing\RuleTestCase; -use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -16,7 +15,6 @@ protected function getRule(): Rule return new DateIntervalInstantiationRule(); } - #[RequiresPhp('>= 8.1.0')] public function test(): void { $this->analyse( @@ -46,34 +44,4 @@ public function test(): void ); } - #[RequiresPhp('< 8.1.0')] - public function testLegacyPhp(): void - { - $this->analyse( - [__DIR__ . '/data/date-interval-instantiation.php'], - [ - [ - 'Instantiating DateInterval with 1M produces an error: DateInterval::__construct(): Unknown or bad format (1M)', - 5, - ], - [ - 'Instantiating DateInterval with asdfasdf produces an error: DateInterval::__construct(): Unknown or bad format (asdfasdf)', - 18, - ], - [ - 'Instantiating DateInterval with produces an error: DateInterval::__construct(): Unknown or bad format ()', - 21, - ], - [ - 'Instantiating DateInterval with 1M produces an error: DateInterval::__construct(): Unknown or bad format (1M)', - 30, - ], - [ - 'Instantiating DateInterval with invalid produces an error: DateInterval::__construct(): Unknown or bad format (invalid)', - 37, - ], - ], - ); - } - } From 1d8670ce962345b9aaa7516d191515299c4eaf86 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 5 May 2026 19:55:17 +0200 Subject: [PATCH 6/7] Update DateIntervalInstantiationRule.php --- src/Rules/DateIntervalInstantiationRule.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Rules/DateIntervalInstantiationRule.php b/src/Rules/DateIntervalInstantiationRule.php index 25109216702..6fb2cb5ca3d 100644 --- a/src/Rules/DateIntervalInstantiationRule.php +++ b/src/Rules/DateIntervalInstantiationRule.php @@ -50,6 +50,7 @@ public function processNode(Node $node, Scope $scope): array $errors[] = RuleErrorBuilder::message(sprintf( 'Instantiating DateInterval with %s produces an error: %s', $dateIntervalString, + // normalize PHP7 errors to PHP8 format str_replace('DateInterval::__construct(): ', '', $e->getMessage()), ))->identifier('new.dateInterval')->build(); } From 7210b30bb90efe4d589939133c1e74f786775894 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 5 May 2026 18:16:56 +0000 Subject: [PATCH 7/7] Stop normalizing DateInterval error messages across PHP versions Be consistent with other rules by not normalizing PHP exception messages. Split test expectations by PHP version instead. Co-Authored-By: Claude Opus 4.6 --- src/Rules/DateIntervalInstantiationRule.php | 4 +--- .../Rules/DateIntervalInstantiationRuleTest.php | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Rules/DateIntervalInstantiationRule.php b/src/Rules/DateIntervalInstantiationRule.php index 6fb2cb5ca3d..97e5753aa1d 100644 --- a/src/Rules/DateIntervalInstantiationRule.php +++ b/src/Rules/DateIntervalInstantiationRule.php @@ -9,7 +9,6 @@ use Throwable; use function count; use function sprintf; -use function str_replace; use function strtolower; /** @@ -50,8 +49,7 @@ public function processNode(Node $node, Scope $scope): array $errors[] = RuleErrorBuilder::message(sprintf( 'Instantiating DateInterval with %s produces an error: %s', $dateIntervalString, - // normalize PHP7 errors to PHP8 format - str_replace('DateInterval::__construct(): ', '', $e->getMessage()), + $e->getMessage(), ))->identifier('new.dateInterval')->build(); } } diff --git a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php index 5025de12fa3..5d8bc57ef9f 100644 --- a/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php +++ b/tests/PHPStan/Rules/DateIntervalInstantiationRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -17,27 +18,33 @@ protected function getRule(): Rule public function test(): void { + if (PHP_VERSION_ID < 80100) { + $prefix = 'DateInterval::__construct(): '; + } else { + $prefix = ''; + } + $this->analyse( [__DIR__ . '/data/date-interval-instantiation.php'], [ [ - 'Instantiating DateInterval with 1M produces an error: Unknown or bad format (1M)', + 'Instantiating DateInterval with 1M produces an error: ' . $prefix . 'Unknown or bad format (1M)', 5, ], [ - 'Instantiating DateInterval with asdfasdf produces an error: Unknown or bad format (asdfasdf)', + 'Instantiating DateInterval with asdfasdf produces an error: ' . $prefix . 'Unknown or bad format (asdfasdf)', 18, ], [ - 'Instantiating DateInterval with produces an error: Unknown or bad format ()', + 'Instantiating DateInterval with produces an error: ' . $prefix . 'Unknown or bad format ()', 21, ], [ - 'Instantiating DateInterval with 1M produces an error: Unknown or bad format (1M)', + 'Instantiating DateInterval with 1M produces an error: ' . $prefix . 'Unknown or bad format (1M)', 30, ], [ - 'Instantiating DateInterval with invalid produces an error: Unknown or bad format (invalid)', + 'Instantiating DateInterval with invalid produces an error: ' . $prefix . 'Unknown or bad format (invalid)', 37, ], ],