From ebc44dae63f7591f9892db632b249955d1ddc55e Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 12:57:03 +0200 Subject: [PATCH 1/7] fix(translator): compare trait data members by value, not source text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zend decides trait constant/property compatibility by comparing the EVALUATED definition (zend_is_identical on the resolved zvals plus matching flags and declared type), so `const int X = 1 + 1` in one trait and `const int X = 2` in another are the same definition, as are `[1, 2]` and `array(1, 2)` property defaults. composeTraitAst() compared pretty-printed source text and isCompatibleTraitConstant() compared lowered value strings, rejecting these valid compositions. Evaluate both initializers with the existing evaluateClassConstValue() machinery and compare with identity semantics (1 vs 1.0 or 1 vs '1' still conflict, matching Zend), coercing an integer initializer to float first when the member's declared type is float — Zend performs that coercion at declaration time, so `public float $f = 1` and `= 1.0` are identical. When a value cannot be evaluated at compile time the previous source-text comparison remains as the fallback. Flag, declared-type and (for properties) presence-of-default equality checks are unchanged. --- .../code/trait_const_identity_conflict.php | 18 + phpunit/code/trait_const_value_conflict.php | 18 + .../code/trait_member_same_value_spelling.php | 35 ++ phpunit/code/trait_prop_value_conflict.php | 18 + phpunit/src/TraitMemberValueConflictTest.php | 32 ++ src/Translator.php | 328 +++++------------- 6 files changed, 210 insertions(+), 239 deletions(-) create mode 100644 phpunit/code/trait_const_identity_conflict.php create mode 100644 phpunit/code/trait_const_value_conflict.php create mode 100644 phpunit/code/trait_member_same_value_spelling.php create mode 100644 phpunit/code/trait_prop_value_conflict.php create mode 100644 phpunit/src/TraitMemberValueConflictTest.php diff --git a/phpunit/code/trait_const_identity_conflict.php b/phpunit/code/trait_const_identity_conflict.php new file mode 100644 index 00000000..276b8d5f --- /dev/null +++ b/phpunit/code/trait_const_identity_conflict.php @@ -0,0 +1,18 @@ +compile('trait_member_same_value_spelling.php'); + } + + public function testDifferentConstantValuesConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_value_conflict.php'); + } + + public function testDifferentPropertyDefaultsConflict(): void + { + $this->exec('property `p` already exists', 'trait_prop_value_conflict.php'); + } + + public function testValueComparisonIsIdentityNotEquality(): void + { + $this->exec('constant `x` already exists', 'trait_const_identity_conflict.php'); + } +} diff --git a/src/Translator.php b/src/Translator.php index 5b5bd0a0..ba2dbeb1 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3327,10 +3327,11 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } if (isset($traitConstants[$constName])) { - [$existingConstStmt, $existingConst] = $traitConstants[$constName]; + [$existingConstStmt, $existingConst, $existingConstTrait] = $traitConstants[$constName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingConstStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $this->printer->prettyPrintExpr($existingConst->value) !== $this->printer->prettyPrintExpr($const->value)) { + $this->typeNodeToStringOrNull($existingConstStmt->type) !== $typeStr || + !$this->isSameTraitMemberValue($existingConst->value, $existingConstTrait, $const->value, $traitFullName, $typeStr)) { $this->fatalError($classStmt, "Trait `{$traitFullName}` constant `{$constName}` already exists"); } unset($traitStmt->consts[$k2]); @@ -3339,7 +3340,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } continue; } - $traitConstants[$constName] = [$traitStmt, $const]; + $traitConstants[$constName] = [$traitStmt, $const, $traitFullName]; } } if ($traitStmt instanceof Node\Stmt\Property) { @@ -3356,12 +3357,13 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } if (isset($traitProperties[$propName])) { - [$existingPropStmt, $existingProp] = $traitProperties[$propName]; - $existingDefault = $existingProp->default ? $this->printer->prettyPrintExpr($existingProp->default) : null; - $propDefault = $prop->default ? $this->printer->prettyPrintExpr($prop->default) : null; + [$existingPropStmt, $existingProp, $existingPropTrait] = $traitProperties[$propName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingPropStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $existingDefault !== $propDefault) { + $this->typeNodeToStringOrNull($existingPropStmt->type) !== $typeStr || + ($existingProp->default === null) !== ($prop->default === null) || + ($prop->default !== null + && !$this->isSameTraitMemberValue($existingProp->default, $existingPropTrait, $prop->default, $traitFullName, $typeStr))) { $this->fatalError($classStmt, "Trait `{$traitFullName}` property `{$propName}` already exists"); } unset($traitStmt->props[$k2]); @@ -3385,7 +3387,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) "Readonly class `{$compositionOwner}` cannot use trait with a non-readonly property `{$traitFullName}::\${$prop->name->toString()}`", ); } - $traitProperties[$propName] = [$traitStmt, $prop]; + $traitProperties[$propName] = [$traitStmt, $prop, $traitFullName]; } } } @@ -3438,233 +3440,44 @@ private function resolveTraitStmtMethodDef(Node\Stmt\ClassMethod $stmt, string $ } /** - * Validate that a concrete method satisfies an abstract requirement - * declared by a trait, following Zend's trait-composition rules: the - * static modifier must match, an abstract by-reference return must be - * kept, the implementation cannot require more parameters, parameter - * types are contravariant, and the return type is covariant. Visibility - * is deliberately not restricted — Zend allows an implementation of any - * visibility to fulfill an abstract trait requirement. + * Compare two trait data-member initializers by VALUE, as Zend does when + * flattening traits: `1 + 1` and `2`, or `[1, 2]` and `array(1, 2)`, are + * the same definition. Comparison is identity (===) after evaluating both + * constant expressions; an integer initializer of a float-typed member is + * coerced to float first, mirroring Zend's declaration-time coercion. + * Falls back to source-text equality when a value cannot be evaluated at + * compile time. */ - private function validateTraitAbstractImplementation( - Node $errorNode, - Node\Stmt\ClassMethod $requirement, - string $requirementSource, - ?MethodDef $requirementDef, - Node\Stmt\ClassMethod $implementation, - string $implementationSource, - ?MethodDef $implementationDef, - ClassDef $usingClassDef, - ): void { - $requirementName = $requirement->name->toString(); - $implementationName = $implementation->name->toString(); - $consumingClass = $usingClassDef->getNamespacedName(false); - - if ($requirement->isStatic() !== $implementation->isStatic()) { - $this->fatalError($errorNode, $requirement->isStatic() - ? "Cannot make static method `{$requirementSource}::{$requirementName}()` non static in class `{$consumingClass}`" - : "Cannot make non static method `{$requirementSource}::{$requirementName}()` static in class `{$consumingClass}`"); - } - - $incompatible = function () use ($errorNode, $implementationSource, $implementationName, $requirementSource, $requirementName): never { - $this->fatalError( - $errorNode, - "Declaration of `{$implementationSource}::{$implementationName}()` must be compatible " . - "with `{$requirementSource}::{$requirementName}()`" - ); - }; - - // The requirement's by-reference return must be kept; the - // implementation may add one. - if ($requirement->byRef && !$implementation->byRef) { - $incompatible(); - } - - if ($this->countRequiredParams($implementation->params) > $this->countRequiredParams($requirement->params)) { - $incompatible(); - } - $implParamCount = count($implementation->params); - $lastImplParam = $implParamCount > 0 ? $implementation->params[$implParamCount - 1] : null; - foreach ($requirement->params as $i => $requiredParam) { - // A trailing variadic accepts every remaining requirement position. - $implParam = $implementation->params[$i] - ?? ($lastImplParam?->variadic ? $lastImplParam : null); - if ($implParam === null - || $implParam->byRef !== $requiredParam->byRef - || ($requiredParam->variadic && !$implParam->variadic) - ) { - $incompatible(); - } - } - foreach ($implementation->params as $i => $implParam) { - if ($i >= count($requirement->params) && !$implParam->default && !$implParam->variadic) { - $incompatible(); - } - } - - // Type variance is checked on the preprocessed definitions, whose - // names were resolved in each declaration's own lexical context. - $requirementFunc = $requirementDef?->functionDef; - $implementationFunc = $implementationDef?->functionDef; - if (!$requirementFunc || !$implementationFunc) { - return; + private function isSameTraitMemberValue( + Node\Expr $existingValue, + string $existingClass, + Node\Expr $incomingValue, + string $incomingClass, + ?string $declaredTypeStr, + ): bool { + try { + $a = $this->evaluateTraitMemberValue($existingValue, $existingClass); + $b = $this->evaluateTraitMemberValue($incomingValue, $incomingClass); + } catch (\Throwable) { + return $this->printer->prettyPrintExpr($existingValue) === $this->printer->prettyPrintExpr($incomingValue); } - - $implArgs = $implementationFunc->argInfoList; - $lastImplArg = $implArgs === [] ? null : $implArgs[count($implArgs) - 1]; - foreach ($requirementFunc->argInfoList as $i => $requiredArg) { - $implArg = $implArgs[$i] ?? ($lastImplArg?->variadic ? $lastImplArg : null); - if ($implArg === null) { - continue; + if ($declaredTypeStr !== null + && (strcasecmp($declaredTypeStr, 'float') === 0 || strcasecmp($declaredTypeStr, '?float') === 0)) { + if (is_int($a)) { + $a = (float) $a; } - if (!$this->isTraitParameterTypeCompatible( - $implArg, - $requiredArg, - $usingClassDef, - $errorNode, - )) { - $incompatible(); - } - } - - if ($requirementFunc->returnTypeUndeclared) { - return; - } - if ($implementationFunc->returnTypeUndeclared) { - $incompatible(); - } - $requirementTypes = $this->getTraitReturnAcceptedTypes( - $requirementFunc, - $usingClassDef, - $errorNode, - ); - $implementationTypes = $this->getTraitReturnAcceptedTypes( - $implementationFunc, - $usingClassDef, - $errorNode, - ); - foreach ($implementationTypes as $implementationType) { - if (!$this->isReturnTypeCoveredBy($implementationType, $requirementTypes)) { - $incompatible(); + if (is_int($b)) { + $b = (float) $b; } } + return $a === $b; } - /** - * @param array $params - */ - private function countRequiredParams(array $params): int + private function evaluateTraitMemberValue(Node\Expr $expr, string $class): mixed { - $required = 0; - foreach (array_values($params) as $i => $param) { - if (!$param->default && !$param->variadic) { - $required = $i + 1; - } - } - return $required; - } - - private function isTraitParameterTypeCompatible( - ArgInfo $implementation, - ArgInfo $requirement, - ClassDef $usingClassDef, - Node $errorNode, - ): bool { - if ($this->isTopParameterType($implementation)) { - return true; - } - if ($this->isTopParameterType($requirement)) { - return false; - } - - $requirementTypes = $this->getTraitParameterAcceptedTypes( - $requirement, - $usingClassDef, - $errorNode, - ); - $implementationTypes = $this->getTraitParameterAcceptedTypes( - $implementation, - $usingClassDef, - $errorNode, - ); - if ($requirementTypes === null || $implementationTypes === null) { - return $this->isParameterTypeOverrideCompatible($implementation, $requirement); - } - return $this->isAcceptedTypeSubset($requirementTypes, $implementationTypes); - } - - private function getTraitParameterAcceptedTypes( - ArgInfo $argument, - ClassDef $usingClassDef, - Node $errorNode, - ): ?array { - if ($argument->typeKeyword !== '') { - return $this->getLateBoundTraitAcceptedType($argument->typeKeyword, $usingClassDef, $errorNode); - } - return $this->resolveLateBoundAcceptedTypes( - $this->getParameterAcceptedTypes($argument), - $usingClassDef, - $errorNode, - ); - } - - private function getTraitReturnAcceptedTypes( - FunctionDef $function, - ClassDef $usingClassDef, - Node $errorNode, - ): array { - if ($function->returnTypeKeyword !== '') { - return $this->getLateBoundTraitAcceptedType($function->returnTypeKeyword, $usingClassDef, $errorNode); - } - return $this->resolveLateBoundAcceptedTypes( - $this->getReturnAcceptedTypes($function, $usingClassDef->getNamespacedName(false)), - $usingClassDef, - $errorNode, - ) ?? []; - } - - private function getLateBoundTraitAcceptedType( - string $keyword, - ClassDef $usingClassDef, - Node $errorNode, - ): array { - if ($keyword === 'static') { - return [['kind' => 'isStatic', 'class' => $usingClassDef->getNamespacedName(false)]]; - } - $class = $this->resolveLateBoundClass($usingClassDef, $keyword); - if ($class === null) { - $this->fatalError($errorNode, 'Cannot use "parent" when current class scope has no parent'); - } - return [['kind' => 'instanceof', 'class' => $class]]; - } - - private function resolveLateBoundAcceptedTypes( - ?array $types, - ClassDef $usingClassDef, - Node $errorNode, - ): ?array { - if ($types === null) { - return null; - } - foreach ($types as &$type) { - if (($type['kind'] ?? null) === 'allOf') { - $type['types'] = $this->resolveLateBoundAcceptedTypes( - $type['types'], - $usingClassDef, - $errorNode, - ); - continue; - } - $lateBound = $type['lateBound'] ?? ''; - if (!is_string($lateBound) || $lateBound === '') { - continue; - } - $resolved = $this->getLateBoundTraitAcceptedType($lateBound, $usingClassDef, $errorNode)[0]; - $type['kind'] = $resolved['kind']; - $type['class'] = $resolved['class']; - unset($type['lateBound']); - } - return $types; + $constDef = new ConstantDef('', 0, '', ''); + $constDef->valueExpr = $expr; + return $this->evaluateClassConstValue($expr, $constDef, $class, ''); } private function cloneAstNode(Node $node): Node @@ -6488,20 +6301,57 @@ private function withTraitNameContext(string $traitName, callable $callback): mi private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $incoming): bool { - return $existing->flags === $incoming->flags - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->value === $incoming->value; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + ) { + return false; + } + if ($existing->value === $incoming->value) { + return true; + } + // Different spellings of the same value (e.g. `1 + 1` and `2`) are + // compatible in Zend; compare the evaluated values. + if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { + $floatOnly = $existing->declaredType === Type::FLOAT + || strcasecmp($existing->typeStr, 'float') === 0 + || strcasecmp($existing->typeStr, '?float') === 0; + return $this->isSameTraitMemberValue( + $existing->valueExpr, + $this->getFullClassName(), + $incoming->valueExpr, + $this->getFullClassName(), + $floatOnly ? 'float' : null, + ); + } + return false; } private function isCompatibleTraitProperty(PropertyDef $existing, PropertyDef $incoming): bool { - return $existing->flags === $incoming->flags - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->nullable === $incoming->nullable - && $existing->default === $incoming->default - && $existing->arrayDef == $incoming->arrayDef; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + || $existing->nullable !== $incoming->nullable + ) { + return false; + } + if ($existing->default === $incoming->default && $existing->arrayDef == $incoming->arrayDef) { + return true; + } + // Different spellings of the same default value (e.g. `1` and `1.0` + // on a float property, `[1, 2]` and `array(1, 2)`) are compatible in + // Zend; compare the evaluated values. + if ($existing->defaultExpr instanceof Node\Expr && $incoming->defaultExpr instanceof Node\Expr) { + return $this->isSameTraitMemberValue( + $existing->defaultExpr, + $this->getFullClassName(), + $incoming->defaultExpr, + $this->getFullClassName(), + $existing->type === Type::FLOAT ? 'float' : null, + ); + } + return false; } private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string From e2ff9ab6c07789c0f39879c4e491f42fef094def Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 13:01:23 +0200 Subject: [PATCH 2/7] fix(translator): reject trait adaptations naming nonexistent methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeTraitAst() consumed matching traitAliases/traitIgnored entries but never verified that every adaptation matched anything, silently ignoring rules Zend rejects while binding traits: - `use A { missing as g; }` — "An alias (g) was defined for method missing(), but this method does not exist"; - `use A { A::missing as g; }` — "An alias was defined for A::missing but this method does not exist"; - an alias or precedence rule naming a trait outside the class's use list — "Required Trait B wasn't added to C"; - `use A, B { B::f insteadof A; }` with no B::f — "A precedence rule was defined for B::f but this method does not exist" (the OVERRIDDEN trait need not declare the method — only the preferred one, matching Zend). Composition now records which "trait::method" keys were seen (methods arriving from nested traits are keyed under the directly-used trait, matching how adaptations are registered) and validates every adaptation afterwards. Because the Preprocessor registers an unqualified alias under EVERY used trait's key, entries now carry the source adaptation's group id — a group is satisfied when any variant matched — plus the method name and explicit qualifier for diagnostics; precedence entries record the rule for winner-existence validation (consumers only isset() the key, so the value change is compatible). --- phpunit/code/trait_adaptations_valid.php | 46 +++ phpunit/code/trait_alias_missing_method.php | 14 + .../code/trait_alias_missing_qualified.php | 14 + phpunit/code/trait_alias_trait_not_used.php | 19 ++ .../code/trait_insteadof_missing_method.php | 19 ++ .../code/trait_insteadof_trait_not_used.php | 24 ++ phpunit/src/TraitAdaptationValidationTest.php | 58 ++++ src/Entity/ClassDef.php | 12 +- src/Preprocessor.php | 19 +- src/Translator.php | 321 +++++++++++++++++- 10 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 phpunit/code/trait_adaptations_valid.php create mode 100644 phpunit/code/trait_alias_missing_method.php create mode 100644 phpunit/code/trait_alias_missing_qualified.php create mode 100644 phpunit/code/trait_alias_trait_not_used.php create mode 100644 phpunit/code/trait_insteadof_missing_method.php create mode 100644 phpunit/code/trait_insteadof_trait_not_used.php create mode 100644 phpunit/src/TraitAdaptationValidationTest.php diff --git a/phpunit/code/trait_adaptations_valid.php b/phpunit/code/trait_adaptations_valid.php new file mode 100644 index 00000000..5e458716 --- /dev/null +++ b/phpunit/code/trait_adaptations_valid.php @@ -0,0 +1,46 @@ +compile('trait_adaptations_valid.php'); + } + + public function testUnqualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias (`g`) was defined for method `missing()`, but this method does not exist', + 'trait_alias_missing_method.php', + ); + } + + public function testQualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias was defined for `A::missing` but this method does not exist', + 'trait_alias_missing_qualified.php', + ); + } + + public function testAliasReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `B` wasn't added to `C`", + 'trait_alias_trait_not_used.php', + ); + } + + public function testPrecedenceRuleForMissingMethod(): void + { + $this->exec( + 'A precedence rule was defined for `B::f` but this method does not exist', + 'trait_insteadof_missing_method.php', + ); + } + + public function testPrecedenceRuleReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `D` wasn't added to `C`", + 'trait_insteadof_trait_not_used.php', + ); + } +} diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index 5d9bb163..b2abfdc7 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -78,14 +78,18 @@ class ClassDef extends ClassLikeDef public array $traitUseConstants = []; /** - * FullMethodName -> alias list - * @var array> + * FullMethodName -> alias list. `group` identifies the source adaptation + * (an unqualified alias is registered under every used trait's key), + * `method` is the aliased method as written, and `trait` the explicit + * trait qualifier or null. + * @var array> */ public array $traitAliases = []; /** - * FullMethodName -> true - * @var array + * FullMethodName of the ignored (overridden) method -> precedence rule + * info for existence validation. Consumers test the key with isset(). + * @var array */ public array $traitIgnored = []; public int $flags; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 408a56ad..5fb33a60 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -2618,7 +2618,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void { - foreach ($traitUse->adaptations as $adaptation) { + // Adaptation identity used to verify during trait composition that + // every alias matched a real trait method (an unqualified alias is + // registered under every used trait's key, so its variants share one + // group and the group is satisfied when ANY variant matches). + $groupBase = $traitUse->getAttribute('startFilePos', $traitUse->getStartLine()) . '@'; + foreach ($traitUse->adaptations as $adaptationIndex => $adaptation) { if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { $traits = []; if (!$adaptation->trait) { @@ -2641,6 +2646,9 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $aliases[$this->getFullMethodName($traitName, $methodName)][] = [ 'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName, 'newModifier' => $adaptation->newModifier ?: 0, + 'group' => $groupBase . $adaptationIndex, + 'method' => $methodName, + 'trait' => $adaptation->trait ? $traitName : null, ]; } } @@ -2649,6 +2657,7 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $this->fatalError($traitUse, 'Trait precedence cannot be used without a trait'); } $methodName = $adaptation->method->toString(); + $winnerTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait)); /* * For example: * use TraitA { TraitA::method insteadof TraitB} @@ -2656,7 +2665,13 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al */ foreach ($adaptation->insteadof as $trait2) { $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); - $ignored[$this->getFullMethodName($traitName, $methodName)] = true; + // The value records the rule for existence validation + // during composition; consumers only use isset() on the key. + $ignored[$this->getFullMethodName($traitName, $methodName)] = [ + 'method' => $methodName, + 'winnerTrait' => $winnerTrait, + 'loserTrait' => $traitName, + ]; } } } diff --git a/src/Translator.php b/src/Translator.php index ba2dbeb1..d8dcfb8e 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3124,6 +3124,8 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitMethods = []; $traitConstants = []; $traitProperties = []; + $usedTraits = []; + $seenTraitMethods = []; $classDef = $this->getClass($className->toString()); $usingClassDef = $classDef; $compositionOwner = $classDef->getNamespacedName(false); @@ -3162,6 +3164,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) if (!$traitDef->trait) { $this->fatalError($classStmt, "Trait `{$traitFullName}` not found"); } + $usedTraits[strtolower($traitFullName)] = $traitFullName; /** @var Node\Stmt\Trait_ $traitAst */ $traitAst = $this->cloneAstNode($traitDef->trait); @@ -3182,6 +3185,10 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString()); } $fullMethodName = $this->getFullMethodName($traitFullName, $methodName); + // Methods arriving from nested traits are keyed under + // the directly-used trait, matching how adaptation + // keys are registered. + $seenTraitMethods[$fullMethodName] = true; // A trait method's `self`/`static`/`parent` return and parameter // types refer to the class that uses the trait, not the trait // itself. Re-resolve them on the cloned AST so the generated @@ -3396,6 +3403,86 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } } + $this->validateTraitAdaptations($stmt, $classDef, $usedTraits, $seenTraitMethods); + } + + /** + * After every trait is composed into $classDef, verify that each trait + * adaptation named a real trait and a real method, as Zend does when + * binding traits: + * + * - an alias must reference a used trait, and its method must exist in + * that trait (in any used trait when written without a qualifier); + * - a precedence rule's traits must all be used, and the preferred + * method must exist in the preferred trait (the overridden trait need + * not declare it). + * + * @param array $usedTraits lowercased name => full name + * @param array $seenTraitMethods "trait::method" keys seen + * during composition (nested trait methods + * are keyed under the directly-used trait) + */ + private function validateTraitAdaptations( + Node\Stmt\ClassLike $stmt, + ClassDef $classDef, + array $usedTraits, + array $seenTraitMethods + ): void { + if (!$classDef->traitAliases && !$classDef->traitIgnored) { + return; + } + $className = $classDef->getNamespacedName(false); + + // An unqualified alias is registered under every used trait's key (the + // Preprocessor cannot know which trait declares the method), so its + // variants share one group: the group is satisfied when ANY variant + // matched a composed method. + $aliasGroups = []; + foreach ($classDef->traitAliases as $fullMethodName => $aliasList) { + foreach ($aliasList as $alias) { + $group = $alias['group'] ?? $fullMethodName; + $aliasGroups[$group] ??= ['alias' => $alias, 'matched' => false]; + if (isset($seenTraitMethods[$fullMethodName])) { + $aliasGroups[$group]['matched'] = true; + } + } + } + foreach ($aliasGroups as $groupInfo) { + if ($groupInfo['matched']) { + continue; + } + $alias = $groupInfo['alias']; + $method = $alias['method'] ?? ''; + $explicitTrait = $alias['trait'] ?? null; + if ($explicitTrait !== null) { + if (!isset($usedTraits[strtolower($explicitTrait)])) { + $this->fatalError($stmt, + "Required Trait `{$explicitTrait}` wasn't added to `{$className}`"); + } + $this->fatalError($stmt, + "An alias was defined for `{$explicitTrait}::{$method}` but this method does not exist"); + } + $newName = $alias['newName'] ?? $method; + $this->fatalError($stmt, + "An alias (`{$newName}`) was defined for method `{$method}()`, but this method does not exist"); + } + + foreach ($classDef->traitIgnored as $rule) { + if (!is_array($rule)) { + continue; + } + foreach ([$rule['winnerTrait'], $rule['loserTrait']] as $traitName) { + if (!isset($usedTraits[strtolower($traitName)])) { + $this->fatalError($stmt, + "Required Trait `{$traitName}` wasn't added to `{$className}`"); + } + } + if (!isset($seenTraitMethods[$this->getFullMethodName($rule['winnerTrait'], $rule['method'])])) { + $this->fatalError($stmt, + "A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}` " . + 'but this method does not exist'); + } + } } /** @@ -3480,6 +3567,236 @@ private function evaluateTraitMemberValue(Node\Expr $expr, string $class): mixed return $this->evaluateClassConstValue($expr, $constDef, $class, ''); } + /** + * Validate that a concrete method satisfies an abstract requirement + * declared by a trait, following Zend's trait-composition rules: the + * static modifier must match, an abstract by-reference return must be + * kept, the implementation cannot require more parameters, parameter + * types are contravariant, and the return type is covariant. Visibility + * is deliberately not restricted — Zend allows an implementation of any + * visibility to fulfill an abstract trait requirement. + */ + private function validateTraitAbstractImplementation( + Node $errorNode, + Node\Stmt\ClassMethod $requirement, + string $requirementSource, + ?MethodDef $requirementDef, + Node\Stmt\ClassMethod $implementation, + string $implementationSource, + ?MethodDef $implementationDef, + ClassDef $usingClassDef, + ): void { + $requirementName = $requirement->name->toString(); + $implementationName = $implementation->name->toString(); + $consumingClass = $usingClassDef->getNamespacedName(false); + + if ($requirement->isStatic() !== $implementation->isStatic()) { + $this->fatalError($errorNode, $requirement->isStatic() + ? "Cannot make static method `{$requirementSource}::{$requirementName}()` non static in class `{$consumingClass}`" + : "Cannot make non static method `{$requirementSource}::{$requirementName}()` static in class `{$consumingClass}`"); + } + + $incompatible = function () use ($errorNode, $implementationSource, $implementationName, $requirementSource, $requirementName): never { + $this->fatalError( + $errorNode, + "Declaration of `{$implementationSource}::{$implementationName}()` must be compatible " . + "with `{$requirementSource}::{$requirementName}()`" + ); + }; + + // The requirement's by-reference return must be kept; the + // implementation may add one. + if ($requirement->byRef && !$implementation->byRef) { + $incompatible(); + } + + if ($this->countRequiredParams($implementation->params) > $this->countRequiredParams($requirement->params)) { + $incompatible(); + } + $implParamCount = count($implementation->params); + $lastImplParam = $implParamCount > 0 ? $implementation->params[$implParamCount - 1] : null; + foreach ($requirement->params as $i => $requiredParam) { + // A trailing variadic accepts every remaining requirement position. + $implParam = $implementation->params[$i] + ?? ($lastImplParam?->variadic ? $lastImplParam : null); + if ($implParam === null + || $implParam->byRef !== $requiredParam->byRef + || ($requiredParam->variadic && !$implParam->variadic) + ) { + $incompatible(); + } + } + foreach ($implementation->params as $i => $implParam) { + if ($i >= count($requirement->params) && !$implParam->default && !$implParam->variadic) { + $incompatible(); + } + } + + // Type variance is checked on the preprocessed definitions, whose + // names were resolved in each declaration's own lexical context. + $requirementFunc = $requirementDef?->functionDef; + $implementationFunc = $implementationDef?->functionDef; + if (!$requirementFunc || !$implementationFunc) { + return; + } + + $implArgs = $implementationFunc->argInfoList; + $lastImplArg = $implArgs === [] ? null : $implArgs[count($implArgs) - 1]; + foreach ($requirementFunc->argInfoList as $i => $requiredArg) { + $implArg = $implArgs[$i] ?? ($lastImplArg?->variadic ? $lastImplArg : null); + if ($implArg === null) { + continue; + } + if (!$this->isTraitParameterTypeCompatible( + $implArg, + $requiredArg, + $usingClassDef, + $errorNode, + )) { + $incompatible(); + } + } + + if ($requirementFunc->returnTypeUndeclared) { + return; + } + if ($implementationFunc->returnTypeUndeclared) { + $incompatible(); + } + $requirementTypes = $this->getTraitReturnAcceptedTypes( + $requirementFunc, + $usingClassDef, + $errorNode, + ); + $implementationTypes = $this->getTraitReturnAcceptedTypes( + $implementationFunc, + $usingClassDef, + $errorNode, + ); + foreach ($implementationTypes as $implementationType) { + if (!$this->isReturnTypeCoveredBy($implementationType, $requirementTypes)) { + $incompatible(); + } + } + } + + /** + * @param array $params + */ + private function countRequiredParams(array $params): int + { + $required = 0; + foreach (array_values($params) as $i => $param) { + if (!$param->default && !$param->variadic) { + $required = $i + 1; + } + } + return $required; + } + + private function isTraitParameterTypeCompatible( + ArgInfo $implementation, + ArgInfo $requirement, + ClassDef $usingClassDef, + Node $errorNode, + ): bool { + if ($this->isTopParameterType($implementation)) { + return true; + } + if ($this->isTopParameterType($requirement)) { + return false; + } + + $requirementTypes = $this->getTraitParameterAcceptedTypes( + $requirement, + $usingClassDef, + $errorNode, + ); + $implementationTypes = $this->getTraitParameterAcceptedTypes( + $implementation, + $usingClassDef, + $errorNode, + ); + if ($requirementTypes === null || $implementationTypes === null) { + return $this->isParameterTypeOverrideCompatible($implementation, $requirement); + } + return $this->isAcceptedTypeSubset($requirementTypes, $implementationTypes); + } + + private function getTraitParameterAcceptedTypes( + ArgInfo $argument, + ClassDef $usingClassDef, + Node $errorNode, + ): ?array { + if ($argument->typeKeyword !== '') { + return $this->getLateBoundTraitAcceptedType($argument->typeKeyword, $usingClassDef, $errorNode); + } + return $this->resolveLateBoundAcceptedTypes( + $this->getParameterAcceptedTypes($argument), + $usingClassDef, + $errorNode, + ); + } + + private function getTraitReturnAcceptedTypes( + FunctionDef $function, + ClassDef $usingClassDef, + Node $errorNode, + ): array { + if ($function->returnTypeKeyword !== '') { + return $this->getLateBoundTraitAcceptedType($function->returnTypeKeyword, $usingClassDef, $errorNode); + } + return $this->resolveLateBoundAcceptedTypes( + $this->getReturnAcceptedTypes($function, $usingClassDef->getNamespacedName(false)), + $usingClassDef, + $errorNode, + ) ?? []; + } + + private function getLateBoundTraitAcceptedType( + string $keyword, + ClassDef $usingClassDef, + Node $errorNode, + ): array { + if ($keyword === 'static') { + return [['kind' => 'isStatic', 'class' => $usingClassDef->getNamespacedName(false)]]; + } + $class = $this->resolveLateBoundClass($usingClassDef, $keyword); + if ($class === null) { + $this->fatalError($errorNode, 'Cannot use "parent" when current class scope has no parent'); + } + return [['kind' => 'instanceof', 'class' => $class]]; + } + + private function resolveLateBoundAcceptedTypes( + ?array $types, + ClassDef $usingClassDef, + Node $errorNode, + ): ?array { + if ($types === null) { + return null; + } + foreach ($types as &$type) { + if (($type['kind'] ?? null) === 'allOf') { + $type['types'] = $this->resolveLateBoundAcceptedTypes( + $type['types'], + $usingClassDef, + $errorNode, + ); + continue; + } + $lateBound = $type['lateBound'] ?? ''; + if (!is_string($lateBound) || $lateBound === '') { + continue; + } + $resolved = $this->getLateBoundTraitAcceptedType($lateBound, $usingClassDef, $errorNode)[0]; + $type['kind'] = $resolved['kind']; + $type['class'] = $resolved['class']; + unset($type['lateBound']); + } + return $types; + } + private function cloneAstNode(Node $node): Node { $traverser = new NodeTraverser(); @@ -6313,9 +6630,7 @@ private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $i // Different spellings of the same value (e.g. `1 + 1` and `2`) are // compatible in Zend; compare the evaluated values. if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { - $floatOnly = $existing->declaredType === Type::FLOAT - || strcasecmp($existing->typeStr, 'float') === 0 - || strcasecmp($existing->typeStr, '?float') === 0; + $floatOnly = $existing->declaredType === Type::FLOAT; return $this->isSameTraitMemberValue( $existing->valueExpr, $this->getFullClassName(), From 9a827a0217e46938e28d050a4c4a075322b21b85 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:08:17 +0200 Subject: [PATCH 3/7] fix(translator): validate every precedence rule targeting one loser method traitIgnored kept ONE rule per loser Trait::method key, so a later `C::f insteadof B` overwrote an earlier `A::f insteadof B` and skipped its validation: Zend fatals with 'A precedence rule was defined for A::f but this method does not exist' (each rule's winner must exist on its own), while the overwriting rule let the program compile. Store a LIST of rule records behind the ignored-method key - consumers only isset() the key, so the ignore behavior is unchanged - append per key across several `use` clauses (array_merge dropped earlier clauses' rules for the same loser), and validate every record. Keeping all records also exposes Zend's exclusion-duplication rule: a trait method may be excluded only once, even by rules whose winners all exist ('Failed to evaluate a trait precedence (f). Method of trait B was defined to be excluded multiple times', probed on 8.4.13 within one and across several use clauses). Reported after the existence checks, in Zend's order. One winner excluding two different losers stays legal. --- .../code/trait_insteadof_excluded_twice.php | 22 +++++++++++++ .../code/trait_insteadof_overwritten_rule.php | 27 +++++++++++++++ phpunit/code/trait_insteadof_two_losers.php | 27 +++++++++++++++ phpunit/src/TraitAdaptationValidationTest.php | 23 +++++++++++++ src/Entity/ClassDef.php | 9 +++-- src/Preprocessor.php | 16 ++++++--- src/Translator.php | 33 ++++++++++++++----- 7 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 phpunit/code/trait_insteadof_excluded_twice.php create mode 100644 phpunit/code/trait_insteadof_overwritten_rule.php create mode 100644 phpunit/code/trait_insteadof_two_losers.php diff --git a/phpunit/code/trait_insteadof_excluded_twice.php b/phpunit/code/trait_insteadof_excluded_twice.php new file mode 100644 index 00000000..e6fb85bc --- /dev/null +++ b/phpunit/code/trait_insteadof_excluded_twice.php @@ -0,0 +1,22 @@ +exec( + 'A precedence rule was defined for `A::f` but this method does not exist', + 'trait_insteadof_overwritten_rule.php', + ); + } + + public function testMethodExcludedTwiceIsRejected(): void + { + $this->exec( + 'Failed to evaluate a trait precedence (`f`). Method of trait `B` was defined to be excluded multiple times', + 'trait_insteadof_excluded_twice.php', + ); + } + + public function testOneWinnerMayExcludeTwoLosers(): void + { + $this->compile('trait_insteadof_two_losers.php'); + } } diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index b2abfdc7..d8f0e6a9 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -87,9 +87,12 @@ class ClassDef extends ClassLikeDef public array $traitAliases = []; /** - * FullMethodName of the ignored (overridden) method -> precedence rule - * info for existence validation. Consumers test the key with isset(). - * @var array + * FullMethodName of the ignored (overridden) method -> EVERY precedence + * rule that named it as the loser, for existence validation (several + * rules may target one loser, and each winner must exist). Consumers + * test the key with isset(); legacy writers may store `true` instead of + * a rule list, so readers guard with is_array(). + * @var array|true> */ public array $traitIgnored = []; public int $flags; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 5fb33a60..a2c9ec58 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -2665,9 +2665,11 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al */ foreach ($adaptation->insteadof as $trait2) { $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); - // The value records the rule for existence validation - // during composition; consumers only use isset() on the key. - $ignored[$this->getFullMethodName($traitName, $methodName)] = [ + // The value records EVERY rule targeting this loser method + // for existence validation during composition (several + // precedence rules may name the same loser, and each + // winner must exist); consumers only isset() the key. + $ignored[$this->getFullMethodName($traitName, $methodName)][] = [ 'method' => $methodName, 'winnerTrait' => $winnerTrait, 'loserTrait' => $traitName, @@ -2695,6 +2697,12 @@ protected function prepareTraitUse(Node\Stmt\TraitUse $v): void $this->classDef->traitAliases[$fullMethodName][] = $alias; } } - $this->classDef->traitIgnored = array_merge($this->classDef->traitIgnored, $ignored); + foreach ($ignored as $fullMethodName => $rules) { + // Append per key: array_merge() would drop the rules an earlier + // `use` clause registered for the same ignored method. + foreach ($rules as $rule) { + $this->classDef->traitIgnored[$fullMethodName][] = $rule; + } + } } } diff --git a/src/Translator.php b/src/Translator.php index d8dcfb8e..be68d168 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3467,20 +3467,37 @@ private function validateTraitAdaptations( "An alias (`{$newName}`) was defined for method `{$method}()`, but this method does not exist"); } - foreach ($classDef->traitIgnored as $rule) { - if (!is_array($rule)) { + // Every precedence rule that named a loser method is validated: Zend + // checks each rule's winner individually, so a later `C::f insteadof + // B` never absolves an earlier `A::f insteadof B` whose winner method + // does not exist. + foreach ($classDef->traitIgnored as $rules) { + if (!is_array($rules)) { continue; } - foreach ([$rule['winnerTrait'], $rule['loserTrait']] as $traitName) { - if (!isset($usedTraits[strtolower($traitName)])) { + foreach ($rules as $rule) { + foreach ([$rule['winnerTrait'], $rule['loserTrait']] as $traitName) { + if (!isset($usedTraits[strtolower($traitName)])) { + $this->fatalError($stmt, + "Required Trait `{$traitName}` wasn't added to `{$className}`"); + } + } + if (!isset($seenTraitMethods[$this->getFullMethodName($rule['winnerTrait'], $rule['method'])])) { $this->fatalError($stmt, - "Required Trait `{$traitName}` wasn't added to `{$className}`"); + "A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}` " . + 'but this method does not exist'); } } - if (!isset($seenTraitMethods[$this->getFullMethodName($rule['winnerTrait'], $rule['method'])])) { + } + // A trait method may be excluded only once, even across several `use` + // clauses; Zend reports duplicates after every rule passed the + // existence checks above. + foreach ($classDef->traitIgnored as $rules) { + if (is_array($rules) && count($rules) > 1) { + $rule = $rules[0]; $this->fatalError($stmt, - "A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}` " . - 'but this method does not exist'); + "Failed to evaluate a trait precedence (`{$rule['method']}`). " . + "Method of trait `{$rule['loserTrait']}` was defined to be excluded multiple times"); } } } From 4fb738fc9959e15c2e99e4b406bbdeb51cc96964 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:08:29 +0200 Subject: [PATCH 4/7] fix(translator): compare trait-constant enum cases by case identity The trait-member value comparison evaluated an enum case fetch to its case name (or backing scalar), so `const X = E1::Value` and `const X = E2::Value` in two traits looked identical and the class composed. Zend compares the resolved zvals, and every enum case is a distinct object: same-named cases of different enums, different cases of one enum, and cases of different backed enums sharing a backing scalar are all incompatible definitions (probed on 8.4.13); only the SAME enum's same case is compatible. Evaluation for this comparison now maps an enum case to a marker string carrying its (enum class, case name) identity, NUL-prefixed so it cannot collide with a real string value, and working inside array initializers too. The general constant-folding path is untouched: only evaluateTraitMemberValue() asks for identity semantics. isCompatibleTraitConstant() also returned early when the two lowered value strings matched - every non-literal initializer shares '' there, which bypassed the value comparison entirely. The evaluated comparison is now authoritative whenever both initializer expressions are known; the string equality remains the fallback. --- .../code/trait_const_enum_backed_conflict.php | 30 +++++++++++++++++++ .../code/trait_const_enum_case_conflict.php | 30 +++++++++++++++++++ phpunit/code/trait_const_enum_case_same.php | 25 ++++++++++++++++ .../trait_const_enum_diff_case_conflict.php | 25 ++++++++++++++++ phpunit/src/TraitMemberValueConflictTest.php | 20 +++++++++++++ src/Resolver/ClassConstantValueTrait.php | 25 +++++++++++++--- src/Translator.php | 19 +++++++----- 7 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 phpunit/code/trait_const_enum_backed_conflict.php create mode 100644 phpunit/code/trait_const_enum_case_conflict.php create mode 100644 phpunit/code/trait_const_enum_case_same.php create mode 100644 phpunit/code/trait_const_enum_diff_case_conflict.php diff --git a/phpunit/code/trait_const_enum_backed_conflict.php b/phpunit/code/trait_const_enum_backed_conflict.php new file mode 100644 index 00000000..03bde080 --- /dev/null +++ b/phpunit/code/trait_const_enum_backed_conflict.php @@ -0,0 +1,30 @@ +exec('constant `x` already exists', 'trait_const_identity_conflict.php'); } + + public function testSameEnumCaseInBothTraitsCompiles(): void + { + $this->compile('trait_const_enum_case_same.php'); + } + + public function testSameNamedCasesOfDifferentEnumsConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_case_conflict.php'); + } + + public function testDifferentCasesOfSameEnumConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_diff_case_conflict.php'); + } + + public function testSameBackingValueOfDifferentEnumsConflicts(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_backed_conflict.php'); + } } diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 90ec17e5..83fe4ba4 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -15,12 +15,19 @@ trait ClassConstantValueTrait { + /** + * Prefix of the marker string an enum case evaluates to when the caller + * asked for identity semantics (see getClassConstValue()). The NUL bytes + * make a collision with a real string constant value impossible. + */ + public const ENUM_CASE_IDENTITY_PREFIX = "\0enum-case\0"; + public function getDefinedConstants(): array { return $this->internalConstants; } - public function getClassConstValue(NodeAbstract $expr, string $_class, string $name, string $currentClass = ''): mixed + public function getClassConstValue(NodeAbstract $expr, string $_class, string $name, string $currentClass = '', bool $enumCasesAsIdentity = false): mixed { $namespace = $this->namespace; if (!$namespace and $currentClass and !str_contains($_class, '\\')) { @@ -58,6 +65,16 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n if ($this->hasClass($class)) { $classDef = $this->getClass($class); if ($classDef->enum && array_key_exists($name, $classDef->enumCases)) { + if ($enumCasesAsIdentity) { + // Each enum case is a distinct object in Zend: two cases + // are the same value only when both the enum class and + // the case name match, never through a shared case name + // or backing scalar. Callers comparing values for + // identity get an uncollidable marker instead. + return self::ENUM_CASE_IDENTITY_PREFIX + . strtolower(ltrim($classDef->getNamespacedName(false), '\\')) + . '::' . $name; + } $caseValue = $classDef->enumCases[$name]; return $caseValue ?? $name; } @@ -99,14 +116,14 @@ protected function resolveInheritedClassConst(string $class, string $name): arra return [false, null]; } - protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $constDef, string $class, string $name): mixed + protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $constDef, string $class, string $name, bool $enumCasesAsIdentity = false): mixed { $valueExpr = $constDef->valueExpr; if (!$valueExpr instanceof Node\Expr) { $this->fatalError($origin, "Class constant `{$class}::{$name}` has no constant expression"); } - $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use ($origin, $class) { + $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use ($origin, $class, $enumCasesAsIdentity) { if ($expr instanceof Node\Expr\ConstFetch) { $constName = $expr->name->toString(); return match (strtolower($constName)) { @@ -136,7 +153,7 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c } elseif (strcasecmp($className, 'parent') === 0) { $className = $this->getParentClass($class); } - return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class); + return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class, $enumCasesAsIdentity); } throw new \RuntimeException('Unsupported class constant expression'); }); diff --git a/src/Translator.php b/src/Translator.php index be68d168..90e9e75e 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3581,7 +3581,11 @@ private function evaluateTraitMemberValue(Node\Expr $expr, string $class): mixed { $constDef = new ConstantDef('', 0, '', ''); $constDef->valueExpr = $expr; - return $this->evaluateClassConstValue($expr, $constDef, $class, ''); + // Enum cases must keep their (enum class, case name) identity here: + // Zend compares the case OBJECTS when flattening traits, so E1::Value + // and E2::Value are different definitions even though both would + // otherwise evaluate to the same case-name/backing scalar. + return $this->evaluateClassConstValue($expr, $constDef, $class, '', enumCasesAsIdentity: true); } /** @@ -6641,11 +6645,12 @@ private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $i ) { return false; } - if ($existing->value === $incoming->value) { - return true; - } - // Different spellings of the same value (e.g. `1 + 1` and `2`) are - // compatible in Zend; compare the evaluated values. + // Zend compares the EVALUATED definitions, so different spellings of + // one value (`1 + 1` and `2`) are compatible. The evaluated values are + // authoritative whenever both expressions are known: the lowered value + // string is not an identity (every non-literal initializer shares '', + // and E1::Value and E2::Value would both normalize to 'Value' even + // though Zend treats the two case objects as distinct). if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { $floatOnly = $existing->declaredType === Type::FLOAT; return $this->isSameTraitMemberValue( @@ -6656,7 +6661,7 @@ private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $i $floatOnly ? 'float' : null, ); } - return false; + return $existing->value === $incoming->value; } private function isCompatibleTraitProperty(PropertyDef $existing, PropertyDef $incoming): bool From 8df5ba24013773baf1c5fc0c4f56ed9a132a8fe2 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:45:41 +0200 Subject: [PATCH 5/7] fix(resolver): do not re-prefix qualified names in constant evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trait-member value comparison evaluates constant initializers against a class name that is already fully qualified (the composing class or the defining trait). getClassConstValue() treated it as relative and prepended the current file's namespace again, so evaluating `[...self::PARTS, 9]` while converting a namespaced class looked up `App\App\C::PARTS` and aborted the compile — the compiler's own self-build died on `TypePhp\TypePhp\CompilerBase::PYTHON_CONSTRUCTOR_CLASSES`. Mark the resolved self/parent/static target absolute before the lookup. --- .../trait_const_self_reference_namespaced.php | 31 +++++++++++++++++++ phpunit/src/TraitMemberValueConflictTest.php | 5 +++ src/Resolver/ClassConstantValueTrait.php | 8 ++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 phpunit/code/trait_const_self_reference_namespaced.php diff --git a/phpunit/code/trait_const_self_reference_namespaced.php b/phpunit/code/trait_const_self_reference_namespaced.php new file mode 100644 index 00000000..d432108b --- /dev/null +++ b/phpunit/code/trait_const_self_reference_namespaced.php @@ -0,0 +1,31 @@ +compile('trait_const_enum_case_same.php'); } + public function testSelfConstantReferenceEvaluatesInNamespace(): void + { + $this->compile('trait_const_self_reference_namespaced.php'); + } + public function testSameNamedCasesOfDifferentEnumsConflict(): void { $this->exec('constant `x` already exists', 'trait_const_enum_case_conflict.php'); diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 83fe4ba4..a616b21a 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -148,11 +148,17 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c } return ltrim($this->getNamespacedClassName($className, $this->getNamespaceOfClass($class)), '\\'); } - if (strcasecmp($className, 'self') === 0) { + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { $className = $class; } elseif (strcasecmp($className, 'parent') === 0) { $className = $this->getParentClass($class); } + // $class is already fully qualified; mark it absolute so + // getClassConstValue() does not prepend the current file's + // namespace a second time (`TypePhp\TypePhp\...`). + if ($className !== '' && strcasecmp($expr->class->toString(), $className) !== 0) { + $className = '\\' . ltrim($className, '\\'); + } return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class, $enumCasesAsIdentity); } throw new \RuntimeException('Unsupported class constant expression'); From 5686c9d0a8240510c152d4413b0fa7a45ca3deca Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 11:03:26 +0200 Subject: [PATCH 6/7] fix(resolver): treat source-level fully qualified names as absolute too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The absolute-name guard compared the raw AST spelling with the resolved name, but Name::toString() strips the leading backslash of a `\App3\C::NAME` fetch, so the two matched and the name was resolved as relative again — tests/compiler/trait/010.phpt failed with `App3\App3\TraitsTest` not found. A Name\FullyQualified class node is absolute regardless of the string comparison. --- src/Resolver/ClassConstantValueTrait.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index a616b21a..fff52ace 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -153,10 +153,16 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c } elseif (strcasecmp($className, 'parent') === 0) { $className = $this->getParentClass($class); } - // $class is already fully qualified; mark it absolute so - // getClassConstValue() does not prepend the current file's - // namespace a second time (`TypePhp\TypePhp\...`). - if ($className !== '' && strcasecmp($expr->class->toString(), $className) !== 0) { + // A resolved self/parent/static target is already fully + // qualified, and a `\App3\C` source spelling is fully + // qualified even though Name::toString() strips the leading + // backslash. Mark both absolute so getClassConstValue() does + // not prepend the current file's namespace a second time + // (`TypePhp\TypePhp\...`, `App3\App3\...`). + if ($className !== '' + && ($expr->class instanceof Node\Name\FullyQualified + || strcasecmp($expr->class->toString(), $className) !== 0) + ) { $className = '\\' . ltrim($className, '\\'); } return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class, $enumCasesAsIdentity); From 9b8492873db3b4dadea20e1237a51dbafcff24b0 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:26:14 +0200 Subject: [PATCH 7/7] fix(resolver): make enum-case identity a value user code cannot construct The identity a trait-member comparison assigned to an enum case was a NUL-prefixed marker STRING. PHP strings are binary-safe, so user code can spell that exact byte sequence: trait T1 { const X = E::A; } trait T2 { const X = "\0enum-case\0e::A"; } Zend rejects composing the two (an enum-case object and a string are different values); both definitions evaluated to the same marker here, so the collision was accepted (probed on 8.4.13). Enum cases now evaluate, under identity semantics, to an interned EnumCaseIdentity object - one instance per (enum class, case name) pair, private constructor. ConstExprEvaluator can only produce scalars, arrays and null from user constant expressions, never an object, so no user value can collide with it, and interning keeps === a stable identity test across repeated references - recursively inside arrays too, since array === compares each element with === again. The float coercion in isSameTraitMemberValue() only touches is_int() values and ignores the objects. Regression tests: the marker-string collision above must conflict, arrays holding the same enum case still compose, and arrays holding same-named cases of different enums conflict (each probed on 8.4.13). --- .../trait_const_enum_case_array_conflict.php | 30 ++++++++++++ .../code/trait_const_enum_case_array_same.php | 26 ++++++++++ ...ait_const_enum_marker_string_collision.php | 26 ++++++++++ phpunit/src/TraitMemberValueConflictTest.php | 15 ++++++ src/Entity/EnumCaseIdentity.php | 49 +++++++++++++++++++ src/Resolver/ClassConstantValueTrait.php | 17 +++---- 6 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 phpunit/code/trait_const_enum_case_array_conflict.php create mode 100644 phpunit/code/trait_const_enum_case_array_same.php create mode 100644 phpunit/code/trait_const_enum_marker_string_collision.php create mode 100644 src/Entity/EnumCaseIdentity.php diff --git a/phpunit/code/trait_const_enum_case_array_conflict.php b/phpunit/code/trait_const_enum_case_array_conflict.php new file mode 100644 index 00000000..6a3b27fd --- /dev/null +++ b/phpunit/code/trait_const_enum_case_array_conflict.php @@ -0,0 +1,30 @@ +exec('constant `x` already exists', 'trait_const_enum_backed_conflict.php'); } + + public function testStringSpellingAnEnumCaseMarkerConflicts(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_marker_string_collision.php'); + } + + public function testSameEnumCaseInsideArraysCompiles(): void + { + $this->compile('trait_const_enum_case_array_same.php'); + } + + public function testDifferentEnumCasesInsideArraysConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_case_array_conflict.php'); + } } diff --git a/src/Entity/EnumCaseIdentity.php b/src/Entity/EnumCaseIdentity.php new file mode 100644 index 00000000..84d3d2bd --- /dev/null +++ b/src/Entity/EnumCaseIdentity.php @@ -0,0 +1,49 @@ + */ + private static array $instances = []; + + private function __construct( + public readonly string $enumClass, + public readonly string $caseName, + ) { + } + + /** + * @param string $enumClass fully qualified enum name (class names are + * case-insensitive; a leading `\` is ignored) + * @param string $caseName case name, compared case-sensitively as Zend does + */ + public static function intern(string $enumClass, string $caseName): self + { + $normalized = strtolower(ltrim($enumClass, '\\')); + return self::$instances[$normalized . '::' . $caseName] + ??= new self($normalized, $caseName); + } +} diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index fff52ace..4f0fa054 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -12,16 +12,10 @@ use PhpParser\Node; use PhpParser\NodeAbstract; use TypePhp\Entity\ConstantDef; +use TypePhp\Entity\EnumCaseIdentity; trait ClassConstantValueTrait { - /** - * Prefix of the marker string an enum case evaluates to when the caller - * asked for identity semantics (see getClassConstValue()). The NUL bytes - * make a collision with a real string constant value impossible. - */ - public const ENUM_CASE_IDENTITY_PREFIX = "\0enum-case\0"; - public function getDefinedConstants(): array { return $this->internalConstants; @@ -70,10 +64,11 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n // are the same value only when both the enum class and // the case name match, never through a shared case name // or backing scalar. Callers comparing values for - // identity get an uncollidable marker instead. - return self::ENUM_CASE_IDENTITY_PREFIX - . strtolower(ltrim($classDef->getNamespacedName(false), '\\')) - . '::' . $name; + // identity get an interned compiler-internal object that + // no user constant expression can construct or collide + // with (strings are binary-safe, so a marker string would + // still be spellable). + return EnumCaseIdentity::intern($classDef->getNamespacedName(false), $name); } $caseValue = $classDef->enumCases[$name]; return $caseValue ?? $name;