Skip to content

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

Closed
ondrejmirtes wants to merge 4 commits into
2.2.xfrom
opus-extensions-collection
Closed

Introduce ExtensionsCollection and #[AutowiredExtensions], replace extension providers (by Claude Opus 5)#6099
ondrejmirtes wants to merge 4 commits into
2.2.xfrom
opus-extensions-collection

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Built by Claude Opus 5. This is an alternative implementation of the same feature as #6098 (built by Claude Fable 5) — see Differences from #6098 at the bottom before picking one.

Since #6091 every extension interface declares its own service tag via #[ExtensionInterface(tag: ...)], so the complete interface => tag map is derivable. That makes a whole family of classes redundant: whenever a service needed "all extensions of type X" it either took a Container and called getServicesByTag(SomeExtension::EXTENSION_TAG) (~25 call sites), or went through a hand-written *ExtensionProvider whose only jobs were deferring the lookup so the container doesn't hit a circular reference, and offering a Direct* twin for tests.

The mechanism

Container::getExtensions() looks extensions up by interface:

/**
 * @template T of object
 * @param class-string<T> $interfaceName
 * @return list<T>
 */
public function getExtensions(string $interfaceName): array;

The #[ExtensionInterface] map is baked into the compiled container through a small ExtensionInterfaceTags service, so vendor/attributes.php is not needed at runtime. MemoizingContainer memoizes per interface.

Services declare what they need on the constructor parameter:

/**
 * @param ExtensionsCollection<ReadWritePropertiesExtension> $extensions
 */
public function __construct(
	#[AutowiredExtensions(interface: ReadWritePropertiesExtension::class)]
	private ExtensionsCollection $extensions,
) {}

AutowiredAttributeServicesExtension::beforeCompile() wires those parameters. It has to be beforeCompile() and not loadConfiguration(): Nette registers NEON services: after every other extension's loadConfiguration(), so NEON-registered consumers (RichParser in conf/parsers.neon) are not in the builder yet at that point. ContainerBuilder::resolve() runs before beforeCompile() and argument autowiring runs after, so setting arguments there is safe.

No extra service definitions are registered. The argument becomes an inline Statement, which Nette renders at the use site:

public function createService0258(): PHPStan\Analyser\FileAnalyser
{
	return new PHPStan\Analyser\FileAnalyser(
		...
		new PHPStan\DependencyInjection\LazyExtensionsCollection(
			$this->getService('0434'),
			'PHPStan\Analyser\IgnoreErrorExtension'
		),
		...
	);
}

LazyExtensionsCollection resolves on the first getAll() — so the deferral the Lazy* providers existed for is preserved — and then nulls its Container reference, the trick LazyClassReflectionExtensionRegistryProvider does by hand today. Long-lived holders like ClassPropertiesNode therefore don't become transitive handles on the whole DI container, which means "holds the container" is no longer an argument against replacing a provider with a collection.

DirectExtensionsCollection is the test seam that Direct*ExtensionProvider used to be. RuleTestCase::getReadWritePropertiesExtensions() keeps its array signature; only the wrapping changed.

Passing an interface without #[ExtensionInterface] throws NotAnExtensionInterfaceException during container compilation.

Deleted

All eight extension-list providers with their Direct/Lazy twins:

ReadWritePropertiesExtensionProvider, AlwaysUsedClassConstantsExtensionProvider, AlwaysUsedMethodExtensionProvider, IgnoreErrorExtensionProvider, ParameterClosureThisExtensionProvider, ParameterClosureTypeExtensionProvider, ParameterOutTypeExtensionProvider, DynamicThrowTypeExtensionProvider

…and four registry providers, whose registries became #[AutowiredService]s holding collections:

ExpressionTypeResolverExtensionRegistryProvider, OperatorTypeSpecifyingExtensionRegistryProvider, UnaryOperatorTypeSpecifyingExtensionRegistryProvider, DynamicReturnTypeExtensionRegistryProvider

26 files gone, 6 added. The service tag constants they carried moved onto the extension interfaces themselves, next to the #[ExtensionInterface] attribute that references them.

