From 642e190b322f358bc2f03e424d9fa2f6a543542b Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 16:00:01 +0200 Subject: [PATCH 1/2] 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 18b99a6e..43d24f8c 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( @@ -1258,6 +1267,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 @@ -1746,6 +1768,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 7a50c2d841320a0f3d5e79a3cc9d70b427373c2d Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:14:25 +0200 Subject: [PATCH 2/2] 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'); + } +}