From 7ff7b1a37851c2ead7a69cb42d8e1f1e548afe84 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 16:00:01 +0200 Subject: [PATCH 1/4] fix(preprocessor): validate compound type declarations and class-scope type keywords resolveTypeDecl now runs a shared well-formedness pass before resolving, so parameters, returns, properties, class/interface constants, and closure signatures all obey Zend's compile-time compound-type rules (each probed on 8.4.13): - duplicate union members, case-insensitive and after alias/namespace resolution ("Duplicate type int is redundant", "Duplicate type App\Sub\Thing is redundant"); iterable is expanded to array|Traversable first, so iterable|array and iterable|\Traversable report the overlapping component exactly like Zend, while a namespace-local Traversable stays legal - bool with false/true names the literal as the duplicate in either order; true|false demands bool ("Type contains both true and false, bool must be used instead") - mixed/void/never inside a union ("... can only be used as a standalone type"), ?mixed ("Type mixed cannot be marked as nullable since mixed already includes null"), ?null, ?void, ?never - intersection members must be class types ("Type int cannot be part of an intersection type"); duplicate intersection members are redundant; self/parent/static keep the established TypeCheckGenerator diagnostic; redundancy between whole DNF groups is not checked (Zend uses a distinct "Type X&Y is redundant with type X&Y" pass) - self/static return types on free functions ("Cannot use \"static\" when no class scope is active"); closures keep accepting them since they may be bound to a scope later, matching Zend - duplicate interfaces in an implements list, for classes and enums ("Class A cannot implement previously implemented interface I"); duplicate trait use stays legal - Zend deduplicates it silently --- src/Preprocessor.php | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 408a56ad..aa780859 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -993,6 +993,15 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } + // `self`/`static` return types need a class scope; Zend rejects them + // on free functions at compile time. `parent` is already rejected in + // parseTypeDecl for every declaration context. + if (($returnTypeKeyword === 'self' || $returnTypeKeyword === 'static') + && $v instanceof Node\Stmt\Function_ + && $this->classDef === null + ) { + $this->fatalError($v->returnType, "Cannot use \"{$returnTypeKeyword}\" when no class scope is active"); + } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); $this->assertSupportedNativeObjectTypeNode($v->returnType, self::DECL_TYPE_OF_RETURN, $v); $nullableNativeReturn = $this->resolveNullableNativeObjectType( @@ -1273,6 +1282,19 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum } if (!$class instanceof Node\Stmt\Trait_) { $this->classDef->implements = $this->parseImplements($class->implements); + $implemented = []; + foreach ($this->classDef->implements as $i => $interfaceName) { + $interfaceLower = strtolower($interfaceName); + $errorNode = $class->implements[$i] ?? $class; + if (isset($implemented[$interfaceLower])) { + $kind = $class instanceof Node\Stmt\Enum_ ? 'Enum' : 'Class'; + $this->fatalError( + $errorNode, + "{$kind} `{$fullClassName}` cannot implement previously implemented interface `{$interfaceName}`", + ); + } + $implemented[$interfaceLower] = true; + } } else { $this->classDef->trait = $class; // Trait members are compiled later in the consuming class, but @@ -1776,6 +1798,164 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } + /** + * Validate compound well-formedness before resolving, so every context a + * type declaration is parsed in (parameters, returns, properties, class + * and interface constants, closures) shares the same Zend rules. + */ + protected function resolveTypeDecl(?NodeAbstract $type, int $what): array + { + $this->validateCompoundTypeDecl($type); + return parent::resolveTypeDecl($type, $what); + } + + /** + * Compile-time well-formedness of compound type declarations, mirroring + * Zend: standalone-only types inside unions, invalid nullable targets, + * duplicate members (after alias/namespace resolution, with iterable + * expanded to array|Traversable), the bool/true/false overlaps, and + * non-class standard types inside intersections. Redundancy between whole + * DNF groups is not checked. + */ + private function validateCompoundTypeDecl(?NodeAbstract $type): void + { + if ($type instanceof NullableType) { + $inner = $type->type; + if (!$inner instanceof Node\Identifier && !$inner instanceof Node\Name) { + return; + } + $innerLower = strtolower($this->parseIdentifier($inner)); + if ($innerLower === 'mixed') { + $this->fatalError($type, 'Type `mixed` cannot be marked as nullable since mixed already includes null'); + } + if ($innerLower === 'null') { + $this->fatalError($type, '`null` cannot be marked as nullable'); + } + if ($innerLower === 'void' || $innerLower === 'never') { + $this->fatalError($type, "Type `{$innerLower}` can only be used as a standalone type"); + } + return; + } + if ($type instanceof UnionType) { + $this->validateUnionTypeDecl($type); + } elseif ($type instanceof IntersectionType) { + $this->validateIntersectionTypeDecl($type); + } + } + + private function validateUnionTypeDecl(UnionType $type): void + { + $seen = []; + $addMember = function (string $key, string $display, NodeAbstract $node) use (&$seen): void { + if (isset($seen[$key])) { + $this->fatalError($node, "Duplicate type `{$display}` is redundant"); + } + $seen[$key] = true; + }; + foreach ($type->types as $member) { + if ($member instanceof IntersectionType) { + // A DNF group: its members obey the intersection rules. + $this->validateIntersectionTypeDecl($member); + continue; + } + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'mixed' || $nameLower === 'void' || $nameLower === 'never') { + $this->fatalError($member, "Type `{$nameLower}` can only be used as a standalone type"); + } + if ($nameLower === 'bool' || $nameLower === 'false' || $nameLower === 'true') { + // Zend folds false/true into bool: a union may not repeat the + // overlap, and naming both literals asks for bool instead. + if (($nameLower === 'true' && isset($seen['false'])) + || ($nameLower === 'false' && isset($seen['true'])) + ) { + $this->fatalError($member, 'Type contains both `true` and `false`, `bool` must be used instead'); + } + if ($nameLower === 'bool') { + foreach (['false', 'true'] as $literal) { + if (isset($seen[$literal])) { + $this->fatalError($member, "Duplicate type `{$literal}` is redundant"); + } + } + } elseif (isset($seen['bool'])) { + $this->fatalError($member, "Duplicate type `{$nameLower}` is redundant"); + } + $addMember($nameLower, $nameLower, $member); + continue; + } + if ($nameLower === 'iterable') { + // Zend expands iterable to array|Traversable before the + // redundancy check and reports the overlapping component. + $addMember('iterable', 'iterable', $member); + $addMember('array', 'array', $member); + $addMember('traversable', 'Traversable', $member); + continue; + } + if (isset($this->zendTypeMap[$nameLower]) + || in_array($nameLower, ['self', 'parent', 'static'], true) + ) { + $addMember($nameLower, $nameLower, $member); + continue; + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $addMember(strtolower($resolved), $resolved, $member); + } + } + + private function validateIntersectionTypeDecl(IntersectionType $type): void + { + $seen = []; + foreach ($type->types as $member) { + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { + // Rejected later by buildTypeCheckFromNode with its + // established "cannot be part of an intersection type" text. + continue; + } + if (in_array($nameLower, [ + 'int', 'float', 'bool', 'false', 'true', 'string', 'array', + 'object', 'mixed', 'null', 'void', 'never', 'callable', 'iterable', + ], true)) { + $this->fatalError($member, "Type `{$nameLower}` cannot be part of an intersection type"); + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $resolvedLower = strtolower($resolved); + if (isset($seen[$resolvedLower])) { + $this->fatalError($member, "Duplicate type `{$resolved}` is redundant"); + } + $seen[$resolvedLower] = true; + } + } + + /** + * Whether a declared type mentions `callable` outside an intersection. + * Zend forbids callable in property and class-constant types; members of + * an intersection are rejected separately as non-class types. + */ + private function typeDeclContainsCallable(NodeAbstract $typeNode): bool + { + if ($typeNode instanceof NullableType) { + return $this->typeDeclContainsCallable($typeNode->type); + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + if ($this->typeDeclContainsCallable($member)) { + return true; + } + } + return false; + } + if ($typeNode instanceof IntersectionType) { + return false; + } + return strtolower($this->parseIdentifier($typeNode)) === 'callable'; + } + private function validateAsymmetricPropertyDeclaration( string $name, int $flags, From 2162d53c69bfea37d5510c9ab6d6f8f2107ce759 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:14:25 +0200 Subject: [PATCH 2/4] test(preprocessor): cover compound type declaration rules --- phpunit/code/type_rule_bool_false.php | 4 + phpunit/code/type_rule_dup_class_union.php | 5 ++ phpunit/code/type_rule_dup_union.php | 4 + phpunit/code/type_rule_implements_dup.php | 5 ++ phpunit/code/type_rule_intersect_dup.php | 5 ++ phpunit/code/type_rule_intersect_scalar.php | 4 + phpunit/code/type_rule_iterable_array.php | 4 + phpunit/code/type_rule_mixed_union.php | 4 + phpunit/code/type_rule_nullable_mixed.php | 4 + phpunit/code/type_rule_self_return_global.php | 4 + .../code/type_rule_static_return_global.php | 4 + phpunit/code/type_rule_true_false.php | 4 + phpunit/code/type_rule_valid.php | 6 ++ phpunit/code/type_rule_void_union.php | 4 + phpunit/src/CompoundTypeValidationTest.php | 80 +++++++++++++++++++ 15 files changed, 141 insertions(+) create mode 100644 phpunit/code/type_rule_bool_false.php create mode 100644 phpunit/code/type_rule_dup_class_union.php create mode 100644 phpunit/code/type_rule_dup_union.php create mode 100644 phpunit/code/type_rule_implements_dup.php create mode 100644 phpunit/code/type_rule_intersect_dup.php create mode 100644 phpunit/code/type_rule_intersect_scalar.php create mode 100644 phpunit/code/type_rule_iterable_array.php create mode 100644 phpunit/code/type_rule_mixed_union.php create mode 100644 phpunit/code/type_rule_nullable_mixed.php create mode 100644 phpunit/code/type_rule_self_return_global.php create mode 100644 phpunit/code/type_rule_static_return_global.php create mode 100644 phpunit/code/type_rule_true_false.php create mode 100644 phpunit/code/type_rule_valid.php create mode 100644 phpunit/code/type_rule_void_union.php create mode 100644 phpunit/src/CompoundTypeValidationTest.php diff --git a/phpunit/code/type_rule_bool_false.php b/phpunit/code/type_rule_bool_false.php new file mode 100644 index 00000000..8551de0a --- /dev/null +++ b/phpunit/code/type_rule_bool_false.php @@ -0,0 +1,4 @@ +exec('Duplicate type `int` is redundant', 'type_rule_dup_union.php'); + } + + public function testDuplicateClassUnionMemberIsRejected(): void + { + $this->exec('Duplicate type `Foo` is redundant', 'type_rule_dup_class_union.php'); + } + + public function testBoolWithFalseIsRedundant(): void + { + $this->exec('Duplicate type `false` is redundant', 'type_rule_bool_false.php'); + } + + public function testTrueWithFalseMustUseBool(): void + { + $this->exec('Type contains both `true` and `false`, `bool` must be used instead', 'type_rule_true_false.php'); + } + + public function testMixedCannotBeUnionMember(): void + { + $this->exec('Type `mixed` can only be used as a standalone type', 'type_rule_mixed_union.php'); + } + + public function testMixedCannotBeNullable(): void + { + $this->exec('Type `mixed` cannot be marked as nullable since mixed already includes null', 'type_rule_nullable_mixed.php'); + } + + public function testVoidCannotBeUnionMember(): void + { + $this->exec('Type `void` can only be used as a standalone type', 'type_rule_void_union.php'); + } + + public function testIterableExpansionDetectsArrayDuplicate(): void + { + $this->exec('Duplicate type `array` is redundant', 'type_rule_iterable_array.php'); + } + + public function testScalarCannotJoinIntersection(): void + { + $this->exec('Type `int` cannot be part of an intersection type', 'type_rule_intersect_scalar.php'); + } + + public function testDuplicateIntersectionMemberIsRejected(): void + { + $this->exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.php'); + } + + public function testStaticReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_return_global.php'); + } + + public function testSelfReturnRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_return_global.php'); + } + + public function testDuplicateImplementsIsRejected(): void + { + $this->exec('Class `C` cannot implement previously implemented interface `Ia`', 'type_rule_implements_dup.php'); + } + + public function testWellFormedCompoundTypesStillCompile(): void + { + $this->compile('type_rule_valid.php'); + } +} From 518e89c09448c5b4b8d67028d2949ae556f06d0e Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:11:47 +0200 Subject: [PATCH 3/4] fix(preprocessor): complete Zend union redundancy and class-scope keyword rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Zend compile-time type rules were still accepted, all probed against PHP 8.4.13: - object absorbing class types: a union naming object beside any class type (a class, interface, or enum name, self/parent/static, or a DNF group) is rejected in either member order with Zend's message and type rendering — class types first in source order, then the standard types in Zend's canonical order, e.g. "Type Foo|object|null contains both object and a class type, which is redundant". iterable beside object stays legal, matching Zend. - whole-DNF redundancy: intersection groups and plain class members are compared as canonical, order-insensitive member sets. An equal set is "Type B&A is redundant with type A&B"; a strict superset is rejected as more restrictive, in both orders: (A&B)|A, A|(A&B), and (A&B)|(A&B&C2) all fail like Zend. The stale comment claiming whole-DNF redundancy is not checked is gone. - class-scope type keywords: self/parent/static are validated recursively through nullable, union, intersection, and DNF nodes in parameters, returns, properties, and class or interface constants. A free function has no class scope (Zend errors no matter where it is declared), while closures keep their runtime binding and stay exempt. parent additionally requires the scope to have a parent class ("Cannot use \"parent\" when current class scope has no parent"), with traits exempt because parent stays late-bound until the consuming class is known. static outside a return type never reaches the compiler: PHP's grammar rejects it in parameter and property types, and Zend accepts it in class-constant types, which always have a class scope. --- phpunit/code/type_rule_dnf_permuted.php | 4 + phpunit/code/type_rule_dnf_subset.php | 4 + .../code/type_rule_dnf_subset_reversed.php | 4 + phpunit/code/type_rule_dnf_superset_group.php | 4 + phpunit/code/type_rule_object_class_union.php | 4 + .../type_rule_object_class_union_reversed.php | 4 + phpunit/code/type_rule_object_dnf_union.php | 4 + .../code/type_rule_object_interface_union.php | 4 + .../type_rule_parent_no_parent_method.php | 4 + .../type_rule_parent_no_parent_property.php | 4 + .../code/type_rule_parent_param_global.php | 4 + phpunit/code/type_rule_scope_valid.php | 47 +++++ .../code/type_rule_self_dnf_param_global.php | 4 + .../type_rule_self_nullable_param_global.php | 4 + phpunit/code/type_rule_self_param_global.php | 4 + .../type_rule_self_union_return_global.php | 4 + .../type_rule_static_union_return_global.php | 4 + phpunit/code/type_rule_valid.php | 2 + phpunit/src/CompoundTypeValidationTest.php | 109 ++++++++++- src/Preprocessor.php | 170 ++++++++++++++++-- 20 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 phpunit/code/type_rule_dnf_permuted.php create mode 100644 phpunit/code/type_rule_dnf_subset.php create mode 100644 phpunit/code/type_rule_dnf_subset_reversed.php create mode 100644 phpunit/code/type_rule_dnf_superset_group.php create mode 100644 phpunit/code/type_rule_object_class_union.php create mode 100644 phpunit/code/type_rule_object_class_union_reversed.php create mode 100644 phpunit/code/type_rule_object_dnf_union.php create mode 100644 phpunit/code/type_rule_object_interface_union.php create mode 100644 phpunit/code/type_rule_parent_no_parent_method.php create mode 100644 phpunit/code/type_rule_parent_no_parent_property.php create mode 100644 phpunit/code/type_rule_parent_param_global.php create mode 100644 phpunit/code/type_rule_scope_valid.php create mode 100644 phpunit/code/type_rule_self_dnf_param_global.php create mode 100644 phpunit/code/type_rule_self_nullable_param_global.php create mode 100644 phpunit/code/type_rule_self_param_global.php create mode 100644 phpunit/code/type_rule_self_union_return_global.php create mode 100644 phpunit/code/type_rule_static_union_return_global.php diff --git a/phpunit/code/type_rule_dnf_permuted.php b/phpunit/code/type_rule_dnf_permuted.php new file mode 100644 index 00000000..51e9de70 --- /dev/null +++ b/phpunit/code/type_rule_dnf_permuted.php @@ -0,0 +1,4 @@ +exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.php'); } + public function testObjectWithClassTypeIsRedundant(): void + { + $this->exec( + 'Type `Foo|object` contains both object and a class type, which is redundant', + 'type_rule_object_class_union.php', + ); + } + + public function testObjectWithClassTypeIsRedundantInEitherOrder(): void + { + $this->exec( + 'Type `Foo|object` contains both object and a class type, which is redundant', + 'type_rule_object_class_union_reversed.php', + ); + } + + public function testObjectWithInterfaceTypeIsRedundant(): void + { + $this->exec( + 'Type `Ifc|object` contains both object and a class type, which is redundant', + 'type_rule_object_interface_union.php', + ); + } + + public function testObjectWithDnfGroupIsRedundant(): void + { + $this->exec( + 'Type `(A&B)|object` contains both object and a class type, which is redundant', + 'type_rule_object_dnf_union.php', + ); + } + + public function testPermutedDnfGroupIsRedundant(): void + { + $this->exec('Type `B&A` is redundant with type `A&B`', 'type_rule_dnf_permuted.php'); + } + + public function testDnfGroupMoreRestrictiveThanPlainMemberIsRedundant(): void + { + $this->exec( + 'Type `A&B` is redundant as it is more restrictive than type `A`', + 'type_rule_dnf_subset.php', + ); + } + + public function testDnfGroupMoreRestrictiveThanPlainMemberIsRedundantInEitherOrder(): void + { + $this->exec( + 'Type `A&B` is redundant as it is more restrictive than type `A`', + 'type_rule_dnf_subset_reversed.php', + ); + } + + public function testDnfSupersetGroupIsRedundant(): void + { + $this->exec( + 'Type `A&B&C2` is redundant as it is more restrictive than type `A&B`', + 'type_rule_dnf_superset_group.php', + ); + } + public function testStaticReturnRequiresClassScope(): void { $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_return_global.php'); } + public function testStaticUnionReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_union_return_global.php'); + } + public function testSelfReturnRequiresClassScope(): void { $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_return_global.php'); } + public function testSelfParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_param_global.php'); + } + + public function testSelfUnionReturnRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_union_return_global.php'); + } + + public function testSelfNullableParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_nullable_param_global.php'); + } + + public function testSelfDnfParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_dnf_param_global.php'); + } + + public function testParentParameterRequiresClassScope(): void + { + $this->exec('Cannot use "parent" when no class scope is active', 'type_rule_parent_param_global.php'); + } + + public function testParentParameterRequiresParentClass(): void + { + $this->exec('Cannot use "parent" when current class scope has no parent', 'type_rule_parent_no_parent_method.php'); + } + + public function testParentPropertyRequiresParentClass(): void + { + $this->exec('Cannot use "parent" when current class scope has no parent', 'type_rule_parent_no_parent_property.php'); + } + public function testDuplicateImplementsIsRejected(): void { $this->exec('Class `C` cannot implement previously implemented interface `Ia`', 'type_rule_implements_dup.php'); @@ -77,4 +179,9 @@ public function testWellFormedCompoundTypesStillCompile(): void { $this->compile('type_rule_valid.php'); } + + public function testClassScopeKeywordsInsideClassLikeScopesStillCompile(): void + { + $this->compile('type_rule_scope_valid.php'); + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index aa780859..9254ce0e 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -993,14 +993,15 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } - // `self`/`static` return types need a class scope; Zend rejects them - // on free functions at compile time. `parent` is already rejected in - // parseTypeDecl for every declaration context. - if (($returnTypeKeyword === 'self' || $returnTypeKeyword === 'static') - && $v instanceof Node\Stmt\Function_ - && $this->classDef === null - ) { - $this->fatalError($v->returnType, "Cannot use \"{$returnTypeKeyword}\" when no class scope is active"); + // Class-scope type keywords need an active class scope, in every + // declaration context and at any nesting depth. Methods always have + // one (class, interface, trait, enum); a free function never does, + // no matter where it is declared. + $classScope = $v instanceof Node\Stmt\ClassMethod; + $scopeHasParent = $classScope && $this->currentClassScopeHasParent(); + $this->validateClassScopeTypeKeywords($v->returnType, $classScope, $scopeHasParent); + foreach ($v->params as $param) { + $this->validateClassScopeTypeKeywords($param->type, $classScope, $scopeHasParent); } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); $this->assertSupportedNativeObjectTypeNode($v->returnType, self::DECL_TYPE_OF_RETURN, $v); @@ -1584,6 +1585,7 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + $this->validateClassScopeTypeKeywords($v->type, true, $this->currentClassScopeHasParent()); [$declaredType, $class] = $v->type ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) : [null, '']; @@ -1727,6 +1729,7 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ } } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); + $this->validateClassScopeTypeKeywords($typeNode, true, $this->currentClassScopeHasParent()); [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( @@ -1813,9 +1816,11 @@ protected function resolveTypeDecl(?NodeAbstract $type, int $what): array * Compile-time well-formedness of compound type declarations, mirroring * Zend: standalone-only types inside unions, invalid nullable targets, * duplicate members (after alias/namespace resolution, with iterable - * expanded to array|Traversable), the bool/true/false overlaps, and - * non-class standard types inside intersections. Redundancy between whole - * DNF groups is not checked. + * expanded to array|Traversable), the bool/true/false overlaps, + * non-class standard types inside intersections, redundancy between + * whole DNF groups (a repeated member set in any order, or a group + * strictly more restrictive than another group or plain class member), + * and `object` absorbing every class type. */ private function validateCompoundTypeDecl(?NodeAbstract $type): void { @@ -1852,10 +1857,23 @@ private function validateUnionTypeDecl(UnionType $type): void } $seen[$key] = true; }; + // Rendered like Zend's zend_type_to_string(): class types keep their + // source order in front, standard types follow in a fixed order. + $classish = []; + $builtins = []; + $hasObject = false; + $hasClassType = false; + // Every DNF group and plain class member, as a canonical member set, + // for Zend's whole-list redundancy comparison. + $groups = []; foreach ($type->types as $member) { if ($member instanceof IntersectionType) { // A DNF group: its members obey the intersection rules. - $this->validateIntersectionTypeDecl($member); + $groupMembers = $this->validateIntersectionTypeDecl($member); + $display = implode('&', $groupMembers); + $classish[] = '(' . $display . ')'; + $hasClassType = true; + $groups[] = [array_keys($groupMembers), $display, $member]; continue; } $name = $this->parseIdentifier($member); @@ -1881,30 +1899,86 @@ private function validateUnionTypeDecl(UnionType $type): void $this->fatalError($member, "Duplicate type `{$nameLower}` is redundant"); } $addMember($nameLower, $nameLower, $member); + $builtins[] = $nameLower; continue; } if ($nameLower === 'iterable') { // Zend expands iterable to array|Traversable before the // redundancy check and reports the overlapping component. + // The expansion alone does not count as a class type for the + // object-redundancy rule. $addMember('iterable', 'iterable', $member); $addMember('array', 'array', $member); $addMember('traversable', 'Traversable', $member); + $classish[] = 'Traversable'; + $builtins[] = 'array'; continue; } - if (isset($this->zendTypeMap[$nameLower]) - || in_array($nameLower, ['self', 'parent', 'static'], true) - ) { + if (isset($this->zendTypeMap[$nameLower])) { $addMember($nameLower, $nameLower, $member); + if ($nameLower === 'object') { + $hasObject = true; + } else { + $builtins[] = $nameLower; + } + continue; + } + if (in_array($nameLower, ['self', 'parent', 'static'], true)) { + $addMember($nameLower, $nameLower, $member); + $classish[] = $nameLower; + $hasClassType = true; continue; } $resolved = $member instanceof Node\Name\FullyQualified ? $member->toString() : $this->getNamespacedClassName($name); $addMember(strtolower($resolved), $resolved, $member); + $classish[] = $resolved; + $hasClassType = true; + $groups[] = [[strtolower($resolved)], $resolved, $member]; + } + + // Whole-DNF redundancy: Zend compares every pair of intersection + // groups and plain class members as canonical member sets. An equal + // set in any member order is a repeat; a strict superset is redundant + // because it is more restrictive than the smaller type it can never + // widen: (A&B)|(B&A), (A&B)|A and A|(A&B) are all rejected. + $groupCount = count($groups); + for ($i = 0; $i < $groupCount; $i++) { + for ($j = $i + 1; $j < $groupCount; $j++) { + [$setI, $displayI] = $groups[$i]; + [$setJ, $displayJ, $nodeJ] = $groups[$j]; + if (count($setI) === count($setJ)) { + if (array_diff($setI, $setJ) === []) { + $this->fatalError($nodeJ, "Type `{$displayJ}` is redundant with type `{$displayI}`"); + } + } elseif (count($setI) > count($setJ)) { + if (array_diff($setJ, $setI) === []) { + $this->fatalError($groups[$i][2], "Type `{$displayI}` is redundant as it is more restrictive than type `{$displayJ}`"); + } + } elseif (array_diff($setI, $setJ) === []) { + $this->fatalError($nodeJ, "Type `{$displayJ}` is redundant as it is more restrictive than type `{$displayI}`"); + } + } + } + + if ($hasObject && $hasClassType) { + // `object` already accepts every object: naming a class type + // (including self/parent/static and DNF groups) beside it is + // redundant. Zend rejects the whole declared type. + $order = array_flip(['callable', 'object', 'array', 'string', 'int', 'float', 'bool', 'false', 'true', 'null']); + $builtins[] = 'object'; + usort($builtins, static fn (string $a, string $b): int => ($order[$a] ?? 99) <=> ($order[$b] ?? 99)); + $typeStr = implode('|', array_merge($classish, $builtins)); + $this->fatalError($type, "Type `{$typeStr}` contains both object and a class type, which is redundant"); } } - private function validateIntersectionTypeDecl(IntersectionType $type): void + /** + * @return array resolved member names in declaration + * order, keyed by their lowercase form + */ + private function validateIntersectionTypeDecl(IntersectionType $type): array { $seen = []; foreach ($type->types as $member) { @@ -1928,8 +2002,65 @@ private function validateIntersectionTypeDecl(IntersectionType $type): void if (isset($seen[$resolvedLower])) { $this->fatalError($member, "Duplicate type `{$resolved}` is redundant"); } - $seen[$resolvedLower] = true; + $seen[$resolvedLower] = $resolved; + } + return $seen; + } + + /** + * Zend rejects class-scope type keywords at compile time in every + * declaration context and at any nesting depth (nullable, union, + * intersection, DNF): `self`, `parent`, and `static` need an active + * class scope, and `parent` additionally needs that scope to have a + * parent class. A free function never has a class scope, no matter + * where it is declared; traits keep `parent` late-bound until the + * consuming class is known. (`static` outside a return type never + * reaches the compiler: PHP's grammar rejects it in parameter and + * property types, and class-constant types — where Zend accepts it — + * always have a class scope.) + */ + private function validateClassScopeTypeKeywords(?NodeAbstract $type, bool $classScope, bool $hasParent): void + { + if ($type === null) { + return; + } + if ($type instanceof NullableType) { + $this->validateClassScopeTypeKeywords($type->type, $classScope, $hasParent); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $member) { + $this->validateClassScopeTypeKeywords($member, $classScope, $hasParent); + } + return; + } + if (!$type instanceof Node\Identifier && !$type instanceof Node\Name) { + return; + } + $nameLower = strtolower($this->parseIdentifier($type)); + if (!in_array($nameLower, ['self', 'parent', 'static'], true)) { + return; + } + if (!$classScope) { + $this->fatalError($type, "Cannot use \"{$nameLower}\" when no class scope is active"); + } + if ($nameLower === 'parent' && !$hasParent) { + $this->fatalError($type, 'Cannot use "parent" when current class scope has no parent'); + } + } + + /** + * Whether `parent` may appear in a type declared in the current + * class-like scope: the class has a parent, or the scope is a trait + * where `parent` stays late-bound until the consuming class is known. + * Interfaces and enums never have a parent class. + */ + private function currentClassScopeHasParent(): bool + { + if ($this->classDef === null) { + return false; } + return $this->classDef->trait !== null || $this->classDef->extends !== ''; } /** @@ -2628,6 +2759,9 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void $this->fatalError($stmt, "Duplicate constant `{$constName}`"); } if ($stmt->type) { + // Interface constants have a class scope but never a + // parent class, matching Zend. + $this->validateClassScopeTypeKeywords($stmt->type, true, false); [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); } else { $class = ''; @@ -2755,6 +2889,8 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void } } + // Interface properties have a class scope but never a parent class. + $this->validateClassScopeTypeKeywords($property->type, true, false); [$type, $class] = $this->resolveTypeDecl($property->type, self::DECL_TYPE_OF_PROPERTY); $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { From 626a289957ebd4f81e6068f890a4430f5770571f Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:31:10 +0200 Subject: [PATCH 4/4] fix(preprocessor): reject class-scope keywords in intersections at the compound layer validateIntersectionTypeDecl skipped self/parent/static on the assumption that buildTypeCheckFromNode rejects them later, but that rejection only runs when the top-level node is an IntersectionType: a DNF group nested inside a union goes through buildTypeCheckClause, which flattens the intersection and accepted the keyword as a late-bound class type. Inside a class, `(self&Countable)|stdClass $value` compiled while Zend fatals. Probed against PHP 8.4.13: a class-scope keyword can never be part of an intersection, bare or as a DNF member, in any declaration context. A bare `self&Ix` parameter, a `(self&Ix)|Other` parameter, promoted parameter, or property, a `(parent&Ix)|Other` parameter or class constant in a class with a parent, and `static&Ix` or `(static&Ix)|Other` return types all fail with "Type self cannot be part of an intersection type" in the matching spelling. The scope errors keep their Zend precedence: with no class scope, or no parent class, the "Cannot use ..." fatals from validateClassScopeTypeKeywords fire first, exactly as Zend orders them. A keyword as a plain union member beside a DNF group, e.g. `(Ia&Ib)|self`, stays legal. The compound layer now rejects the keyword directly, so bare and DNF shapes report the same text; the buildTypeCheckFromNode backstop and the ClassTest expectations adopt the same backtick rendering. --- .../type_rule_keyword_beside_dnf_valid.php | 11 +++++ phpunit/code/type_rule_parent_dnf_const.php | 4 ++ phpunit/code/type_rule_parent_dnf_method.php | 4 ++ phpunit/code/type_rule_self_dnf_method.php | 4 ++ phpunit/code/type_rule_self_dnf_promoted.php | 4 ++ phpunit/code/type_rule_self_dnf_property.php | 4 ++ .../code/type_rule_self_intersect_method.php | 4 ++ phpunit/code/type_rule_static_dnf_return.php | 4 ++ .../type_rule_static_intersect_return.php | 4 ++ phpunit/src/ClassTest.php | 6 +-- phpunit/src/CompoundTypeValidationTest.php | 45 +++++++++++++++++++ src/Generator/TypeCheckGenerator.php | 2 +- src/Preprocessor.php | 14 ++++-- 13 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 phpunit/code/type_rule_keyword_beside_dnf_valid.php create mode 100644 phpunit/code/type_rule_parent_dnf_const.php create mode 100644 phpunit/code/type_rule_parent_dnf_method.php create mode 100644 phpunit/code/type_rule_self_dnf_method.php create mode 100644 phpunit/code/type_rule_self_dnf_promoted.php create mode 100644 phpunit/code/type_rule_self_dnf_property.php create mode 100644 phpunit/code/type_rule_self_intersect_method.php create mode 100644 phpunit/code/type_rule_static_dnf_return.php create mode 100644 phpunit/code/type_rule_static_intersect_return.php diff --git a/phpunit/code/type_rule_keyword_beside_dnf_valid.php b/phpunit/code/type_rule_keyword_beside_dnf_valid.php new file mode 100644 index 00000000..0d9fb7a2 --- /dev/null +++ b/phpunit/code/type_rule_keyword_beside_dnf_valid.php @@ -0,0 +1,11 @@ +exec("Type 'self' cannot be part of an intersection type", 'intersection_type_self_not_allowed.php'); + $this->exec('Type `self` cannot be part of an intersection type', 'intersection_type_self_not_allowed.php'); } public function testParentCannotBePartOfIntersectionType() { - $this->exec("Type 'parent' cannot be part of an intersection type", 'intersection_type_parent_not_allowed.php'); + $this->exec('Type `parent` cannot be part of an intersection type', 'intersection_type_parent_not_allowed.php'); } public function testStaticCannotBePartOfIntersectionType() { - $this->exec("Type 'static' cannot be part of an intersection type", 'intersection_type_static_not_allowed.php'); + $this->exec('Type `static` cannot be part of an intersection type', 'intersection_type_static_not_allowed.php'); } public function testConstructorCannotDeclareReturnType() diff --git a/phpunit/src/CompoundTypeValidationTest.php b/phpunit/src/CompoundTypeValidationTest.php index c39be2d8..b8790fa3 100644 --- a/phpunit/src/CompoundTypeValidationTest.php +++ b/phpunit/src/CompoundTypeValidationTest.php @@ -59,6 +59,51 @@ public function testDuplicateIntersectionMemberIsRejected(): void $this->exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.php'); } + public function testSelfCannotJoinBareIntersection(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_intersect_method.php'); + } + + public function testSelfCannotJoinDnfIntersection(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_method.php'); + } + + public function testParentCannotJoinDnfIntersection(): void + { + $this->exec('Type `parent` cannot be part of an intersection type', 'type_rule_parent_dnf_method.php'); + } + + public function testStaticCannotJoinBareIntersectionReturn(): void + { + $this->exec('Type `static` cannot be part of an intersection type', 'type_rule_static_intersect_return.php'); + } + + public function testStaticCannotJoinDnfIntersectionReturn(): void + { + $this->exec('Type `static` cannot be part of an intersection type', 'type_rule_static_dnf_return.php'); + } + + public function testSelfCannotJoinDnfIntersectionInProperty(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_property.php'); + } + + public function testParentCannotJoinDnfIntersectionInConstant(): void + { + $this->exec('Type `parent` cannot be part of an intersection type', 'type_rule_parent_dnf_const.php'); + } + + public function testSelfCannotJoinDnfIntersectionInPromotedParam(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_promoted.php'); + } + + public function testClassScopeKeywordBesideDnfGroupStillCompiles(): void + { + $this->compile('type_rule_keyword_beside_dnf_valid.php'); + } + public function testObjectWithClassTypeIsRedundant(): void { $this->exec( diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index 87a9d897..8ca355af 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -159,7 +159,7 @@ protected function buildTypeCheckFromNode(NodeAbstract $typeNode, bool $includeS foreach ($typeNode->types as $subType) { $nameLower = strtolower($this->parseIdentifier($subType)); if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { - $this->fatalError($subType, "Type '{$nameLower}' cannot be part of an intersection type"); + $this->fatalError($subType, "Type `{$nameLower}` cannot be part of an intersection type"); } } $clause = $this->buildTypeCheckClause($typeNode); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 9254ce0e..524911f1 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1817,7 +1817,8 @@ protected function resolveTypeDecl(?NodeAbstract $type, int $what): array * Zend: standalone-only types inside unions, invalid nullable targets, * duplicate members (after alias/namespace resolution, with iterable * expanded to array|Traversable), the bool/true/false overlaps, - * non-class standard types inside intersections, redundancy between + * non-class standard types and class-scope keywords (self, parent, + * static) inside intersections, whether bare or DNF, redundancy between * whole DNF groups (a repeated member set in any order, or a group * strictly more restrictive than another group or plain class member), * and `object` absorbing every class type. @@ -1985,9 +1986,14 @@ private function validateIntersectionTypeDecl(IntersectionType $type): array $name = $this->parseIdentifier($member); $nameLower = strtolower($name); if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { - // Rejected later by buildTypeCheckFromNode with its - // established "cannot be part of an intersection type" text. - continue; + // Zend never resolves class-scope keywords inside an + // intersection, bare or as a DNF member of a union: the + // scope errors ("no class scope", "no parent") take + // precedence via validateClassScopeTypeKeywords, then any + // surviving keyword is rejected here. buildTypeCheckFromNode + // only catches the top-level intersection case, so DNF + // members must be rejected at this layer. + $this->fatalError($member, "Type `{$nameLower}` cannot be part of an intersection type"); } if (in_array($nameLower, [ 'int', 'float', 'bool', 'false', 'true', 'string', 'array',