Skip to content

fix(weaving): handle #[\Override] on intercepted trait methods - #763

Open
lisachenko wants to merge 4 commits into
masterfrom
fix/761-trait-override-guard
Open

lisachenko wants to merge 4 commits into
masterfrom
fix/761-trait-override-guard

Conversation

@lisachenko

@lisachenko lisachenko commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What and why

Fixes #761 the way decided in the issue: detect the trait-imported case, fail with a clear message, and document it. The related case of a woven trait's own #[\Override] method is fixed properly (see the last section).

The bug: on PHP 8.3+, intercepting a method that a class imports from a trait and that carries #[\Override] produced an unloadable proxy. PHP copies the attribute to the private …OriginalAlias alias in the proxy's trait-use block. That alias overrides nothing, so loading the class failed with Fatal error: …::helloOriginalAlias() has #[\Override] attribute, but no matching parent method exists. Class-body methods get the attribute stripped from the woven source, but a trait's attribute lives in the shared trait file and cannot be edited per class.

The change:

  • Guard: WeavingTransformer::assertNoTraitImportedOverride() runs in processSingleClass() before the proxy generators, which cannot see the case because native reflection hides the trait. It throws a WeavingException when all of these hold:
    • the intercepted method is a parser-reflection method;
    • it belongs to the woven class, so it gets an alias;
    • it is declared in a trait;
    • it carries #[\Override], compared by resolved name, so use Override as O is handled.
  • Message (same style as the by-reference limitation): Method Bug3\Child::hello() is imported from trait Bug3\HelloTrait and has #[\Override], so it can not be intercepted: PHP copies the attribute to the private "helloOriginalAlias" alias of the proxy, which overrides nothing. Exclude it from the pointcut with "&& !matchInherited()".
  • Covered: trait aliases (use T { foo as bar; }), static methods, enums, and woven traits that use another trait.
  • Not affected:
    • Trait methods a class inherits from its parent are still woven normally: the proxy calls parent::m(...) and creates no alias.
    • Class-body #[\Override] handling is unchanged.
  • Refactor: the #[\Override] check is extracted into isOverrideAttribute(), which the existing stripping code now shares.
  • Docs:
    • src/Instrument/AGENTS.md: the limitation and its workarounds.
    • docs/advices.md, Limitations: workarounds are && !matchInherited(), the narrower && !execution(public Foo->hello(*)), removing #[\Override] from the trait, or advising the trait itself.
    • docs/pointcuts.md: matchInherited() also covers members imported from a trait, which was always true but undocumented.
    • CHANGELOG: one line next to the by-reference entry.

Verification

Repro from #761 (Child extends Base { use HelloTrait; }, where the trait's hello() has #[\Override]):

