Skip to content

Introduce ExtensionsCollection and #[AutowiredExtensions], replace extension providers (by Claude Fable 5)#6098

Open
ondrejmirtes wants to merge 2 commits into
2.2.xfrom
fable-extensions-collection
Open

Introduce ExtensionsCollection and #[AutowiredExtensions], replace extension providers (by Claude Fable 5)#6098
ondrejmirtes wants to merge 2 commits into
2.2.xfrom
fable-extensions-collection

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Note

This PR was created by Claude Fable 5 in a Claude Code session driven by @ondrejmirtes.

Extension lookup by interface is now a first-class DI concept, replacing the hand-written provider layer.

What's new

  • Container::getExtensions(SomeExtension::class) and getExtensionsCollection() — grab all registered extensions by an interface marked with #[ExtensionInterface]. Baked into the container at compile time: AutowiredExtensionsExtension registers one LazyExtensionsCollection service per extension interface (prefixed service name derived from the FQCN), with the tag as a compile-time argument. No runtime attribute scanning; unknown interfaces throw a descriptive MissingServiceException.
  • #[AutowiredExtensions(of: SomeExtension::class)] above a constructor parameter typed ExtensionsCollection<T> injects the lazy collection. Wired in beforeCompile after type resolution, so it works for both attribute-registered and neon-registered services. The pass only touches definitions whose creator is the class itself — aliases (factory: @otherService) and method factories are skipped (covered by a regression test; TestCase.neon's currentPhpVersionSimpleParser alias found this the hard way).
  • ExtensionsCollection<T> with getAll(): list<T>, implemented by LazyExtensionsCollection (container + tag, memoized) and DirectExtensionsCollection (plain array, for tests).

