Skip to content

Repeat a shadowed class's interfaces on its stub shell - #6113

Merged
ondrejmirtes merged 3 commits into
2.2.xfrom
turbo-shadow-di-services
Jul 27, 2026
Merged

Repeat a shadowed class's interfaces on its stub shell#6113
ondrejmirtes merged 3 commits into
2.2.xfrom
turbo-shadow-di-services

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

The turbo extension's stub shells (final class Foo extends \PHPStanTurbo\Foo {}) only reproduced the inheritance of a shadowed class, never the rest of its type identity. Interfaces silently disappeared with the extension active.

That is live today: PhpParser\NodeTraverser implements NodeTraverserInterface, so with the extension on, $traverser instanceof NodeTraverserInterface was false. Nothing in phpstan-src or the vendored trees consumes that interface, so it never surfaced, but third-party code could hit it. The nastier variant would be a shadowed service losing the tags PHPStan's own autowiring derives from implementsInterface() — silent, not loud.

Repeat the interfaces on the stub shell

TurboAttributeCollector now emits the class's own implements clause, and require_onces the interface sources — the stubs are declared before the Composer autoloader registers, so nothing can autoload them at that point.

Once the shell implements an interface, the engine enforces variance against it, so reg::Class gained an optional return type (argInfo[0].type was hardcoded to "none"). NodeTraverser's three interface methods needed it.

A parent class cannot survive the same way — PHP is single-inheritance and the shell's one slot is spent on the native class — so the collector now rejects a shadowed class that has one instead of dropping it silently. It fails on composer dump-autoload and in side-by-side.php.

The DI-service rule was describing the previous mechanism

README design rule 6 said never to shadow a DI-service class, because getByType() normalizes requested types through reflection to the real class name. That is true of class_alias, which is what the extension used before the C++ rewrite — the stub shells are subclasses carrying the original name, so Helpers::normalizeClass() is a no-op on them.

Verified by shadowing a throwaway #[AutowiredService] with an autowired constructor dependency and an interface, compiling the container from scratch with the extension active: the service resolves, the dependency is injected, getByType() on the interface works, and analysis output stays byte-identical. What a shadowed service does require is a constructor arginfo declaring the real parameter class names — Nette autowires by reflecting __construct, and erased types fail container compilation for every shadowed service at once. Rule 6 now says that instead.

This matters because the classes worth absorbing next — NodeScopeResolver, TypeSpecifier — are DI services, and the rule as written walled them off.

Container cache key

Configurator::loadContainer() keyed on TurboExtensionEnabler::isLoaded(), true whenever the extension is present, including when enableIfLoaded() declined to declare the stubs on a version mismatch. A container compiled against the stub shells could then be reused by a run reflecting the PHP implementations. isActive() is the state the compiled container actually depends on.

Verification

side-by-side.php, smoke.php (ALL OK), signature-parity.php (74 methods), parser-corpus.php (7988 files, 0 failed), 427 tests green with the extension loaded, self-analysis clean and byte-identical with the extension on vs PHPSTAN_TURBO=0.

🤖 Generated with Claude Code

https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2

ondrejmirtes and others added 3 commits July 27, 2026 18:00
The generated stub only extended the native class, so every interface the
shadowed class declared disappeared with the extension active:
PhpParser\NodeTraverser stopped being an instanceof NodeTraverserInterface,
and a shadowed service would silently lose the tags PHPStan's own
autowiring derives from implementsInterface().

The shell now repeats the implements clause and requires the interface
sources itself, because it is declared before the Composer autoloader
registers. The engine then enforces variance against those interfaces, so
reg::Class can declare a method's return type — NodeTraverser's three
interface methods needed it.

A parent class cannot be preserved the same way, PHP being
single-inheritance, so the collector now rejects a shadowed class that has
one instead of dropping it silently.

The DI-service rule in the README described the class_alias approach the
C++ rewrite replaced: getByType() normalizes through reflection to the real
class name, which a stub subclass carrying the original name is. What a
shadowed service does require is a constructor arginfo declaring the real
parameter class names, since Nette autowires by reflecting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2
The key used TurboExtensionEnabler::isLoaded(), which is true whenever the
extension is present — including when enableIfLoaded() declined to declare
the stubs because the version did not match. A container compiled while the
stubs shadowed a service class would then be reused by a run reflecting the
PHP implementations instead. isActive() is the state the compiled container
actually depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2
@ondrejmirtes
ondrejmirtes merged commit 030b59f into 2.2.x Jul 27, 2026
736 of 741 checks passed
@ondrejmirtes
ondrejmirtes deleted the turbo-shadow-di-services branch July 27, 2026 16:16
@staabm

staabm commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

do we need a similar mechanic for Traits used by such classes?

@ondrejmirtes

Copy link
Copy Markdown
Member Author

It'd be nice to have class_uses to still respond truthfully with/without Turbo but it's much less common.

The problem is that before this commit, instanceof was broken for these interfaces.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants