From f6abd4bcc9d2ed01d1d51214ce8f3d5c84132fc9 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 10 Apr 2026 19:11:13 +0200 Subject: [PATCH 1/8] Add support for #[MapInput] console command DTOs Co-Authored-By: Claude Code --- src/Provider/SymfonyUsageProvider.php | 89 +++++++++++++++++++++++++++ tests/Rule/data/providers/symfony.php | 30 +++++++++ 2 files changed, 119 insertions(+) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 6cb633b..6e1e8a1 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -162,6 +162,7 @@ public function getUsages( $usages = [ ...$usages, ...$this->getMethodUsagesFromAttributeReflection($node, $scope), + ...$this->getMapInputUsages($node), ]; } @@ -644,6 +645,94 @@ private function getMethodUsagesFromAttributeReflection( return $usages; } + /** + * @return list + */ + private function getMapInputUsages(InClassMethodNode $node): array + { + $usages = []; + + foreach ($node->getMethodReflection()->getParameters() as $parameter) { + $isMapInput = false; + + foreach ($parameter->getAttributes() as $attributeReflection) { + if ($attributeReflection->getName() === 'Symfony\Component\Console\Attribute\MapInput') { + $isMapInput = true; + break; + } + } + + if (!$isMapInput) { + continue; + } + + $parameterType = $parameter->getType(); + + if (!$parameterType->isObject()->yes()) { + continue; + } + + foreach ($parameterType->getObjectClassNames() as $dtoClassName) { + if (!$this->reflectionProvider->hasClass($dtoClassName)) { + continue; + } + + $this->collectMapInputDtoUsages($dtoClassName, $usages); + } + } + + return $usages; + } + + /** + * @param list $usages + */ + private function collectMapInputDtoUsages( + string $dtoClassName, + array &$usages, + ): void + { + $dtoReflection = $this->reflectionProvider->getClass($dtoClassName); + $origin = UsageOrigin::createVirtual($this, VirtualUsageData::withNote('Console input DTO via #[MapInput]')); + + foreach ($dtoReflection->getNativeReflection()->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + $isInputProperty = $property->getAttributes('Symfony\Component\Console\Attribute\Argument') !== [] + || $property->getAttributes('Symfony\Component\Console\Attribute\Option') !== []; + + if (!$isInputProperty) { + continue; + } + + $usages[] = new ClassPropertyUsage( + $origin, + new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), + AccessType::WRITE, + ); + $usages[] = new ClassPropertyUsage( + $origin, + new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), + AccessType::READ, + ); + } + + foreach ($dtoReflection->getNativeReflection()->getMethods() as $dtoMethod) { + if ($dtoMethod->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + if ($dtoMethod->getAttributes('Symfony\Component\Console\Attribute\Interact') !== []) { + $usages[] = new ClassMethodUsage( + $origin, + new ClassMethodRef($dtoClassName, $dtoMethod->getName(), possibleDescendant: false), + ); + } + } + } + private function shouldMarkAsUsed(ReflectionMethod $method): ?string { if ($this->isBundleConstructor($method)) { diff --git a/tests/Rule/data/providers/symfony.php b/tests/Rule/data/providers/symfony.php index f7fb12b..e25f980 100644 --- a/tests/Rule/data/providers/symfony.php +++ b/tests/Rule/data/providers/symfony.php @@ -235,3 +235,33 @@ class RequiredPropertyService { public object $unused; // error: Property Symfony\RequiredPropertyService::$unused is never read // error: Property Symfony\RequiredPropertyService::$unused is never written } +class ImportInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $file; + + #[\Symfony\Component\Console\Attribute\Option] + public bool $force = false; + + public string $notAnInput; // error: Property Symfony\ImportInput::$notAnInput is never read // error: Property Symfony\ImportInput::$notAnInput is never written + + #[Interact] + public function askForConfirmation(): void {} +} + +#[AsCommand(name: 'app:import')] +class ImportCommand extends Command { + public function __invoke( + #[\Symfony\Component\Console\Attribute\MapInput] ImportInput $input, + ): int { + echo $input->file; + return 0; + } +} + +class OrphanedInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $name; // error: Property Symfony\OrphanedInput::$name is never read // error: Property Symfony\OrphanedInput::$name is never written + + #[Interact] + public function askSomething(): void {} // error: Unused Symfony\OrphanedInput::askSomething +} From 17ce9d0b17b59a310059929708104544dc499e1c Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 15:27:21 +0200 Subject: [PATCH 2/8] Fix PHPDoc types for getMapInputUsages and collectMapInputDtoUsages Co-Authored-By: Claude Code --- src/Provider/SymfonyUsageProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 6e1e8a1..8b5dd8c 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -646,7 +646,7 @@ private function getMethodUsagesFromAttributeReflection( } /** - * @return list + * @return list */ private function getMapInputUsages(InClassMethodNode $node): array { @@ -685,7 +685,7 @@ private function getMapInputUsages(InClassMethodNode $node): array } /** - * @param list $usages + * @param list $usages */ private function collectMapInputDtoUsages( string $dtoClassName, From d76d366296cd6fea1bd136b4923386413d2e165c Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 13:20:09 +0200 Subject: [PATCH 3/8] Support nested #[MapInput] in console command DTOs Recurse into DTO properties annotated with #[MapInput] so their reported as dead. The host property is marked read+written. --- src/Provider/SymfonyUsageProvider.php | 27 ++++++++++++++++++++- tests/Rule/data/providers/symfony.php | 34 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 8b5dd8c..bb9fafe 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -686,12 +686,24 @@ private function getMapInputUsages(InClassMethodNode $node): array /** * @param list $usages + * @param array $visited */ private function collectMapInputDtoUsages( string $dtoClassName, array &$usages, + array &$visited = [], ): void { + if (isset($visited[$dtoClassName])) { + return; + } + + $visited[$dtoClassName] = true; + + if (!$this->reflectionProvider->hasClass($dtoClassName)) { + return; + } + $dtoReflection = $this->reflectionProvider->getClass($dtoClassName); $origin = UsageOrigin::createVirtual($this, VirtualUsageData::withNote('Console input DTO via #[MapInput]')); @@ -702,8 +714,9 @@ private function collectMapInputDtoUsages( $isInputProperty = $property->getAttributes('Symfony\Component\Console\Attribute\Argument') !== [] || $property->getAttributes('Symfony\Component\Console\Attribute\Option') !== []; + $nestedMapInput = $property->getAttributes('Symfony\Component\Console\Attribute\MapInput') !== []; - if (!$isInputProperty) { + if (!$isInputProperty && !$nestedMapInput) { continue; } @@ -717,6 +730,18 @@ private function collectMapInputDtoUsages( new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), AccessType::READ, ); + + if (!$nestedMapInput) { + continue; + } + + $propertyType = $property->getType(); + + if (!$propertyType instanceof ReflectionNamedType || $propertyType->isBuiltin()) { + continue; + } + + $this->collectMapInputDtoUsages($propertyType->getName(), $usages, $visited); } foreach ($dtoReflection->getNativeReflection()->getMethods() as $dtoMethod) { diff --git a/tests/Rule/data/providers/symfony.php b/tests/Rule/data/providers/symfony.php index e25f980..ee1f6b0 100644 --- a/tests/Rule/data/providers/symfony.php +++ b/tests/Rule/data/providers/symfony.php @@ -265,3 +265,37 @@ class OrphanedInput { #[Interact] public function askSomething(): void {} // error: Unused Symfony\OrphanedInput::askSomething } + +class NestedFiltersInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $tag; + + #[\Symfony\Component\Console\Attribute\Option] + public bool $strict = false; + + public string $notAnInput; // error: Property Symfony\NestedFiltersInput::$notAnInput is never read // error: Property Symfony\NestedFiltersInput::$notAnInput is never written + + #[Interact] + public function askForTag(): void {} + + public function deadOnNested(): void {} // error: Unused Symfony\NestedFiltersInput::deadOnNested +} + +class WrappedImportInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $name; + + #[\Symfony\Component\Console\Attribute\MapInput] + public NestedFiltersInput $filters; +} + +#[AsCommand(name: 'app:import-wrapped')] +class WrappedImportCommand extends Command { + public function __invoke( + #[\Symfony\Component\Console\Attribute\MapInput] WrappedImportInput $input, + ): int { + echo $input->name; + echo $input->filters->tag; + return 0; + } +} From 1b84252e3201ac78091d9d5bab288163940aa6b4 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 15:05:09 +0200 Subject: [PATCH 4/8] Ignore ReflectionProperty::getAttributes class-string error on PHP 8.1 Symfony Console 7.x (which introduced MapInput/Argument/Option attributes) requires PHP 8.2+, so on PHP 8.1 those classes are unknown and PHPStan rejects the literal class-string. Uses reportUnmatched: false so the ignore doesn't fail on PHP 8.2+. --- phpstan.neon.dist | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 937c478..59385e3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -54,3 +54,9 @@ parameters: # allow referencing any attribute classes - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionClass\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionMethod\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' + + # Symfony Console 7.x (which introduced MapInput/Argument/Option attributes) requires PHP 8.2+, + # so on PHP 8.1 those classes are unknown and the literal class-string is rejected + - + message: '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionProperty\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' + reportUnmatched: false From c754da8eb87670f62c294e1002484871fa650b2f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 15:58:24 +0200 Subject: [PATCH 5/8] Consolidate ReflectionProperty getAttributes ignore with existing group Fits under the same "allow referencing any attribute classes" block as the Class/Method variants. --- phpstan.neon.dist | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 59385e3..8d4d0b9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -51,12 +51,10 @@ parameters: message: "#but it's missing from the PHPDoc @throws tag\\.$#" # allow uncatched exceptions in tests path: tests/* - # allow referencing any attribute classes + # allow referencing any attribute classes (ReflectionProperty variant only triggers on PHP 8.1, + # where Symfony Console 6.x is installed and MapInput/Argument/Option attribute classes are unknown) - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionClass\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionMethod\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' - - # Symfony Console 7.x (which introduced MapInput/Argument/Option attributes) requires PHP 8.2+, - # so on PHP 8.1 those classes are unknown and the literal class-string is rejected - message: '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionProperty\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' reportUnmatched: false From 948cc7f85e69b6713269bd417ea66bdfdc4df169 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 16 Apr 2026 12:53:59 +0200 Subject: [PATCH 6/8] Restrict MapInput detection to __invoke on console commands Only process #[MapInput] parameters when the method is __invoke and the owning class has #[AsCommand] or extends Command. --- src/Provider/SymfonyUsageProvider.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index bb9fafe..c34ceab 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -650,6 +650,23 @@ private function getMethodUsagesFromAttributeReflection( */ private function getMapInputUsages(InClassMethodNode $node): array { + if ($node->getMethodReflection()->getName() !== '__invoke') { + return []; + } + + $nativeReflection = $node->getClassReflection()->getNativeReflection(); + + if ($nativeReflection instanceof ReflectionEnum) { + return []; + } + + $isCommand = $this->hasAttribute($nativeReflection, 'Symfony\Component\Console\Attribute\AsCommand') + || $nativeReflection->isSubclassOf('Symfony\Component\Console\Command\Command'); + + if (!$isCommand) { + return []; + } + $usages = []; foreach ($node->getMethodReflection()->getParameters() as $parameter) { From 7a163d8b9a2fc1bfa5fb41f9f0d2c2661412bd3b Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 16 Apr 2026 12:59:14 +0200 Subject: [PATCH 7/8] Widen hasAttribute to accept ReflectionEnum instead of guarding callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enum cannot be a command, so the isCommand check already handles that case — no need for an instanceof ReflectionEnum guard. --- src/Provider/SymfonyUsageProvider.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index c34ceab..2aeb593 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; +use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty; use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound; @@ -30,7 +31,6 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionAttribute; -use ReflectionEnum; use ReflectionNamedType; use Reflector; use ShipMonk\PHPStan\DeadCode\Enum\AccessType; @@ -656,10 +656,6 @@ private function getMapInputUsages(InClassMethodNode $node): array $nativeReflection = $node->getClassReflection()->getNativeReflection(); - if ($nativeReflection instanceof ReflectionEnum) { - return []; - } - $isCommand = $this->hasAttribute($nativeReflection, 'Symfony\Component\Console\Attribute\AsCommand') || $nativeReflection->isSubclassOf('Symfony\Component\Console\Command\Command'); @@ -1456,7 +1452,7 @@ private function isProbablySymfonyListener(ReflectionMethod $method): bool } /** - * @param ReflectionClass|ReflectionMethod|ReflectionProperty $classOrMethod + * @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionEnum $classOrMethod * @param ReflectionAttribute::IS_*|0 $flags */ private function hasAttribute( From cab96997c518c3bc173fee8b1ff7995eee5e548b Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 16 Apr 2026 13:14:57 +0200 Subject: [PATCH 8/8] Refactor collectMapInputDtoUsages: return usages, reuse helpers - Return list instead of mutating &$usages parameter - Use hasAttribute() for all attribute checks (IdentifierNotFound guard) - Use createPropertyUsage() instead of inline construction - Extract getNativeReflection() to local variable - Drop redundant hasClass check from caller --- src/Provider/SymfonyUsageProvider.php | 49 +++++++++++---------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 2aeb593..4e40657 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -686,11 +686,7 @@ private function getMapInputUsages(InClassMethodNode $node): array } foreach ($parameterType->getObjectClassNames() as $dtoClassName) { - if (!$this->reflectionProvider->hasClass($dtoClassName)) { - continue; - } - - $this->collectMapInputDtoUsages($dtoClassName, $usages); + $usages = [...$usages, ...$this->collectMapInputDtoUsages($dtoClassName)]; } } @@ -698,51 +694,44 @@ private function getMapInputUsages(InClassMethodNode $node): array } /** - * @param list $usages * @param array $visited + * @return list */ private function collectMapInputDtoUsages( string $dtoClassName, - array &$usages, array &$visited = [], - ): void + ): array { if (isset($visited[$dtoClassName])) { - return; + return []; } $visited[$dtoClassName] = true; if (!$this->reflectionProvider->hasClass($dtoClassName)) { - return; + return []; } $dtoReflection = $this->reflectionProvider->getClass($dtoClassName); - $origin = UsageOrigin::createVirtual($this, VirtualUsageData::withNote('Console input DTO via #[MapInput]')); + $nativeReflection = $dtoReflection->getNativeReflection(); + $note = 'Console input DTO via #[MapInput]'; + $usages = []; - foreach ($dtoReflection->getNativeReflection()->getProperties() as $property) { + foreach ($nativeReflection->getProperties() as $property) { if ($property->getDeclaringClass()->getName() !== $dtoClassName) { continue; } - $isInputProperty = $property->getAttributes('Symfony\Component\Console\Attribute\Argument') !== [] - || $property->getAttributes('Symfony\Component\Console\Attribute\Option') !== []; - $nestedMapInput = $property->getAttributes('Symfony\Component\Console\Attribute\MapInput') !== []; + $isInputProperty = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Argument') + || $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Option'); + $nestedMapInput = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\MapInput'); if (!$isInputProperty && !$nestedMapInput) { continue; } - $usages[] = new ClassPropertyUsage( - $origin, - new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), - AccessType::WRITE, - ); - $usages[] = new ClassPropertyUsage( - $origin, - new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), - AccessType::READ, - ); + $usages[] = $this->createPropertyUsage($property, $note, AccessType::WRITE); + $usages[] = $this->createPropertyUsage($property, $note, AccessType::READ); if (!$nestedMapInput) { continue; @@ -754,21 +743,23 @@ private function collectMapInputDtoUsages( continue; } - $this->collectMapInputDtoUsages($propertyType->getName(), $usages, $visited); + $usages = [...$usages, ...$this->collectMapInputDtoUsages($propertyType->getName(), $visited)]; } - foreach ($dtoReflection->getNativeReflection()->getMethods() as $dtoMethod) { + foreach ($nativeReflection->getMethods() as $dtoMethod) { if ($dtoMethod->getDeclaringClass()->getName() !== $dtoClassName) { continue; } - if ($dtoMethod->getAttributes('Symfony\Component\Console\Attribute\Interact') !== []) { + if ($this->hasAttribute($dtoMethod, 'Symfony\Component\Console\Attribute\Interact')) { $usages[] = new ClassMethodUsage( - $origin, + UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), new ClassMethodRef($dtoClassName, $dtoMethod->getName(), possibleDescendant: false), ); } } + + return $usages; } private function shouldMarkAsUsed(ReflectionMethod $method): ?string