From 01508dd5a40a57805f97524ab49c1c8a838fa7c4 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 10:35:01 +0200 Subject: [PATCH 01/21] Extract RootType::namespaces() and refactor FieldValue::parentNamespaces() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Schema/RootType.php | 13 +++++++++++++ src/Schema/Values/FieldValue.php | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Schema/RootType.php b/src/Schema/RootType.php index 75ca8afc14..1dc54f5816 100644 --- a/src/Schema/RootType.php +++ b/src/Schema/RootType.php @@ -21,4 +21,17 @@ public static function isRootType(string $typeName): bool ], ); } + + /** @return array */ + public static function namespaces(string $rootType): array + { + $key = match ($rootType) { + self::QUERY => 'queries', + self::MUTATION => 'mutations', + self::SUBSCRIPTION => 'subscriptions', + default => throw new \InvalidArgumentException("Invalid root type: {$rootType}."), + }; + + return (array) config("lighthouse.namespaces.{$key}"); + } } diff --git a/src/Schema/Values/FieldValue.php b/src/Schema/Values/FieldValue.php index d2e18ca430..c8497069fd 100644 --- a/src/Schema/Values/FieldValue.php +++ b/src/Schema/Values/FieldValue.php @@ -81,9 +81,9 @@ public function parentNamespaces(): array $parentName = $this->getParentName(); return match ($parentName) { - RootType::QUERY => (array) config('lighthouse.namespaces.queries'), - RootType::MUTATION => (array) config('lighthouse.namespaces.mutations'), - RootType::SUBSCRIPTION => (array) config('lighthouse.namespaces.subscriptions'), + RootType::QUERY, + RootType::MUTATION, + RootType::SUBSCRIPTION => RootType::namespaces($parentName), default => array_map( static fn (string $typesNamespace): string => "{$typesNamespace}\\{$parentName}", (array) config('lighthouse.namespaces.types'), From 3683814fa004588b39618c4cc27097ef5d607ca3 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:12:49 +0200 Subject: [PATCH 02/21] Add RootResolverSignatureRector with type-fixing behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 314 ++++++++++++++++++ .../Fixture/missing_root_param.php.inc | 23 ++ .../Fixture/narrow_mixed_root.php.inc | 23 ++ .../Fixture/single_wrong_root_param.php.inc | 23 ++ .../Fixture/skip_correct_null.php.inc | 11 + .../Fixture/skip_custom_context.php.inc | 14 + .../skip_non_resolver_namespace.php.inc | 11 + .../Fixture/skip_subdirectory.php.inc | 11 + .../Fixture/skip_zero_params.php.inc | 11 + .../Fixture/wrong_context_type.php.inc | 29 ++ .../Fixture/wrong_root_type.php.inc | 23 ++ .../Fixture/wrong_second_param.php.inc | 23 ++ .../RootResolverSignatureRectorTest.php | 26 ++ .../Source/CustomContext.php | 24 ++ .../config/configured_rule.php | 12 + 15 files changed, 578 insertions(+) create mode 100644 src/Rector/RootResolverSignatureRector.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/missing_root_param.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/narrow_mixed_root.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/single_wrong_root_param.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/skip_correct_null.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/skip_custom_context.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/skip_non_resolver_namespace.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/skip_subdirectory.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/skip_zero_params.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/wrong_context_type.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/wrong_root_type.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/wrong_second_param.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorTest.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Source/CustomContext.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule.php diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php new file mode 100644 index 0000000000..6e9c4ef623 --- /dev/null +++ b/src/Rector/RootResolverSignatureRector.php @@ -0,0 +1,314 @@ + */ + private array $paramNames = []; + + public function __construct( + private PhpVersionProvider $phpVersionProvider, + ) {} + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Fix root resolver __invoke signatures to match Lighthouse calling convention', [ + new CodeSample( + <<<'CODE_SAMPLE' +namespace App\GraphQL\Queries; + +class Users +{ + public function __invoke(array $args) + { + return []; + } +} +CODE_SAMPLE, + <<<'CODE_SAMPLE' +namespace App\GraphQL\Queries; + +class Users +{ + public function __invoke(null $root, array $args) + { + return []; + } +} +CODE_SAMPLE, + ), + ]); + } + + /** @return array> */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** @param array $configuration */ + public function configure(array $configuration): void + { + $paramNames = $configuration['paramNames'] ?? []; + + if (count($paramNames) > 4) { + throw new InvalidConfigurationException('paramNames must have at most 4 elements.'); + } + + $this->paramNames = $paramNames; + } + + /** @param Class_ $node */ + public function refactor(Node $node): ?Node + { + if (! $this->isRootResolver($node)) { + return null; + } + + $invokeMethod = $node->getMethod('__invoke'); + if (! $invokeMethod instanceof ClassMethod) { + return null; + } + + if ($invokeMethod->params === []) { + return null; + } + + $changed = false; + + if ($this->isMissingRootParam($invokeMethod)) { + $this->prependRootParam($invokeMethod); + $changed = true; + } + + if ($this->fixParamType($invokeMethod, 0, $this->rootTypeIdentifier())) { + $changed = true; + } + + if ($this->ensureMinParams($invokeMethod, 2)) { + $changed = true; + } + + if ($this->fixParamType($invokeMethod, 1, new Identifier('array'))) { + $changed = true; + } + + if (isset($invokeMethod->params[2]) && $this->fixContextParam($invokeMethod)) { + $changed = true; + } + + if (isset($invokeMethod->params[3]) && $this->fixResolveInfoParam($invokeMethod)) { + $changed = true; + } + + if ($this->normalizeNames($invokeMethod)) { + $changed = true; + } + + if (! $changed) { + return null; + } + + return $node; + } + + private function isRootResolver(Class_ $node): bool + { + $fqcn = $node->namespacedName?->toString(); + if ($fqcn === null) { + return false; + } + + foreach ($this->resolverNamespaces() as $namespace) { + if ($this->isDirectChildOfNamespace($fqcn, $namespace)) { + return true; + } + } + + return false; + } + + /** @return list */ + private function resolverNamespaces(): array + { + try { + $namespaces = [ + ...RootType::namespaces(RootType::QUERY), + ...RootType::namespaces(RootType::MUTATION), + ]; + } catch (\Throwable) { + return self::DEFAULT_NAMESPACES; + } + + if ($namespaces === []) { + return self::DEFAULT_NAMESPACES; + } + + return $namespaces; + } + + private function isDirectChildOfNamespace(string $fqcn, string $namespace): bool + { + return str_starts_with($fqcn, $namespace . '\\') + && ! str_contains(substr($fqcn, strlen($namespace) + 1), '\\'); + } + + private function isMissingRootParam(ClassMethod $method): bool + { + $firstParam = $method->params[0]; + + return $firstParam->type instanceof Identifier + && $firstParam->type->name === 'array'; + } + + private function prependRootParam(ClassMethod $method): void + { + $rootParam = new Param( + new Variable('root'), + null, + $this->rootTypeIdentifier(), + ); + + array_unshift($method->params, $rootParam); + } + + private function rootTypeIdentifier(): Identifier + { + if ($this->phpVersionProvider->isAtLeastPhpVersion(PhpVersion::PHP_82)) { + return new Identifier('null'); + } + + return new Identifier('mixed'); + } + + private function fixParamType(ClassMethod $method, int $index, Identifier $expectedType): bool + { + if (! isset($method->params[$index])) { + return false; + } + + $param = $method->params[$index]; + $currentType = $param->type; + + if ($currentType instanceof Identifier && $currentType->name === $expectedType->name) { + return false; + } + + $param->type = $expectedType; + + return true; + } + + private function ensureMinParams(ClassMethod $method, int $minCount): bool + { + if (count($method->params) >= $minCount) { + return false; + } + + while (count($method->params) < $minCount) { + $index = count($method->params); + $method->params[] = match ($index) { + 1 => new Param(new Variable('args'), null, new Identifier('array')), + default => throw new \LogicException("Unexpected param index: {$index}."), + }; + } + + return true; + } + + private function fixContextParam(ClassMethod $method): bool + { + $param = $method->params[2]; + $currentType = $param->type; + + if ($currentType instanceof FullyQualified || $currentType instanceof Node\Name) { + $typeName = $currentType->toString(); + $objectType = new ObjectType($typeName); + $contextType = new ObjectType(\Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class); + + if ($contextType->isSuperTypeOf($objectType)->yes()) { + return false; + } + } + + $param->type = new FullyQualified(\Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class); + + return true; + } + + private function fixResolveInfoParam(ClassMethod $method): bool + { + $param = $method->params[3]; + $currentType = $param->type; + + if ($currentType instanceof FullyQualified || $currentType instanceof Node\Name) { + $typeName = $currentType->toString(); + $objectType = new ObjectType($typeName); + $resolveInfoType = new ObjectType(\Nuwave\Lighthouse\Execution\ResolveInfo::class); + + if ($resolveInfoType->isSuperTypeOf($objectType)->yes() || $objectType->equals($resolveInfoType)) { + return false; + } + } + + $param->type = new FullyQualified(\Nuwave\Lighthouse\Execution\ResolveInfo::class); + + return true; + } + + private function normalizeNames(ClassMethod $method): bool + { + if ($this->paramNames === []) { + return false; + } + + $changed = false; + + foreach ($this->paramNames as $index => $name) { + if ($name === null) { + continue; + } + + if (! isset($method->params[$index])) { + continue; + } + + $param = $method->params[$index]; + if (! $param->var instanceof Variable) { + continue; + } + + if ($param->var->name === $name) { + continue; + } + + $param->var = new Variable($name); + $changed = true; + } + + return $changed; + } +} diff --git a/tests/Unit/Rector/RootResolverSignatureRector/Fixture/missing_root_param.php.inc b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/missing_root_param.php.inc new file mode 100644 index 0000000000..e341b113cc --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/missing_root_param.php.inc @@ -0,0 +1,23 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/Unit/Rector/RootResolverSignatureRector/Source/CustomContext.php b/tests/Unit/Rector/RootResolverSignatureRector/Source/CustomContext.php new file mode 100644 index 0000000000..8d35ba7f00 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/Source/CustomContext.php @@ -0,0 +1,24 @@ +rule(RootResolverSignatureRector::class); + $rectorConfig->importNames(); + $rectorConfig->bootstrapFiles([ + __DIR__ . '/../Source/CustomContext.php', + ]); +}; From be9a69cee1c94571fd0ec657bafe8941d1bb8d3a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:14:10 +0200 Subject: [PATCH 03/21] Add name normalization tests for RootResolverSignatureRector --- .../normalize_partial.php.inc | 29 +++++++++++++++++++ .../FixtureWithNames/normalize_names.php.inc | 23 +++++++++++++++ ...esolverSignatureRectorPartialNamesTest.php | 26 +++++++++++++++++ ...otResolverSignatureRectorWithNamesTest.php | 26 +++++++++++++++++ .../config/configured_rule_partial_names.php | 13 +++++++++ .../config/configured_rule_with_names.php | 13 +++++++++ 6 files changed, 130 insertions(+) create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/FixturePartialNames/normalize_partial.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/FixtureWithNames/normalize_names.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorPartialNamesTest.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php diff --git a/tests/Unit/Rector/RootResolverSignatureRector/FixturePartialNames/normalize_partial.php.inc b/tests/Unit/Rector/RootResolverSignatureRector/FixturePartialNames/normalize_partial.php.inc new file mode 100644 index 0000000000..810747fe4d --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/FixturePartialNames/normalize_partial.php.inc @@ -0,0 +1,29 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/FixturePartialNames'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule_partial_names.php'; + } +} diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php new file mode 100644 index 0000000000..691c806303 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php @@ -0,0 +1,26 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/FixtureWithNames'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule_with_names.php'; + } +} diff --git a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php new file mode 100644 index 0000000000..ce6e6c35f5 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php @@ -0,0 +1,13 @@ +ruleWithConfiguration(RootResolverSignatureRector::class, [ + 'paramNames' => [null, null, 'context', 'resolveInfo'], + ]); + $rectorConfig->bootstrapFiles([ + __DIR__ . '/../Source/CustomContext.php', + ]); +}; diff --git a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php new file mode 100644 index 0000000000..b91cffb024 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php @@ -0,0 +1,13 @@ +ruleWithConfiguration(RootResolverSignatureRector::class, [ + 'paramNames' => ['_', 'args', 'context', 'resolveInfo'], + ]); + $rectorConfig->bootstrapFiles([ + __DIR__ . '/../Source/CustomContext.php', + ]); +}; From e6f85c1c21112db2ed1cba689cd6f968ca4cb49c Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:15:08 +0200 Subject: [PATCH 04/21] Document root resolvers and the RootResolverSignatureRector rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- docs/master/api-reference/resolvers.md | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/master/api-reference/resolvers.md b/docs/master/api-reference/resolvers.md index e91fabbe94..18fc7cf18d 100644 --- a/docs/master/api-reference/resolvers.md +++ b/docs/master/api-reference/resolvers.md @@ -22,6 +22,74 @@ function (mixed $root, array $args, GraphQLContext $context, ResolveInfo $resolv The return value of this must fit the return type defined for the corresponding field from the schema. +## Root resolvers + +Root resolvers are classes with an `__invoke` method that sit directly in the configured +`lighthouse.namespaces.queries` or `lighthouse.namespaces.mutations` namespaces. +Lighthouse calls them with positional arguments `($root, $args, $context, $resolveInfo)`, +where `$root` is always `null` for root types. + +Omitting `$root` causes Lighthouse's positional `null` to bind to `$args`, producing TypeErrors at runtime. +The canonical signature for a root resolver is: + +```php +use Nuwave\Lighthouse\Execution\ResolveInfo; +use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; + +class MyQuery +{ + public function __invoke(null $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) + { + // ... + } +} +``` + +### Rector rule + +Lighthouse ships a Rector rule that enforces correct `__invoke` signatures on root resolvers. +Enable it in your `rector.php`: + +```php +use Nuwave\Lighthouse\Rector\RootResolverSignatureRector; +use Rector\Config\RectorConfig; + +return static function (RectorConfig $rectorConfig): void { + $rectorConfig->rule(RootResolverSignatureRector::class); + + // Required: Larastan bootstrap makes config() available + $rectorConfig->bootstrapFiles([ + __DIR__ . '/vendor/larastan/larastan/bootstrap.php', + ]); +}; +``` + +The rule fixes: + +- Missing `$root` parameter (detected when the first param is typed `array`) +- Wrong type on the `$root` parameter (must be `null` on PHP 8.2+, `mixed` on earlier versions) +- Wrong type on `$args` (must be `array`) +- Wrong type on `$context` if present (must implement `GraphQLContext`) +- Wrong type on `$resolveInfo` if present (must be or extend `ResolveInfo`) + +#### Name normalization + +Optionally configure preferred parameter names: + +```php +$rectorConfig->ruleWithConfiguration(RootResolverSignatureRector::class, [ + 'paramNames' => ['_', 'args', 'context', 'resolveInfo'], +]); +``` + +Use `null` at any position to skip renaming that parameter: + +```php +$rectorConfig->ruleWithConfiguration(RootResolverSignatureRector::class, [ + 'paramNames' => [null, null, 'context', 'resolveInfo'], +]); +``` + ## Complexity function signature The complexity function is used to calculate a query complexity score for a field. From 89bcd0b289283ad97e2afe7a7d0a7ff22542eab7 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:26:37 +0200 Subject: [PATCH 05/21] Require Larastan bootstrap for RootResolverSignatureRector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule calls RootType::namespaces() which requires config() to be available. Instead of silently falling back to defaults when config() is unavailable, fail loudly as the spec requires. The test bootstrap loads the Larastan bootstrap and registers the Lighthouse config. 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 23 ++++--------------- .../RootResolverSignatureRector/bootstrap.php | 11 +++++++++ .../config/configured_rule.php | 1 + .../config/configured_rule_partial_names.php | 1 + .../config/configured_rule_with_names.php | 1 + 5 files changed, 18 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 6e9c4ef623..f886bbf57d 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -21,11 +21,6 @@ class RootResolverSignatureRector extends AbstractRector implements ConfigurableRectorInterface { - private const DEFAULT_NAMESPACES = [ - 'App\\GraphQL\\Queries', - 'App\\GraphQL\\Mutations', - ]; - /** @var array */ private array $paramNames = []; @@ -154,20 +149,10 @@ private function isRootResolver(Class_ $node): bool /** @return list */ private function resolverNamespaces(): array { - try { - $namespaces = [ - ...RootType::namespaces(RootType::QUERY), - ...RootType::namespaces(RootType::MUTATION), - ]; - } catch (\Throwable) { - return self::DEFAULT_NAMESPACES; - } - - if ($namespaces === []) { - return self::DEFAULT_NAMESPACES; - } - - return $namespaces; + return [ + ...RootType::namespaces(RootType::QUERY), + ...RootType::namespaces(RootType::MUTATION), + ]; } private function isDirectChildOfNamespace(string $fqcn, string $namespace): bool diff --git a/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php b/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php new file mode 100644 index 0000000000..837593c71f --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php @@ -0,0 +1,11 @@ +make('config')->set('lighthouse', require __DIR__ . '/../../../../src/lighthouse.php'); diff --git a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule.php b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule.php index b5c6d9f0b8..04234a8c93 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule.php @@ -7,6 +7,7 @@ $rectorConfig->rule(RootResolverSignatureRector::class); $rectorConfig->importNames(); $rectorConfig->bootstrapFiles([ + __DIR__ . '/../bootstrap.php', __DIR__ . '/../Source/CustomContext.php', ]); }; diff --git a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php index ce6e6c35f5..d5ccfe775c 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_partial_names.php @@ -8,6 +8,7 @@ 'paramNames' => [null, null, 'context', 'resolveInfo'], ]); $rectorConfig->bootstrapFiles([ + __DIR__ . '/../bootstrap.php', __DIR__ . '/../Source/CustomContext.php', ]); }; diff --git a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php index b91cffb024..d2894bfa9b 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/config/configured_rule_with_names.php @@ -8,6 +8,7 @@ 'paramNames' => ['_', 'args', 'context', 'resolveInfo'], ]); $rectorConfig->bootstrapFiles([ + __DIR__ . '/../bootstrap.php', __DIR__ . '/../Source/CustomContext.php', ]); }; From 111038015f370d8780d813bdbebdd0804301cf09 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:38:01 +0200 Subject: [PATCH 06/21] Use protected visibility in RootResolverSignatureRector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the project convention of protected over private for extensibility. Also remove redundant $objectType->equals() check (isSuperTypeOf covers it). 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index f886bbf57d..6b6baf9b7f 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -22,10 +22,10 @@ class RootResolverSignatureRector extends AbstractRector implements ConfigurableRectorInterface { /** @var array */ - private array $paramNames = []; + protected array $paramNames = []; public function __construct( - private PhpVersionProvider $phpVersionProvider, + protected PhpVersionProvider $phpVersionProvider, ) {} public function getRuleDefinition(): RuleDefinition @@ -130,7 +130,7 @@ public function refactor(Node $node): ?Node return $node; } - private function isRootResolver(Class_ $node): bool + protected function isRootResolver(Class_ $node): bool { $fqcn = $node->namespacedName?->toString(); if ($fqcn === null) { @@ -147,7 +147,7 @@ private function isRootResolver(Class_ $node): bool } /** @return list */ - private function resolverNamespaces(): array + protected function resolverNamespaces(): array { return [ ...RootType::namespaces(RootType::QUERY), @@ -155,13 +155,13 @@ private function resolverNamespaces(): array ]; } - private function isDirectChildOfNamespace(string $fqcn, string $namespace): bool + protected function isDirectChildOfNamespace(string $fqcn, string $namespace): bool { return str_starts_with($fqcn, $namespace . '\\') && ! str_contains(substr($fqcn, strlen($namespace) + 1), '\\'); } - private function isMissingRootParam(ClassMethod $method): bool + protected function isMissingRootParam(ClassMethod $method): bool { $firstParam = $method->params[0]; @@ -169,7 +169,7 @@ private function isMissingRootParam(ClassMethod $method): bool && $firstParam->type->name === 'array'; } - private function prependRootParam(ClassMethod $method): void + protected function prependRootParam(ClassMethod $method): void { $rootParam = new Param( new Variable('root'), @@ -180,7 +180,7 @@ private function prependRootParam(ClassMethod $method): void array_unshift($method->params, $rootParam); } - private function rootTypeIdentifier(): Identifier + protected function rootTypeIdentifier(): Identifier { if ($this->phpVersionProvider->isAtLeastPhpVersion(PhpVersion::PHP_82)) { return new Identifier('null'); @@ -189,7 +189,7 @@ private function rootTypeIdentifier(): Identifier return new Identifier('mixed'); } - private function fixParamType(ClassMethod $method, int $index, Identifier $expectedType): bool + protected function fixParamType(ClassMethod $method, int $index, Identifier $expectedType): bool { if (! isset($method->params[$index])) { return false; @@ -207,7 +207,7 @@ private function fixParamType(ClassMethod $method, int $index, Identifier $expec return true; } - private function ensureMinParams(ClassMethod $method, int $minCount): bool + protected function ensureMinParams(ClassMethod $method, int $minCount): bool { if (count($method->params) >= $minCount) { return false; @@ -224,7 +224,7 @@ private function ensureMinParams(ClassMethod $method, int $minCount): bool return true; } - private function fixContextParam(ClassMethod $method): bool + protected function fixContextParam(ClassMethod $method): bool { $param = $method->params[2]; $currentType = $param->type; @@ -244,7 +244,7 @@ private function fixContextParam(ClassMethod $method): bool return true; } - private function fixResolveInfoParam(ClassMethod $method): bool + protected function fixResolveInfoParam(ClassMethod $method): bool { $param = $method->params[3]; $currentType = $param->type; @@ -254,7 +254,7 @@ private function fixResolveInfoParam(ClassMethod $method): bool $objectType = new ObjectType($typeName); $resolveInfoType = new ObjectType(\Nuwave\Lighthouse\Execution\ResolveInfo::class); - if ($resolveInfoType->isSuperTypeOf($objectType)->yes() || $objectType->equals($resolveInfoType)) { + if ($resolveInfoType->isSuperTypeOf($objectType)->yes()) { return false; } } @@ -264,7 +264,7 @@ private function fixResolveInfoParam(ClassMethod $method): bool return true; } - private function normalizeNames(ClassMethod $method): bool + protected function normalizeNames(ClassMethod $method): bool { if ($this->paramNames === []) { return false; From 96d6bfb7c8eef3ac27bcdf8982caaa9a051fe088 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 11:46:23 +0200 Subject: [PATCH 07/21] Ignore docs/superpowers in .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 06e14c1c94..78e80e43b7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .vscode phpunit.xml .gitpod.yml +docs/superpowers # Generated files .phpunit.result.cache From 9d888371ef543b253cd3b7ec530088f2284c1195 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:06:07 +0200 Subject: [PATCH 08/21] Fix isMissingRootParam producing duplicate param names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a resolver had multiple params with the first typed array (e.g. __invoke(array $root, array $args)), or a single untyped param, the rule would incorrectly prepend a new $root, creating duplicate variable names. Now only considers root as missing when there is exactly one param that is either untyped or typed array. 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 8 +++++-- .../Fixture/array_root_with_args.php.inc | 23 +++++++++++++++++++ .../Fixture/untyped_first_param.php.inc | 23 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/array_root_with_args.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/untyped_first_param.php.inc diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 6b6baf9b7f..1e93b215c4 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -163,10 +163,14 @@ protected function isDirectChildOfNamespace(string $fqcn, string $namespace): bo protected function isMissingRootParam(ClassMethod $method): bool { + if (count($method->params) !== 1) { + return false; + } + $firstParam = $method->params[0]; - return $firstParam->type instanceof Identifier - && $firstParam->type->name === 'array'; + return $firstParam->type === null + || ($firstParam->type instanceof Identifier && $firstParam->type->name === 'array'); } protected function prependRootParam(ClassMethod $method): void diff --git a/tests/Unit/Rector/RootResolverSignatureRector/Fixture/array_root_with_args.php.inc b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/array_root_with_args.php.inc new file mode 100644 index 0000000000..424fade346 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/array_root_with_args.php.inc @@ -0,0 +1,23 @@ + Date: Fri, 10 Jul 2026 12:11:50 +0200 Subject: [PATCH 09/21] Validate paramNames element types in configure() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert the value is an array (satisfies PHPStan level-8 type narrowing) and throw InvalidConfigurationException for non-string, non-null elements. 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 7 ++++ .../RootResolverSignatureRectorConfigTest.php | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 1e93b215c4..7ac8bc8c7d 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -68,11 +68,18 @@ public function getNodeTypes(): array public function configure(array $configuration): void { $paramNames = $configuration['paramNames'] ?? []; + assert(is_array($paramNames), 'paramNames must be an array.'); if (count($paramNames) > 4) { throw new InvalidConfigurationException('paramNames must have at most 4 elements.'); } + foreach ($paramNames as $name) { + if ($name !== null && ! is_string($name)) { + throw new InvalidConfigurationException('Each paramNames element must be a string or null.'); + } + } + $this->paramNames = $paramNames; } diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php new file mode 100644 index 0000000000..a399e2976d --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php @@ -0,0 +1,34 @@ +make(RootResolverSignatureRector::class); + + $this->expectException(InvalidConfigurationException::class); + $rector->configure($configuration); + } + + /** @return iterable}> */ + public static function invalidConfigurations(): iterable + { + yield 'too many elements' => [['paramNames' => ['a', 'b', 'c', 'd', 'e']]]; + yield 'non-string element' => [['paramNames' => [42]]]; + yield 'array element' => [['paramNames' => [[]]]]; + yield 'boolean element' => [['paramNames' => [true]]]; + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} From 6d7dacda4d25163081575ce577a43e45ab2949d8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:13:29 +0200 Subject: [PATCH 10/21] Throw when resolver namespaces are empty (misconfigured bootstrap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of silently doing nothing when config() has no Lighthouse namespaces loaded, throw a RuntimeException guiding the user to add the required bootstrapFiles entry. 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 7ac8bc8c7d..4cf6d34b43 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -153,13 +153,23 @@ protected function isRootResolver(Class_ $node): bool return false; } - /** @return list */ + /** + * Subscriptions are excluded — their resolver convention differs (they use subscriber classes, not __invoke). + * + * @return non-empty-list + */ protected function resolverNamespaces(): array { - return [ + $namespaces = [ ...RootType::namespaces(RootType::QUERY), ...RootType::namespaces(RootType::MUTATION), ]; + + if ($namespaces === []) { + throw new \RuntimeException('Lighthouse resolver namespaces are empty. Ensure your Rector config includes bootstrapFiles with the Larastan bootstrap and Lighthouse config loaded.'); + } + + return $namespaces; } protected function isDirectChildOfNamespace(string $fqcn, string $namespace): bool From 2ac1ac81cdf75536fedb9950110415256b90d716 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:48:13 +0200 Subject: [PATCH 11/21] Refactor RootResolverSignatureRector for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move fixParamType(0, ...) into else branch (no-op after prependRootParam) - Split isset() && fix() into nested ifs (one-thing-per-line) - Replace ensureMinParams while-loop with direct ensureArgsParam - Extract fixObjectParam from duplicate fixContextParam/fixResolveInfoParam 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 63 +++++++--------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 4cf6d34b43..d2b3924bdf 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -104,13 +104,11 @@ public function refactor(Node $node): ?Node if ($this->isMissingRootParam($invokeMethod)) { $this->prependRootParam($invokeMethod); $changed = true; - } - - if ($this->fixParamType($invokeMethod, 0, $this->rootTypeIdentifier())) { + } elseif ($this->fixParamType($invokeMethod, 0, $this->rootTypeIdentifier())) { $changed = true; } - if ($this->ensureMinParams($invokeMethod, 2)) { + if ($this->ensureArgsParam($invokeMethod)) { $changed = true; } @@ -118,12 +116,16 @@ public function refactor(Node $node): ?Node $changed = true; } - if (isset($invokeMethod->params[2]) && $this->fixContextParam($invokeMethod)) { - $changed = true; + if (isset($invokeMethod->params[2])) { + if ($this->fixObjectParam($invokeMethod, 2, \Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class)) { + $changed = true; + } } - if (isset($invokeMethod->params[3]) && $this->fixResolveInfoParam($invokeMethod)) { - $changed = true; + if (isset($invokeMethod->params[3])) { + if ($this->fixObjectParam($invokeMethod, 3, \Nuwave\Lighthouse\Execution\ResolveInfo::class)) { + $changed = true; + } } if ($this->normalizeNames($invokeMethod)) { @@ -228,59 +230,32 @@ protected function fixParamType(ClassMethod $method, int $index, Identifier $exp return true; } - protected function ensureMinParams(ClassMethod $method, int $minCount): bool + protected function ensureArgsParam(ClassMethod $method): bool { - if (count($method->params) >= $minCount) { + if (count($method->params) >= 2) { return false; } - while (count($method->params) < $minCount) { - $index = count($method->params); - $method->params[] = match ($index) { - 1 => new Param(new Variable('args'), null, new Identifier('array')), - default => throw new \LogicException("Unexpected param index: {$index}."), - }; - } + $method->params[] = new Param(new Variable('args'), null, new Identifier('array')); return true; } - protected function fixContextParam(ClassMethod $method): bool + protected function fixObjectParam(ClassMethod $method, int $index, string $expectedClass): bool { - $param = $method->params[2]; - $currentType = $param->type; - - if ($currentType instanceof FullyQualified || $currentType instanceof Node\Name) { - $typeName = $currentType->toString(); - $objectType = new ObjectType($typeName); - $contextType = new ObjectType(\Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class); - - if ($contextType->isSuperTypeOf($objectType)->yes()) { - return false; - } - } - - $param->type = new FullyQualified(\Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class); - - return true; - } - - protected function fixResolveInfoParam(ClassMethod $method): bool - { - $param = $method->params[3]; + $param = $method->params[$index]; $currentType = $param->type; if ($currentType instanceof FullyQualified || $currentType instanceof Node\Name) { - $typeName = $currentType->toString(); - $objectType = new ObjectType($typeName); - $resolveInfoType = new ObjectType(\Nuwave\Lighthouse\Execution\ResolveInfo::class); + $objectType = new ObjectType($currentType->toString()); + $expectedType = new ObjectType($expectedClass); - if ($resolveInfoType->isSuperTypeOf($objectType)->yes()) { + if ($expectedType->isSuperTypeOf($objectType)->yes()) { return false; } } - $param->type = new FullyQualified(\Nuwave\Lighthouse\Execution\ResolveInfo::class); + $param->type = new FullyQualified($expectedClass); return true; } From 7acf822b4433cbf89a6cacee963ba589e20c0cd0 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:48:35 +0200 Subject: [PATCH 12/21] Fix docs: sentence line break and missing $args insertion bullet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- docs/master/api-reference/resolvers.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/master/api-reference/resolvers.md b/docs/master/api-reference/resolvers.md index 18fc7cf18d..d30dfe5df6 100644 --- a/docs/master/api-reference/resolvers.md +++ b/docs/master/api-reference/resolvers.md @@ -26,8 +26,7 @@ The return value of this must fit the return type defined for the corresponding Root resolvers are classes with an `__invoke` method that sit directly in the configured `lighthouse.namespaces.queries` or `lighthouse.namespaces.mutations` namespaces. -Lighthouse calls them with positional arguments `($root, $args, $context, $resolveInfo)`, -where `$root` is always `null` for root types. +Lighthouse calls them with positional arguments `($root, $args, $context, $resolveInfo)` where `$root` is always `null` for root types. Omitting `$root` causes Lighthouse's positional `null` to bind to `$args`, producing TypeErrors at runtime. The canonical signature for a root resolver is: @@ -66,7 +65,8 @@ return static function (RectorConfig $rectorConfig): void { The rule fixes: -- Missing `$root` parameter (detected when the first param is typed `array`) +- Missing `$root` parameter (detected when there is a single param typed `array` or untyped) +- Missing `$args` parameter when only `$root` is present - Wrong type on the `$root` parameter (must be `null` on PHP 8.2+, `mixed` on earlier versions) - Wrong type on `$args` (must be `array`) - Wrong type on `$context` if present (must implement `GraphQLContext`) From 1840f9079267611fcc292b29da01b97b39f7221d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:49:41 +0200 Subject: [PATCH 13/21] Add missing test fixtures for fixObjectParam paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover wrong ResolveInfo type and untyped context/resolveInfo params. 🤖 Generated with Claude Code --- .../untyped_context_and_resolve_info.php.inc | 29 +++++++++++++++++++ .../Fixture/wrong_resolve_info_type.php.inc | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/untyped_context_and_resolve_info.php.inc create mode 100644 tests/Unit/Rector/RootResolverSignatureRector/Fixture/wrong_resolve_info_type.php.inc diff --git a/tests/Unit/Rector/RootResolverSignatureRector/Fixture/untyped_context_and_resolve_info.php.inc b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/untyped_context_and_resolve_info.php.inc new file mode 100644 index 0000000000..358a837eb0 --- /dev/null +++ b/tests/Unit/Rector/RootResolverSignatureRector/Fixture/untyped_context_and_resolve_info.php.inc @@ -0,0 +1,29 @@ + Date: Fri, 10 Jul 2026 12:53:15 +0200 Subject: [PATCH 14/21] Add CHANGELOG entry for RootResolverSignatureRector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c50455f9df..d90881812c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add `RootResolverSignatureRector` rule to auto-fix root resolver `__invoke` signatures https://github.com/nuwave/lighthouse/pull/TODO + ## v6.68.0 ### Added From ebab0a4d9f56d3f7a9541f4a37c0fd56b776b707 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 12:55:02 +0200 Subject: [PATCH 15/21] Apply fixes from tooling (rector, php-cs-fixer, phpstan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 12 ++++-------- .../RootResolverSignatureRectorConfigTest.php | 1 + .../RootResolverSignatureRectorPartialNamesTest.php | 3 +-- .../RootResolverSignatureRectorTest.php | 3 +-- .../RootResolverSignatureRectorWithNamesTest.php | 3 +-- .../Rector/RootResolverSignatureRector/bootstrap.php | 2 ++ 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index d2b3924bdf..8103b46fbd 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -116,16 +116,12 @@ public function refactor(Node $node): ?Node $changed = true; } - if (isset($invokeMethod->params[2])) { - if ($this->fixObjectParam($invokeMethod, 2, \Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class)) { - $changed = true; - } + if (isset($invokeMethod->params[2]) && $this->fixObjectParam($invokeMethod, 2, \Nuwave\Lighthouse\Support\Contracts\GraphQLContext::class)) { + $changed = true; } - if (isset($invokeMethod->params[3])) { - if ($this->fixObjectParam($invokeMethod, 3, \Nuwave\Lighthouse\Execution\ResolveInfo::class)) { - $changed = true; - } + if (isset($invokeMethod->params[3]) && $this->fixObjectParam($invokeMethod, 3, \Nuwave\Lighthouse\Execution\ResolveInfo::class)) { + $changed = true; } if ($this->normalizeNames($invokeMethod)) { diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php index a399e2976d..bf8dd5bbf3 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorConfigTest.php @@ -9,6 +9,7 @@ final class RootResolverSignatureRectorConfigTest extends AbstractRectorTestCase { + /** @param array $configuration */ #[DataProvider('invalidConfigurations')] public function testRejectsInvalidConfiguration(array $configuration): void { diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorPartialNamesTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorPartialNamesTest.php index fe09e8aa4b..0ca68f136b 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorPartialNamesTest.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorPartialNamesTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Rector\RootResolverSignatureRector; -use Iterator; use PHPUnit\Framework\Attributes\DataProvider; use Rector\Testing\PHPUnit\AbstractRectorTestCase; @@ -14,7 +13,7 @@ public function test(string $filePath): void $this->doTestFile($filePath); } - public static function provideData(): Iterator + public static function provideData(): \Iterator { return self::yieldFilesFromDirectory(__DIR__ . '/FixturePartialNames'); } diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorTest.php index d4d070207f..83130110f9 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorTest.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Rector\RootResolverSignatureRector; -use Iterator; use PHPUnit\Framework\Attributes\DataProvider; use Rector\Testing\PHPUnit\AbstractRectorTestCase; @@ -14,7 +13,7 @@ public function test(string $filePath): void $this->doTestFile($filePath); } - public static function provideData(): Iterator + public static function provideData(): \Iterator { return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); } diff --git a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php index 691c806303..3297c4a2a3 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/RootResolverSignatureRectorWithNamesTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Rector\RootResolverSignatureRector; -use Iterator; use PHPUnit\Framework\Attributes\DataProvider; use Rector\Testing\PHPUnit\AbstractRectorTestCase; @@ -14,7 +13,7 @@ public function test(string $filePath): void $this->doTestFile($filePath); } - public static function provideData(): Iterator + public static function provideData(): \Iterator { return self::yieldFilesFromDirectory(__DIR__ . '/FixtureWithNames'); } diff --git a/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php b/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php index 837593c71f..effa715917 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php +++ b/tests/Unit/Rector/RootResolverSignatureRector/bootstrap.php @@ -1,5 +1,7 @@ Date: Fri, 10 Jul 2026 13:23:53 +0200 Subject: [PATCH 16/21] Fix normalizeNames to rename variable usages in method body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Rector's VariableRenamer to update all references to the old parameter name within the method body, not just the signature. https://github.com/nuwave/lighthouse/pull/2779#discussion_r3558444064 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 6 +++++- .../FixtureWithNames/normalize_names.php.inc | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 8103b46fbd..8321de65fd 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -13,6 +13,7 @@ use PHPStan\Type\ObjectType; use Rector\Contract\Rector\ConfigurableRectorInterface; use Rector\Exception\Configuration\InvalidConfigurationException; +use Rector\Naming\VariableRenamer; use Rector\Php\PhpVersionProvider; use Rector\Rector\AbstractRector; use Rector\ValueObject\PhpVersion; @@ -26,6 +27,7 @@ class RootResolverSignatureRector extends AbstractRector implements Configurable public function __construct( protected PhpVersionProvider $phpVersionProvider, + protected VariableRenamer $variableRenamer, ) {} public function getRuleDefinition(): RuleDefinition @@ -278,11 +280,13 @@ protected function normalizeNames(ClassMethod $method): bool continue; } - if ($param->var->name === $name) { + $oldName = $param->var->name; + if (! is_string($oldName) || $oldName === $name) { continue; } $param->var = new Variable($name); + $this->variableRenamer->renameVariableInFunctionLike($method, $oldName, $name); $changed = true; } diff --git a/tests/Unit/Rector/RootResolverSignatureRector/FixtureWithNames/normalize_names.php.inc b/tests/Unit/Rector/RootResolverSignatureRector/FixtureWithNames/normalize_names.php.inc index 64ea00124d..509fb2e22f 100644 --- a/tests/Unit/Rector/RootResolverSignatureRector/FixtureWithNames/normalize_names.php.inc +++ b/tests/Unit/Rector/RootResolverSignatureRector/FixtureWithNames/normalize_names.php.inc @@ -6,7 +6,9 @@ class Users { public function __invoke(null $root, array $arguments) { - return []; + $filtered = $arguments['filter'] ?? null; + + return $root ?? $filtered; } } ----- @@ -18,6 +20,8 @@ class Users { public function __invoke(null $_, array $args) { - return []; + $filtered = $args['filter'] ?? null; + + return $_ ?? $filtered; } } From 4db75706717b4be7e5f456112c4859904e5315b8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 13:25:50 +0200 Subject: [PATCH 17/21] Use mixed $root in docs example for PHP 8.0/8.1 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone null type requires PHP 8.2+, but the package supports ^8. Use mixed as the safe default and note that PHP 8.2+ can use null. https://github.com/nuwave/lighthouse/pull/2779#discussion_r3558444092 🤖 Generated with Claude Code --- docs/master/api-reference/resolvers.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/master/api-reference/resolvers.md b/docs/master/api-reference/resolvers.md index d30dfe5df6..49d42dd18d 100644 --- a/docs/master/api-reference/resolvers.md +++ b/docs/master/api-reference/resolvers.md @@ -37,13 +37,16 @@ use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; class MyQuery { - public function __invoke(null $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) + public function __invoke(mixed $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) { // ... } } ``` +On PHP 8.2+, you can use the more precise `null` type instead of `mixed`. +The Rector rule below automatically picks the correct type for your PHP version. + ### Rector rule Lighthouse ships a Rector rule that enforces correct `__invoke` signatures on root resolvers. From 5c95630d211477f733e6480f42a4c506d9630533 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 13:27:40 +0200 Subject: [PATCH 18/21] Replace assert with InvalidConfigurationException for paramNames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assertions can be disabled at runtime. Use a proper exception to guarantee validation regardless of PHP assert settings. https://github.com/nuwave/lighthouse/pull/2779#discussion_r3558444040 🤖 Generated with Claude Code --- src/Rector/RootResolverSignatureRector.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Rector/RootResolverSignatureRector.php b/src/Rector/RootResolverSignatureRector.php index 8321de65fd..d5e65d4ab1 100644 --- a/src/Rector/RootResolverSignatureRector.php +++ b/src/Rector/RootResolverSignatureRector.php @@ -70,7 +70,9 @@ public function getNodeTypes(): array public function configure(array $configuration): void { $paramNames = $configuration['paramNames'] ?? []; - assert(is_array($paramNames), 'paramNames must be an array.'); + if (! is_array($paramNames)) { + throw new InvalidConfigurationException('paramNames must be an array.'); + } if (count($paramNames) > 4) { throw new InvalidConfigurationException('paramNames must have at most 4 elements.'); From 046cee7258e1c9f99295402df8987062c7739cb9 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 13:27:49 +0200 Subject: [PATCH 19/21] Replace TODO placeholder with actual PR number in CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/nuwave/lighthouse/pull/2779#discussion_r3558444125 🤖 Generated with Claude Code --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d90881812c..7e1940c1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Added -- Add `RootResolverSignatureRector` rule to auto-fix root resolver `__invoke` signatures https://github.com/nuwave/lighthouse/pull/TODO +- Add `RootResolverSignatureRector` rule to auto-fix root resolver `__invoke` signatures https://github.com/nuwave/lighthouse/pull/2779 ## v6.68.0 From 332fee53b0b4274a96ce72adcf7e707d6a0dcfd2 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 15:14:15 +0200 Subject: [PATCH 20/21] Keep rector/rector installed in CI to validate Rector code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .github/workflows/validate.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b37efd3a77..139d8270a2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -74,7 +74,7 @@ jobs: key: php-${{ matrix.php-version }}-composer-${{ matrix.laravel-version }}-os-${{ matrix.os }}-composer-${{ matrix.composer.name }} - name: "Remove conflicting dependencies that are not needed here" - run: composer remove --dev --no-update phpbench/phpbench rector/rector + run: composer remove --dev --no-update phpbench/phpbench - name: "Remove Pennant for Laravel 9 because it is not compatible" if: matrix.laravel-version == '^9' @@ -168,7 +168,7 @@ jobs: key: php-${{ matrix.php-version }}-composer-${{ matrix.laravel-version }}-os-${{ matrix.os }}-composer-${{ matrix.composer.name }} - name: "Remove conflicting dependencies that are not needed here" - run: composer remove --dev --no-update larastan/larastan phpstan/phpstan-mockery phpbench/phpbench rector/rector + run: composer remove --dev --no-update larastan/larastan phpstan/phpstan-mockery phpbench/phpbench - name: "Remove Pennant for Laravel 9 because it is not compatible" if: matrix.laravel-version == '^9' @@ -224,7 +224,7 @@ jobs: path: ~/.composer/cache key: php-${{ matrix.php-version }}-composer-${{ matrix.laravel-version }} - - run: composer remove --dev phpbench/phpbench rector/rector --no-update + - run: composer remove --dev phpbench/phpbench --no-update - run: composer require laravel/framework:${{ matrix.laravel-version }} --no-interaction --prefer-dist --no-progress From 585638b80ae320e527d39624d23fe1e84f4a46d1 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Jul 2026 15:52:05 +0200 Subject: [PATCH 21/21] Keep larastan installed in PHPUnit CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 139d8270a2..711c28ff9f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -168,7 +168,7 @@ jobs: key: php-${{ matrix.php-version }}-composer-${{ matrix.laravel-version }}-os-${{ matrix.os }}-composer-${{ matrix.composer.name }} - name: "Remove conflicting dependencies that are not needed here" - run: composer remove --dev --no-update larastan/larastan phpstan/phpstan-mockery phpbench/phpbench + run: composer remove --dev --no-update phpstan/phpstan-mockery phpbench/phpbench - name: "Remove Pennant for Laravel 9 because it is not compatible" if: matrix.laravel-version == '^9'