Repeat a shadowed class's interfaces on its stub shell - #6113
Merged
Conversation
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
Contributor
|
do we need a similar mechanic for Traits used by such classes? |
Member
Author
|
It'd be nice to have The problem is that before this commit, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 NodeTraverserInterfacewas 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 fromimplementsInterface()— silent, not loud.Repeat the interfaces on the stub shell
TurboAttributeCollectornow emits the class's ownimplementsclause, andrequire_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::Classgained an optional return type (argInfo[0].typewas 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-autoloadand inside-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 ofclass_alias, which is what the extension used before the C++ rewrite — the stub shells are subclasses carrying the original name, soHelpers::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 onTurboExtensionEnabler::isLoaded(), true whenever the extension is present, including whenenableIfLoaded()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 vsPHPSTAN_TURBO=0.🤖 Generated with Claude Code
https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2