Register compiler extensions from attributes instead of an ordered config list#6102
Open
ondrejmirtes wants to merge 4 commits into
Open
Register compiler extensions from attributes instead of an ordered config list#6102ondrejmirtes wants to merge 4 commits into
ondrejmirtes wants to merge 4 commits into
Conversation
… lookups
processConstructorParameters() resolved `%foo%` references by reading
ContainerBuilder::$parameters during loadConfiguration(), baking the value into
the service definition. That made the extension order in config.neon
load-bearing: ValidateExcludePathsExtension rewrites $parameters['excludePaths']
in its own loadConfiguration() (unwrapping OptionalPath objects), so registering
autowiredAttributeServices before validateExcludePaths handed FileExcluderFactory
a list of OptionalPath objects instead of strings and blew up with a TypeError.
References are now compiled into `$this->getParameter('foo')` lookups that read
the final parameters at service creation time, so no extension can leave a stale
snapshot behind. This is the representation Nette already emits for dynamic
parameters - `%foo.bar%` becomes `$this->getParameter('foo')['bar']`,
`%foo%/suffix` becomes a concatenation, and FactoryDefinition rewrites `$this` to
`$this->container` inside generated factories. Parameter names cannot change
after loadConfiguration(), so a typo in a reference is still reported while
compiling the container.
Verified with 30 random permutations of the ten extensions in config.neon: all
produce byte-identical containers, where previously 15 of them died on the
excludePaths TypeError. Analysis output is unchanged.
Bumps NeonAdapter::CACHE_KEY because the container cache key does not hash
PHPStan's own sources, so warm caches would keep serving the old container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
…n are covered ValidateServiceTagsExtension checked the tags in beforeCompile(), but beforeCompile() hooks run in registration order, so it only ever saw tags added by extensions registered before it in config.neon. Two cases escaped validation entirely: ConditionalTagsExtension when the two were swapped, and every extension registered from a project config file - the compiler always processes those after PHPStan's own ones. A tag on a service not implementing the associated interface then surfaced during analysis as "Call to undefined method FileExcluderFactory::getNodeType()" instead of a message naming the tag and the interface. afterCompile() runs after every extension's beforeCompile(), and tags added at that point no longer reach the generated container, so it is the first moment the tag list is complete. With the check there, all 20 front/back positions of the ten extensions report the misconfiguration, and so does an extension registered from a project config. Validation now happens after ContainerBuilder::complete(). The test config registered a second autowired FileHelper, which makes DefaultStubFilesProvider's constructor ambiguous - previously irrelevant because the tag check threw before autowiring ran. Marking it autowired: false keeps the fixture focused on the tag. Also builds the ReflectionClass lazily: it was constructed for every definition, now only for those carrying a tag from the mapping. The container cache key was already bumped earlier on this branch, so no separate bump is needed to pick this up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
The ten extensions PHPStan registers for itself had to be listed in the `extensions:` section of conf/config.neon, in an order that mattered but was documented nowhere. They now carry #[ContainerExtension(name: '...')] and are registered by ContainerExtensionsExtension, so the section is gone. ContainerExtensionsExtension extends Nette's ExtensionsExtension and ContainerFactory installs it in the compiler's `extensions` slot. Both are required and neither is cosmetic: Compiler::processExtensions() runs loadConfiguration() on the extensions matching ParametersExtension or ExtensionsExtension by instanceof, then snapshots its extension list and rejects anything registered afterwards with "Extensions ... were added while container was being compiled". A plain CompilerExtension therefore cannot register extensions even from that slot, and a subclass registered through `extensions:` does not exist yet when the snapshot is taken. Occupying the slot also makes this class responsible for `extensions:` sections of configuration files - third-party extensions and conf/config.stubValidator.neon - which the parent implementation keeps handling. Extensions are registered ordered by name so the container does not depend on the order the attribute collector reports classes in. That order puts autowiredAttributeServices before validateExcludePaths, which was fatal until #[AutowiredParameter] references stopped snapshotting parameter values. The generated container is unchanged: all 600 createService() bodies are byte-identical to the ones built from the `extensions:` section, and reversing the registration order produces no difference either. Note that a project config listing an extension under a name PHPStan already uses now fails with "Name 'rules' is already used or reserved." instead of silently replacing PHPStan's own extension, and that adding or renaming a #[ContainerExtension] requires composer dump-autoload, like the other attributes. No NeonAdapter::CACHE_KEY bump needed: the container cache key hashes vendor/attributes.php, which gains the new attribute's targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
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
extensions:section ofconf/config.neonlisted ten compiler extensions whose order was load-bearing but documented nowhere. This removes the section, and first removes the reasons the order mattered.I permuted the ten extensions and compared the generated containers. Two orderings out of the pairs were genuinely load-bearing, and both are fixed here.
1.
#[AutowiredParameter]snapshotted parameter valuesprocessConstructorParameters()resolved%foo%by readingContainerBuilder::$parametersduringloadConfiguration()and baking the value into the definition.ValidateExcludePathsExtensionrewrites$parameters['excludePaths']in itsloadConfiguration()(unwrappingOptionalPathobjects), so registeringautowiredAttributeServicesfirst handedFileExcluderFactorya list ofOptionalPathobjects:References now compile to
$this->getParameter('foo')lookups that read the final parameters at service creation time — the representation Nette already emits for dynamic parameters, so%foo.bar%becomes$this->getParameter('foo')['bar'],%foo%/suffixbecomes a concatenation, andFactoryDefinitionrewrites$thisto$this->containerinside generated factories. Parameter names cannot change afterloadConfiguration(), so a typo in a reference is still reported while compiling.Over 30 random permutations: 15 died on that TypeError before, all 30 produce byte-identical containers after.
2. Service tags were validated too early
ValidateServiceTagsExtensionchecked tags inbeforeCompile(), but those hooks run in registration order, so it only saw tags added by extensions registered before it. Two cases escaped entirely:ConditionalTagsExtensionwhen the two were swapped, and every extension registered from a project config file — the compiler always processes those after PHPStan's own. A bad tag then surfaced during analysis asCall to undefined method PHPStan\File\FileExcluderFactory::getNodeType().Moving the check to
afterCompile()— after every extension'sbeforeCompile(), and still before the tags matter — makes all 20 front/back positions report it, and covers third-party extensions for the first time.The check now runs after
ContainerBuilder::complete(). The test config registered a second autowiredFileHelper, makingDefaultStubFilesProvider's constructor ambiguous; that used to be masked because validation threw before autowiring ran.autowired: falsekeeps the fixture focused on the tag.3. The list itself
The ten extensions now carry
#[ContainerExtension(name: '...')]and are registered byContainerExtensionsExtension, whichContainerFactoryinstalls in the compiler'sextensionsslot.Extending Nette's
ExtensionsExtensionand occupying that slot are both required.Compiler::processExtensions()runsloadConfiguration()on extensions matchingParametersExtensionorExtensionsExtensionby instanceof, then snapshots its extension list and rejects later additions withExtensions ... were added while container was being compiled. So a plainCompilerExtensioncannot register extensions even from that slot, and a subclass registered throughextensions:does not exist yet when the snapshot is taken. Occupying the slot also keepsextensions:sections working — third-party extensions andconf/config.stubValidator.neon— through the parent implementation.Registration is ordered by name so the container does not depend on the attribute collector's reporting order. That order happens to put
autowiredAttributeServicesbeforevalidateExcludePaths— the combination that was fatal before the first commit.Verification
createService()bodies byte-identical to those built from theextensions:section; reversing the registration order changes nothing either.bin/phpstan analyse -l 8 src/Analyser/TypeSpecifier.php src/Rules/Comparison --error-format=jsonbyte-identical to before.make phpstanclean; 379 tests acrossDependencyInjection,File,Command,Php,Parallelgreen;StubValidatorIntegrationTestgreen (it exercises both theextensions:path andStubValidatorRuleServicesExtension).Behavior changes worth a look
Name 'rules' is already used or reserved.instead of silently replacing PHPStan's own extension.complete().#[ContainerExtension]requirescomposer dump-autoload, like the other attributes.NeonAdapter::CACHE_KEYis bumped once, in the first commit.Not included
No test pins the order-independence itself — the only way to exercise it is to permute the
extensions:block, which no longer exists. Happy to add a unit test over the parameter-reference forms (%a%,%a.b%,%a%/suffix,%%, missing-parameter throw) if you want one.🤖 Generated with Claude Code
https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6