The ~25 inline getServicesByTag() call sites are migrated too — the 10 RestrictedUsage rules, ClassNameCheck, ClassForbiddenNameCheck, InstantiationRule, ConstructorsHelper, DeprecationProvider (7 collections), DefaultStubFilesProvider, LazyRegistry, Collectors\RegistryFactory, ResultCacheManager, RichParser all drop their Container and their memo fields. Where the Container is still needed for other reasons (TypeSpecifierFactory, PromoteParameterRule, ExprHandlerRegistry's static dispatch, the diagnose commands) the tag lookup becomes the typed Container::getExtensions().

ForbiddenClassNameExtension, AdditionalConstructorsExtension and ConstantDeprecationExtension were missing #[ExtensionInterface] and get it here.

Kept, on purpose

TypeNodeResolverExtensionRegistryProvider and ClassReflectionExtensionRegistryProvider both break a real cycle — their registries need TypeNodeResolver and PhpClassReflectionExtension, which reach back to the registry through ReflectionProvider/ClassReflection. Only their tag pulls are typed now.

The single remaining getServicesByTag() caller is StubValidator: phpstan.stubValidator.rule is a second tag on Rule, which a 1:1 interface→tag map cannot express.

Notable trap

#[AutowiredExtensions] arguments are only set on definitions that instantiate the class themselves. src/Testing/TestCase.neon has

currentPhpVersionSimpleParser!:
	factory: @currentPhpVersionRichParser

whose resolved type is RichParser, so a naive type match sets the argument on the alias too and Nette generates $this->createServiceCurrentPhpVersionRichParser(visitors: ...) — "Unknown named parameter $visitors" at runtime, in every test. The guard is that the creator entity has to be the class itself.

Differences from #6098

Both branches were built independently from a7dfaa7a50 and reached the same scope, the same alias trap, the same three missing #[ExtensionInterface] attributes and the same two kept providers. Where they differ:

Verification

make phpstan, make cs, make tests (17678 tests) and make tests-integration are green. New tests/PHPStan/DependencyInjection/ExtensionsCollectionTest.php covers getExtensions() against the equivalent getServicesByTag() result, the unknown-interface error, the container release, and that a real #[AutowiredExtensions] consumer resolves from the container.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XLPJxFJDzLPsieqEwJKpkn

ondrejmirtes and others added 2 commits July 24, 2026 22:26
Container::getExtensions() looks up extensions by their interface instead of
by a service tag. The #[ExtensionInterface] mapping is baked into the compiled
container through the new ExtensionInterfaceTags service, so vendor/attributes.php
is not needed at runtime.

Services ask for extensions with #[AutowiredExtensions] above a constructor
parameter typed as ExtensionsCollection. AutowiredAttributeServicesExtension
wires those parameters in beforeCompile() - services from NEON files are only
in the builder at that point - with an inline LazyExtensionsCollection, which
keeps the resolution deferred the way the Lazy*ExtensionProvider classes did.
LazyExtensionsCollection releases its Container reference once the extensions
are resolved so that long-lived holders don't retain the whole container.

IgnoreErrorExtensionProvider is the first one replaced by this. ForbiddenClassNameExtension,
AdditionalConstructorsExtension and ConstantDeprecationExtension were missing
#[ExtensionInterface] and get it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLPJxFJDzLPsieqEwJKpkn
The Direct*/Lazy* provider pairs existed only to defer getServicesByTag()
past container construction and to let tests hand extensions in directly -
both of which ExtensionsCollection now does. Deleted:

- ReadWritePropertiesExtensionProvider
- AlwaysUsedClassConstantsExtensionProvider
- AlwaysUsedMethodExtensionProvider
- ParameterClosureThisExtensionProvider
- ParameterClosureTypeExtensionProvider
- ParameterOutTypeExtensionProvider
- DynamicThrowTypeExtensionProvider

The service tag constants they carried move onto the extension interfaces
themselves, next to the #[ExtensionInterface] attribute that references them.
Providers with several getters (three per parameter/throw-type family) become
one ExtensionsCollection parameter each, so consumers no longer go through
a lookup method to reach a single list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLPJxFJDzLPsieqEwJKpkn
@ondrejmirtes
ondrejmirtes force-pushed the opus-extensions-collection branch from 315ad13 to ee2a4dc Compare July 25, 2026 17:57
ondrejmirtes and others added 2 commits July 25, 2026 19:21
Rules, checks and factories that held a Container only to call
getServicesByTag() now declare what they need with #[AutowiredExtensions],
which also drops their per-class memo fields and the tag constants from
their bodies. Where the Container is still needed for other reasons
(TypeSpecifierFactory, PromoteParameterRule, ExprHandlerRegistry's static
dispatch, the diagnose commands) the tag lookup becomes the typed
Container::getExtensions().

#[AutowiredExtensions] arguments are only set on definitions that
instantiate the class themselves - an alias like TestCase.neon's
currentPhpVersionSimpleParser delegates to another service's creator, where
the extra argument would become an unknown named parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLPJxFJDzLPsieqEwJKpkn
ExpressionTypeResolverExtensionRegistry, OperatorTypeSpecifyingExtensionRegistry,
UnaryOperatorTypeSpecifyingExtensionRegistry and DynamicReturnTypeExtensionRegistry
take ExtensionsCollection instead of plain arrays, which makes them cheap enough
to be #[AutowiredService]s that consumers depend on directly. Their
*RegistryProvider interfaces and Lazy* implementations are gone.

Two providers stay: TypeNodeResolverExtensionRegistryProvider and
ClassReflectionExtensionRegistryProvider both break a real cycle - their
registries need TypeNodeResolver and PhpClassReflectionExtension, which reach
back to the registry through ReflectionProvider/ClassReflection. Their tag
lookups still switch to Container::getExtensions().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLPJxFJDzLPsieqEwJKpkn
@ondrejmirtes
ondrejmirtes force-pushed the opus-extensions-collection branch from ee2a4dc to baa2e46 Compare July 25, 2026 18:27
@ondrejmirtes

Copy link
Copy Markdown
Member Author

Closing in favour of #6098 — we're taking the Fable implementation forward.

Both PRs implemented the same feature independently and reached the same conclusions on scope (same alias trap, same three missing #[ExtensionInterface] attributes, same two providers kept). The design differences were:

Ideas from this PR that carried over to #6098: deleting Collectors\\RegistryFactory and having Collectors\\Registry take an ExtensionsCollection directly.

@ondrejmirtes
ondrejmirtes deleted the opus-extensions-collection branch July 25, 2026 18:31
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