From 4c4c6cde3cacec413f23dc6c208971955152a258 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 26 Aug 2026 22:48:39 +0200 Subject: [PATCH 1/6] Mark extension-usable DI attributes @api and enforce the promise on attribute usage AutowiredService, NonAutowiredService, RegisteredRule, RegisteredCollector, GenerateFactory and AutowiredParameter are the attributes third parties will be able to use through attributeServicesDirectories. The new ApiAttributeRule listens on every attribute position and reports non-@api PHPStan attributes used outside PHPStan code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- .../AutowiredParameter.php | 6 ++ src/DependencyInjection/AutowiredService.php | 5 ++ src/DependencyInjection/GenerateFactory.php | 5 ++ .../NonAutowiredService.php | 7 +- .../RegisteredCollector.php | 8 ++ src/DependencyInjection/RegisteredRule.php | 9 ++ src/Rules/Api/ApiAttributeRule.php | 69 ++++++++++++++++ .../Rules/Api/ApiAttributeRuleTest.php | 51 ++++++++++++ .../Rules/Api/data/attribute-in-phpstan.php | 27 ++++++ .../Api/data/attribute-out-of-phpstan.php | 82 +++++++++++++++++++ 10 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/Rules/Api/ApiAttributeRule.php create mode 100644 tests/PHPStan/Rules/Api/ApiAttributeRuleTest.php create mode 100644 tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php create mode 100644 tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php diff --git a/src/DependencyInjection/AutowiredParameter.php b/src/DependencyInjection/AutowiredParameter.php index d8547af2f86..8926f493434 100644 --- a/src/DependencyInjection/AutowiredParameter.php +++ b/src/DependencyInjection/AutowiredParameter.php @@ -12,6 +12,12 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on constructor parameters + * of classes in directories listed in the `attributeServicesDirectories` section + * of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_PARAMETER)] final class AutowiredParameter diff --git a/src/DependencyInjection/AutowiredService.php b/src/DependencyInjection/AutowiredService.php index 85778b23fa4..da8f2715af7 100644 --- a/src/DependencyInjection/AutowiredService.php +++ b/src/DependencyInjection/AutowiredService.php @@ -13,6 +13,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class AutowiredService diff --git a/src/DependencyInjection/GenerateFactory.php b/src/DependencyInjection/GenerateFactory.php index c4b6a59ce03..db7578708a5 100644 --- a/src/DependencyInjection/GenerateFactory.php +++ b/src/DependencyInjection/GenerateFactory.php @@ -15,6 +15,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class GenerateFactory diff --git a/src/DependencyInjection/NonAutowiredService.php b/src/DependencyInjection/NonAutowiredService.php index d9891975027..08c6a840d46 100644 --- a/src/DependencyInjection/NonAutowiredService.php +++ b/src/DependencyInjection/NonAutowiredService.php @@ -6,9 +6,14 @@ /** * Registers a non-autowired named service in the DI container. - + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class NonAutowiredService diff --git a/src/DependencyInjection/RegisteredCollector.php b/src/DependencyInjection/RegisteredCollector.php index 42af140bfcf..eff1f04d86e 100644 --- a/src/DependencyInjection/RegisteredCollector.php +++ b/src/DependencyInjection/RegisteredCollector.php @@ -7,8 +7,16 @@ /** * Registers a collector in the DI container on the set rule level. * + * The collector is active when the analysis runs on this level or higher. + * Level 0 means the collector is always active. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredCollector diff --git a/src/DependencyInjection/RegisteredRule.php b/src/DependencyInjection/RegisteredRule.php index 3bb80348d50..25fc94a0d10 100644 --- a/src/DependencyInjection/RegisteredRule.php +++ b/src/DependencyInjection/RegisteredRule.php @@ -7,8 +7,17 @@ /** * Registers a rule in the DI container on the set rule level. * + * The rule is active when the analysis runs on this level or higher. + * Level 0 means the rule is always active - the equivalent + * of registering the rule in the `rules` section. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredRule diff --git a/src/Rules/Api/ApiAttributeRule.php b/src/Rules/Api/ApiAttributeRule.php new file mode 100644 index 00000000000..5ecc00ade4d --- /dev/null +++ b/src/Rules/Api/ApiAttributeRule.php @@ -0,0 +1,69 @@ + + */ +#[RegisteredRule(level: 0)] +final class ApiAttributeRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Attribute::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $attributeClassName = $scope->resolveName($node->name); + if (!$this->reflectionProvider->hasClass($attributeClassName)) { + return []; + } + + $attributeClassReflection = $this->reflectionProvider->getClass($attributeClassName); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $attributeClassReflection->getName(), $attributeClassReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Using attribute %s is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + $attributeClassReflection->getDisplayName(), + ))->identifier('phpstanApi.attribute')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + $docBlock = $attributeClassReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/tests/PHPStan/Rules/Api/ApiAttributeRuleTest.php b/tests/PHPStan/Rules/Api/ApiAttributeRuleTest.php new file mode 100644 index 00000000000..1aca4025049 --- /dev/null +++ b/tests/PHPStan/Rules/Api/ApiAttributeRuleTest.php @@ -0,0 +1,51 @@ + + */ +class ApiAttributeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ApiAttributeRule(new ApiRuleHelper(), self::createReflectionProvider()); + } + + public function testRuleInPhpStan(): void + { + $this->analyse([__DIR__ . '/data/attribute-in-phpstan.php'], []); + } + + public function testRuleOutOfPhpStan(): void + { + $tip = sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ); + + $this->analyse([__DIR__ . '/data/attribute-out-of-phpstan.php'], [ + [ + 'Using attribute PHPStan\DependencyInjection\AutowiredExtensions is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 23, + $tip, + ], + [ + 'Using attribute PHPStan\DependencyInjection\ContainerExtension is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 60, + $tip, + ], + [ + 'Using attribute PHPStan\DependencyInjection\ValidatesStubFiles is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 66, + $tip, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php b/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php new file mode 100644 index 00000000000..26ec970e7ad --- /dev/null +++ b/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php @@ -0,0 +1,27 @@ += 8.0 + +namespace PHPStan\Fixture\ApiAttribute; + +use PHPStan\DependencyInjection\AutowiredExtensions; +use PHPStan\DependencyInjection\ContainerExtension; +use PHPStan\DependencyInjection\ValidatesStubFiles; +use PHPStan\Rules\Rule; + +#[ContainerExtension(name: 'inPhpStan')] +class MyExtension +{ + +} + +#[ValidatesStubFiles] +class MyStubRule +{ + + public function __construct( + #[AutowiredExtensions(of: Rule::class)] + private mixed $rules, + ) + { + } + +} diff --git a/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php new file mode 100644 index 00000000000..be6bc6427b4 --- /dev/null +++ b/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php @@ -0,0 +1,82 @@ += 8.0 + +namespace AppAttribute; + +use PHPStan\DependencyInjection\AutowiredExtensions; +use PHPStan\DependencyInjection\AutowiredParameter; +use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\ContainerExtension; +use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\DependencyInjection\NonAutowiredService; +use PHPStan\DependencyInjection\RegisteredCollector; +use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\DependencyInjection\ValidatesStubFiles; +use PHPStan\Rules\Rule; + +#[AutowiredService] +class MyService +{ + + public function __construct( + #[AutowiredParameter(ref: '%tmpDir%')] + private string $tmpDir, + #[AutowiredExtensions(of: Rule::class)] + private mixed $rules, + ) + { + } + +} + +#[NonAutowiredService(name: 'appAttribute.service')] +class MyNamedService +{ + +} + +#[RegisteredRule(level: 0)] +class MyRule +{ + +} + +#[RegisteredCollector(level: 5)] +class MyCollector +{ + +} + +interface MyFactory +{ + +} + +#[GenerateFactory(interface: MyFactory::class)] +class MyResult +{ + +} + +#[ContainerExtension(name: 'appAttribute')] +class MyExtension +{ + +} + +#[ValidatesStubFiles] +class MyStubRule +{ + +} + +#[\Attribute] +class MyCustomAttribute +{ + +} + +#[MyCustomAttribute] +class UsesCustom +{ + +} From 856834199ae7a9ffb8cf5220a3a0544fd258e408 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 26 Aug 2026 22:51:56 +0200 Subject: [PATCH 2/6] Extract attribute-service registration into a reusable AttributeServices seam AttributeServicesRegistrar owns the target-to-definition logic and AttributeTargetsProvider is the single merged view over vendor/attributes.php and (soon) targets discovered in attributeServicesDirectories. No behavior change; DiscoveredAttributeTargets is empty until discovery lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- phpstan-baseline.neon | 2 +- .../AttributeServicesRegistrar.php | 262 ++++++++++++++++++ .../AttributeTargetsProvider.php | 93 +++++++ .../DiscoveredAttributeTargets.php | 31 +++ .../AutowiredAttributeServicesExtension.php | 239 +--------------- .../StubValidatorRuleServicesExtension.php | 23 +- 6 files changed, 400 insertions(+), 250 deletions(-) create mode 100644 src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php create mode 100644 src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php create mode 100644 src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fef664d8b33..9ac8ed89850 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -208,7 +208,7 @@ parameters: rawMessage: 'Call to static method escape() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass count: 1 - path: src/DependencyInjection/AutowiredAttributeServicesExtension.php + path: src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php new file mode 100644 index 00000000000..0cbea0b0062 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -0,0 +1,262 @@ +findTargetClasses(AutowiredService::class) as $class) { + $reflection = new ReflectionClass($class->name); + $attribute = $class->attribute; + + $definition = $builder->addDefinition($attribute->name) + ->setType($class->name) + ->setAutowired($attribute->as); + + if ($attribute->factory !== null) { + [$ref, $method] = explode('::', $attribute->factory); + $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + } + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + + if (!$attribute->autoTag) { + continue; + } + + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + if (!$reflection->implementsInterface($interface)) { + continue; + } + + $definition->addTag($tag); + } + } + + foreach ($targets->findTargetClasses(NonAutowiredService::class) as $class) { + $attribute = $class->attribute; + + $definition = $builder->addDefinition($attribute->name) + ->setType($class->name) + ->setAutowired(false); + + if ($attribute->factory !== null) { + [$ref, $method] = explode('::', $attribute->factory); + $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + } + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + + foreach ($targets->findTargetClasses(GenerateFactory::class) as $class) { + $attribute = $class->attribute; + $definition = $builder->addFactoryDefinition(null) + ->setImplement($attribute->interface); + + if ($attribute->resultType !== null) { + $definition->getResultDefinition()->setType($attribute->resultType); + } + + $resultDefinition = $definition->getResultDefinition(); + self::processConstructorParameters($builder, $class->name, $resultDefinition, $constructorParameters); + } + + if ($level === null) { + return; + } + + foreach ($targets->findTargetClasses(RegisteredRule::class) as $class) { + $attribute = $class->attribute; + if ($attribute->level > $level) { + continue; + } + + $definition = $builder->addDefinition(null) + ->setFactory($class->name) + ->setAutowired($class->name) + ->addTag(LazyRegistry::RULE_TAG); + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + + foreach ($targets->findTargetClasses(RegisteredCollector::class) as $class) { + $attribute = $class->attribute; + if ($attribute->level > $level) { + continue; + } + + $definition = $builder->addDefinition(null) + ->setFactory($class->name) + ->setAutowired($class->name) + ->addTag(RegistryFactory::COLLECTOR_TAG); + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + } + + /** + * @return array>> + */ + public static function collectConstructorParameters(AttributeTargetsProvider $targets): array + { + $constructorParameters = []; + foreach ($targets->findTargetMethodParameters(AutowiredParameter::class) as $parameter) { + if (strcasecmp($parameter->method, '__construct') !== 0) { + continue; + } + $lowerClass = strtolower($parameter->class); + $constructorParameters[$lowerClass] ??= []; + $constructorParameters[$lowerClass][] = $parameter; + } + + return $constructorParameters; + } + + /** + * @param class-string $className + * @param array>> $constructorParameters + */ + public static function processConstructorParameters(ContainerBuilder $builder, string $className, ServiceDefinition $definition, array $constructorParameters): void + { + foreach ($constructorParameters[strtolower($className)] ?? [] as $autowiredParameter) { + $ref = $autowiredParameter->attribute->ref; + if ($ref === null) { + $argument = self::createDeferredParameter($builder, '%' . Helpers::escape($autowiredParameter->name) . '%'); + } elseif (Strings::match($ref, '#^@[\w\\\\]+$#D') !== null) { + $argument = new Reference(substr($ref, 1)); + } else { + $argument = self::createDeferredParameter($builder, $ref); + } + $definition->setArgument($autowiredParameter->name, $argument); + } + } + + /** + * Turns a `%foo%` reference into a deferred `$this->getParameter('foo')` lookup instead of reading + * ContainerBuilder::$parameters right now. Extensions registered after this one still rewrite the + * parameters during loadConfiguration() - ValidateExcludePathsExtension unwraps OptionalPath objects + * in `excludePaths` - and a value snapshotted here would keep the pre-rewrite contents. + * + * `%foo.bar%` becomes `$this->getParameter('foo')['bar']` and `%foo%/suffix` becomes a concatenation, + * mirroring how Nette itself compiles references to dynamic parameters. + * + * @return PhpLiteral|string + * @throws ShouldNotHappenException when the reference points at a parameter that does not exist + */ + private static function createDeferredParameter(ContainerBuilder $builder, string $ref) + { + $parts = preg_split('#%([\w.-]*)%#', $ref, flags: PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + throw new ShouldNotHappenException(); + } + + $dumper = new Dumper(); + $lookups = []; + $pieces = []; + $withoutReferences = ''; + foreach ($parts as $i => $part) { + if ($i % 2 === 0) { + if ($part !== '') { + $pieces[] = $dumper->dump($part); + $withoutReferences .= $part; + } + continue; + } + + if ($part === '') { + // '%%' is an escaped percent sign + $pieces[] = $dumper->dump('%'); + $withoutReferences .= '%'; + continue; + } + + $keys = explode('.', $part); + self::checkParameterExists($builder, $part, $keys); + + $code = $dumper->format('$this->getParameter(?)', $keys[0]); + foreach (array_slice($keys, 1) as $key) { + $code .= sprintf('[%s]', $dumper->dump($key)); + } + + $lookups[] = $code; + $pieces[] = sprintf('(%s)', $code); + } + + if (count($lookups) === 0) { + return $withoutReferences; + } + + if (count($pieces) === 1) { + // the reference is the whole value, no string coercion + return ContainerBuilder::literal($lookups[0]); + } + + return ContainerBuilder::literal(implode(' . ', $pieces)); + } + + /** + * The values are resolved at runtime but the keys never change after loadConfiguration(), + * so a typo in a reference is still caught while compiling the container. + * + * @param non-empty-list $keys + * @throws ShouldNotHappenException + */ + private static function checkParameterExists(ContainerBuilder $builder, string $ref, array $keys): void + { + $value = $builder->parameters; + foreach ($keys as $key) { + if (!is_array($value)) { + // a dynamic parameter - cannot be traversed while compiling + return; + } + if (!array_key_exists($key, $value)) { + throw new ShouldNotHappenException(sprintf("Missing parameter '%s'.", $ref)); + } + + $value = $value[$key]; + } + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php new file mode 100644 index 00000000000..46bb9d721aa --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php @@ -0,0 +1,93 @@ + $attributeClass + * @return list> + */ + public function findTargetClasses(string $attributeClass): array + { + $targets = array_values(Attributes::findTargetClasses($attributeClass)); + $knownClasses = []; + foreach ($targets as $target) { + $knownClasses[strtolower($target->name)] = true; + } + + foreach ($this->discoveredTargets->targetClasses[$attributeClass] ?? [] as $target) { + if (array_key_exists(strtolower($target->name), $knownClasses)) { + continue; + } + + /** @var TargetClass $target */ + $targets[] = $target; + } + + return $targets; + } + + /** + * @template T of object + * @param class-string $attributeClass + * @return list> + */ + public function findTargetMethodParameters(string $attributeClass): array + { + $targets = array_values(Attributes::findTargetMethodParameters($attributeClass)); + $knownParameters = []; + foreach ($targets as $target) { + $knownParameters[self::getParameterKey($target)] = true; + } + + foreach ($this->discoveredTargets->targetMethodParameters[$attributeClass] ?? [] as $target) { + if (array_key_exists(self::getParameterKey($target), $knownParameters)) { + continue; + } + + /** @var TargetMethodParameter $target */ + $targets[] = $target; + } + + return $targets; + } + + /** + * @param TargetMethodParameter $target + */ + private static function getParameterKey(TargetMethodParameter $target): string + { + return sprintf('%s::%s $%s', strtolower($target->class), strtolower($target->method), $target->name); + } + +} diff --git a/src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php b/src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php new file mode 100644 index 00000000000..98e9b97377d --- /dev/null +++ b/src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php @@ -0,0 +1,31 @@ +>> $targetClasses + * @param array>> $targetMethodParameters + */ + public function __construct( + public array $targetClasses, + public array $targetMethodParameters, + ) + { + } + + public static function createEmpty(): self + { + return new self([], []); + } + +} diff --git a/src/DependencyInjection/AutowiredAttributeServicesExtension.php b/src/DependencyInjection/AutowiredAttributeServicesExtension.php index 6f71bd6b86a..6e17df6e622 100644 --- a/src/DependencyInjection/AutowiredAttributeServicesExtension.php +++ b/src/DependencyInjection/AutowiredAttributeServicesExtension.php @@ -3,36 +3,12 @@ namespace PHPStan\DependencyInjection; use Nette\DI\CompilerExtension; -use Nette\DI\ContainerBuilder; -use Nette\DI\Definitions\Reference; -use Nette\DI\Definitions\ServiceDefinition; -use Nette\DI\Definitions\Statement; -use Nette\DI\Helpers; -use Nette\PhpGenerator\Dumper; -use Nette\PhpGenerator\PhpLiteral; use Nette\Schema\Expect; use Nette\Schema\Schema; -use Nette\Utils\Strings; -use olvlvl\ComposerAttributeCollector\Attributes; -use olvlvl\ComposerAttributeCollector\TargetMethodParameter; use Override; -use PHPStan\Collectors\RegistryFactory; -use PHPStan\Rules\LazyRegistry; -use PHPStan\ShouldNotHappenException; -use ReflectionClass; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesRegistrar; +use PHPStan\DependencyInjection\AttributeServices\AttributeTargetsProvider; use stdClass; -use function array_key_exists; -use function array_slice; -use function count; -use function explode; -use function implode; -use function is_array; -use function preg_split; -use function sprintf; -use function strcasecmp; -use function strtolower; -use function substr; -use const PREG_SPLIT_DELIM_CAPTURE; #[ContainerExtension(name: 'autowiredAttributeServices')] final class AutowiredAttributeServicesExtension extends CompilerExtension @@ -49,214 +25,13 @@ public function getConfigSchema(): Schema #[Override] public function loadConfiguration(): void { - require_once __DIR__ . '/../../vendor/attributes.php'; - $builder = $this->getContainerBuilder(); - - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); - $constructorParameters = []; - foreach ($autowiredParameters as $parameter) { - if (strcasecmp($parameter->method, '__construct') !== 0) { - continue; - } - $lowerClass = strtolower($parameter->class); - $constructorParameters[$lowerClass] ??= []; - $constructorParameters[$lowerClass][] = $parameter; - } - - foreach (Attributes::findTargetClasses(AutowiredService::class) as $class) { - $reflection = new ReflectionClass($class->name); - $attribute = $class->attribute; - - $definition = $builder->addDefinition($attribute->name) - ->setType($class->name) - ->setAutowired($attribute->as); - - if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); - } - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - - if (!$attribute->autoTag) { - continue; - } - - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { - if (!$reflection->implementsInterface($interface)) { - continue; - } - - $definition->addTag($tag); - } - } - - foreach (Attributes::findTargetClasses(NonAutowiredService::class) as $class) { - $attribute = $class->attribute; - - $definition = $builder->addDefinition($attribute->name) - ->setType($class->name) - ->setAutowired(false); - - if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); - } - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - - foreach (Attributes::findTargetClasses(GenerateFactory::class) as $class) { - $attribute = $class->attribute; - $definition = $builder->addFactoryDefinition(null) - ->setImplement($attribute->interface); - - if ($attribute->resultType !== null) { - $definition->getResultDefinition()->setType($attribute->resultType); - } - - $resultDefinition = $definition->getResultDefinition(); - self::processConstructorParameters($builder, $class->name, $resultDefinition, $constructorParameters); - } - /** @var stdClass&object{level: int|null} $config */ $config = $this->getConfig(); - if ($config->level === null) { - return; - } - - foreach (Attributes::findTargetClasses(RegisteredRule::class) as $class) { - $attribute = $class->attribute; - if ($attribute->level > $config->level) { - continue; - } - - $definition = $builder->addDefinition(null) - ->setFactory($class->name) - ->setAutowired($class->name) - ->addTag(LazyRegistry::RULE_TAG); - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - - foreach (Attributes::findTargetClasses(RegisteredCollector::class) as $class) { - $attribute = $class->attribute; - if ($attribute->level > $config->level) { - continue; - } - - $definition = $builder->addDefinition(null) - ->setFactory($class->name) - ->setAutowired($class->name) - ->addTag(RegistryFactory::COLLECTOR_TAG); - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - } - - /** - * @param class-string $className - * @param array>> $constructorParameters - */ - public static function processConstructorParameters(ContainerBuilder $builder, string $className, ServiceDefinition $definition, array $constructorParameters): void - { - foreach ($constructorParameters[strtolower($className)] ?? [] as $autowiredParameter) { - $ref = $autowiredParameter->attribute->ref; - if ($ref === null) { - $argument = self::createDeferredParameter($builder, '%' . Helpers::escape($autowiredParameter->name) . '%'); - } elseif (Strings::match($ref, '#^@[\w\\\\]+$#D') !== null) { - $argument = new Reference(substr($ref, 1)); - } else { - $argument = self::createDeferredParameter($builder, $ref); - } - $definition->setArgument($autowiredParameter->name, $argument); - } - } - - /** - * Turns a `%foo%` reference into a deferred `$this->getParameter('foo')` lookup instead of reading - * ContainerBuilder::$parameters right now. Extensions registered after this one still rewrite the - * parameters during loadConfiguration() - ValidateExcludePathsExtension unwraps OptionalPath objects - * in `excludePaths` - and a value snapshotted here would keep the pre-rewrite contents. - * - * `%foo.bar%` becomes `$this->getParameter('foo')['bar']` and `%foo%/suffix` becomes a concatenation, - * mirroring how Nette itself compiles references to dynamic parameters. - * - * @return PhpLiteral|string - * @throws ShouldNotHappenException when the reference points at a parameter that does not exist - */ - private static function createDeferredParameter(ContainerBuilder $builder, string $ref) - { - $parts = preg_split('#%([\w.-]*)%#', $ref, flags: PREG_SPLIT_DELIM_CAPTURE); - if ($parts === false) { - throw new ShouldNotHappenException(); - } - - $dumper = new Dumper(); - $lookups = []; - $pieces = []; - $withoutReferences = ''; - foreach ($parts as $i => $part) { - if ($i % 2 === 0) { - if ($part !== '') { - $pieces[] = $dumper->dump($part); - $withoutReferences .= $part; - } - continue; - } - - if ($part === '') { - // '%%' is an escaped percent sign - $pieces[] = $dumper->dump('%'); - $withoutReferences .= '%'; - continue; - } - - $keys = explode('.', $part); - self::checkParameterExists($builder, $part, $keys); - - $code = $dumper->format('$this->getParameter(?)', $keys[0]); - foreach (array_slice($keys, 1) as $key) { - $code .= sprintf('[%s]', $dumper->dump($key)); - } - - $lookups[] = $code; - $pieces[] = sprintf('(%s)', $code); - } - - if (count($lookups) === 0) { - return $withoutReferences; - } - - if (count($pieces) === 1) { - // the reference is the whole value, no string coercion - return ContainerBuilder::literal($lookups[0]); - } - - return ContainerBuilder::literal(implode(' . ', $pieces)); - } - - /** - * The values are resolved at runtime but the keys never change after loadConfiguration(), - * so a typo in a reference is still caught while compiling the container. - * - * @param non-empty-list $keys - * @throws ShouldNotHappenException - */ - private static function checkParameterExists(ContainerBuilder $builder, string $ref, array $keys): void - { - $value = $builder->parameters; - foreach ($keys as $key) { - if (!is_array($value)) { - // a dynamic parameter - cannot be traversed while compiling - return; - } - if (!array_key_exists($key, $value)) { - throw new ShouldNotHappenException(sprintf("Missing parameter '%s'.", $ref)); - } - - $value = $value[$key]; - } + AttributeServicesRegistrar::registerServices( + $this->getContainerBuilder(), + AttributeTargetsProvider::create(), + $config->level, + ); } } diff --git a/src/DependencyInjection/StubValidatorRuleServicesExtension.php b/src/DependencyInjection/StubValidatorRuleServicesExtension.php index f737d202dfd..3922a7e8358 100644 --- a/src/DependencyInjection/StubValidatorRuleServicesExtension.php +++ b/src/DependencyInjection/StubValidatorRuleServicesExtension.php @@ -3,11 +3,10 @@ namespace PHPStan\DependencyInjection; use Nette\DI\CompilerExtension; -use olvlvl\ComposerAttributeCollector\Attributes; use Override; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesRegistrar; +use PHPStan\DependencyInjection\AttributeServices\AttributeTargetsProvider; use PHPStan\PhpDoc\StubValidator; -use function strcasecmp; -use function strtolower; final class StubValidatorRuleServicesExtension extends CompilerExtension { @@ -15,27 +14,17 @@ final class StubValidatorRuleServicesExtension extends CompilerExtension #[Override] public function loadConfiguration(): void { - require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); + $targets = AttributeTargetsProvider::create(); + $constructorParameters = AttributeServicesRegistrar::collectConstructorParameters($targets); - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); - $constructorParameters = []; - foreach ($autowiredParameters as $parameter) { - if (strcasecmp($parameter->method, '__construct') !== 0) { - continue; - } - $lowerClass = strtolower($parameter->class); - $constructorParameters[$lowerClass] ??= []; - $constructorParameters[$lowerClass][] = $parameter; - } - - foreach (Attributes::findTargetClasses(ValidatesStubFiles::class) as $class) { + foreach ($targets->findTargetClasses(ValidatesStubFiles::class) as $class) { $definition = $builder->addDefinition(null) ->setFactory($class->name) ->setAutowired(false) ->addTag(StubValidator::SERVICE_RULE_TAG); - AutowiredAttributeServicesExtension::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + AttributeServicesRegistrar::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); } } From 337dcba7bd92aa8537e7ab0678ba4b539fa6dbbb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 26 Aug 2026 23:00:21 +0200 Subject: [PATCH 3/6] Add the attributeServicesDirectories section, its resolver and container-cache keying The top-level section lists directories whose classes may carry PHPStan's DI attributes. Entries are absolutized relative to the declaring config file by NeonAdapter (also in ContainerFactory's pre-pass, so the resolver sees final paths before the container compiles). The resolver validates each directory against the owning Composer package's (or the root project's) autoload rules, gates the feature to PHP 8.0+, and computes the directory's contribution to the container cache key: a package version token for regularly installed vendor packages, per-file content hashes for project-own directories and path repositories. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- conf/parametersSchema.neon | 2 + .../AttributeServicesDirectoriesResolver.php | 382 ++++++++++++++++++ .../AttributeServices/AutoloadRules.php | 45 +++ .../AttributeServices/ComposerPackage.php | 26 ++ .../AttributeServices/ComposerProject.php | 51 +++ .../ComposerProjectFactory.php | 260 ++++++++++++ ...dAttributeServicesDirectoriesException.php | 27 ++ .../ResolvedAttributeServicesDirectories.php | 51 +++ .../ResolvedAttributeServicesDirectory.php | 32 ++ .../AttributeServicesDirectoriesExtension.php | 26 ++ src/DependencyInjection/Configurator.php | 12 + src/DependencyInjection/ContainerFactory.php | 10 +- src/DependencyInjection/NeonAdapter.php | 2 +- ...tributeServicesDirectoriesResolverTest.php | 169 ++++++++ 14 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php create mode 100644 src/DependencyInjection/AttributeServices/AutoloadRules.php create mode 100644 src/DependencyInjection/AttributeServices/ComposerPackage.php create mode 100644 src/DependencyInjection/AttributeServices/ComposerProject.php create mode 100644 src/DependencyInjection/AttributeServices/ComposerProjectFactory.php create mode 100644 src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php create mode 100644 src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php create mode 100644 src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php create mode 100644 src/DependencyInjection/AttributeServicesDirectoriesExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 953bab24371..8639253129e 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -209,12 +209,14 @@ parametersSchema: analysedPathsFromConfig: listOf(string()) usedLevel: string() cliAutoloadFile: schema(string(), nullable()) + attributeServicesDirectories: listOf(string()) # internal - editor mode singleReflectionFile: schema(string(), nullable()) singleReflectionInsteadOfFile: schema(string(), nullable()) expandRelativePaths: + - '[attributeServicesDirectories][]' - '[parameters][paths][]' - '[parameters][excludePaths][]' - '[parameters][excludePaths][analyse][]' diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php new file mode 100644 index 00000000000..821a4a3733b --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php @@ -0,0 +1,382 @@ +|null */ + private ?array $composerProjects = null; + + /** + * @param string[] $composerAutoloaderProjectPaths + */ + public function __construct( + private FileHelper $fileHelper, + private array $composerAutoloaderProjectPaths, + private int $runtimePhpVersionId = PHP_VERSION_ID, + ) + { + $this->composerProjectFactory = new ComposerProjectFactory($fileHelper); + } + + /** + * @param mixed $rawSectionValue + * @throws InvalidAttributeServicesDirectoriesException + */ + public function resolve($rawSectionValue): ResolvedAttributeServicesDirectories + { + if ($rawSectionValue === null || $rawSectionValue === []) { + return ResolvedAttributeServicesDirectories::createEmpty(); + } + + if (!is_array($rawSectionValue)) { + throw new InvalidAttributeServicesDirectoriesException([ + 'The attributeServicesDirectories section must contain a list of directory paths.', + ]); + } + + $errors = []; + $directories = []; + foreach ($rawSectionValue as $entry) { + if (!is_string($entry)) { + $errors[] = 'The attributeServicesDirectories section must contain a list of directory paths.'; + continue; + } + if (str_contains($entry, '%')) { + $errors[] = sprintf('Entry %s in the attributeServicesDirectories section must be a plain path - %% parameters are not supported.', $entry); + continue; + } + if (str_starts_with($entry, '*')) { + $errors[] = sprintf('Entry %s in the attributeServicesDirectories section must be a plain path - wildcards are not supported.', $entry); + continue; + } + + $directories[] = rtrim($this->fileHelper->normalizePath($this->fileHelper->absolutizePath($entry), '/'), '/'); + } + + $directories = $this->deduplicateNested($directories); + + if (count($directories) > 0 && $this->runtimePhpVersionId < 80000) { + $errors[] = sprintf( + 'The attributeServicesDirectories section requires PHP 8.0 or later, PHPStan is running on PHP %d.%d.%d.', + intdiv($this->runtimePhpVersionId, 10000), + intdiv($this->runtimePhpVersionId, 100) % 100, + $this->runtimePhpVersionId % 100, + ); + throw new InvalidAttributeServicesDirectoriesException($errors); + } + + $resolved = []; + foreach ($directories as $directory) { + if (!is_dir($directory)) { + $errors[] = sprintf('Directory %s from the attributeServicesDirectories section does not exist.', $directory); + continue; + } + + $resolvedDirectory = $this->resolveDirectory($directory, $errors); + if ($resolvedDirectory === null) { + continue; + } + + $resolved[] = $resolvedDirectory; + } + + if (count($errors) > 0) { + throw new InvalidAttributeServicesDirectoriesException($errors); + } + + return new ResolvedAttributeServicesDirectories($resolved); + } + + /** + * @param list $errors + */ + private function resolveDirectory(string $directory, array &$errors): ?ResolvedAttributeServicesDirectory + { + $ownership = $this->findOwnership($directory); + if ($ownership === null) { + $errors[] = sprintf('Directory %s from the attributeServicesDirectories section is not inside any project with Composer metadata known to PHPStan.', $directory); + return null; + } + + [$project, $package] = $ownership; + + if ($package !== null) { + // autoload-dev of a dependency is never installed by Composer + $rules = $package->autoload; + } elseif ($project->devInstalled) { + $rules = $project->rootAutoload->union($project->rootAutoloadDev); + } else { + $rules = $project->rootAutoload; + } + + $psr4 = []; + foreach ($rules->psr4 as $namespacePrefix => $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + if (!$this->pathsIntersect($directory, $baseDirectory)) { + continue; + } + + $psr4[$namespacePrefix][] = $baseDirectory; + } + } + + $classmapPaths = []; + foreach ($rules->classmapPaths as $classmapPath) { + if (!$this->pathsIntersect($directory, $classmapPath)) { + continue; + } + + $classmapPaths[] = $classmapPath; + } + + if (count($psr4) === 0 && count($classmapPaths) === 0) { + $errors[] = $this->describeUncoveredDirectory($directory, $project, $package); + return null; + } + + if ($package !== null && $package->cacheToken !== null) { + $cacheKeyComponent = [$directory => sprintf('package:%s:%s', $package->name, $package->cacheToken)]; + } else { + $cacheKeyComponent = $this->hashDirectory($directory, $errors); + } + + return new ResolvedAttributeServicesDirectory( + $directory, + $package === null ? null : $package->name, + $psr4, + $classmapPaths, + $project->getAutoloadClassmapPath(), + $cacheKeyComponent, + ); + } + + private function describeUncoveredDirectory(string $directory, ComposerProject $project, ?ComposerPackage $package): string + { + $subject = $package === null + ? sprintf('the autoload section of %s/composer.json', $project->rootPath) + : sprintf('the autoload section of the Composer package %s', $package->name); + + if ($package === null) { + foreach ($this->collectPsrAndFilePaths($project->rootAutoloadDev) as $path) { + if (!$this->pathsIntersect($directory, $path)) { + continue; + } + + if (!$project->devInstalled) { + return sprintf( + 'Directory %s from the attributeServicesDirectories section is only covered by the autoload-dev section of %s/composer.json but Composer dependencies were installed with --no-dev.', + $directory, + $project->rootPath, + ); + } + } + } + + $unsupportedRules = $package === null + ? ($project->devInstalled ? $project->rootAutoload->union($project->rootAutoloadDev) : $project->rootAutoload) + : $package->autoload; + foreach ($unsupportedRules->psr0 as $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + if (!$this->pathsIntersect($directory, $baseDirectory)) { + continue; + } + + return sprintf( + 'Directory %s from the attributeServicesDirectories section is only covered by a psr-0 autoload rule of %s. Only psr-4 and classmap rules are supported.', + $directory, + $subject, + ); + } + } + + return sprintf( + 'Directory %s from the attributeServicesDirectories section is not covered by %s. Add it to autoload.psr-4 or autoload.classmap in composer.json and run composer dump-autoload.', + $directory, + $subject, + ); + } + + /** + * @return array{ComposerProject, ComposerPackage|null}|null + */ + private function findOwnership(string $directory): ?array + { + foreach ($this->getComposerProjects() as $project) { + $package = $project->findPackageOfDirectory($directory); + if ($package !== null) { + return [$project, $package]; + } + } + + $containing = null; + foreach ($this->getComposerProjects() as $project) { + if (!$project->containsDirectory($directory)) { + continue; + } + + if ($containing !== null && strlen($containing->rootPath) >= strlen($project->rootPath)) { + continue; + } + + $containing = $project; + } + + if ($containing === null) { + return null; + } + + return [$containing, null]; + } + + /** + * @return list + */ + private function getComposerProjects(): array + { + if ($this->composerProjects !== null) { + return $this->composerProjects; + } + + $projects = []; + foreach ($this->composerAutoloaderProjectPaths as $projectPath) { + $project = $this->composerProjectFactory->create($projectPath); + if ($project === null) { + continue; + } + + $projects[] = $project; + } + + return $this->composerProjects = $projects; + } + + /** + * @return list + */ + private function collectPsrAndFilePaths(AutoloadRules $rules): array + { + $paths = []; + foreach ($rules->psr4 as $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + $paths[] = $baseDirectory; + } + } + foreach ($rules->classmapPaths as $path) { + $paths[] = $path; + } + + return $paths; + } + + private function pathsIntersect(string $a, string $b): bool + { + return $a === $b || str_starts_with($a, $b . '/') || str_starts_with($b, $a . '/'); + } + + /** + * Content hashes of all PHP files under the directory - the cache-key fallback when + * a package version cannot stand in for the directory contents. Mirrors how config + * files invalidate the container in Configurator::getAllConfigFilesHashes(). + * + * @param list $errors + * @return array + */ + private function hashDirectory(string $directory, array &$errors): array + { + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + foreach ($iterator as $fileInfo) { + if (!$fileInfo instanceof SplFileInfo || !$fileInfo->isFile()) { + continue; + } + if (!str_ends_with($fileInfo->getFilename(), '.php')) { + continue; + } + + $files[] = $this->fileHelper->normalizePath($fileInfo->getPathname(), '/'); + } + + sort($files); + + $hashes = []; + foreach ($files as $file) { + $hash = hash_file('sha256', $file); + if ($hash === false) { + $errors[] = (new CouldNotReadFileException($file))->getMessage(); + continue; + } + + $hashes[$file] = $hash; + } + + ksort($hashes); + + return $hashes; + } + + /** + * Unique directories with entries nested inside another configured directory dropped - + * the outer directory already covers them for both discovery and hashing. + * + * @param list $directories + * @return list + */ + private function deduplicateNested(array $directories): array + { + usort($directories, static fn (string $a, string $b): int => strlen($a) <=> strlen($b)); + + $result = []; + foreach ($directories as $directory) { + foreach ($result as $kept) { + if ($directory === $kept || str_starts_with($directory, $kept . '/')) { + continue 2; + } + } + + $result[] = $directory; + } + + sort($result); + + return $result; + } + +} diff --git a/src/DependencyInjection/AttributeServices/AutoloadRules.php b/src/DependencyInjection/AttributeServices/AutoloadRules.php new file mode 100644 index 00000000000..1190e43f1d7 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AutoloadRules.php @@ -0,0 +1,45 @@ +> $psr4 namespace prefix => base directories + * @param list $classmapPaths + * @param array> $psr0 + * @param list $files + */ + public function __construct( + public array $psr4, + public array $classmapPaths, + public array $psr0, + public array $files, + ) + { + } + + public static function createEmpty(): self + { + return new self([], [], [], []); + } + + public function union(self $other): self + { + return new self( + array_merge_recursive($this->psr4, $other->psr4), + array_merge($this->classmapPaths, $other->classmapPaths), + array_merge_recursive($this->psr0, $other->psr0), + array_merge($this->files, $other->files), + ); + } + +} diff --git a/src/DependencyInjection/AttributeServices/ComposerPackage.php b/src/DependencyInjection/AttributeServices/ComposerPackage.php new file mode 100644 index 00000000000..172e028b7a7 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ComposerPackage.php @@ -0,0 +1,26 @@ + $packagesByInstallPath install path => package, longest path first + */ + public function __construct( + public string $rootPath, + public string $vendorDirectory, + public bool $devInstalled, + public AutoloadRules $rootAutoload, + public AutoloadRules $rootAutoloadDev, + public array $packagesByInstallPath, + ) + { + } + + public function findPackageOfDirectory(string $directory): ?ComposerPackage + { + foreach ($this->packagesByInstallPath as $installPath => $package) { + if ($directory === $installPath || str_starts_with($directory, $installPath . '/')) { + return $package; + } + } + + return null; + } + + public function containsDirectory(string $directory): bool + { + return $directory === $this->rootPath || str_starts_with($directory, $this->rootPath . '/'); + } + + public function getAutoloadClassmapPath(): string + { + return $this->vendorDirectory . '/composer/autoload_classmap.php'; + } + +} diff --git a/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php new file mode 100644 index 00000000000..8af00ff051a --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php @@ -0,0 +1,260 @@ +fileHelper->normalizePath($projectPath, '/'), '/'); + $vendorDirectory = rtrim($this->fileHelper->normalizePath( + ComposerHelper::getVendorDirFromComposerConfig($projectPath, $composer), + '/', + ), '/'); + + $installedJson = $this->loadInstalledJson($vendorDirectory); + $installedPackages = []; + $devInstalled = true; + if ($installedJson !== null) { + $installedPackages = $installedJson['packages'] ?? $installedJson; + $devInstalled = (bool) ($installedJson['dev'] ?? true); + } + + $versions = $this->loadInstalledVersions($vendorDirectory); + + $packagesByInstallPath = []; + if (is_array($installedPackages)) { + foreach ($installedPackages as $package) { + if (!is_array($package) || !isset($package['name']) || !is_string($package['name'])) { + continue; + } + + if (isset($package['install-path']) && is_string($package['install-path'])) { + $installPath = $vendorDirectory . '/composer/' . $package['install-path']; + } else { + $installPath = $vendorDirectory . '/' . $package['name']; + } + $installPath = rtrim($this->fileHelper->normalizePath($installPath, '/'), '/'); + + $packagesByInstallPath[$installPath] = new ComposerPackage( + $package['name'], + $installPath, + $this->createCacheToken($package['name'], $installPath, $vendorDirectory, $versions), + $this->extractAutoloadRules($package, 'autoload', $installPath), + ); + } + } + + // Longest install path first, so a package nested under another package's directory matches first. + uksort($packagesByInstallPath, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return new ComposerProject( + $rootPath, + $vendorDirectory, + $devInstalled, + $this->extractAutoloadRules($composer, 'autoload', $rootPath), + $this->extractAutoloadRules($composer, 'autoload-dev', $rootPath), + $packagesByInstallPath, + ); + } + + /** + * @return array|null + */ + private function loadInstalledJson(string $vendorDirectory): ?array + { + $installedJsonPath = $vendorDirectory . '/composer/installed.json'; + if (!is_file($installedJsonPath)) { + return null; + } + + try { + $installedJson = Json::decode(FileReader::read($installedJsonPath), Json::FORCE_ARRAY); + } catch (CouldNotReadFileException | JsonException) { + return null; + } + + if (!is_array($installedJson)) { + return null; + } + + return $installedJson; + } + + /** + * @return array> + */ + private function loadInstalledVersions(string $vendorDirectory): array + { + $installedPhp = $vendorDirectory . '/composer/installed.php'; + if (!is_file($installedPhp)) { + return []; + } + + $installed = require $installedPhp; + if (!is_array($installed) || !isset($installed['versions']) || !is_array($installed['versions'])) { + return []; + } + + $versions = []; + foreach ($installed['versions'] as $package => $info) { + if (!is_string($package) || !is_array($info)) { + continue; + } + + $versions[$package] = $info; + } + + return $versions; + } + + /** + * Version identity of the package usable as a container cache key, or null when the installed + * files can change without the recorded version changing - a path repository (the install path + * escapes the vendor directory or is a symlink) or a missing reference. Null makes the resolver + * fall back to hashing the directory contents. + * + * @param array> $versions + */ + private function createCacheToken(string $packageName, string $installPath, string $vendorDirectory, array $versions): ?string + { + if (!str_starts_with($installPath, $vendorDirectory . '/')) { + return null; + } + + $realInstallPath = realpath($installPath); + if ($realInstallPath === false || rtrim($this->fileHelper->normalizePath($realInstallPath, '/'), '/') !== $installPath) { + return null; + } + + $info = $versions[$packageName] ?? null; + if ($info === null || !isset($info['pretty_version']) || !is_string($info['pretty_version'])) { + return null; + } + + if (preg_match('/[^v\d.]/', $info['pretty_version']) === 0) { + // a tagged version, see ComposerHelper::processPackageVersion() + return $info['pretty_version']; + } + + if (isset($info['reference']) && is_string($info['reference']) && $info['reference'] !== '') { + return $info['pretty_version'] . '@' . $info['reference']; + } + + return null; + } + + /** + * @param array $package + */ + private function extractAutoloadRules(array $package, string $autoloadSection, string $basePath): AutoloadRules + { + $section = $package[$autoloadSection] ?? []; + if (!is_array($section)) { + return AutoloadRules::createEmpty(); + } + + return new AutoloadRules( + $this->extractPsrRules($section, 'psr-4', $basePath), + $this->extractPathList($section, 'classmap', $basePath), + $this->extractPsrRules($section, 'psr-0', $basePath), + $this->extractPathList($section, 'files', $basePath), + ); + } + + /** + * @param array $section + * @return array> + */ + private function extractPsrRules(array $section, string $key, string $basePath): array + { + $rules = $section[$key] ?? []; + if (!is_array($rules)) { + return []; + } + + $result = []; + foreach ($rules as $namespacePrefix => $paths) { + if (!is_string($namespacePrefix)) { + continue; + } + + $absolutePaths = []; + foreach (is_array($paths) ? $paths : [$paths] as $path) { + if (!is_string($path)) { + continue; + } + + $absolutePaths[] = $this->absolutizeRulePath($basePath, $path); + } + + if ($absolutePaths === []) { + continue; + } + + $result[$namespacePrefix] = $absolutePaths; + } + + return $result; + } + + /** + * @param array $section + * @return list + */ + private function extractPathList(array $section, string $key, string $basePath): array + { + $paths = $section[$key] ?? []; + if (!is_array($paths)) { + return []; + } + + $result = []; + foreach ($paths as $path) { + if (!is_string($path)) { + continue; + } + + $result[] = $this->absolutizeRulePath($basePath, $path); + } + + return $result; + } + + private function absolutizeRulePath(string $basePath, string $path): string + { + return rtrim($this->fileHelper->normalizePath($basePath . '/' . $path, '/'), '/'); + } + +} diff --git a/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php b/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php new file mode 100644 index 00000000000..f1e9f4da529 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php @@ -0,0 +1,27 @@ + $errors + */ + public function __construct(private array $errors) + { + parent::__construct(implode("\n\n", $errors)); + } + + /** + * @return non-empty-list + */ + public function getErrors(): array + { + return $this->errors; + } + +} diff --git a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php new file mode 100644 index 00000000000..74d4459ec6b --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php @@ -0,0 +1,51 @@ + $directories + */ + public function __construct(public array $directories) + { + } + + public static function createEmpty(): self + { + return new self([]); + } + + /** + * @return list + */ + public function getDirectoryPaths(): array + { + $paths = []; + foreach ($this->directories as $directory) { + $paths[] = $directory->directory; + } + + return $paths; + } + + /** + * @return array + */ + public function getCacheKeyComponent(): array + { + $component = []; + foreach ($this->directories as $directory) { + foreach ($directory->cacheKeyComponent as $key => $value) { + $component[$key] = $value; + } + } + + return $component; + } + +} diff --git a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php new file mode 100644 index 00000000000..0af89999861 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php @@ -0,0 +1,32 @@ +> $psr4 namespace prefix => base directories intersecting the directory + * @param list $classmapPaths classmap rule paths intersecting the directory + * @param string $autoloadClassmapPath the owning project's vendor/composer/autoload_classmap.php + * @param array $cacheKeyComponent the directory's contribution to the container cache key - + * a package version token, or per-file content hashes + */ + public function __construct( + public string $directory, + public ?string $packageName, + public array $psr4, + public array $classmapPaths, + public string $autoloadClassmapPath, + public array $cacheKeyComponent, + ) + { + } + +} diff --git a/src/DependencyInjection/AttributeServicesDirectoriesExtension.php b/src/DependencyInjection/AttributeServicesDirectoriesExtension.php new file mode 100644 index 00000000000..517eb633d9a --- /dev/null +++ b/src/DependencyInjection/AttributeServicesDirectoriesExtension.php @@ -0,0 +1,26 @@ + */ + private array $attributeServicesDirectoriesCacheKey = []; + public function __construct(private LoaderFactory $loaderFactory, private bool $journalContainer) { parent::__construct(); @@ -58,6 +61,14 @@ public function setAllConfigFiles(array $allConfigFiles): void $this->allConfigFiles = $allConfigFiles; } + /** + * @param array $cacheKeyComponent + */ + public function setAttributeServicesDirectoriesCacheKey(array $cacheKeyComponent): void + { + $this->attributeServicesDirectoriesCacheKey = $cacheKeyComponent; + } + /** * @return mixed[] */ @@ -103,6 +114,7 @@ public function loadContainer(): string is_file($attributesPhp) ? hash_file('sha256', $attributesPhp) : 'attributes-missing', NeonAdapter::CACHE_KEY, $this->getAllConfigFilesHashes(), + $this->attributeServicesDirectoriesCacheKey, ]; $className = $loader->load( diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index b2d2e6dc4cd..0e276bc5b73 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -23,6 +23,7 @@ use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use PHPStan\Command\CommandHelper; use PHPStan\Command\Environment; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesDirectoriesResolver; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\Printer; use PHPStan\Php\PhpVersion; @@ -122,6 +123,9 @@ public function create( ], ); + $directoriesResolver = new AttributeServicesDirectoriesResolver($this->fileHelper, $composerAutoloaderProjectPaths); + $attributeServicesDirectories = $directoriesResolver->resolve($projectConfig['attributeServicesDirectories'] ?? []); + $configurator = new Configurator(new LoaderFactory( $this->fileHelper, $this->rootDirectory, @@ -147,6 +151,7 @@ public function create( 'generateBaselineFile' => $generateBaselineFile, 'usedLevel' => $usedLevel, 'cliAutoloadFile' => $cliAutoloadFile, + 'attributeServicesDirectories' => $attributeServicesDirectories->getDirectoryPaths(), 'env' => Environment::getCleanedArray(), ], $additionalParameters)); $configurator->addDynamicParameters([ @@ -161,6 +166,7 @@ public function create( } $configurator->setAllConfigFiles($allConfigFiles); + $configurator->setAttributeServicesDirectoriesCacheKey($attributeServicesDirectories->getCacheKeyComponent()); $container = $configurator->createContainer()->getByType(Container::class); $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']); @@ -240,7 +246,9 @@ private function detectDuplicateIncludedFiles( array $loaderParameters, ): array { - $neonAdapter = new NeonCachedFileReader([]); + // attributeServicesDirectories must come out absolutized relative to the declaring file + // even in this pre-pass - ContainerFactory reads the section before the container compiles + $neonAdapter = new NeonCachedFileReader(['[attributeServicesDirectories][]']); $phpAdapter = new PhpAdapter(); $allConfigFiles = []; $configArray = []; diff --git a/src/DependencyInjection/NeonAdapter.php b/src/DependencyInjection/NeonAdapter.php index 1f8f93db5c2..0b87675d879 100644 --- a/src/DependencyInjection/NeonAdapter.php +++ b/src/DependencyInjection/NeonAdapter.php @@ -30,7 +30,7 @@ final class NeonAdapter implements Adapter { - public const CACHE_KEY = 'v32-deferred-autowired-parameters'; + public const CACHE_KEY = 'v33-attribute-services-directories'; private const PREVENT_MERGING_SUFFIX = '!'; diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php new file mode 100644 index 00000000000..d48470b9356 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php @@ -0,0 +1,169 @@ +createResolver(); + $this->assertSame([], $resolver->resolve(null)->directories); + $this->assertSame([], $resolver->resolve([])->directories); + } + + public function testPhpVersionGate(): void + { + $resolver = $this->createResolver(70428); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section requires PHP 8.0 or later, PHPStan is running on PHP 7.4.28.'); + $resolver->resolve([__DIR__]); + } + + public function testNonListSection(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section must contain a list of directory paths.'); + $resolver->resolve('src'); + } + + public function testNonStringEntry(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section must contain a list of directory paths.'); + $resolver->resolve([['src']]); + } + + public function testParameterEntryRejected(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('Entry %rootDir%/src in the attributeServicesDirectories section must be a plain path - % parameters are not supported.'); + $resolver->resolve(['%rootDir%/src']); + } + + public function testWildcardEntryRejected(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('Entry */src in the attributeServicesDirectories section must be a plain path - wildcards are not supported.'); + $resolver->resolve(['*/src']); + } + + public function testMissingDirectory(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('does not exist'); + $resolver->resolve([__DIR__ . '/does-not-exist']); + } + + public function testDirectoryOutsideComposerProjects(): void + { + $resolver = new AttributeServicesDirectoriesResolver(new FileHelper(self::getRepoRoot()), []); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('is not inside any project with Composer metadata known to PHPStan'); + $resolver->resolve([__DIR__]); + } + + public function testProjectOwnPsr4Directory(): void + { + $repoRoot = self::getRepoRoot(); + $directory = $repoRoot . '/src/DependencyInjection/AttributeServices'; + $resolved = $this->createResolver()->resolve([$directory]); + + $this->assertCount(1, $resolved->directories); + $resolvedDirectory = $resolved->directories[0]; + $this->assertNull($resolvedDirectory->packageName); + $this->assertArrayHasKey('PHPStan\\', $resolvedDirectory->psr4); + $this->assertContains($repoRoot . '/src', $resolvedDirectory->psr4['PHPStan\\']); + + $expectedFile = $directory . '/AttributeServicesDirectoriesResolver.php'; + $this->assertArrayHasKey($expectedFile, $resolvedDirectory->cacheKeyComponent); + $this->assertSame(hash_file('sha256', $expectedFile), $resolvedDirectory->cacheKeyComponent[$expectedFile]); + } + + public function testProjectOwnClassmapDirectoryFromAutoloadDev(): void + { + $repoRoot = self::getRepoRoot(); + $resolved = $this->createResolver()->resolve([__DIR__]); + + $this->assertCount(1, $resolved->directories); + $resolvedDirectory = $resolved->directories[0]; + $this->assertNull($resolvedDirectory->packageName); + $this->assertContains($repoRoot . '/tests/PHPStan', $resolvedDirectory->classmapPaths); + $this->assertSame($repoRoot . '/vendor/composer/autoload_classmap.php', $resolvedDirectory->autoloadClassmapPath); + } + + public function testVendorPackageDirectory(): void + { + $repoRoot = self::getRepoRoot(); + $directory = $repoRoot . '/vendor/nikic/php-parser/lib/PhpParser'; + $resolved = $this->createResolver()->resolve([$directory]); + + $this->assertCount(1, $resolved->directories); + $resolvedDirectory = $resolved->directories[0]; + $this->assertSame('nikic/php-parser', $resolvedDirectory->packageName); + + $this->assertSame([$directory], array_keys($resolvedDirectory->cacheKeyComponent)); + $this->assertTrue(str_starts_with(array_values($resolvedDirectory->cacheKeyComponent)[0], 'package:nikic/php-parser:')); + } + + public function testUncoveredDirectory(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('is not covered by the autoload section of'); + $resolver->resolve([self::getRepoRoot() . '/bin']); + } + + public function testNestedDirectoriesDeduplicated(): void + { + $repoRoot = self::getRepoRoot(); + $resolved = $this->createResolver()->resolve([ + $repoRoot . '/src/DependencyInjection/AttributeServices', + $repoRoot . '/src/DependencyInjection', + ]); + + $this->assertCount(1, $resolved->directories); + $this->assertSame($repoRoot . '/src/DependencyInjection', $resolved->directories[0]->directory); + } + + public function testAllErrorsAreCollected(): void + { + $resolver = $this->createResolver(); + try { + $resolver->resolve([ + __DIR__ . '/does-not-exist', + self::getRepoRoot() . '/bin', + ]); + $this->fail('Expected InvalidAttributeServicesDirectoriesException.'); + } catch (InvalidAttributeServicesDirectoriesException $e) { + $this->assertCount(2, $e->getErrors()); + } + } + +} From 0acd4e74759ef16fa2188acc5c4b9e60b306cbe6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 26 Aug 2026 23:07:50 +0200 Subject: [PATCH 4/6] Discover DI attributes in attributeServicesDirectories via Composer autoload data AttributeServicesDiscoverer enumerates candidate classes from the PSR-4 path contract and vendor/composer/autoload_classmap.php - no file is parsed - and reads the allowlisted attributes (AutowiredService, NonAutowiredService, RegisteredRule, RegisteredCollector, GenerateFactory, AutowiredParameter on constructor parameters) with native reflection. A cheap content prefilter keeps classes without PHPStan attributes from being autoloaded. Files that suggest a DI attribute but yield no autoloadable class, and PHPStan-internal attributes (#[ContainerExtension], #[ExtensionInterface], #[AutowiredExtensions], ...), are explicit errors. ContainerFactory seeds the discovery context before each container build; AttributeTargetsProvider merges the discovered targets with vendor/attributes.php lazily, so a container-cache hit loads nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- .../AttributeServicesDiscoverer.php | 481 ++++++++++++++++++ .../AttributeServicesDiscoveryContext.php | 47 ++ .../AttributeTargetsProvider.php | 2 +- .../AttributeServices/ComposerPackage.php | 2 - .../ComposerProjectFactory.php | 1 - src/DependencyInjection/ContainerFactory.php | 2 + .../AttributeServicesIntegrationTest.php | 152 ++++++ .../AttributeServices/attributeServices.neon | 5 + .../autowiredExtensionsParam.neon | 2 + .../AttributeServices/containerExtension.neon | 2 + .../BadAutowiredExtensionsService.php | 24 + .../BadCompilerExtension.php | 11 + .../BadExtensionInterface.php | 11 + .../UsesValidatesStubFiles.php | 11 + .../data/services/DiscoveredCollector.php | 27 + .../data/services/DiscoveredNamedService.php | 11 + .../DiscoveredNonAutowiredService.php | 11 + .../services/DiscoveredRuleLevelEight.php | 27 + .../services/DiscoveredRuleLevelThree.php | 27 + .../data/services/DiscoveredService.php | 48 ++ .../data/services/DiscoveredValue.php | 20 + .../data/services/DiscoveredValueFactory.php | 10 + .../data/unloadable/UnloadableService.php | 11 + .../AttributeServices/extensionInterface.neon | 2 + .../AttributeServices/internalAttribute.neon | 2 + .../AttributeServices/unloadable.neon | 2 + 26 files changed, 947 insertions(+), 4 deletions(-) create mode 100644 src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php create mode 100644 src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterface/BadExtensionInterface.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/internalAttribute/UsesValidatesStubFiles.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredCollector.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNonAutowiredService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelEight.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValueFactory.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/unloadable/UnloadableService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/extensionInterface.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/internalAttribute.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/unloadable.neon diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php new file mode 100644 index 00000000000..f43a3e2f242 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php @@ -0,0 +1,481 @@ +>> autoload_classmap.php path => file => class names */ + private array $reverseClassmaps = []; + + /** @var list */ + private array $errors = []; + + /** + * @throws InvalidAttributeServicesDirectoriesException + */ + public function discover(ResolvedAttributeServicesDirectories $resolvedDirectories): DiscoveredAttributeTargets + { + $this->errors = []; + $targetClasses = []; + $targetMethodParameters = []; + $seenClasses = []; + + foreach ($resolvedDirectories->directories as $directory) { + foreach ($this->findCandidateClasses($directory) as $className => $file) { + if (array_key_exists(strtolower($className), $seenClasses)) { + continue; + } + $seenClasses[strtolower($className)] = true; + + $this->collectClass($className, $file, $targetClasses, $targetMethodParameters); + } + } + + if (count($this->errors) > 0) { + throw new InvalidAttributeServicesDirectoriesException($this->errors); + } + + foreach ($targetClasses as $attributeClass => $targets) { + usort($targets, static fn (TargetClass $a, TargetClass $b): int => $a->name <=> $b->name); + $targetClasses[$attributeClass] = $targets; + } + foreach ($targetMethodParameters as $attributeClass => $targets) { + usort($targets, static fn (TargetMethodParameter $a, TargetMethodParameter $b): int => [$a->class, $a->name] <=> [$b->class, $b->name]); + $targetMethodParameters[$attributeClass] = $targets; + } + + return new DiscoveredAttributeTargets($targetClasses, $targetMethodParameters); + } + + /** + * Candidate classes of one directory, derived from Composer's autoload data. Files that + * cannot yield an autoloadable class are only an error when their contents suggest one + * of PHPStan's DI attributes - anything else in the directory is none of our business. + * + * @return array class name => file + */ + private function findCandidateClasses(ResolvedAttributeServicesDirectory $directory): array + { + $candidates = []; + foreach ($this->listPhpFiles($directory->directory) as $file) { + $className = $this->derivePsr4ClassName($directory, $file); + if ($className !== null) { + $candidates[$className] = $file; + continue; + } + + $classmapClasses = $this->findClassmapClasses($directory, $file); + if ($classmapClasses !== null) { + if (count($classmapClasses) === 0) { + if ($this->suggestsDiAttributes($file)) { + $this->errors[] = sprintf( + 'File %s in a directory from the attributeServicesDirectories section is not present in Composer\'s class map. Run `composer dump-autoload` and try again.', + $file, + ); + } + continue; + } + + foreach ($classmapClasses as $classmapClass) { + $candidates[$classmapClass] = $file; + } + continue; + } + + if ($this->suggestsDiAttributes($file)) { + $this->errors[] = sprintf( + 'File %s in a directory from the attributeServicesDirectories section is not covered by the Composer autoload rules of the directory, so its class cannot be autoloaded.', + $file, + ); + } + } + + ksort($candidates); + + return $candidates; + } + + /** + * @param array>> $targetClasses + * @param array>> $targetMethodParameters + */ + private function collectClass(string $className, string $file, array &$targetClasses, array &$targetMethodParameters): void + { + if (!$this->isPrefilterPositive($file)) { + return; + } + + if (!$this->classCanBeLoaded($className)) { + if ($this->suggestsDiAttributes($file)) { + $this->errors[] = sprintf( + 'Class %s expected in %s (through a directory from the attributeServicesDirectories section) cannot be autoloaded.', + $className, + $file, + ); + } + + return; + } + + /** @var class-string $className */ + $reflection = new ReflectionClass($className); + + foreach ($reflection->getAttributes() as $attribute) { + $attributeClass = $this->resolveKnownAttribute($attribute->getName(), self::PUBLIC_CLASS_ATTRIBUTES); + if ($attributeClass !== null) { + try { + $attributeInstance = $attribute->newInstance(); + } catch (Throwable $e) { + $this->errors[] = sprintf('Cannot instantiate attribute #[%s] on class %s: %s', $this->getShortName($attribute->getName()), $className, $e->getMessage()); + continue; + } + + $targetClasses[$attributeClass][] = new TargetClass($attributeInstance, $className); + continue; + } + + $this->checkDisallowedAttribute($attribute->getName(), $className); + } + + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return; + } + + foreach ($constructor->getParameters() as $parameter) { + foreach ($parameter->getAttributes() as $attribute) { + $attributeClass = $this->resolveKnownAttribute($attribute->getName(), self::PUBLIC_PARAMETER_ATTRIBUTES); + if ($attributeClass !== null) { + try { + $attributeInstance = $attribute->newInstance(); + } catch (Throwable $e) { + $this->errors[] = sprintf('Cannot instantiate attribute #[%s] on a constructor parameter of class %s: %s', $this->getShortName($attribute->getName()), $className, $e->getMessage()); + continue; + } + + $targetMethodParameters[$attributeClass][] = new TargetMethodParameter($attributeInstance, $className, $parameter->getName(), '__construct'); + continue; + } + + $this->checkDisallowedAttribute($attribute->getName(), $className); + } + } + } + + private function checkDisallowedAttribute(string $attributeName, string $className): void + { + if (!$this->isPhpStanAttribute($attributeName)) { + return; + } + + $lowerAttributeName = strtolower($attributeName); + if ($lowerAttributeName === strtolower(ContainerExtension::class)) { + $this->errors[] = sprintf( + 'Attribute #[ContainerExtension] on class %s is not supported in directories from the attributeServicesDirectories section - the list of compiler extensions is fixed before the section is processed. Register the class in the `extensions` section of the configuration file instead.', + $className, + ); + return; + } + + if ($lowerAttributeName === strtolower(ExtensionInterface::class)) { + $this->errors[] = sprintf( + 'Attribute #[ExtensionInterface] on %s is not supported in directories from the attributeServicesDirectories section - third-party extension interfaces are not supported.', + $className, + ); + return; + } + + if ($lowerAttributeName === strtolower(AutowiredExtensions::class)) { + $this->errors[] = sprintf( + 'Attribute #[AutowiredExtensions] on a constructor parameter of class %s is not supported in directories from the attributeServicesDirectories section.', + $className, + ); + return; + } + + $this->errors[] = sprintf( + 'Attribute #[%s] on class %s is only supported on classes shipped with PHPStan itself, not on classes discovered through the attributeServicesDirectories section.', + $this->getShortName($attributeName), + $className, + ); + } + + /** + * Canonical attribute class name when $attributeName is one of $allowedAttributes, null otherwise. + * + * @param list $allowedAttributes + * @return class-string|null + */ + private function resolveKnownAttribute(string $attributeName, array $allowedAttributes): ?string + { + foreach ($allowedAttributes as $allowedAttribute) { + if (strtolower($attributeName) === strtolower($allowedAttribute)) { + return $allowedAttribute; + } + } + + return null; + } + + private function isPhpStanAttribute(string $attributeName): bool + { + if (!class_exists($attributeName)) { + return false; + } + + $file = (new ReflectionClass($attributeName))->getFileName(); + if ($file === false) { + return false; + } + + $phpstanRoot = strtr(dirname(__DIR__, 3), '\\', '/'); + + return str_starts_with(strtr($file, '\\', '/'), $phpstanRoot . '/'); + } + + private function classCanBeLoaded(string $className): bool + { + try { + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + return true; + } + + return function_exists('enum_exists') && enum_exists($className); + } catch (Throwable) { + return false; + } + } + + /** + * @return list normalized with forward slashes, sorted + */ + private function listPhpFiles(string $directory): array + { + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + foreach ($iterator as $fileInfo) { + if (!$fileInfo instanceof SplFileInfo || !$fileInfo->isFile()) { + continue; + } + if (!str_ends_with($fileInfo->getFilename(), '.php')) { + continue; + } + + $files[] = strtr($fileInfo->getPathname(), '\\', '/'); + } + + sort($files); + + return $files; + } + + /** + * FQCN of the file per the PSR-4 path contract, or null when no PSR-4 rule of the + * directory covers the file (or a path segment cannot be a PHP name). + */ + private function derivePsr4ClassName(ResolvedAttributeServicesDirectory $directory, string $file): ?string + { + $bestBaseDirectory = null; + $bestPrefix = null; + foreach ($directory->psr4 as $namespacePrefix => $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + if (!str_starts_with($file, $baseDirectory . '/')) { + continue; + } + if ($bestBaseDirectory !== null && strlen($baseDirectory) <= strlen($bestBaseDirectory)) { + continue; + } + + $bestBaseDirectory = $baseDirectory; + $bestPrefix = $namespacePrefix; + } + } + + if ($bestBaseDirectory === null || $bestPrefix === null) { + return null; + } + + $relativePath = substr($file, strlen($bestBaseDirectory) + 1, -4); + $segments = explode('/', $relativePath); + foreach ($segments as $segment) { + if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment) !== 1) { + return null; + } + } + + return $bestPrefix . strtr($relativePath, '/', '\\'); + } + + /** + * Class names Composer's class map records for the file, an empty list when the file + * sits under a classmap rule but the map does not know it (a stale dump), or null + * when no classmap rule of the directory covers the file. + * + * @return list|null + */ + private function findClassmapClasses(ResolvedAttributeServicesDirectory $directory, string $file): ?array + { + $covered = false; + foreach ($directory->classmapPaths as $classmapPath) { + if ($file === $classmapPath || str_starts_with($file, $classmapPath . '/')) { + $covered = true; + break; + } + } + + if (!$covered) { + return null; + } + + $reverseClassmap = $this->getReverseClassmap($directory->autoloadClassmapPath); + + return $reverseClassmap[$file] ?? []; + } + + /** + * @return array> file => class names + */ + private function getReverseClassmap(string $autoloadClassmapPath): array + { + if (array_key_exists($autoloadClassmapPath, $this->reverseClassmaps)) { + return $this->reverseClassmaps[$autoloadClassmapPath]; + } + + $reverse = []; + if (is_file($autoloadClassmapPath)) { + $classmap = require $autoloadClassmapPath; + if (is_array($classmap)) { + foreach ($classmap as $className => $file) { + if (!is_string($className) || !is_string($file)) { + continue; + } + + $reverse[strtr($file, '\\', '/')][] = $className; + } + } + } + + return $this->reverseClassmaps[$autoloadClassmapPath] = $reverse; + } + + /** + * Cheap gate before a class is autoloaded: any syntactically possible reference to one of + * PHPStan's attributes - a `use` import, a fully qualified name, an alias of either - + * contains the string "phpstan" case-insensitively, so a file without it cannot use them. + */ + private function isPrefilterPositive(string $file): bool + { + $contents = FileReader::read($file); + + return str_contains($contents, '#[') && stripos($contents, 'phpstan') !== false; + } + + /** + * Stricter check used only to decide whether a file that yielded no autoloadable class + * deserves an error: does it mention any of the DI attributes by name? + */ + private function suggestsDiAttributes(string $file): bool + { + $contents = FileReader::read($file); + if (!str_contains($contents, '#[')) { + return false; + } + + foreach (self::ATTRIBUTE_SHORT_NAMES as $shortName) { + if (stripos($contents, $shortName) !== false) { + return true; + } + } + + return false; + } + + private function getShortName(string $className): string + { + $parts = explode('\\', $className); + + return $parts[count($parts) - 1]; + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php new file mode 100644 index 00000000000..1f28ed2f114 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php @@ -0,0 +1,47 @@ +directories) === 0) { + return self::$targets = DiscoveredAttributeTargets::createEmpty(); + } + + return self::$targets = (new AttributeServicesDiscoverer())->discover(self::$currentDirectories); + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php index 46bb9d721aa..448ea0e398a 100644 --- a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php +++ b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php @@ -29,7 +29,7 @@ public static function create(): self { require_once __DIR__ . '/../../../vendor/attributes.php'; - return new self(DiscoveredAttributeTargets::createEmpty()); + return new self(AttributeServicesDiscoveryContext::getTargets()); } /** diff --git a/src/DependencyInjection/AttributeServices/ComposerPackage.php b/src/DependencyInjection/AttributeServices/ComposerPackage.php index 172e028b7a7..bbce4ce6ac4 100644 --- a/src/DependencyInjection/AttributeServices/ComposerPackage.php +++ b/src/DependencyInjection/AttributeServices/ComposerPackage.php @@ -9,14 +9,12 @@ final class ComposerPackage { /** - * @param string $installPath normalized with forward slashes, no trailing slash * @param string|null $cacheToken version identity usable as a cache key, or null when the * installed files can change without the version changing * (path repositories, missing reference) */ public function __construct( public string $name, - public string $installPath, public ?string $cacheToken, public AutoloadRules $autoload, ) diff --git a/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php index 8af00ff051a..ee93d118fe9 100644 --- a/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php +++ b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php @@ -69,7 +69,6 @@ public function create(string $projectPath): ?ComposerProject $packagesByInstallPath[$installPath] = new ComposerPackage( $package['name'], - $installPath, $this->createCacheToken($package['name'], $installPath, $vendorDirectory, $versions), $this->extractAutoloadRules($package, 'autoload', $installPath), ); diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 0e276bc5b73..27c28dfd16e 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -24,6 +24,7 @@ use PHPStan\Command\CommandHelper; use PHPStan\Command\Environment; use PHPStan\DependencyInjection\AttributeServices\AttributeServicesDirectoriesResolver; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesDiscoveryContext; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\Printer; use PHPStan\Php\PhpVersion; @@ -125,6 +126,7 @@ public function create( $directoriesResolver = new AttributeServicesDirectoriesResolver($this->fileHelper, $composerAutoloaderProjectPaths); $attributeServicesDirectories = $directoriesResolver->resolve($projectConfig['attributeServicesDirectories'] ?? []); + AttributeServicesDiscoveryContext::set($attributeServicesDirectories); $configurator = new Configurator(new LoaderFactory( $this->fileHelper, diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php new file mode 100644 index 00000000000..9c447f29b83 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -0,0 +1,152 @@ +create(self::$tmpDir, [__DIR__ . '/attributeServices.neon'], [], [dirname(__DIR__, 4)]); + } + + private static function createTmpDir(): string + { + $tmpDir = sys_get_temp_dir() . '/phpstan-attribute-services-' . md5(uniqid(more_entropy: true)); + mkdir($tmpDir, 0777, true); + + return $tmpDir; + } + + public function testDiscoveredServiceWithAutowiredParameters(): void + { + $container = self::createContainer(); + $service = $container->getByType(DiscoveredService::class); + $this->assertSame(__DIR__, $service->getCurrentWorkingDirectory()); + $this->assertSame(self::$tmpDir, $service->getTmpDir()); + } + + public function testDiscoveredServiceIsAutoTagged(): void + { + $container = self::createContainer(); + $extensions = $container->getExtensionsCollection(ReadWritePropertiesExtension::class)->getAll(); + $this->assertCount(1, $extensions); + $this->assertInstanceOf(DiscoveredService::class, $extensions[0]); + } + + public function testDiscoveredNamedService(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredNamedService::class, $container->getService('attributeServicesFixtures.named')); + } + + public function testDiscoveredNonAutowiredService(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredNonAutowiredService::class, $container->getService('attributeServicesFixtures.nonAutowired')); + } + + public function testDiscoveredRulesFollowTheLevel(): void + { + $container = self::createContainer(); + $rules = $container->getServicesByTag(LazyRegistry::RULE_TAG); + $this->assertCount(1, array_filter($rules, static fn ($rule): bool => $rule instanceof DiscoveredRuleLevelThree)); + $this->assertCount(0, array_filter($rules, static fn ($rule): bool => $rule instanceof DiscoveredRuleLevelEight)); + } + + public function testDiscoveredCollector(): void + { + $container = self::createContainer(); + $collectors = $container->getServicesByTag(RegistryFactory::COLLECTOR_TAG); + $this->assertCount(1, array_filter($collectors, static fn ($collector): bool => $collector instanceof DiscoveredCollector)); + } + + public function testDiscoveredGeneratedFactory(): void + { + $container = self::createContainer(); + $factory = $container->getByType(DiscoveredValueFactory::class); + $value = $factory->create('hello'); + $this->assertSame('hello', $value->name); + $this->assertSame(self::$tmpDir, $value->tmpDir); + } + + public function testDirectoriesParameterIsAbsolutized(): void + { + $container = self::createContainer(); + $this->assertSame([__DIR__ . '/data/services'], $container->getParameter('attributeServicesDirectories')); + } + + #[DataProvider('dataErrors')] + public function testErrors(string $neonFile, string $expectedMessage): void + { + $containerFactory = new ContainerFactory(__DIR__); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage($expectedMessage); + $containerFactory->create(self::createTmpDir(), [__DIR__ . '/' . $neonFile], [], [dirname(__DIR__, 4)]); + } + + /** + * @return iterable + */ + public static function dataErrors(): iterable + { + yield [ + 'containerExtension.neon', + 'Attribute #[ContainerExtension] on class AttributeServicesFixtures\ContainerExtension\BadCompilerExtension is not supported in directories from the attributeServicesDirectories section - the list of compiler extensions is fixed before the section is processed. Register the class in the `extensions` section of the configuration file instead.', + ]; + + yield [ + 'extensionInterface.neon', + 'Attribute #[ExtensionInterface] on AttributeServicesFixtures\ExtensionInterface\BadExtensionInterface is not supported in directories from the attributeServicesDirectories section - third-party extension interfaces are not supported.', + ]; + + yield [ + 'autowiredExtensionsParam.neon', + 'Attribute #[AutowiredExtensions] on a constructor parameter of class AttributeServicesFixtures\AutowiredExtensions\BadAutowiredExtensionsService is not supported in directories from the attributeServicesDirectories section.', + ]; + + yield [ + 'internalAttribute.neon', + 'Attribute #[ValidatesStubFiles] on class AttributeServicesFixtures\InternalAttribute\UsesValidatesStubFiles is only supported on classes shipped with PHPStan itself, not on classes discovered through the attributeServicesDirectories section.', + ]; + + yield [ + 'unloadable.neon', + 'cannot be autoloaded.', + ]; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon b/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon new file mode 100644 index 00000000000..0400db39a29 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon @@ -0,0 +1,5 @@ +attributeServicesDirectories: + - data/services + +autowiredAttributeServices: + level: 5 diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon b/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon new file mode 100644 index 00000000000..69d81e5be55 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon @@ -0,0 +1,2 @@ +attributeServicesDirectories: + - data/autowiredExtensions diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon b/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon new file mode 100644 index 00000000000..3a734568639 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon @@ -0,0 +1,2 @@ +attributeServicesDirectories: + - data/containerExtension diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php new file mode 100644 index 00000000000..5e5c3197088 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php @@ -0,0 +1,24 @@ + $rules + */ + public function __construct( + #[AutowiredExtensions(of: Rule::class)] + private ExtensionsCollection $rules, + ) + { + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php new file mode 100644 index 00000000000..bfde8258ce0 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php @@ -0,0 +1,11 @@ + + */ +#[RegisteredCollector(level: 3)] +final class DiscoveredCollector implements Collector +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): ?string + { + return null; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php new file mode 100644 index 00000000000..ba1796092cf --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php @@ -0,0 +1,11 @@ + + */ +#[RegisteredRule(level: 8)] +final class DiscoveredRuleLevelEight implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php new file mode 100644 index 00000000000..f9f8ec86818 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php @@ -0,0 +1,27 @@ + + */ +#[RegisteredRule(level: 3)] +final class DiscoveredRuleLevelThree implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php new file mode 100644 index 00000000000..7442a401cda --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php @@ -0,0 +1,48 @@ +currentWorkingDirectory; + } + + public function getTmpDir(): string + { + return $this->tmpDir; + } + + public function isAlwaysRead(PropertyReflection $property, string $propertyName): bool + { + return false; + } + + public function isAlwaysWritten(PropertyReflection $property, string $propertyName): bool + { + return false; + } + + public function isInitialized(PropertyReflection $property, string $propertyName): bool + { + return false; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php new file mode 100644 index 00000000000..dcc51755879 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php @@ -0,0 +1,20 @@ + Date: Wed, 26 Aug 2026 23:09:51 +0200 Subject: [PATCH 5/6] Invalidate the whole result cache when a package providing attribute services changes A directory listed in attributeServicesDirectories registers services straight from its classes, so a composer update of the owning package can affect the analysis of every file - the file-granular package re-seed is not enough. PackageDependencyResolver::resolveDirectoryPackage() also matches a directory that is a package's install path. The derivative (stub validator) container re-runs discovery through ContainerFactory::create(), covered by a regression test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- src/Analyser/ResultCache/ResultCacheManager.php | 12 ++++++++++++ src/Dependency/PackageDependencyResolver.php | 16 ++++++++++++++++ .../Dependency/PackageDependencyResolverTest.php | 14 ++++++++++++++ .../AttributeServicesIntegrationTest.php | 10 ++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 47840e7dba8..6bb0f511a63 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -83,6 +83,7 @@ final class ResultCacheManager * @param string[] $analysedPaths * @param string[] $analysedPathsFromConfig * @param string[] $composerAutoloaderProjectPaths + * @param string[] $attributeServicesDirectories * @param string[] $bootstrapFiles * @param string[] $scanFiles * @param string[] $scanDirectories @@ -108,6 +109,8 @@ public function __construct( #[AutowiredParameter] private array $composerAutoloaderProjectPaths, #[AutowiredParameter] + private array $attributeServicesDirectories, + #[AutowiredParameter] private string $usedLevel, #[AutowiredParameter] private ?string $cliAutoloadFile, @@ -1367,6 +1370,15 @@ private function streamArrayVarExportToHandle($handle, string $file, array $valu */ private function changedPackagesProvideContainerClass(?array $projectConfig, array $changedPackagesLookup): bool { + // Directories from the attributeServicesDirectories section register services straight from + // their classes, so a changed package owning one of them can affect the analysis of every file. + foreach ($this->attributeServicesDirectories as $attributeServicesDirectory) { + $package = $this->packageDependencyResolver->resolveDirectoryPackage($attributeServicesDirectory); + if ($package !== null && array_key_exists($package, $changedPackagesLookup)) { + return true; + } + } + // Extensions registered directly in the project config (services:/rules:) or via an included // extension neon file: resolve each service class to the package that owns its file. if ($projectConfig !== null) { diff --git a/src/Dependency/PackageDependencyResolver.php b/src/Dependency/PackageDependencyResolver.php index ae958d8e169..078f77be905 100644 --- a/src/Dependency/PackageDependencyResolver.php +++ b/src/Dependency/PackageDependencyResolver.php @@ -12,6 +12,7 @@ use function is_array; use function is_file; use function is_string; +use function rtrim; use function str_starts_with; use function strlen; use function uksort; @@ -57,6 +58,21 @@ public function resolvePackage(string $file): ?string return $this->resolvedPackages[$file] = $this->doResolvePackage($file); } + /** + * Unlike resolvePackage(), also matches a directory that IS a package's install path. + */ + public function resolveDirectoryPackage(string $directory): ?string + { + $normalizedDirectory = rtrim($this->fileHelper->normalizePath($directory, '/'), '/'); + foreach ($this->getInstallPathToPackage() as $installPath => $package) { + if ($normalizedDirectory === $installPath || str_starts_with($normalizedDirectory, $installPath . '/')) { + return $package; + } + } + + return null; + } + private function doResolvePackage(string $file): ?string { // Normalize with a forward slash regardless of platform: normalizePath() defaults to diff --git a/tests/PHPStan/Dependency/PackageDependencyResolverTest.php b/tests/PHPStan/Dependency/PackageDependencyResolverTest.php index 5928940c684..d8adb233c6b 100644 --- a/tests/PHPStan/Dependency/PackageDependencyResolverTest.php +++ b/tests/PHPStan/Dependency/PackageDependencyResolverTest.php @@ -25,6 +25,20 @@ public function testResolvePackage(): void $this->assertNull($resolver->resolvePackage('/outside/the/project/File.php')); } + public function testResolveDirectoryPackage(): void + { + $fixtureRoot = __DIR__ . '/data/package-resolver'; + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $resolver = new PackageDependencyResolver([$fixtureRoot], $fileHelper); + + // Unlike resolvePackage(), the install path itself matches too. + $this->assertSame('acme/widget', $resolver->resolveDirectoryPackage($fixtureRoot . '/vendor/acme/widget')); + $this->assertSame('acme/widget', $resolver->resolveDirectoryPackage($fixtureRoot . '/vendor/acme/widget/src')); + + $this->assertNull($resolver->resolveDirectoryPackage($fixtureRoot . '/src')); + $this->assertNull($resolver->resolveDirectoryPackage('/outside/the/project')); + } + public function testExtractComposerPackageVersions(): void { $resolver = new PackageDependencyResolver([], self::getContainer()->getByType(FileHelper::class)); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php index 9c447f29b83..c6b87a60cb2 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -12,6 +12,7 @@ use PHPStan\Collectors\RegistryFactory; use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\ContainerFactory; +use PHPStan\DependencyInjection\DerivativeContainerFactory; use PHPStan\Rules\LazyRegistry; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; use PHPUnit\Framework\Attributes\DataProvider; @@ -109,6 +110,15 @@ public function testDirectoriesParameterIsAbsolutized(): void $this->assertSame([__DIR__ . '/data/services'], $container->getParameter('attributeServicesDirectories')); } + public function testDerivativeContainerSeesDiscoveredServices(): void + { + $container = self::createContainer(); + $derivativeContainer = $container->getByType(DerivativeContainerFactory::class) + ->create([dirname(__DIR__, 4) . '/conf/config.stubValidator.neon'], ['allStubFiles' => []]); + $service = $derivativeContainer->getByType(DiscoveredService::class); + $this->assertSame(__DIR__, $service->getCurrentWorkingDirectory()); + } + #[DataProvider('dataErrors')] public function testErrors(string $neonFile, string $expectedMessage): void { From 1daa23ae636049df40fcacd01dc365e5a9ac5fa8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 26 Aug 2026 23:25:11 +0200 Subject: [PATCH 6/6] Add the neon2attributes command migrating NEON service registrations to attributes Deterministically converts services:/rules: entries into #[AutowiredService], #[NonAutowiredService], #[RegisteredRule(level: 0)], #[RegisteredCollector] and #[AutowiredParameter] on the classes, declares the edited directories in attributeServicesDirectories, and leaves everything it cannot express (setup, literal arguments, underivable tags, multi-registration, vendor classes) in place with the reason reported. PHP edits are position-guided line insertions (php-parser locates, plain text edits), NEON edits are order-mapped line surgery that aborts on any layout it cannot map. Before keeping the changes the command compiles the original and the converted configuration in fresh subprocesses (the current process has the classes loaded without the new attributes) and requires an identical tagged-services fingerprint, rolling everything back otherwise. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vw4RxhEifXdnTPw57hG3Vw --- bin/phpstan | 2 + .../Neon2AttributesAnalyzer.php | 517 ++++++++++++++++++ .../Neon2AttributesException.php | 10 + .../Neon2Attributes/Neon2AttributesPlan.php | 25 + src/Command/Neon2Attributes/NeonEditor.php | 207 +++++++ .../Neon2Attributes/PhpAttributeInserter.php | 246 +++++++++ .../Neon2Attributes/ServiceConversion.php | 29 + src/Command/Neon2Attributes/SkippedEntry.php | 22 + src/Command/Neon2AttributesCommand.php | 361 ++++++++++++ .../AttributeServicesDiscoverer.php | 12 +- .../AttributeTargetsProvider.php | 4 +- .../Neon2AttributesAnalyzerTest.php | 50 ++ .../Neon2Attributes/NeonEditorTest.php | 91 +++ .../PhpAttributeInserterTest.php | 185 +++++++ .../data/ConvFixtureExtension.php | 26 + .../Neon2Attributes/data/ConvFixtureRule.php | 25 + .../data/ConvFixtureService.php | 20 + .../data/ConvFixtureUntaggedExtension.php | 26 + .../Command/Neon2Attributes/data/convert.neon | 23 + .../AttributeServicesIntegrationTest.php | 1 - 20 files changed, 1874 insertions(+), 8 deletions(-) create mode 100644 src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php create mode 100644 src/Command/Neon2Attributes/Neon2AttributesException.php create mode 100644 src/Command/Neon2Attributes/Neon2AttributesPlan.php create mode 100644 src/Command/Neon2Attributes/NeonEditor.php create mode 100644 src/Command/Neon2Attributes/PhpAttributeInserter.php create mode 100644 src/Command/Neon2Attributes/ServiceConversion.php create mode 100644 src/Command/Neon2Attributes/SkippedEntry.php create mode 100644 src/Command/Neon2AttributesCommand.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/convert.neon diff --git a/bin/phpstan b/bin/phpstan index 91c25266a1d..bd1d59cbda4 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -7,6 +7,7 @@ use PHPStan\Command\ClearResultCacheCommand; use PHPStan\Command\DiagnoseCommand; use PHPStan\Command\DumpParametersCommand; use PHPStan\Command\FixerWorkerCommand; +use PHPStan\Command\Neon2AttributesCommand; use PHPStan\Command\WorkerCommand; use PHPStan\Internal\ComposerHelper; use PHPStan\Turbo\TurboExtensionEnabler; @@ -127,5 +128,6 @@ use Symfony\Component\Console\Helper\ProgressBar; $application->add(new FixerWorkerCommand($reversedComposerAutoloaderProjectPaths)); $application->add(new DumpParametersCommand($reversedComposerAutoloaderProjectPaths)); $application->add(new DiagnoseCommand($reversedComposerAutoloaderProjectPaths)); + $application->add(new Neon2AttributesCommand()); $application->run(); })(); diff --git a/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php b/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php new file mode 100644 index 00000000000..525cb522cf8 --- /dev/null +++ b/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php @@ -0,0 +1,517 @@ +analyzeRuleEntry($rule, $entryIndex, $conversions, $skipped); + $entryIndex++; + } + } + + $services = $decoded['services'] ?? []; + if (is_array($services)) { + $entryIndex = 0; + foreach ($services as $key => $service) { + $this->analyzeServiceEntry(is_string($key) ? $key : null, $service, $entryIndex, $conversions, $skipped); + $entryIndex++; + } + } + + return new Neon2AttributesPlan( + $conversions, + $skipped, + $this->collectDirectoriesToDeclare($neonFile, $conversions), + ); + } + + /** + * @param mixed $rule + * @param list $conversions + * @param list $skipped + */ + private function analyzeRuleEntry($rule, int $entryIndex, array &$conversions, array &$skipped): void + { + if (!is_string($rule)) { + $skipped[] = new SkippedEntry('rules', sprintf('entry #%d', $entryIndex), 'The entry is not a plain class name.'); + return; + } + + $file = $this->getConvertibleClassFile($rule, 'rules', $skipped); + if ($file === null) { + return; + } + + $conversions[] = new ServiceConversion( + 'rules', + $entryIndex, + $rule, + $file, + '#[RegisteredRule(level: 0)]', + [], + ['PHPStan\DependencyInjection\RegisteredRule'], + ); + } + + /** + * @param mixed $service + * @param list $conversions + * @param list $skipped + */ + private function analyzeServiceEntry(?string $name, $service, int $entryIndex, array &$conversions, array &$skipped): void + { + $description = $name ?? sprintf('entry #%d', $entryIndex); + + if (is_string($service)) { + $service = ['class' => $service]; + } + if (!is_array($service)) { + $skipped[] = new SkippedEntry('services', $description, 'The service definition is not a plain class name or a map.'); + return; + } + + if ($name === null && isset($service['class']) && is_string($service['class'])) { + $description = $service['class']; + } + + foreach (array_keys($service) as $key) { + if (!is_string($key) || !in_array($key, self::SUPPORTED_SERVICE_KEYS, true)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The service definition uses `%s` which cannot be expressed with an attribute.', is_string($key) ? $key : (string) $key)); + return; + } + } + + $class = $service['class'] ?? null; + $factory = $service['factory'] ?? null; + if ($class === null && is_string($factory) && !str_contains($factory, '::')) { + $class = $factory; + $factory = null; + } + if (!is_string($class)) { + $skipped[] = new SkippedEntry('services', $description, 'The service class cannot be determined statically.'); + return; + } + $description = $name ?? $class; + + $factoryArgument = null; + if ($factory !== null) { + if (!is_string($factory) || preg_match('#^@[\w\\\\]+::\w+$#', $factory) !== 1) { + $skipped[] = new SkippedEntry('services', $description, 'Only a `@service::method` factory can be expressed with an attribute.'); + return; + } + $factoryArgument = $factory; + } + + $file = $this->getConvertibleClassFile($class, 'services', $skipped, $description); + if ($file === null) { + return; + } + + $reflection = new ReflectionClass($class); /** @phpstan-ignore argument.type */ + + $parameterAttributes = $this->buildParameterAttributes($service['arguments'] ?? [], $reflection, $description, $skipped); + if ($parameterAttributes === null) { + return; + } + + $tags = $service['tags'] ?? []; + if (!is_array($tags)) { + $skipped[] = new SkippedEntry('services', $description, 'The tags cannot be determined statically.'); + return; + } + foreach ($tags as $tag) { + if (is_string($tag)) { + continue; + } + + $skipped[] = new SkippedEntry('services', $description, 'Tags with attributes cannot be expressed with an attribute.'); + return; + } + + $autowired = $service['autowired'] ?? true; + + if ($tags === [LazyRegistry::RULE_TAG] || $tags === [RegistryFactory::COLLECTOR_TAG]) { + if ($name !== null) { + $skipped[] = new SkippedEntry('services', $description, 'A named rule or collector service cannot be expressed with an attribute.'); + return; + } + if ($factoryArgument !== null) { + $skipped[] = new SkippedEntry('services', $description, 'A rule or collector with a factory cannot be expressed with an attribute.'); + return; + } + + $attribute = $tags === [LazyRegistry::RULE_TAG] + ? 'RegisteredRule' + : 'RegisteredCollector'; + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + sprintf('#[%s(level: 0)]', $attribute), + $parameterAttributes, + $this->collectUseImports($attribute, $parameterAttributes), + ); + return; + } + + $derivableTags = []; + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + if (!$reflection->implementsInterface($interface)) { + continue; + } + + $derivableTags[] = $tag; + } + + foreach ($tags as $tag) { + if (in_array($tag, $derivableTags, true)) { + continue; + } + + $skipped[] = new SkippedEntry('services', $description, sprintf('The tag %s cannot be derived from the implemented interfaces.', $tag)); + return; + } + + $sortedTags = array_values($tags); + sort($sortedTags); + $sortedDerivable = $derivableTags; + sort($sortedDerivable); + + if ($sortedTags === $sortedDerivable) { + $autoTag = true; + } elseif (count($tags) === 0) { + $autoTag = false; + } else { + $skipped[] = new SkippedEntry('services', $description, 'The class implements more tagged extension interfaces than the entry declares as tags.'); + return; + } + + if ($autowired === false) { + if ($name === null) { + $skipped[] = new SkippedEntry('services', $description, 'A non-autowired service without a name cannot be expressed with an attribute.'); + return; + } + if (count($derivableTags) > 0 || count($tags) > 0) { + $skipped[] = new SkippedEntry('services', $description, 'A non-autowired service is never auto-tagged, so its tags cannot be expressed with an attribute.'); + return; + } + + $arguments = [sprintf("name: '%s'", addslashes($name))]; + if ($factoryArgument !== null) { + $arguments[] = sprintf("factory: '%s'", addslashes($factoryArgument)); + } + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + sprintf('#[NonAutowiredService(%s)]', implode(', ', $arguments)), + $parameterAttributes, + $this->collectUseImports('NonAutowiredService', $parameterAttributes), + ); + return; + } + + $arguments = []; + if ($name !== null) { + $arguments[] = sprintf("name: '%s'", addslashes($name)); + } + if ($factoryArgument !== null) { + $arguments[] = sprintf("factory: '%s'", addslashes($factoryArgument)); + } + if ($autowired !== true) { + $asValue = $this->renderAutowiredAs($autowired); + if ($asValue === null) { + $skipped[] = new SkippedEntry('services', $description, 'The autowired value cannot be expressed with an attribute.'); + return; + } + $arguments[] = sprintf('as: %s', $asValue); + } + if (!$autoTag) { + $arguments[] = 'autoTag: false'; + } + + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + count($arguments) === 0 + ? '#[AutowiredService]' + : sprintf('#[AutowiredService(%s)]', implode(', ', $arguments)), + $parameterAttributes, + $this->collectUseImports('AutowiredService', $parameterAttributes), + ); + } + + /** + * Attribute code per constructor parameter for the entry's `arguments`, + * null (with a skip recorded) when they cannot be expressed. + * + * @param mixed $arguments + * @param ReflectionClass $reflection + * @param list $skipped + * @return array|null + */ + private function buildParameterAttributes($arguments, ReflectionClass $reflection, string $description, array &$skipped): ?array + { + if (!is_array($arguments)) { + $skipped[] = new SkippedEntry('services', $description, 'The arguments cannot be determined statically.'); + return null; + } + if (count($arguments) === 0) { + return []; + } + + $constructor = $reflection->getConstructor(); + $parameters = $constructor === null ? [] : $constructor->getParameters(); + $parameterNames = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $parameters); + + $parameterAttributes = []; + foreach ($arguments as $argumentKey => $argumentValue) { + if (!is_string($argumentValue) || (preg_match('#^@[\w\\\\]+$#D', $argumentValue) !== 1 && preg_match('#^%[\w.-]+%$#D', $argumentValue) !== 1)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The argument %s is not a %%parameter%% or @service reference.', is_int($argumentKey) ? sprintf('#%d', $argumentKey) : $argumentKey)); + return null; + } + + if (is_int($argumentKey)) { + if (!array_key_exists($argumentKey, $parameterNames)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The constructor has no parameter #%d.', $argumentKey)); + return null; + } + $parameterName = $parameterNames[$argumentKey]; + } else { + if (!in_array($argumentKey, $parameterNames, true)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The constructor has no parameter $%s.', $argumentKey)); + return null; + } + $parameterName = $argumentKey; + } + + if ($argumentValue === '%' . $parameterName . '%') { + $parameterAttributes[$parameterName] = '#[AutowiredParameter]'; + } else { + $parameterAttributes[$parameterName] = sprintf("#[AutowiredParameter(ref: '%s')]", addslashes($argumentValue)); + } + } + + return $parameterAttributes; + } + + /** + * @param mixed $autowired + */ + private function renderAutowiredAs($autowired): ?string + { + if (is_string($autowired)) { + return '\\' . $autowired . '::class'; + } + + if (!is_array($autowired)) { + return null; + } + + $rendered = []; + foreach ($autowired as $autowiredClass) { + if (!is_string($autowiredClass)) { + return null; + } + + $rendered[] = '\\' . $autowiredClass . '::class'; + } + + return '[' . implode(', ', $rendered) . ']'; + } + + /** + * File of the class when the class can carry attributes editable by this command, + * null (with a skip recorded) otherwise. + * + * @param 'services'|'rules' $section + * @param list $skipped + */ + private function getConvertibleClassFile(string $class, string $section, array &$skipped, ?string $description = null): ?string + { + $description ??= $class; + + if (!class_exists($class) && !interface_exists($class)) { + $skipped[] = new SkippedEntry($section, $description, 'The class cannot be autoloaded.'); + return null; + } + + $reflection = new ReflectionClass($class); + $file = $reflection->getFileName(); + if ($file === false) { + $skipped[] = new SkippedEntry($section, $description, 'The class has no source file.'); + return null; + } + + $file = $this->fileHelper->normalizePath($file, '/'); + $normalizedRoot = rtrim($this->fileHelper->normalizePath($this->projectRoot, '/'), '/'); + if (!str_starts_with($file, $normalizedRoot . '/') || str_contains($file, '/vendor/')) { + $skipped[] = new SkippedEntry($section, $description, 'The class is not part of this project.'); + return null; + } + + foreach ($reflection->getAttributes() as $attribute) { + if (!str_starts_with($attribute->getName(), 'PHPStan\\DependencyInjection\\')) { + continue; + } + + $skipped[] = new SkippedEntry($section, $description, sprintf('The class already carries the %s attribute.', $attribute->getName())); + return null; + } + + if ($this->findCoveringAutoloadDirectory($file) === null) { + $skipped[] = new SkippedEntry($section, $description, 'The class file is not covered by a psr-4 or classmap autoload rule of composer.json.'); + return null; + } + + return $file; + } + + /** + * @param array $parameterAttributes + * @return list + */ + private function collectUseImports(string $attributeShortName, array $parameterAttributes): array + { + $imports = ['PHPStan\DependencyInjection\\' . $attributeShortName]; + if (count($parameterAttributes) > 0) { + $imports[] = 'PHPStan\DependencyInjection\AutowiredParameter'; + } + + return $imports; + } + + /** + * @param list $conversions + * @return list + */ + private function collectDirectoriesToDeclare(string $neonFile, array $conversions): array + { + $relativePathHelper = new ParentDirectoryRelativePathHelper(dirname($this->fileHelper->normalizePath($neonFile, '/'))); + + $directories = []; + foreach ($conversions as $conversion) { + $directory = $this->findCoveringAutoloadDirectory($conversion->phpFile); + if ($directory === null) { + continue; + } + + $directories[$directory] = true; + } + + $relative = array_map( + static fn (string $directory): string => strtr($relativePathHelper->getRelativePath($directory), '\\', '/'), + array_keys($directories), + ); + sort($relative); + + return $relative; + } + + private function findCoveringAutoloadDirectory(string $file): ?string + { + $rules = $this->getRootAutoloadRules(); + if ($rules === null) { + return null; + } + + foreach ($rules->psr4 as $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + if (str_starts_with($file, $baseDirectory . '/')) { + return $baseDirectory; + } + } + } + + foreach ($rules->classmapPaths as $classmapPath) { + if ($file === $classmapPath || str_starts_with($file, $classmapPath . '/')) { + return $classmapPath; + } + } + + return null; + } + + private function getRootAutoloadRules(): ?AutoloadRules + { + if ($this->rootAutoloadRules !== null) { + return $this->rootAutoloadRules; + } + + $project = (new ComposerProjectFactory($this->fileHelper))->create($this->projectRoot); + if ($project === null) { + return null; + } + + return $this->rootAutoloadRules = $project->rootAutoload->union($project->rootAutoloadDev); + } + +} diff --git a/src/Command/Neon2Attributes/Neon2AttributesException.php b/src/Command/Neon2Attributes/Neon2AttributesException.php new file mode 100644 index 00000000000..ef69fe2a919 --- /dev/null +++ b/src/Command/Neon2Attributes/Neon2AttributesException.php @@ -0,0 +1,10 @@ + $conversions + * @param list $skipped + * @param list $directoriesToDeclare paths for the attributeServicesDirectories section, + * relative to the NEON file + */ + public function __construct( + public array $conversions, + public array $skipped, + public array $directoriesToDeclare, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/NeonEditor.php b/src/Command/Neon2Attributes/NeonEditor.php new file mode 100644 index 00000000000..c3c82f19dcd --- /dev/null +++ b/src/Command/Neon2Attributes/NeonEditor.php @@ -0,0 +1,207 @@ + $entryIndexes + * @throws Neon2AttributesException + */ + public function removeEntries(string $content, string $section, array $entryIndexes, int $expectedEntryCount): string + { + if (count($entryIndexes) === 0) { + return $content; + } + + $lines = explode("\n", $content); + [$headerLine, $sectionEnd] = $this->findSection($lines, $section); + + $entryStarts = []; + $entryIndent = null; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + $line = $lines[$i]; + if (preg_match('/^(\s+)(\S)/', $line, $matches) !== 1) { + continue; + } + if ($matches[2] === '#') { + continue; + } + + $entryIndent ??= $matches[1]; + if ($matches[1] !== $entryIndent) { + continue; + } + + $entryStarts[] = $i; + } + + if (count($entryStarts) !== $expectedEntryCount) { + throw new Neon2AttributesException(sprintf( + 'Cannot map the `%s` section onto the file - found %d entries in the text but the decoded section has %d. The file layout is too unusual for automatic editing.', + $section, + count($entryStarts), + $expectedEntryCount, + )); + } + + $ranges = []; + foreach ($entryIndexes as $entryIndex) { + if (!isset($entryStarts[$entryIndex])) { + throw new Neon2AttributesException(sprintf('Entry #%d not found in the `%s` section.', $entryIndex, $section)); + } + + $start = $entryStarts[$entryIndex]; + $end = $entryStarts[$entryIndex + 1] ?? $sectionEnd; + $ranges[] = [$start, $end]; + } + + if (count($entryIndexes) === $expectedEntryCount) { + // the whole section goes away, header included + $ranges = [[$headerLine, $sectionEnd]]; + } + + usort($ranges, static fn (array $a, array $b): int => $b[0] <=> $a[0]); + foreach ($ranges as [$start, $end]) { + array_splice($lines, $start, $end - $start); + } + + return implode("\n", $lines); + } + + /** + * @param list $directories relative to the NEON file + * @throws Neon2AttributesException + */ + public function addDirectoriesSection(string $content, array $directories): string + { + if (count($directories) === 0) { + return $content; + } + + $lines = explode("\n", $content); + $indent = $this->detectIndent($lines); + + $existingHeader = null; + foreach ($lines as $i => $line) { + if (preg_match('/^attributeServicesDirectories:\s*(#.*)?$/', $line) !== 1) { + continue; + } + + $existingHeader = $i; + break; + } + + if ($existingHeader !== null) { + [$headerLine, $sectionEnd] = $this->findSection($lines, 'attributeServicesDirectories'); + $existingEntries = []; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + if (preg_match('/^\s+-\s*(.+?)\s*$/', $lines[$i], $matches) !== 1) { + continue; + } + + $existingEntries[] = $matches[1]; + } + + $newLines = []; + foreach ($directories as $directory) { + if (in_array($directory, $existingEntries, true)) { + continue; + } + + $newLines[] = $indent . '- ' . $directory; + } + + // insert right after the last existing entry (before any trailing blank lines) + $insertAt = $headerLine + 1; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + if ($lines[$i] === '') { + continue; + } + + $insertAt = $i + 1; + } + array_splice($lines, $insertAt, 0, $newLines); + + return implode("\n", $lines); + } + + $newLines = ['attributeServicesDirectories:']; + foreach ($directories as $directory) { + $newLines[] = $indent . '- ' . $directory; + } + $newLines[] = ''; + + array_splice($lines, 0, 0, $newLines); + + return implode("\n", $lines); + } + + /** + * @param list $lines + * @return array{int, int} header line index, exclusive section end index + * @throws Neon2AttributesException + */ + private function findSection(array $lines, string $section): array + { + $headerLine = null; + foreach ($lines as $i => $line) { + if (preg_match(sprintf('/^%s:\s*(#.*)?$/', preg_quote($section, '/')), $line) !== 1) { + continue; + } + + $headerLine = $i; + break; + } + + if ($headerLine === null) { + throw new Neon2AttributesException(sprintf('Cannot find the `%s` section in the file.', $section)); + } + + $sectionEnd = count($lines); + for ($i = $headerLine + 1; $i < count($lines); $i++) { + if (preg_match('/^[^\s#]/', $lines[$i]) !== 1) { + continue; + } + + $sectionEnd = $i; + break; + } + + return [$headerLine, $sectionEnd]; + } + + /** + * @param list $lines + */ + private function detectIndent(array $lines): string + { + foreach ($lines as $line) { + if (preg_match('/^(\t+| +)\S/', $line, $matches) !== 1) { + continue; + } + + return $matches[1]; + } + + return "\t"; + } + +} diff --git a/src/Command/Neon2Attributes/PhpAttributeInserter.php b/src/Command/Neon2Attributes/PhpAttributeInserter.php new file mode 100644 index 00000000000..2d75437b6aa --- /dev/null +++ b/src/Command/Neon2Attributes/PhpAttributeInserter.php @@ -0,0 +1,246 @@ + $conversions conversions whose classes are declared in this content + * @throws Neon2AttributesException + */ + public function insert(string $content, array $conversions): string + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $stmts = $parser->parse($content); + if ($stmts === null) { + throw new Neon2AttributesException('Cannot parse the PHP file.'); + } + + $traverser = new NodeTraverser(new NameResolver(options: ['replaceNodes' => false])); + $stmts = $traverser->traverse($stmts); + $nodeFinder = new NodeFinder(); + + $lines = explode("\n", $content); + + $existingImports = []; + $existingAliases = []; + $lastUseLine = null; + $firstUseLine = null; + foreach ($nodeFinder->findInstanceOf($stmts, Use_::class) as $use) { + if ($use->type !== Use_::TYPE_NORMAL) { + continue; + } + foreach ($use->uses as $useUse) { + $existingImports[strtolower($useUse->name->toString())] = true; + $existingAliases[strtolower($useUse->getAlias()->toString())] = $useUse->name->toString(); + } + $firstUseLine ??= $use->getStartLine(); + $lastUseLine = $use->getEndLine(); + } + + /** @var list}> $insertions line number (1-based, insert before) => lines */ + $insertions = []; + $importsToAdd = []; + + foreach ($conversions as $conversion) { + $classNode = null; + foreach ($nodeFinder->findInstanceOf($stmts, ClassLike::class) as $candidate) { + if ($candidate->namespacedName === null || strcasecmp($candidate->namespacedName->toString(), $conversion->className) !== 0) { + continue; + } + + $classNode = $candidate; + break; + } + + if ($classNode === null) { + throw new Neon2AttributesException(sprintf('Class %s is not declared in %s.', $conversion->className, $conversion->phpFile)); + } + + $attributeCode = $conversion->attributeCode; + $parameterAttributes = $conversion->parameterAttributes; + foreach ($conversion->useImports as $import) { + $shortName = self::getShortName($import); + $lowerImport = strtolower($import); + $lowerShortName = strtolower($shortName); + if (isset($existingImports[$lowerImport])) { + continue; + } + if (isset($existingAliases[$lowerShortName]) && strcasecmp($existingAliases[$lowerShortName], $import) !== 0) { + // another class already claims the short name - fall back to the fully qualified form + $attributeCode = str_replace('#[' . $shortName, '#[\\' . $import, $attributeCode); + foreach ($parameterAttributes as $parameterName => $parameterAttributeCode) { + $parameterAttributes[$parameterName] = str_replace('#[' . $shortName, '#[\\' . $import, $parameterAttributeCode); + } + continue; + } + + $importsToAdd[$import] = true; + $existingAliases[$lowerShortName] = $import; + $existingImports[$lowerImport] = true; + } + + $classLine = $classNode->getStartLine(); + $indent = self::getIndent($lines[$classLine - 1] ?? ''); + $insertions[] = [$classLine, [$indent . $attributeCode]]; + + if (count($parameterAttributes) === 0) { + continue; + } + + $constructor = null; + foreach ($classNode->getMethods() as $method) { + if (strcasecmp($method->name->toString(), '__construct') !== 0) { + continue; + } + + $constructor = $method; + break; + } + if ($constructor === null) { + throw new Neon2AttributesException(sprintf('Class %s has no constructor to carry #[AutowiredParameter].', $conversion->className)); + } + + foreach ($parameterAttributes as $parameterName => $parameterAttributeCode) { + $parameterNode = null; + foreach ($constructor->getParams() as $param) { + if (!$param->var instanceof Variable || $param->var->name !== $parameterName) { + continue; + } + + $parameterNode = $param; + break; + } + if ($parameterNode === null) { + throw new Neon2AttributesException(sprintf('Constructor of %s has no parameter $%s.', $conversion->className, $parameterName)); + } + + $parameterLine = $parameterNode->getStartLine(); + if (!self::parameterStartsItsLine($constructor, $parameterLine, $parameterNode)) { + throw new Neon2AttributesException(sprintf('Parameter $%s of %s does not start its own line, cannot insert #[AutowiredParameter] deterministically.', $parameterName, $conversion->className)); + } + + $parameterIndent = self::getIndent($lines[$parameterLine - 1] ?? ''); + $insertions[] = [$parameterLine, [$parameterIndent . $parameterAttributeCode]]; + } + } + + if (count($importsToAdd) > 0) { + $insertions[] = $this->buildImportInsertion($lines, $stmts, $nodeFinder, $importsToAdd, $firstUseLine, $lastUseLine); + } + + usort($insertions, static fn (array $a, array $b): int => $b[0] <=> $a[0]); + foreach ($insertions as [$line, $newLines]) { + array_splice($lines, $line - 1, 0, $newLines); + } + + return implode("\n", $lines); + } + + /** + * @param list $lines + * @param Node[] $stmts + * @param array $importsToAdd + * @return array{int, list} + * @throws Neon2AttributesException + */ + private function buildImportInsertion(array $lines, array $stmts, NodeFinder $nodeFinder, array $importsToAdd, ?int $firstUseLine, ?int $lastUseLine): array + { + $newImports = array_keys($importsToAdd); + usort($newImports, static fn (string $a, string $b): int => strcasecmp($a, $b)); + $newLines = []; + foreach ($newImports as $import) { + $newLines[] = sprintf('use %s;', $import); + } + + if ($firstUseLine !== null && $lastUseLine !== null) { + // insert after the last existing import; exact alphabetical interleaving would + // require reordering foreign lines, appending keeps the edit minimal + return [$lastUseLine + 1, $newLines]; + } + + $namespaceNodes = $nodeFinder->findInstanceOf($stmts, Namespace_::class); + if (count($namespaceNodes) > 0 && $namespaceNodes[0]->name !== null) { + $namespaceLine = $namespaceNodes[0]->name->getEndLine(); + $insertAt = $namespaceLine + 1; + while (isset($lines[$insertAt - 1]) && $lines[$insertAt - 1] === '') { + $insertAt++; + } + + $newLines[] = ''; + return [$insertAt, $newLines]; + } + + throw new Neon2AttributesException('Cannot find a place for the use imports - the file has no namespace declaration.'); + } + + private static function parameterStartsItsLine(ClassMethod $constructor, int $parameterLine, Param $parameterNode): bool + { + if ($constructor->getStartLine() === $parameterLine) { + return false; + } + + foreach ($constructor->getParams() as $param) { + if ($param === $parameterNode) { + continue; + } + if ($param->getStartLine() === $parameterLine || $param->getEndLine() === $parameterLine) { + return false; + } + } + + return true; + } + + private static function getIndent(string $line): string + { + $matches = []; + if (preg_match('/^(\s*)/', $line, $matches) === 1) { + return $matches[1]; + } + + return ''; + } + + private static function getShortName(string $className): string + { + $pos = strrpos($className, '\\'); + if ($pos === false) { + return $className; + } + + return substr($className, $pos + 1); + } + +} diff --git a/src/Command/Neon2Attributes/ServiceConversion.php b/src/Command/Neon2Attributes/ServiceConversion.php new file mode 100644 index 00000000000..e70e72b583b --- /dev/null +++ b/src/Command/Neon2Attributes/ServiceConversion.php @@ -0,0 +1,29 @@ + $parameterAttributes constructor parameter name => attribute code + * @param list $useImports fully qualified names to import + */ + public function __construct( + public string $section, + public int $entryIndex, + public string $className, + public string $phpFile, + public string $attributeCode, + public array $parameterAttributes, + public array $useImports, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/SkippedEntry.php b/src/Command/Neon2Attributes/SkippedEntry.php new file mode 100644 index 00000000000..1bcc0de8bfe --- /dev/null +++ b/src/Command/Neon2Attributes/SkippedEntry.php @@ -0,0 +1,22 @@ +setName(self::NAME) + ->setDescription('Converts services and rules registered in a NEON file into PHPStan DI attributes on their classes') + ->setDefinition([ + new InputArgument('neon-file', InputArgument::REQUIRED, 'Path to the NEON file to convert'), + new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Only print what would be converted'), + new InputOption('print-service-fingerprint', null, InputOption::VALUE_NONE, '(internal) Print the tagged-services fingerprint of the configuration'), + ]); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (PHP_VERSION_ID < 80000) { + $output->writeln('The neon2attributes command requires PHP 8.0 or later.'); + return 1; + } + + $currentWorkingDirectory = getcwd(); + if ($currentWorkingDirectory === false) { + throw new ShouldNotHappenException(); + } + $fileHelper = new FileHelper($currentWorkingDirectory); + + $neonFileArgument = $input->getArgument('neon-file'); + if (!is_string($neonFileArgument)) { + throw new ShouldNotHappenException(); + } + $neonFile = $fileHelper->normalizePath($fileHelper->absolutizePath($neonFileArgument), '/'); + if (!is_file($neonFile)) { + $output->writeln(sprintf('File %s does not exist.', $neonFile)); + return 1; + } + + if ((bool) $input->getOption('print-service-fingerprint')) { + return $this->printFingerprint($neonFile, $currentWorkingDirectory, $output); + } + + $analyzer = new Neon2AttributesAnalyzer($fileHelper, $currentWorkingDirectory); + try { + $plan = $analyzer->analyze($neonFile); + } catch (Neon2AttributesException $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + return 1; + } + + [$plan, $newContents] = $this->buildPhpFileEdits($plan); + + foreach ($plan->conversions as $conversion) { + $output->writeln(sprintf('Converting %s → %s', $conversion->className, $conversion->attributeCode)); + } + foreach ($plan->skipped as $skipped) { + $output->writeln(sprintf('Keeping %s in `%s`: %s', $skipped->description, $skipped->section, $skipped->reason)); + } + + if (count($plan->conversions) === 0) { + $output->writeln('Nothing to convert.'); + return 0; + } + + if ((bool) $input->getOption('dry-run')) { + $output->writeln(sprintf('Would convert %d entries (dry run, nothing written).', count($plan->conversions))); + return 0; + } + + $originalFingerprint = $this->computeFingerprintInSubprocess($neonFile, $output); + if ($originalFingerprint === null) { + $output->writeln('The original configuration does not compile on its own - the conversion cannot be verified automatically.'); + } + + try { + $newContents[$neonFile] = $this->computeNeonContent($neonFile, $plan); + } catch (Neon2AttributesException $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + return 1; + } + + $backups = []; + foreach ($newContents as $file => $newContent) { + $backups[$file] = FileReader::read($file); + } + foreach ($newContents as $file => $newContent) { + FileWriter::write($file, $newContent); + } + + if ($originalFingerprint !== null) { + $convertedFingerprint = $this->computeFingerprintInSubprocess($neonFile, $output); + if ($convertedFingerprint === null || $convertedFingerprint !== $originalFingerprint) { + foreach ($backups as $file => $originalContent) { + FileWriter::write($file, $originalContent); + } + + if ($convertedFingerprint === null) { + $output->writeln('The converted configuration does not compile - all changes were rolled back.'); + } else { + $output->writeln('The converted configuration compiles into different tagged services than the original - all changes were rolled back.'); + $this->printFingerprintDiff($originalFingerprint, $convertedFingerprint, $output); + } + + return 1; + } + + $output->writeln('Verified: the converted configuration compiles into the same tagged services as the original.'); + } + + $output->writeln(sprintf('Converted %d entries; %d entries stay in the NEON file.', count($plan->conversions), count($plan->skipped))); + + return 0; + } + + /** + * Applies the attribute insertions file by file. A file the inserter cannot edit + * deterministically (an unusual layout) demotes its conversions back to kept entries + * instead of aborting the whole run. + * + * @return array{Neon2AttributesPlan, array} adjusted plan, php file => new content + */ + private function buildPhpFileEdits(Neon2AttributesPlan $plan): array + { + $conversionsByFile = []; + foreach ($plan->conversions as $conversion) { + $conversionsByFile[$conversion->phpFile][] = $conversion; + } + + $inserter = new PhpAttributeInserter(); + $survivingConversions = []; + $skipped = $plan->skipped; + $newContents = []; + foreach ($conversionsByFile as $file => $conversions) { + try { + $newContents[$file] = $inserter->insert(FileReader::read($file), $conversions); + } catch (Neon2AttributesException $e) { + foreach ($conversions as $conversion) { + $skipped[] = new SkippedEntry($conversion->section, $conversion->className, $e->getMessage()); + } + continue; + } + + foreach ($conversions as $conversion) { + $survivingConversions[] = $conversion; + } + } + + return [new Neon2AttributesPlan($survivingConversions, $skipped, $plan->directoriesToDeclare), $newContents]; + } + + /** + * @throws Neon2AttributesException + */ + private function computeNeonContent(string $neonFile, Neon2AttributesPlan $plan): string + { + $sectionIndexes = ['rules' => [], 'services' => []]; + $sectionCounts = ['rules' => 0, 'services' => 0]; + foreach ($plan->conversions as $conversion) { + $sectionIndexes[$conversion->section][] = $conversion->entryIndex; + } + $decoded = Neon::decode(FileReader::read($neonFile)); + if (is_array($decoded)) { + foreach (['rules', 'services'] as $section) { + $sectionValue = $decoded[$section] ?? []; + $sectionCounts[$section] = is_array($sectionValue) ? count($sectionValue) : 0; + } + } + + $editor = new NeonEditor(); + $neonContent = FileReader::read($neonFile); + foreach (['rules', 'services'] as $section) { + $neonContent = $editor->removeEntries($neonContent, $section, $sectionIndexes[$section], $sectionCounts[$section]); + } + + return $editor->addDirectoriesSection($neonContent, $plan->directoriesToDeclare); + } + + private function printFingerprint(string $neonFile, string $currentWorkingDirectory, OutputInterface $output): int + { + $tmpDir = sys_get_temp_dir() . '/phpstan-neon2attributes-' . md5(uniqid(more_entropy: true)); + @mkdir($tmpDir, 0777, true); + + // with a rule level in play (level 0 keeps the diff minimal), so that classes converted + // to #[RegisteredRule]/#[RegisteredCollector] register just like their rules:/tagged + // originals; without it autowiredAttributeServices.level stays null and none would + $container = (new ContainerFactory($currentWorkingDirectory))->create($tmpDir, [__DIR__ . '/../../conf/config.level0.neon', $neonFile], [], [$currentWorkingDirectory]); + $fingerprint = $this->buildFingerprint($container); + + $output->writeln(self::FINGERPRINT_DELIMITER); + $output->writeln(Json::encode($fingerprint)); + $output->writeln(self::FINGERPRINT_DELIMITER); + + return 0; + } + + /** + * Tag => sorted service class names of the compiled container. What Ondrej and Caleb + * compared by hand in the issue - same classes, same tags, same multiplicities. + * + * @return array> + */ + private function buildFingerprint(Container $container): array + { + $netteContainer = $container->getByType(NetteDIContainer::class); + + $tagsProperty = new ReflectionProperty(NetteDIContainer::class, 'tags'); + /** @var array> $tags */ + $tags = $tagsProperty->getValue($netteContainer); + + $fingerprint = []; + foreach ($tags as $tag => $services) { + $types = []; + foreach (array_keys($services) as $serviceName) { + $types[] = $netteContainer->getServiceType((string) $serviceName); + } + sort($types); + $fingerprint[$tag] = $types; + } + + ksort($fingerprint); + + return $fingerprint; + } + + /** + * @return array>|null + */ + private function computeFingerprintInSubprocess(string $neonFile, OutputInterface $output): ?array + { + $phpstanBinary = $_SERVER['argv'][0] ?? null; + if (!is_string($phpstanBinary)) { + return null; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open( + [PHP_BINARY, $phpstanBinary, self::NAME, '--print-service-fingerprint', $neonFile], + $descriptorSpec, + $pipes, + ); + if (!is_resource($process)) { + return null; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + if ($exitCode !== 0 || $stdout === false) { + if ($output->isVerbose() && $stderr !== false && $stderr !== '') { + $output->writeln($stderr); + } + + return null; + } + + $matches = []; + if (preg_match('/' . self::FINGERPRINT_DELIMITER . '\n(.*)\n' . self::FINGERPRINT_DELIMITER . '/s', $stdout, $matches) !== 1) { + return null; + } + + try { + $decoded = Json::decode($matches[1], Json::FORCE_ARRAY); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array> $original + * @param array> $converted + */ + private function printFingerprintDiff(array $original, array $converted, OutputInterface $output): void + { + foreach ($original as $tag => $types) { + $convertedTypes = $converted[$tag] ?? []; + if ($types === $convertedTypes) { + continue; + } + + $output->writeln(sprintf('Tag %s: %d services originally, %d after conversion.', $tag, count($types), count($convertedTypes))); + } + foreach ($converted as $tag => $types) { + if (isset($original[$tag])) { + continue; + } + + $output->writeln(sprintf('Tag %s: 0 services originally, %d after conversion.', $tag, count($types))); + } + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php index f43a3e2f242..b4edbdc0579 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php @@ -161,12 +161,14 @@ private function findCandidateClasses(ResolvedAttributeServicesDirectory $direct continue; } - if ($this->suggestsDiAttributes($file)) { - $this->errors[] = sprintf( - 'File %s in a directory from the attributeServicesDirectories section is not covered by the Composer autoload rules of the directory, so its class cannot be autoloaded.', - $file, - ); + if (!$this->suggestsDiAttributes($file)) { + continue; } + + $this->errors[] = sprintf( + 'File %s in a directory from the attributeServicesDirectories section is not covered by the Composer autoload rules of the directory, so its class cannot be autoloaded.', + $file, + ); } ksort($candidates); diff --git a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php index 448ea0e398a..8d486bb1162 100644 --- a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php +++ b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php @@ -50,10 +50,10 @@ public function findTargetClasses(string $attributeClass): array continue; } - /** @var TargetClass $target */ $targets[] = $target; } + /** @var list> */ return $targets; } @@ -75,10 +75,10 @@ public function findTargetMethodParameters(string $attributeClass): array continue; } - /** @var TargetMethodParameter $target */ $targets[] = $target; } + /** @var list> */ return $targets; } diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php new file mode 100644 index 00000000000..ec037308142 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php @@ -0,0 +1,50 @@ +analyze(__DIR__ . '/data/convert.neon'); + + $this->assertSame([ + ['rules', 0, 'Neon2AttributesFixtures\ConvFixtureRule', '#[RegisteredRule(level: 0)]'], + ['services', 0, 'Neon2AttributesFixtures\ConvFixtureService', '#[AutowiredService]'], + ['services', 1, 'Neon2AttributesFixtures\ConvFixtureExtension', '#[AutowiredService]'], + ['services', 2, 'Neon2AttributesFixtures\ConvFixtureUntaggedExtension', '#[AutowiredService(autoTag: false)]'], + ], array_map( + static fn (ServiceConversion $conversion): array => [$conversion->section, $conversion->entryIndex, $conversion->className, $conversion->attributeCode], + $plan->conversions, + )); + + $this->assertSame( + [ + 'tmpDir' => '#[AutowiredParameter]', + 'level' => "#[AutowiredParameter(ref: '%usedLevel%')]", + ], + $plan->conversions[1]->parameterAttributes, + ); + + $this->assertSame([ + ['PHPStan\File\FileHelper', 'The class already carries the PHPStan\DependencyInjection\AutowiredService attribute.'], + ['Nette\Neon\Neon', 'The class is not part of this project.'], + ['Neon2AttributesFixtures\ConvFixtureService', 'The service definition uses `setup` which cannot be expressed with an attribute.'], + ], array_map( + static fn (SkippedEntry $skipped): array => [$skipped->description, $skipped->reason], + $plan->skipped, + )); + + // the fixtures are autoloaded through the autoload-dev classmap rule covering tests/PHPStan + $this->assertSame(['../../..'], $plan->directoriesToDeclare); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php b/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php new file mode 100644 index 00000000000..ec80d29252f --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php @@ -0,0 +1,91 @@ +removeEntries(self::NEON, 'rules', [0], 2); + $result = $editor->removeEntries($result, 'services', [0, 2], 3); + + $this->assertSame(<<<'NEON' +parameters: + level: 0 + +rules: + - Foo\SecondRule + +services: + named: + class: Foo\NamedService +NEON, $result); + } + + public function testRemoveWholeSection(): void + { + $editor = new NeonEditor(); + $result = $editor->removeEntries(self::NEON, 'rules', [0, 1], 2); + + $this->assertSame(<<<'NEON' +parameters: + level: 0 + +services: + - + class: Foo\FirstService + arguments: + - %tmpDir% + named: + class: Foo\NamedService + - Foo\ThirdService +NEON, $result); + } + + public function testEntryCountMismatchAborts(): void + { + $editor = new NeonEditor(); + $this->expectException(Neon2AttributesException::class); + $this->expectExceptionMessage('Cannot map the `rules` section onto the file'); + $editor->removeEntries(self::NEON, 'rules', [0], 3); + } + + public function testAddDirectoriesSection(): void + { + $editor = new NeonEditor(); + $result = $editor->addDirectoriesSection("parameters:\n\tlevel: 0\n", ['src']); + + $this->assertSame("attributeServicesDirectories:\n\t- src\n\nparameters:\n\tlevel: 0\n", $result); + } + + public function testAddDirectoriesToExistingSection(): void + { + $editor = new NeonEditor(); + $result = $editor->addDirectoriesSection("attributeServicesDirectories:\n\t- src\n\nparameters:\n\tlevel: 0\n", ['src', 'rules']); + + $this->assertSame("attributeServicesDirectories:\n\t- src\n\t- rules\n\nparameters:\n\tlevel: 0\n", $result); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php b/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php new file mode 100644 index 00000000000..baae17cbbf6 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php @@ -0,0 +1,185 @@ + '#[AutowiredParameter]'], + ['PHPStan\DependencyInjection\AutowiredService', 'PHPStan\DependencyInjection\AutowiredParameter'], + ); + + $this->assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testInsertWithoutUseBlock(): void + { + $content = <<<'PHP' +assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testShortNameConflictFallsBackToFullyQualifiedForm(): void + { + $content = <<<'PHP' +assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testParameterSharingLineAborts(): void + { + $content = <<<'PHP' + '#[AutowiredParameter]'], + ['PHPStan\DependencyInjection\AutowiredService', 'PHPStan\DependencyInjection\AutowiredParameter'], + ); + + $this->expectException(Neon2AttributesException::class); + $this->expectExceptionMessage('does not start its own line'); + (new PhpAttributeInserter())->insert($content, [$conversion]); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php new file mode 100644 index 00000000000..7ffbf4ea37c --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php @@ -0,0 +1,26 @@ + + */ +final class ConvFixtureRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php new file mode 100644 index 00000000000..9ab6e756c2b --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php @@ -0,0 +1,20 @@ +tmpDir . $this->level; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php new file mode 100644 index 00000000000..e5015527364 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php @@ -0,0 +1,26 @@ +