Deleted (all non-@api)

  • List providers incl. Lazy/Direct variants: ParameterClosureThisExtensionProvider, ParameterClosureTypeExtensionProvider, ParameterOutTypeExtensionProvider, DynamicThrowTypeExtensionProvider, ReadWritePropertiesExtensionProvider, AlwaysUsedClassConstantsExtensionProvider, AlwaysUsedMethodExtensionProvider, IgnoreErrorExtensionProvider
  • Registry providers: DynamicReturnTypeExtensionRegistryProvider, ExpressionTypeResolverExtensionRegistryProvider, OperatorTypeSpecifyingExtensionRegistryProvider, UnaryOperatorTypeSpecifyingExtensionRegistryProvider — the registries survive as autowired services holding lazy collections and are injected directly
  • Tag constants moved from deleted providers onto the extension interfaces themselves (EXTENSION_TAG, referenced by their own #[ExtensionInterface] attribute); tag string values unchanged

Second commit: DeprecationProvider + every inline getServicesByTag() caller

  • DeprecationProvider injects seven lazy collections instead of the container
  • Container dropped where it existed only for a tag pull: the ten RestrictedUsage rules, InstantiationRule, ClassNameCheck, ClassForbiddenNameCheck, ConstructorsHelper, DefaultStubFilesProvider, LazyRegistry, Collectors\RegistryFactory, ResultCacheManager, RichParser
  • Container kept but calls typed via getExtensions(): TypeSpecifierFactory, PromoteParameterRule, ExprHandlerRegistry, AnalyseCommand/DiagnoseCommand, and the kept ClassReflection/TypeNodeResolver registry providers
  • AdditionalConstructorsExtension, ConstantDeprecationExtension, ForbiddenClassNameExtension now carry #[ExtensionInterface] — they declared tags but were missing from the interface-tag mapping, so their tagged services were never validated
  • The only remaining getServicesByTag() caller is StubValidator (its stub-validation rule tag is a second tag on Rule services; the one-to-one interface-tag mapping can't cover it)

Not converted (follow-ups)

ClassReflectionExtensionRegistryProvider (~30 call sites in ClassReflection plus the deliberate drop-the-container memory optimization) and TypeNodeResolverExtensionRegistryProvider (setTypeNodeResolver() back-injection cycle) keep their provider shape; only their tag pulls were typed.

Notes

  • composer-attribute-collector only scans src/, so #[AutowiredExtensions] is for PHPStan's own services; third-party extensions keep registering via tags, which are unchanged.
  • Api-rule fixtures now use ReflectionProviderProvider as the canonical non-@api interface specimen (was DynamicThrowTypeExtensionProvider).
  • Verified: full test suite (17 676 tests), make phpstan, make cs, make lint all green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NPozex9wKR84GgzFQ6Vx5o

ondrejmirtes and others added 2 commits July 25, 2026 19:21
…:getExtensionsCollection(), replace extension providers

Every interface marked with #[ExtensionInterface] now gets a compiled
LazyExtensionsCollection service registered by AutowiredExtensionsExtension.
Extensions can be looked up by interface name via
Container::getExtensionsCollection(), and injected into constructors with
#[AutowiredExtensions(of: SomeExtension::class)] above an
ExtensionsCollection<T> parameter. Tests substitute extensions with
DirectExtensionsCollection.

This replaces the hand-written provider layer whose only job was lazily
pulling tagged services out of the container:

- ParameterClosureThisExtensionProvider + Lazy
- ParameterClosureTypeExtensionProvider + Lazy
- ParameterOutTypeExtensionProvider + Lazy
- DynamicThrowTypeExtensionProvider + Lazy
- ReadWritePropertiesExtensionProvider + Lazy + Direct
- AlwaysUsedClassConstantsExtensionProvider + Lazy + Direct
- AlwaysUsedMethodExtensionProvider + Lazy + Direct
- IgnoreErrorExtensionProvider
- DynamicReturnTypeExtensionRegistryProvider + Lazy
- ExpressionTypeResolverExtensionRegistryProvider + Lazy
- OperatorTypeSpecifyingExtensionRegistryProvider + Lazy
- UnaryOperatorTypeSpecifyingExtensionRegistryProvider + Lazy

The four *Registry classes stay but are now autowired services holding
lazy collections, so they are injected directly. Tag constants moved from
the deleted providers onto the extension interfaces themselves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPozex9wKR84GgzFQ6Vx5o
… to extension collections

Services whose Container dependency existed only to pull a tagged extension
list now inject an ExtensionsCollection via #[AutowiredExtensions]:
DeprecationProvider (seven deprecation extension collections), the ten
RestrictedUsage rules, InstantiationRule, ClassNameCheck,
ClassForbiddenNameCheck, ConstructorsHelper, DefaultStubFilesProvider,
LazyRegistry, Collectors\RegistryFactory, ResultCacheManager and RichParser.

Callers that keep the Container for other reasons (TypeSpecifierFactory,
PromoteParameterRule, ExprHandlerRegistry, AnalyseCommand, DiagnoseCommand,
and the kept ClassReflection/TypeNodeResolver registry providers) switch
from getServicesByTag() to the typed Container::getExtensions().

AdditionalConstructorsExtension, ConstantDeprecationExtension and
ForbiddenClassNameExtension now carry #[ExtensionInterface] - they declared
service tags but were missing from the interface-tag mapping, so their
tagged services were never validated.

AutowiredExtensionsExtension now skips definitions whose creator is not the
class itself - a service aliasing another (factory: @otherService), like
TestCase.neon's currentPhpVersionSimpleParser, resolves getType() to the
aliased class and must not receive constructor arguments.

The only remaining getServicesByTag() caller is StubValidator, whose
stub-validation rule tag is a second tag on Rule services and cannot appear
in the one-to-one interface-tag mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPozex9wKR84GgzFQ6Vx5o
@ondrejmirtes
ondrejmirtes force-pushed the fable-extensions-collection branch from b1cdebd to 0ac067c Compare July 25, 2026 18:27
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.

1 participant