From 7d92742a57fcf0787ab74b5c050ebd92abe313c3 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 7 Oct 2026 23:43:03 +0200 Subject: [PATCH 1/3] Report a class declared in a method instead of crashing PHP does not compile a class, interface, trait or enum declared inside a class. The analysis entered such a class while the scope was already in the class of the method, and ScopeContext::enterClass() threw an internal error. Skip the declaration like a class that is not at the top level, and report it with a new rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- Makefile | 1 + src/Analyser/StmtHandler/ClassLikeHandler.php | 4 +- .../Classes/NestedClassDeclarationRule.php | 55 ++++++++++++++ .../NestedClassDeclarationRuleTest.php | 51 +++++++++++++ .../Classes/data/nested-class-declaration.php | 74 +++++++++++++++++++ turbo-ext/src/ClassLikeHandler.cpp | 9 +++ 6 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/Rules/Classes/NestedClassDeclarationRule.php create mode 100644 tests/PHPStan/Rules/Classes/NestedClassDeclarationRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/nested-class-declaration.php diff --git a/Makefile b/Makefile index ea78ec2e2cb..611bbbbccff 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ lint: --exclude tests/PHPStan/Rules/Classes/data/implements-error.php \ --exclude tests/PHPStan/Rules/Classes/data/interface-extends-error.php \ --exclude tests/PHPStan/Rules/Classes/data/trait-use-error.php \ + --exclude tests/PHPStan/Rules/Classes/data/nested-class-declaration.php \ --exclude tests/PHPStan/Rules/Methods/data/method-in-enum-without-body.php \ --exclude tests/PHPStan/Rules/Properties/data/default-value-for-native-property-type.php \ --exclude tests/PHPStan/Rules/Arrays/data/empty-array-item.php \ diff --git a/src/Analyser/StmtHandler/ClassLikeHandler.php b/src/Analyser/StmtHandler/ClassLikeHandler.php index 88ae6f66543..5c11dd4b42c 100644 --- a/src/Analyser/StmtHandler/ClassLikeHandler.php +++ b/src/Analyser/StmtHandler/ClassLikeHandler.php @@ -96,7 +96,9 @@ public function processStmt( $name = $stmt->namespacedName ?? $stmt->name; $scope = $scope->invalidateExistenceCheckExpressions($existenceCheckFunctionNames, $name instanceof Name ? $name->toString() : null); - if (!$context->isTopLevel()) { + // PHP does not compile a class declared inside another class (NestedClassDeclarationRule), + // and entering it here would replace the class the scope is already in + if (!$context->isTopLevel() || (isset($stmt->namespacedName) && $scope->isInClass())) { return new InternalStatementResult($scope, hasYield: false, isAlwaysTerminating: false, exitPoints: [], throwPoints: [], impurePoints: [], dependencies: $declarationDependencies); } if (isset($stmt->namespacedName)) { diff --git a/src/Rules/Classes/NestedClassDeclarationRule.php b/src/Rules/Classes/NestedClassDeclarationRule.php new file mode 100644 index 00000000000..d71c9535202 --- /dev/null +++ b/src/Rules/Classes/NestedClassDeclarationRule.php @@ -0,0 +1,55 @@ + + */ +#[RegisteredRule(level: 0)] +final class NestedClassDeclarationRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\ClassLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!isset($node->namespacedName) || !$scope->isInClass()) { + return []; + } + + if ($node instanceof Node\Stmt\Interface_) { + $kind = 'Interface'; + } elseif ($node instanceof Node\Stmt\Trait_) { + $kind = 'Trait'; + } elseif ($node instanceof Node\Stmt\Enum_) { + $kind = 'Enum'; + } else { + $kind = 'Class'; + } + + return [ + RuleErrorBuilder::message(sprintf( + '%s %s is declared in a method, but class declarations may not be nested.', + $kind, + $node->namespacedName->toString(), + )) + ->identifier('class.nested') + ->nonIgnorable() + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/NestedClassDeclarationRuleTest.php b/tests/PHPStan/Rules/Classes/NestedClassDeclarationRuleTest.php new file mode 100644 index 00000000000..83b88c0a113 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/NestedClassDeclarationRuleTest.php @@ -0,0 +1,51 @@ + + */ +class NestedClassDeclarationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NestedClassDeclarationRule(); + } + + #[RequiresPhp('>= 8.1.0')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/nested-class-declaration.php'], [ + [ + 'Class NestedClassDeclaration\NestedClass is declared in a method, but class declarations may not be nested.', + 10, + ], + [ + 'Interface NestedClassDeclaration\NestedInterface is declared in a method, but class declarations may not be nested.', + 14, + ], + [ + 'Trait NestedClassDeclaration\NestedTrait is declared in a method, but class declarations may not be nested.', + 18, + ], + [ + 'Enum NestedClassDeclaration\NestedEnum is declared in a method, but class declarations may not be nested.', + 22, + ], + [ + 'Class NestedClassDeclaration\InClosure is declared in a method, but class declarations may not be nested.', + 27, + ], + [ + 'Class NestedClassDeclaration\InTraitMethod is declared in a method, but class declarations may not be nested.', + 62, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/nested-class-declaration.php b/tests/PHPStan/Rules/Classes/data/nested-class-declaration.php new file mode 100644 index 00000000000..4cd8a54980d --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/nested-class-declaration.php @@ -0,0 +1,74 @@ += 8.1 + +namespace NestedClassDeclaration; + +class Foo +{ + + public function doFoo(): void + { + class NestedClass + { + } + + interface NestedInterface + { + } + + trait NestedTrait + { + } + + enum NestedEnum + { + } + + $f = function (): void { + class InClosure + { + } + }; + + $anonymous = new class { + + public function doBar(): int + { + return 1; + } + + }; + } + +} + +function doBaz(): void +{ + class InFunction + { + } + + $f = function (): void { + class InClosureInFunction + { + } + }; +} + +trait DeclaresInMethod +{ + + public function doTrait(): void + { + class InTraitMethod + { + } + } + +} + +class UsesTheTrait +{ + + use DeclaresInMethod; + +} diff --git a/turbo-ext/src/ClassLikeHandler.cpp b/turbo-ext/src/ClassLikeHandler.cpp index 555ac1abee6..444a2faa344 100644 --- a/turbo-ext/src/ClassLikeHandler.cpp +++ b/turbo-ext/src/ClassLikeHandler.cpp @@ -282,6 +282,15 @@ class ClassLikeHandler zv::Val classReflection; zval *namespacedName = rawNodeProperty(pt_clh_namespaced_name_site, stmt, PT_LC("namespacedName")); if (namespacedName != NULL) ZVAL_DEREF(namespacedName); + /* PHP does not compile a class declared inside another class, and entering it would + * replace the class the scope is already in - the PHP twin's isset() && isInClass() */ + if (namespacedName != NULL && Z_TYPE_P(namespacedName) != IS_UNDEF && Z_TYPE_P(namespacedName) != IS_NULL) { + bool isInClass; + if (UNEXPECTED(!pt_scope_is_in_class(Z_OBJ_P(resultScope), isInClass))) return zv::Val(); + if (isInClass) { + return pt_internal_statement_result_new(resultScope, false, false, &emptyArray, &emptyArray, &emptyArray, NULL, NULL, -1, declarationDependencies.raw()); + } + } if (namespacedName != NULL && Z_TYPE_P(namespacedName) != IS_UNDEF && Z_TYPE_P(namespacedName) != IS_NULL) { if (UNEXPECTED(Z_TYPE_P(namespacedName) != IS_OBJECT)) { memberCallOnNonObject("toString", namespacedName); From 56f571700f35b4c89ddedc564164a123df1c5524 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 8 Oct 2026 23:02:04 +0200 Subject: [PATCH 2/3] Use the class.inner identifier for a class declared in a method The error is the counterpart of function.inner for a function declared inside a function. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Rules/Classes/NestedClassDeclarationRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/Classes/NestedClassDeclarationRule.php b/src/Rules/Classes/NestedClassDeclarationRule.php index d71c9535202..ee7e467dfb6 100644 --- a/src/Rules/Classes/NestedClassDeclarationRule.php +++ b/src/Rules/Classes/NestedClassDeclarationRule.php @@ -46,7 +46,7 @@ public function processNode(Node $node, Scope $scope): array $kind, $node->namespacedName->toString(), )) - ->identifier('class.nested') + ->identifier('class.inner') ->nonIgnorable() ->build(), ]; From 09990f943ededb1d03cd2871421c48be4d8c0d92 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 8 Oct 2026 23:14:06 +0200 Subject: [PATCH 3/3] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..e209dc3ff1e 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = '7d92742'; private static bool $active = false;