before after
execution(public Bug3\Child->hello(*)) Fatal error: …helloOriginalAlias() has #[\Override] attribute, but no matching parent method exists WeavingException with the message above
same pointcut && !matchInherited() n/a loads; hello() returns trait, and an own method of Child is still advised
execution(public OwnOverrideTrait->hello(*)) (trait's own method) same fatal error loads, [advice] own-override-trait

Extra cases run on master and on this branch:

  • A trait alias, a static trait method, and a woven trait using another trait: all fail with the same fatal error on master, and all throw the clear exception here.
  • A trait method that arrives through the parent: woven and advised on both.

Tests

  • WeavingTransformerTest:
    • testWeaverRejectsTraitImportedMethodWithOverride, plus an …InEnum variant: the message names the class, the method, the trait, the alias and !matchInherited().
    • testWeaverInterceptsTraitImportedMethodWithOtherAttribute: a trait-imported method with a non-Override attribute is aliased and intercepted as usual.
    • testWeaverInterceptsTraitMethodWithOverrideInheritedFromParent: no exception, and the proxy calls parent::count(...) with no alias.
    • The existing testWeaverDoesNotStripAttributesOfTraitImportedMethods now advises only the own method, because it used to intercept exactly the rejected case.
    • Both rejection tests fail when the guard is disabled.
  • PointcutSemanticsTest: !matchInherited() excludes trait-imported methods on a parser-reflection class.
  • Functional TraitCompositionTest: fixtures Issue761Base, Issue761OverrideTrait and Issue761Child, with the pointcut …Issue761Child->*(*) && !matchInherited(). The class is woven and loads in a subprocess, its own method is advised, and the trait method is not.
  • Gates, run locally: full phpunit, phpstan level 10 and php-cs-fixer are green.
  • Transformation performance group (master vs head, 6 alternating runs, median ratios): WeavingTransformer 0.240 → 0.240, rewriter cases within noise. The compare script found no case more than 10% slower.

Woven trait with its own #[\Override] method — fixed here

Weaving a trait whose own intercepted method has #[\Override] used to fail the same way (OwnOverrideUser::helloOriginalAlias() has #[\Override]…): adjustOriginalTrait() kept the attribute while TraitProxyGenerator aliased the method. Here the attribute lives in the file being woven, so it is fixed properly instead of rejected:

  • The trait branch of processSingleClass() now calls stripOverrideAttributeFromInterceptedMethods() too (same line-preserving mechanism as classes and enums). The proxy trait method keeps #[\Override] (copied from the AST), so PHP still checks the override in the using class.
  • Tests: WeavingTransformerTest::testWeaverStripsOverrideAttributeFromInterceptedMethodsOfTrait (dynamic, static via use Override as OverrideAlias, attribute group; a non-intercepted method keeps the attribute; lines preserved; proxy methods keep #[\Override]) and functional TraitCompositionTest::testWovenTraitMethodWithOverrideIsIntercepted (subprocess prints advised:woven-trait). Both fail without the fix.
  • Repro: before PHP Fatal error: Scen\OwnOverrideUser::helloOriginalAlias() has #[\Override] attribute…, after [advice] own-override-trait.
  • Perf (previous head vs this, 6 interleaved runs, medians): WeavingTransformer 0.243 → 0.245 (+1.2%), rewriter default 0.097 → 0.098, all rules 1.341 → 1.325 — within noise.

Checklist

  • The title follows Conventional Commits (type(scope): summary)
  • Tests cover the change
  • composer check passes
  • User-facing or breaking changes are in CHANGELOG.md (and the upgrade guide for breaking changes)

🤖 Generated with Claude Code

https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c

…verride]

Intercepting a method that a class imports from a trait and that carries
#[\Override] produced a proxy that could not be loaded: PHP copies the
attribute to the private "<method>OriginalAlias" alias of the proxy, which
overrides nothing ("has #[\Override] attribute, but no matching parent
method exists"). The attribute lives in the shared trait file, so it can
not be stripped per class like it is for methods of the class body.

WeavingTransformer::processSingleClass() now checks the intercepted
dynamic and static methods right after indexing them, before any
generator runs (native reflection reports the using class as declaring
class, so the generators can not detect it). A method whose `class` is
the woven class, whose declaring class is a trait and whose node carries
#[\Override] fails with a WeavingException naming the class, the method
and the trait, with the hint to exclude it with "&& !matchInherited()".
This covers trait aliases, static methods, enums and woven traits using
another trait. Trait methods inherited through a parent class are called
through parent::m(...) without an alias and keep working. The #[\Override]
name check is shared with the class-body stripping (isOverrideAttribute()).

Documented the limitation and its workarounds (`!matchInherited()`, a
narrower `!execution(...)`, or dropping #[\Override] from the trait) in
src/Instrument/AGENTS.md, docs/advices.md and the CHANGELOG; docs/pointcuts.md
now says that matchInherited() also matches members imported from traits.

Transformation performance group, base vs head interleaved, 6 runs each,
median ratios: SyntaxTreeRewriter default rules 0.096 -> 0.098 (+2.8%,
path not touched), all rules 1.337 -> 1.339 (+0.2%), WeavingTransformer
0.240 -> 0.240 (-0.2%).

Closes #761

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.67%. Comparing base (4e95fdd) to head (a526037).

Additional details and impacted files
@@             Coverage Diff              @@
##             master     #763      +/-   ##
============================================
+ Coverage     97.63%   97.67%   +0.03%     
- Complexity     1829     1839      +10     
============================================
  Files           104      104              
  Lines          4829     4857      +28     
============================================
+ Hits           4715     4744      +29     
+ Misses          114      113       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lisachenko
lisachenko marked this pull request as ready for review October 9, 2026 22:32
claude added 2 commits October 9, 2026 22:43
…aits

Weaving a trait directly (a pointcut on the trait name) whose own
intercepted method carries #[\Override] produced a fatal error in every
class using it: the renamed original trait kept the attribute, the proxy
trait aliases the method (`hello as private helloOriginalAlias`) and PHP
copies #[\Override] to the alias, which overrides nothing.

Unlike a method imported from another trait, the attribute lives in the
file being woven, so the trait branch of processSingleClass() now strips
it with stripOverrideAttributeFromInterceptedMethods(), the mechanism
already used for classes and enums (lines are kept in place). The proxy
trait method keeps #[\Override], copied from the AST by MethodGenerator,
so PHP still checks the override in the using class. The rejection of
trait-imported methods with #[\Override] is unchanged.

Docs (src/Instrument/AGENTS.md, docs/advices.md, CHANGELOG) describe both
cases and mention advising the trait itself as a way to intercept such a
method.

Transformation performance group, previous branch head vs this commit,
interleaved, 6 runs each, median ratios: SyntaxTreeRewriter default rules
0.097 -> 0.098 (+1.0%), all rules 1.341 -> 1.325 (-1.2%),
WeavingTransformer 0.243 -> 0.245 (+1.2%).

Refs #761

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXjD75egSqDNujmNS5kr5c
@lisachenko lisachenko changed the title fix(weaving): reject interception of trait-imported methods with #[\Override] fix(weaving): handle #[\Override] on intercepted trait methods Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Intercepting a method imported from a trait with #[\Override] fails: OriginalAlias "has #[\Override] attribute, but no matching parent method exists"

2 participants