From a0be8bf3499e3ff2aba2576604aefa0d7bbdfa98 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Fri, 28 Aug 2026 17:49:58 +0200 Subject: [PATCH] fix(translator): correct trait composition precedence and alias modifiers Three defects in composeTraitAst diverged from PHP trait semantics: 1. When a later trait supplied a concrete method for a name an earlier trait declared abstract, the branch unset the new concrete statement while the already-merged abstract one stayed in the class AST. The composed class kept only the abstract declaration, so instantiating it failed with "Cannot instantiate abstract class" even though the method body existed. The reverse trait order worked. Now the merged abstract declaration is dropped and the concrete method is kept. 2. Alias adaptations assigned the new modifier over the whole flag set, wiping static/final/abstract: `use Maker { make as protected; }` turned a static method into an instance method, and static:: calls on it miscompiled. PHP replaces only the visibility bits (and keeps the original visibility when the modifier carries none, e.g. `as final`). 3. A method defined by the class itself did not suppress the trait-vs-trait conflict check, so `class C { use A, B; function f(){} }` with f() in both traits died with a spurious "method already exists" fatal. The class-method check now runs before conflict resolution and suppressed trait copies are no longer registered as trait methods. --- src/Translator.php | 43 +++++++++++++--- .../trait/trait-composition-precedence.phpt | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 tests/compiler/trait/trait-composition-precedence.phpt diff --git a/src/Translator.php b/src/Translator.php index 57fb2b8a..fe561c62 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3128,13 +3128,13 @@ CODE; $aliasName = strtolower($alias['newName']); if ($aliasName === $methodName) { if ($alias['newModifier']) { - $traitStmt->flags = $alias['newModifier']; + $traitStmt->flags = $this->applyTraitAliasModifier($traitStmt->flags, $alias['newModifier']); } } elseif (!isset($methods[$aliasName]) && !isset($traitMethods[$aliasName])) { $aliasStmt = clone $traitStmt; $aliasStmt->name = new Node\Identifier($alias['newName']); if ($alias['newModifier']) { - $aliasStmt->flags = $alias['newModifier']; + $aliasStmt->flags = $this->applyTraitAliasModifier($aliasStmt->flags, $alias['newModifier']); } $aliasStmts[] = $aliasStmt; $traitMethods[$aliasName] = [$traitFullName, $aliasStmt]; @@ -3144,6 +3144,13 @@ CODE; unset($traitStmts[$k1]); continue; } + if (isset($methods[$methodName])) { + // The class's own method always wins: suppressed + // trait copies must not take part in trait-vs-trait + // conflict resolution. + unset($traitStmts[$k1]); + continue; + } if (isset($traitMethods[$methodName])) { [$existingTraitName, $existingStmt] = $traitMethods[$methodName]; $newAbstract = $traitStmt->isAbstract(); @@ -3167,18 +3174,26 @@ CODE; } if (!$newAbstract && $existingAbstract) { - // New concrete replaces existing abstract + // The new concrete method fulfills the abstract + // requirement: drop the already-collected + // abstract declaration and keep this one. + foreach ($stmt->stmts as $k3 => $mergedStmt) { + if ($mergedStmt === $existingStmt) { + unset($stmt->stmts[$k3]); + } + } + foreach ($aliasStmts as $k3 => $pendingAliasStmt) { + if ($pendingAliasStmt === $existingStmt) { + unset($aliasStmts[$k3]); + } + } $traitMethods[$methodName] = [$traitFullName, $traitStmt]; - unset($traitStmts[$k1]); continue; } // Both concrete — error $this->fatalError($classStmt, "Trait `{$traitFullName}` method `{$methodName}` already exists"); } - if (isset($methods[$methodName])) { - unset($traitStmts[$k1]); - } $traitMethods[$methodName] = [$traitFullName, $traitStmt]; } if ($traitStmt instanceof Node\Stmt\ClassConst) { @@ -3243,6 +3258,20 @@ CODE; } + /** + * Apply a trait alias modifier the way PHP does: a new visibility replaces + * only the visibility bits, and every other flag (static, final, abstract) + * is kept. A modifier without visibility (e.g. `as final`) keeps the + * original visibility. + */ + private function applyTraitAliasModifier(int $flags, int $newModifier): int + { + if ($newModifier & Modifiers::VISIBILITY_MASK) { + $flags &= ~Modifiers::VISIBILITY_MASK; + } + return $flags | $newModifier; + } + private function cloneAstNode(Node $node): Node { $traverser = new NodeTraverser(); diff --git a/tests/compiler/trait/trait-composition-precedence.phpt b/tests/compiler/trait/trait-composition-precedence.phpt new file mode 100644 index 00000000..28f38b94 --- /dev/null +++ b/tests/compiler/trait/trait-composition-precedence.phpt @@ -0,0 +1,51 @@ +--TEST-- +Trait composition: concrete fulfills abstract, alias keeps static, class method wins +--FILE-- +name(), "\n"; + echo (new ConcreteFirst())->name(), "\n"; + echo Factory::build(), "\n"; + echo Stats::total(), "\n"; + echo (new Self1())->who(), "\n"; +} +?> +--EXPECT-- +HasName +HasName +made +7 +Self1