rubocop-graphql 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ff189b7091fa3e71688dcb2a2ef36b15edbe018796943ef3b3dfee85b1c32f8
4
- data.tar.gz: 2d2ffa4470e74e3ae9d90fac4fc85a173db7d95eb2d5a6bfd844759fad59c514
3
+ metadata.gz: f7108c819a363b06725b9de204f6e1eb26a19c0e022d07a51f7cac3570ccc041
4
+ data.tar.gz: 5b5b231155cf6ac19e58df7ab0ded5060517f0a59ed5b3bcef77db04adf9355a
5
5
  SHA512:
6
- metadata.gz: 1651ab77310a0146fe7847161a6a18afa0c5d83148eca7701def628f66d31b24cfa4760fe94843d9c10db45ca43aa7b7c0d0539901c71be89bf5b3299242caca
7
- data.tar.gz: 8b2d079c283776eaac5fdd151fc589d9f29e7afe7826657f23f577a98882166f1a31c57727a1f8dca90a49c058b9def85d3026be5fa483b11227fc81006be50f
6
+ metadata.gz: 2bd922b4a7234a4ca1315dc42fd6b3adf7fff850bce9ec71abf41df175fd56889cd76be254dcdb4bbec09615dc85a8317a87884778fc62797ab991f63eee04bf
7
+ data.tar.gz: 282acda7b0c3f54e51a7e77245964e55d3d9d0847009490bfa096959cb49d5de72536e0e49b94f148dbfe1c72c40877494ce8edc9d5187c9b8eacdcf33be6129
data/config/default.yml CHANGED
@@ -28,6 +28,19 @@ GraphQL/ContextWriteInType:
28
28
  Include:
29
29
  - '**/graphql/types/**/*'
30
30
 
31
+ GraphQL/DefaultForOptionalArgument:
32
+ Enabled: true
33
+ VersionAdded: '1.8.0'
34
+ Description: 'Optional arguments should have a default value in the resolver signature'
35
+
36
+ GraphQL/DisallowedTypes:
37
+ Enabled: false
38
+ VersionAdded: '1.8.0'
39
+ Description: 'Flags field and argument types the project has decided not to expose'
40
+ # Type name => the message explaining what to use instead. Nothing is disallowed
41
+ # until this is configured, so the cop is inert out of the box.
42
+ Types: {}
43
+
31
44
  GraphQL/ExtractInputType:
32
45
  Enabled: true
33
46
  VersionAdded: '0.2.0'
@@ -119,7 +132,7 @@ GraphQL/MaxDepthSchema:
119
132
 
120
133
  GraphQL/MethodShadowedByResolverMethod:
121
134
  Enabled: true
122
- VersionAdded: '<<next>>'
135
+ VersionAdded: '1.8.0'
123
136
  Description: 'Checks for method definitions shadowed by the field''s effective resolver_method'
124
137
 
125
138
  GraphQL/MultipleFieldDefinitions:
@@ -135,6 +148,11 @@ GraphQL/NotAuthorizedNodeType:
135
148
  - '**/graphql/types/**/*'
136
149
  SafeBaseClasses: []
137
150
 
151
+ GraphQL/NullabilityMismatch:
152
+ Enabled: true
153
+ VersionAdded: '1.8.0'
154
+ Description: 'Detects a non-null field whose Sorbet resolver signature returns a nilable type'
155
+
138
156
  GraphQL/ResolverMethodLength:
139
157
  Enabled: true
140
158
  VersionAdded: '0.1.0'
@@ -148,6 +166,13 @@ GraphQL/ObjectDescription:
148
166
  Enabled: true
149
167
  VersionAdded: '0.3.0'
150
168
  Description: 'Ensures all types have a description'
169
+ # Skip the root operation types (Query, Mutation, Subscription), which are
170
+ # usually left undescribed. Set to false to require a description on them too.
171
+ IgnoreRootTypes: true
172
+ # Superclass (or included module) names to treat as GraphQL bases, on top of the
173
+ # conventional ones. Use when your base types are named unconventionally, e.g.
174
+ # `class Types::UserType < ApplicationType`.
175
+ AdditionalTypeBases: []
151
176
  Exclude:
152
177
  - "spec/**/*"
153
178
  - "test/**/*"
@@ -190,5 +215,5 @@ GraphQL/UnnecessaryFieldCamelize:
190
215
 
191
216
  GraphQL/UselessMethodOption:
192
217
  Enabled: true
193
- VersionAdded: '<<next>>'
218
+ VersionAdded: '1.8.0'
194
219
  Description: 'Checks for resolver_method:/method: made ineffective by resolver:, or method: shadowed by a same-named def'
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module GraphQL
6
+ # Optional arguments should have a default value in the resolver signature.
7
+ #
8
+ # When the client omits an argument declared `required: false`, graphql-ruby leaves it
9
+ # out of the keyword arguments entirely, so a required keyword raises
10
+ # `ArgumentError: missing keyword`. Giving the keyword a default value is what makes the
11
+ # argument actually optional at runtime.
12
+ #
13
+ # Arguments declared with a `default_value:` are always passed, so they are not reported.
14
+ # Neither is `required: :nullable`, which still demands the argument be present.
15
+ #
16
+ # Both class-level arguments (checked against `#resolve` and `#authorized?`) and
17
+ # arguments defined inside a field block (checked against that field's resolver method)
18
+ # are covered.
19
+ #
20
+ # @example
21
+ # # bad
22
+ #
23
+ # class SomeResolver < Resolvers::Base
24
+ # argument :name, String, required: false
25
+ #
26
+ # def resolve(name:); end
27
+ # end
28
+ #
29
+ # # good
30
+ #
31
+ # class SomeResolver < Resolvers::Base
32
+ # argument :name, String, required: false
33
+ #
34
+ # def resolve(name: nil); end
35
+ # end
36
+ #
37
+ # # good - a default value means the keyword is always passed
38
+ #
39
+ # class SomeResolver < Resolvers::Base
40
+ # argument :name, String, required: false, default_value: "anonymous"
41
+ #
42
+ # def resolve(name:); end
43
+ # end
44
+ #
45
+ # # bad
46
+ #
47
+ # class UserType < BaseObject
48
+ # field :posts, [PostType], null: false do
49
+ # argument :limit, Integer, required: false
50
+ # end
51
+ #
52
+ # def posts(limit:); end
53
+ # end
54
+ #
55
+ # # good
56
+ #
57
+ # class UserType < BaseObject
58
+ # field :posts, [PostType], null: false do
59
+ # argument :limit, Integer, required: false
60
+ # end
61
+ #
62
+ # def posts(limit: 10); end
63
+ # end
64
+ #
65
+ class DefaultForOptionalArgument < Base
66
+ MSG = "Optional argument `%<keyword>s` has no default value in `%<method>s`, so " \
67
+ "omitting it raises ArgumentError."
68
+
69
+ RESOLVER_METHODS = %i[resolve authorized?].freeze
70
+
71
+ def on_class(node)
72
+ body = node.body
73
+ return unless body
74
+
75
+ defs = definitions_in(node)
76
+ return if defs.empty?
77
+
78
+ check_class_arguments(node, defs)
79
+ check_field_arguments(node, defs)
80
+ end
81
+
82
+ private
83
+
84
+ def check_class_arguments(class_node, defs)
85
+ keywords = optional_keywords(class_arguments(class_node))
86
+ return if keywords.empty?
87
+
88
+ RESOLVER_METHODS.each do |method_name|
89
+ check_signature(defs[method_name], keywords)
90
+ end
91
+ end
92
+
93
+ def check_field_arguments(class_node, defs)
94
+ field_blocks(class_node).each do |block_node|
95
+ field = RuboCop::GraphQL::Field.new(block_node.send_node)
96
+ next if field.kwargs.resolver
97
+
98
+ keywords = optional_keywords(block_arguments(block_node))
99
+ next if keywords.empty?
100
+
101
+ check_signature(defs[field.resolver_method_name.to_sym], keywords)
102
+ end
103
+ end
104
+
105
+ def check_signature(def_node, keywords)
106
+ return unless def_node
107
+
108
+ def_node.arguments.each do |arg_node|
109
+ next unless arg_node.kwarg_type?
110
+ next unless keywords.include?(arg_node.node_parts[0])
111
+
112
+ add_offense(
113
+ arg_node,
114
+ message: format(MSG, keyword: arg_node.node_parts[0], method: def_node.method_name)
115
+ )
116
+ end
117
+ end
118
+
119
+ # Keywords of arguments that graphql-ruby may leave out of the resolver call.
120
+ def optional_keywords(argument_nodes)
121
+ argument_nodes.filter_map do |argument_node|
122
+ argument = RuboCop::GraphQL::Argument.new(argument_node)
123
+ argument.keyword if argument.optional? && !argument.default_value?
124
+ end.to_set
125
+ end
126
+
127
+ # `def`s owned by this class, keyed by name. A nested class gets its own `on_class`.
128
+ def definitions_in(class_node)
129
+ each_in_scope(class_node.body, :def).each_with_object({}) do |def_node, defs|
130
+ defs[def_node.method_name] ||= def_node
131
+ end
132
+ end
133
+
134
+ # Class-level `argument` calls: the walk stops at blocks, so field-block arguments
135
+ # (handled separately, against a different method) are not picked up here.
136
+ def class_arguments(class_node)
137
+ each_in_scope(class_node.body, :send, stop_at_block: true).select do |send_node|
138
+ argument_declaration?(send_node)
139
+ end
140
+ end
141
+
142
+ def block_arguments(block_node)
143
+ body = block_node.body
144
+ return [] unless body
145
+
146
+ each_in_scope(body, :send).select { |send_node| argument_declaration?(send_node) }
147
+ end
148
+
149
+ def field_blocks(class_node)
150
+ each_in_scope(class_node.body, :block).select do |block_node|
151
+ field_declaration?(block_node.send_node)
152
+ end
153
+ end
154
+
155
+ # Collects nodes of `type` without descending into a nested class or module body.
156
+ # With `stop_at_block`, a block contributes only the call that opens it, so a nested
157
+ # `field ... do ... end` keeps its own arguments out of the enclosing scope.
158
+ def each_in_scope(node, type, stop_at_block: false, found: [])
159
+ found << node if node.type == type
160
+ return found if node.type?(:class, :module, :sclass)
161
+
162
+ children =
163
+ if stop_at_block && node.type?(:any_block)
164
+ [node.send_node]
165
+ else
166
+ node.each_child_node
167
+ end
168
+
169
+ children.each do |child|
170
+ each_in_scope(child, type, stop_at_block: stop_at_block, found: found)
171
+ end
172
+ found
173
+ end
174
+
175
+ # @!method argument_declaration?(node)
176
+ def_node_matcher :argument_declaration?, <<~PATTERN
177
+ (send nil? :argument (:sym _) ...)
178
+ PATTERN
179
+
180
+ # @!method field_declaration?(node)
181
+ def_node_matcher :field_declaration?, <<~PATTERN
182
+ (send nil? :field (:sym _) ...)
183
+ PATTERN
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module GraphQL
6
+ # Flags field and argument types the project has decided not to expose, with a message
7
+ # explaining what to use instead.
8
+ #
9
+ # Every schema accumulates types that are still resolvable but shouldn't be reached for
10
+ # in new code: a scalar kept alive only for backwards compatibility, a type that predates
11
+ # a better one, or a builtin whose semantics don't fit the domain -- `Float` for money,
12
+ # say, where the serialization loses precision. The convention is usually documented and
13
+ # then re-litigated in review; this makes it fail the build instead.
14
+ #
15
+ # Nothing is disallowed by default: the cop is inert until `Types` is configured.
16
+ #
17
+ # A configured name matches the written constant exactly, or as a trailing segment of it,
18
+ # so `Float` covers `Float`, `Types::Float` and `GraphQL::Types::Float`. Configure
19
+ # `GraphQL::Types::Float` instead to match only the fully qualified form.
20
+ #
21
+ # List types are unwrapped, so `[Float]` and `[Float, null: true]` are flagged too, and
22
+ # both the positional type and the `type:` keyword are checked.
23
+ #
24
+ # @example Types: {'Float' => 'Use Types::Decimal, which serializes as a string.'}
25
+ # # bad
26
+ # field :amount, Float, null: false
27
+ # argument :amount, Float, required: true
28
+ # field :amounts, [Float], null: false
29
+ # field :amount, type: Float, null: false
30
+ #
31
+ # # good
32
+ # field :amount, Types::Decimal, null: false
33
+ # argument :amount, Types::Decimal, required: true
34
+ #
35
+ # @example Types: {'Types::LegacyDate' => 'Use GraphQL::Types::ISO8601Date.'}
36
+ # # bad
37
+ # field :starts_on, Types::LegacyDate, null: false
38
+ #
39
+ # # good
40
+ # field :starts_on, GraphQL::Types::ISO8601Date, null: false
41
+ #
42
+ class DisallowedTypes < Base
43
+ MSG = "`%<type>s` is not allowed as a field or argument type."
44
+ MSG_WITH_REASON = "`%<type>s` is not allowed as a field or argument type. %<reason>s"
45
+
46
+ RESTRICT_ON_SEND = %i[field argument].freeze
47
+
48
+ def on_send(node)
49
+ return if disallowed_types.empty?
50
+ return unless type_declaration?(node)
51
+
52
+ each_type_const(node) do |const_node|
53
+ configured_name = disallowed_name_for(const_node)
54
+ next unless configured_name
55
+
56
+ add_offense(const_node, message: message_for(configured_name))
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def disallowed_types
63
+ @disallowed_types ||= (cop_config["Types"] || {}).transform_keys(&:to_s)
64
+ end
65
+
66
+ def message_for(configured_name)
67
+ reason = disallowed_types[configured_name].to_s.strip
68
+
69
+ if reason.empty?
70
+ format(MSG, type: configured_name)
71
+ else
72
+ format(MSG_WITH_REASON, type: configured_name, reason: reason)
73
+ end
74
+ end
75
+
76
+ def disallowed_name_for(const_node)
77
+ written_name = const_node.const_name
78
+
79
+ disallowed_types.keys.find do |configured_name|
80
+ written_name == configured_name || written_name.end_with?("::#{configured_name}")
81
+ end
82
+ end
83
+
84
+ # Yields the constants a `field`/`argument` call names as its type: the positional type
85
+ # and the `type:` keyword, each unwrapped through any list nesting.
86
+ def each_type_const(send_node, &block)
87
+ each_const_in(send_node.arguments[1], &block)
88
+ each_const_in(type_kwarg(send_node), &block)
89
+ end
90
+
91
+ def each_const_in(node, &block)
92
+ return unless node
93
+
94
+ case node.type
95
+ when :const then yield node
96
+ when :array then node.children.each { |child| each_const_in(child, &block) }
97
+ end
98
+ end
99
+
100
+ # An explicit receiver means this is some other `field`/`argument` method, not the DSL.
101
+ #
102
+ # @!method type_declaration?(node)
103
+ def_node_matcher :type_declaration?, <<~PATTERN
104
+ (send nil? {:field :argument} ...)
105
+ PATTERN
106
+
107
+ # @!method type_kwarg(node)
108
+ def_node_matcher :type_kwarg, <<~PATTERN
109
+ (send nil? {:field :argument} ... (hash <(pair (sym :type) $_) ...>))
110
+ PATTERN
111
+ end
112
+ end
113
+ end
114
+ end
@@ -18,6 +18,16 @@ module RuboCop
18
18
  # field :name, String, null: true
19
19
  # end
20
20
  #
21
+ # Fields built from a resolver, mutation or subscription class are not
22
+ # flagged: graphql-ruby takes their description from that class.
23
+ #
24
+ # @example
25
+ # # good
26
+ #
27
+ # class UserType < BaseType
28
+ # field :posts, resolver: PostsResolver
29
+ # end
30
+ #
21
31
  class FieldDescription < Base
22
32
  include RuboCop::GraphQL::NodePattern
23
33
 
@@ -28,6 +38,7 @@ module RuboCop
28
38
  return unless field_definition?(node)
29
39
 
30
40
  field = RuboCop::GraphQL::Field.new(node)
41
+ return if field.kwargs.resolver_class
31
42
 
32
43
  add_offense(node) unless field.description
33
44
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module GraphQL
6
+ # A non-null field should not have a resolver whose Sorbet signature returns a nilable
7
+ # type. The two disagree: the schema promises a value, the signature admits `nil`, and
8
+ # graphql-ruby raises an invalid-null error for every object that resolves to `nil`.
9
+ #
10
+ # Sorbet cannot catch this, because the field declaration is not part of the signature.
11
+ # Only the crashing direction is reported: a nullable field with a non-nilable resolver
12
+ # is merely imprecise, not broken.
13
+ #
14
+ # Codebases without Sorbet signatures never trigger this cop.
15
+ #
16
+ # @example
17
+ # # bad
18
+ #
19
+ # class UserType < BaseObject
20
+ # field :name, String, null: false
21
+ #
22
+ # sig { override.returns(T.nilable(String)) }
23
+ # def name
24
+ # object.name
25
+ # end
26
+ # end
27
+ #
28
+ # # good - the schema admits what the resolver may return
29
+ #
30
+ # class UserType < BaseObject
31
+ # field :name, String, null: true
32
+ #
33
+ # sig { override.returns(T.nilable(String)) }
34
+ # def name
35
+ # object.name
36
+ # end
37
+ # end
38
+ #
39
+ # # good - the resolver guarantees what the schema promises
40
+ #
41
+ # class UserType < BaseObject
42
+ # field :name, String, null: false
43
+ #
44
+ # sig { override.returns(String) }
45
+ # def name
46
+ # object.name || "anonymous"
47
+ # end
48
+ # end
49
+ #
50
+ class NullabilityMismatch < Base
51
+ include RuboCop::GraphQL::Sorbet
52
+
53
+ MSG = "Field `%<field>s` is `null: false` but its resolver signature returns a " \
54
+ "nilable type, so a nil resolves to an invalid null error."
55
+
56
+ def on_class(node)
57
+ non_null_fields = collect_non_null_fields(node)
58
+ return if non_null_fields.empty?
59
+
60
+ node.each_descendant(:def) do |def_node|
61
+ next unless owned_by?(def_node, node)
62
+
63
+ field_node = non_null_fields[def_node.method_name]
64
+ next unless field_node && nilable_signature?(def_node)
65
+
66
+ field_name = RuboCop::GraphQL::Field.new(field_node).name
67
+ add_offense(field_node, message: format(MSG, field: field_name))
68
+ end
69
+ end
70
+ alias on_module on_class
71
+
72
+ private
73
+
74
+ # Non-null fields owned by this type, keyed by the method that resolves them. Fields
75
+ # handed off to a `resolver:` class are resolved elsewhere, so they are skipped.
76
+ def collect_non_null_fields(node)
77
+ fields = {}
78
+
79
+ node.each_descendant(:send) do |send_node|
80
+ next unless non_null_field?(send_node) && owned_by?(send_node, node)
81
+
82
+ field = RuboCop::GraphQL::Field.new(send_node)
83
+ next if field.kwargs.resolver
84
+
85
+ fields[field.resolver_method_name.to_sym] ||= send_node
86
+ end
87
+
88
+ fields
89
+ end
90
+
91
+ def nilable_signature?(def_node)
92
+ signature = sorbet_signature_for(def_node)
93
+ return false unless signature
94
+
95
+ signature.each_descendant(:send).any? do |send_node|
96
+ send_node.method?(:returns) && nilable_type?(send_node.first_argument)
97
+ end
98
+ end
99
+
100
+ # Only the outermost type counts. `T::Array[T.nilable(String)]` is a non-null list of
101
+ # nullable items, which pairs correctly with a `null: false` field.
102
+ def nilable_type?(node)
103
+ return false unless node
104
+ return true if t_nilable?(node)
105
+
106
+ Array(t_any_types(node)).any? { |type| nil_class?(type) }
107
+ end
108
+
109
+ # True when `node`'s nearest enclosing class or module is `owner`, so that a nested
110
+ # type's fields and methods are not attributed to the one around it.
111
+ def owned_by?(node, owner)
112
+ node.each_ancestor(:class, :module).first == owner
113
+ end
114
+
115
+ # @!method non_null_field?(node)
116
+ def_node_matcher :non_null_field?, <<~PATTERN
117
+ (send nil? :field (sym _) ... (hash <(pair (sym :null) (false)) ...>))
118
+ PATTERN
119
+
120
+ # @!method t_nilable?(node)
121
+ def_node_matcher :t_nilable?, <<~PATTERN
122
+ (send (const {nil? cbase} :T) :nilable ...)
123
+ PATTERN
124
+
125
+ # @!method t_any_types(node)
126
+ def_node_matcher :t_any_types, <<~PATTERN
127
+ (send (const {nil? cbase} :T) :any $...)
128
+ PATTERN
129
+
130
+ # @!method nil_class?(node)
131
+ def_node_matcher :nil_class?, <<~PATTERN
132
+ (const {nil? cbase} :NilClass)
133
+ PATTERN
134
+ end
135
+ end
136
+ end
137
+ end
@@ -6,6 +6,19 @@ module RuboCop
6
6
  # This cop checks if a type (object, input, interface, scalar, union,
7
7
  # mutation, subscription, and resolver) has a description.
8
8
  #
9
+ # Only classes that actually declare a GraphQL type are checked: those whose
10
+ # superclass resolves to a GraphQL base (`Object`, `InputObject`, `Union`,
11
+ # `Enum`, `Scalar`, or anything ending in `Mutation`, `Subscription` or
12
+ # `Resolver`), plus modules that `include` an `*Interface` base. Plain Ruby
13
+ # classes that happen to live alongside types - error classes, analyzers,
14
+ # validators, loaders, generators - are skipped, so they no longer need to be
15
+ # silenced one by one.
16
+ #
17
+ # Two kinds of real type declarations are also skipped, because neither
18
+ # surfaces a description in the schema: abstract `Base*` types that other
19
+ # types inherit from, and (unless `IgnoreRootTypes` is disabled) the root
20
+ # operation types `Query`, `Mutation` and `Subscription`.
21
+ #
9
22
  # @example
10
23
  # # good
11
24
  #
@@ -20,44 +33,185 @@ module RuboCop
20
33
  # # ...
21
34
  # end
22
35
  #
36
+ # @example
37
+ # # good - not a GraphQL type, so no description is expected
38
+ #
39
+ # class TrackingInfoNotAvailable < StandardError; end
40
+ # class UserLoader < GraphQL::Batch::Loader; end
41
+ #
42
+ # @example
43
+ # # good - abstract base and root operation types carry no description
44
+ #
45
+ # class Types::BaseObject < GraphQL::Schema::Object; end
46
+ # class Types::Query < Types::BaseObject; end
47
+ #
48
+ # @example AdditionalTypeBases: [] (default)
49
+ # # good - `ApplicationType` is not a recognized GraphQL base, so this
50
+ # # class is not checked
51
+ #
52
+ # class Types::UserType < ApplicationType
53
+ # end
54
+ #
55
+ # @example AdditionalTypeBases: ['ApplicationType']
56
+ # # bad - `ApplicationType` is now treated as a GraphQL base
57
+ #
58
+ # class Types::UserType < ApplicationType
59
+ # end
60
+ #
23
61
  class ObjectDescription < Base
24
- include RuboCop::GraphQL::NodePattern
25
62
  include RuboCop::GraphQL::DescriptionMethod
26
63
 
27
64
  MSG = "Missing type description"
28
65
 
29
- # @!method interface?(node)
30
- def_node_matcher :interface?, <<~PATTERN
31
- (send nil? :include (const ...))
32
- PATTERN
66
+ # Base class names that mark a GraphQL type when they match exactly, or with
67
+ # a `Base` prefix (`GraphQL::Schema::Object`, `Types::BaseObject`).
68
+ # Ambiguous words are deliberately not matched as suffixes, so a plain Ruby
69
+ # `ValueObject` base is not mistaken for a GraphQL one. `Interface` is absent
70
+ # on purpose: graphql-ruby interfaces are modules, so a *class* inheriting an
71
+ # `*::Interface` constant is always some other abstract base.
72
+ EXACT_BASES = %w[Object InputObject Union Enum Scalar].freeze
73
+
74
+ # These read unambiguously as GraphQL even inside a longer name, so they are
75
+ # matched as suffixes (`RelayClassicMutation`, `Base::PermissionedMutation`).
76
+ SUFFIX_BASES = %w[Mutation Subscription Resolver].freeze
77
+
78
+ # A base named exactly `Base` (`Resolvers::Base`) is only a GraphQL base when
79
+ # it sits in a namespace that says so.
80
+ GRAPHQL_NAMESPACES = %w[
81
+ Types Mutations Subscriptions Resolvers Inputs Interfaces Unions Enums Scalars
82
+ ].freeze
83
+
84
+ # GraphQL reserves these names for the root operation types.
85
+ ROOT_TYPE_NAMES = %w[Query Mutation Subscription].freeze
86
+
87
+ # `Base`, or `Base` followed by another word - the naming convention for the
88
+ # abstract types that other types inherit from (`BaseObject`, `BaseInterface`).
89
+ ABSTRACT_BASE_NAME = /\ABase(?:[A-Z]|\z)/
90
+
91
+ # Sorbet's `T` namespace is reserved, and `T::Enum` / `T::Struct` collide with
92
+ # the base names above.
93
+ SORBET_NAMESPACE = :T
33
94
 
34
95
  def on_class(node)
35
- return if child_nodes(node).find { |child_node| has_description?(child_node) }
96
+ return unless graphql_type_class?(node)
97
+ return if exempt_type?(node)
98
+ return if described_or_root_named?(node)
36
99
 
37
100
  add_offense(node.identifier)
38
101
  end
39
102
 
40
103
  def on_module(node)
41
- return if child_nodes(node).none? { |child_node| interface?(child_node) }
104
+ return if abstract_base?(node)
105
+ return unless undescribed_interface_module?(node)
42
106
 
43
- if child_nodes(node).none? { |child_node| has_description?(child_node) }
44
- add_offense(node.identifier)
45
- end
107
+ add_offense(node.identifier)
46
108
  end
47
109
 
48
110
  private
49
111
 
50
- def has_description?(node)
51
- description_method_call?(node)
112
+ # Abstract bases and the root operation types are real GraphQL declarations,
113
+ # but neither surfaces a description in the schema.
114
+ def exempt_type?(node)
115
+ return true if abstract_base?(node)
116
+
117
+ ignore_root_types? && ROOT_TYPE_NAMES.include?(type_name(node))
118
+ end
119
+
120
+ def abstract_base?(node)
121
+ ABSTRACT_BASE_NAME.match?(type_name(node))
122
+ end
123
+
124
+ def type_name(node)
125
+ node.identifier.short_name.to_s
126
+ end
127
+
128
+ def graphql_type_class?(node)
129
+ superclass = node.parent_class
130
+ return false if superclass.nil? || !superclass.const_type?
131
+ return false if superclass.namespace&.short_name == SORBET_NAMESPACE
132
+
133
+ graphql_base?(superclass)
52
134
  end
53
135
 
54
- def child_nodes(node)
55
- if node.body.instance_of? RuboCop::AST::Node
56
- node.body.child_nodes
57
- else
58
- node.child_nodes
136
+ def graphql_base?(const_node)
137
+ name = const_node.short_name.to_s
138
+ return true if additional_type_bases.include?(name)
139
+ return graphql_namespace?(const_node) if name == "Base"
140
+ return true if SUFFIX_BASES.any? { |suffix| name.end_with?(suffix) }
141
+
142
+ EXACT_BASES.include?(name.delete_prefix("Base"))
143
+ end
144
+
145
+ def graphql_namespace?(const_node)
146
+ namespace = const_node.namespace
147
+ return false if namespace.nil? || !namespace.const_type?
148
+
149
+ GRAPHQL_NAMESPACES.include?(namespace.short_name.to_s)
150
+ end
151
+
152
+ # A method call with no explicit receiver (e.g. `description "..."`,
153
+ # `include Foo`) - the shape of a GraphQL type DSL declaration.
154
+ def bare_call?(node)
155
+ node.send_type? && node.receiver.nil?
156
+ end
157
+
158
+ # Single pass over the module body: an interface module both `include`s an
159
+ # `*Interface` base and (when compliant) declares a `description`.
160
+ def undescribed_interface_module?(node)
161
+ interface = false
162
+ described = false
163
+
164
+ body_nodes(node).each do |child|
165
+ described ||= description_method_call?(child)
166
+ interface ||= interface_include?(child)
167
+ end
168
+
169
+ interface && !described
170
+ end
171
+
172
+ def interface_include?(node)
173
+ return false unless bare_call?(node) && node.method?(:include)
174
+
175
+ arg = node.first_argument
176
+ return false unless arg&.const_type?
177
+
178
+ name = arg.short_name.to_s
179
+ name.end_with?("Interface") || additional_type_bases.include?(name)
180
+ end
181
+
182
+ # Single pass over the class body: a `description` satisfies the cop, and a
183
+ # `graphql_name "Query"` marks a root operation type declared under some
184
+ # other class name, which is exempt like a class named `Query` itself.
185
+ def described_or_root_named?(node)
186
+ body_nodes(node).any? do |child|
187
+ description_method_call?(child) || root_graphql_name?(child)
59
188
  end
60
189
  end
190
+
191
+ def root_graphql_name?(node)
192
+ return false unless ignore_root_types?
193
+ return false unless bare_call?(node) && node.method?(:graphql_name)
194
+
195
+ arg = node.first_argument
196
+ return false unless arg&.str_type?
197
+
198
+ ROOT_TYPE_NAMES.include?(arg.value)
199
+ end
200
+
201
+ def body_nodes(node)
202
+ body = node.body
203
+ return [] if body.nil?
204
+
205
+ body.begin_type? ? body.child_nodes : [body]
206
+ end
207
+
208
+ def ignore_root_types?
209
+ cop_config.fetch("IgnoreRootTypes", true)
210
+ end
211
+
212
+ def additional_type_bases
213
+ @additional_type_bases ||= Array(cop_config["AdditionalTypeBases"])
214
+ end
61
215
  end
62
216
  end
63
217
  end
@@ -109,7 +109,7 @@ module RuboCop
109
109
  node.node_parts[0]
110
110
  end.to_set
111
111
  declared_args = declared_arg_nodes.map { |node| RuboCop::GraphQL::Argument.new(node) }
112
- declared_args.map(&method(:arg_name)).uniq.reject do |declared_arg_name|
112
+ declared_args.map(&:keyword).uniq.reject do |declared_arg_name|
113
113
  resolve_method_kwargs_names.include?(declared_arg_name)
114
114
  end
115
115
  end
@@ -147,26 +147,6 @@ module RuboCop
147
147
  node.source_range.end
148
148
  end
149
149
 
150
- def inferred_arg_name(name_as_string)
151
- case name_as_string
152
- when /_id$/
153
- name_as_string.sub(/_id$/, "").to_sym
154
- when /_ids$/
155
- name_as_string.sub(/_ids$/, "")
156
- .sub(/([^s])$/, "\\1s")
157
- .to_sym
158
- else
159
- name_as_string.to_sym
160
- end
161
- end
162
-
163
- def arg_name(declared_arg)
164
- return declared_arg.as if declared_arg.kwargs.as
165
- return inferred_arg_name(declared_arg.name.to_s) if declared_arg.kwargs.loads
166
-
167
- declared_arg.name
168
- end
169
-
170
150
  def scoped_node?(node)
171
151
  scope_changing_syntax?(node) || block_or_lambda?(node)
172
152
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ # Cops for the `GraphQL` department. The department's cops are
6
+ # registered for lazy loading and their files are loaded on demand.
7
+ module GraphQL
8
+ extend LazyLoader
9
+
10
+ register_cop :ArgumentDescription, "#{__dir__}/graphql/argument_description"
11
+ register_cop :ArgumentName, "#{__dir__}/graphql/argument_name"
12
+ register_cop :ArgumentUniqueness, "#{__dir__}/graphql/argument_uniqueness"
13
+ register_cop :ContextWriteInType, "#{__dir__}/graphql/context_write_in_type"
14
+ register_cop :DefaultForOptionalArgument, "#{__dir__}/graphql/default_for_optional_argument"
15
+ register_cop :DisallowedTypes, "#{__dir__}/graphql/disallowed_types"
16
+ register_cop :ExtractInputType, "#{__dir__}/graphql/extract_input_type"
17
+ register_cop :ExtractType, "#{__dir__}/graphql/extract_type"
18
+ register_cop :FieldDefinitions, "#{__dir__}/graphql/field_definitions"
19
+ register_cop :FieldDescription, "#{__dir__}/graphql/field_description"
20
+ register_cop :FieldHashKey, "#{__dir__}/graphql/field_hash_key"
21
+ register_cop :FieldMethod, "#{__dir__}/graphql/field_method"
22
+ register_cop :FieldName, "#{__dir__}/graphql/field_name"
23
+ register_cop :FieldUniqueness, "#{__dir__}/graphql/field_uniqueness"
24
+ register_cop :GraphqlName, "#{__dir__}/graphql/graphql_name"
25
+ register_cop :LegacyDsl, "#{__dir__}/graphql/legacy_dsl"
26
+ register_cop :MaxComplexitySchema, "#{__dir__}/graphql/max_complexity_schema"
27
+ register_cop :MaxDepthSchema, "#{__dir__}/graphql/max_depth_schema"
28
+ register_cop :MethodShadowedByResolverMethod, "#{__dir__}/graphql/method_shadowed_by_resolver_method"
29
+ register_cop :MultipleFieldDefinitions, "#{__dir__}/graphql/multiple_field_definitions"
30
+ register_cop :NotAuthorizedNodeType, "#{__dir__}/graphql/not_authorized_node_type"
31
+ register_cop :NullabilityMismatch, "#{__dir__}/graphql/nullability_mismatch"
32
+ register_cop :ResolverMethodLength, "#{__dir__}/graphql/resolver_method_length"
33
+ register_cop :ObjectDescription, "#{__dir__}/graphql/object_description"
34
+ register_cop :OrderedArguments, "#{__dir__}/graphql/ordered_arguments"
35
+ register_cop :OrderedFields, "#{__dir__}/graphql/ordered_fields"
36
+ register_cop :PrepareMethod, "#{__dir__}/graphql/prepare_method"
37
+ register_cop :UnusedArgument, "#{__dir__}/graphql/unused_argument"
38
+ register_cop :UnnecessaryArgumentCamelize, "#{__dir__}/graphql/unnecessary_argument_camelize"
39
+ register_cop :UnnecessaryFieldAlias, "#{__dir__}/graphql/unnecessary_field_alias"
40
+ register_cop :UnnecessaryFieldCamelize, "#{__dir__}/graphql/unnecessary_field_camelize"
41
+ register_cop :UselessMethodOption, "#{__dir__}/graphql/useless_method_option"
42
+ end
43
+ end
44
+ end
@@ -1,31 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "graphql/argument_description"
4
- require_relative "graphql/argument_name"
5
- require_relative "graphql/argument_uniqueness"
6
- require_relative "graphql/context_write_in_type"
7
- require_relative "graphql/extract_input_type"
8
- require_relative "graphql/extract_type"
9
- require_relative "graphql/field_definitions"
10
- require_relative "graphql/field_description"
11
- require_relative "graphql/field_hash_key"
12
- require_relative "graphql/field_method"
13
- require_relative "graphql/field_name"
14
- require_relative "graphql/field_uniqueness"
15
- require_relative "graphql/graphql_name"
16
- require_relative "graphql/legacy_dsl"
17
- require_relative "graphql/max_complexity_schema"
18
- require_relative "graphql/max_depth_schema"
19
- require_relative "graphql/method_shadowed_by_resolver_method"
20
- require_relative "graphql/multiple_field_definitions"
21
- require_relative "graphql/not_authorized_node_type"
22
- require_relative "graphql/resolver_method_length"
23
- require_relative "graphql/object_description"
24
- require_relative "graphql/ordered_arguments"
25
- require_relative "graphql/ordered_fields"
26
- require_relative "graphql/prepare_method"
27
- require_relative "graphql/unused_argument"
28
- require_relative "graphql/unnecessary_argument_camelize"
29
- require_relative "graphql/unnecessary_field_alias"
30
- require_relative "graphql/unnecessary_field_camelize"
31
- require_relative "graphql/useless_method_option"
3
+ # @deprecated This file is deprecated. Cops are registered for lazy loading in
4
+ # `rubocop/cop/graphql`; this file is kept for compatibility with code
5
+ # that requires it directly.
6
+ require_relative "graphql"
@@ -36,6 +36,16 @@ module RuboCop
36
36
  (pair (sym :camelize) ...)
37
37
  PATTERN
38
38
 
39
+ # @!method required_kwarg?(node)
40
+ def_node_matcher :required_kwarg?, <<~PATTERN
41
+ (pair (sym :required) ...)
42
+ PATTERN
43
+
44
+ # @!method default_value_kwarg?(node)
45
+ def_node_matcher :default_value_kwarg?, <<~PATTERN
46
+ (pair (sym :default_value) ...)
47
+ PATTERN
48
+
39
49
  def initialize(argument_node)
40
50
  @nodes = argument_kwargs(argument_node) || []
41
51
  end
@@ -55,6 +65,14 @@ module RuboCop
55
65
  def as
56
66
  @nodes.find { |kwarg| as_kwarg?(kwarg) }
57
67
  end
68
+
69
+ def required
70
+ @nodes.find { |kwarg| required_kwarg?(kwarg) }
71
+ end
72
+
73
+ def default_value
74
+ @nodes.find { |kwarg| default_value_kwarg?(kwarg) }
75
+ end
58
76
  end
59
77
  end
60
78
  end
@@ -20,6 +20,11 @@ module RuboCop
20
20
  (pair (sym :as) (sym $_))
21
21
  PATTERN
22
22
 
23
+ # @!method argument_required(node)
24
+ def_node_matcher :argument_required, <<~PATTERN
25
+ (pair (sym :required) $_)
26
+ PATTERN
27
+
23
28
  attr_reader :node
24
29
 
25
30
  def initialize(node)
@@ -34,6 +39,29 @@ module RuboCop
34
39
  @as ||= argument_as(kwargs.as)
35
40
  end
36
41
 
42
+ # The keyword this argument is passed as to #resolve and friends. `as:` wins when both
43
+ # are given, matching graphql-ruby's `kwargs[:as] ||= inferred_arg_name`.
44
+ def keyword
45
+ return as if kwargs.as
46
+ return inferred_keyword if kwargs.loads
47
+
48
+ name
49
+ end
50
+
51
+ # `required: false` is the only value that lets graphql-ruby omit the keyword:
52
+ # `required: :nullable` still demands the argument be present, though it may be null.
53
+ def optional?
54
+ required_node = kwargs.required
55
+ return false unless required_node
56
+
57
+ argument_required(required_node)&.false_type? || false
58
+ end
59
+
60
+ # A configured default is always passed, even when the client omits the argument.
61
+ def default_value?
62
+ !kwargs.default_value.nil?
63
+ end
64
+
37
65
  def description
38
66
  @description ||= argument_description(@node) || kwargs.description || block.description
39
67
  end
@@ -45,6 +73,23 @@ module RuboCop
45
73
  def block
46
74
  @block ||= Argument::Block.new(@node.parent)
47
75
  end
76
+
77
+ private
78
+
79
+ # Mirrors graphql-ruby: a `loads:` argument named `foo_id` arrives as `foo:`, and one
80
+ # named `foo_ids` arrives as `foos:`.
81
+ def inferred_keyword
82
+ name_as_string = name.to_s
83
+
84
+ case name_as_string
85
+ when /_id$/
86
+ name_as_string.sub(/_id$/, "").to_sym
87
+ when /_ids$/
88
+ name_as_string.sub(/_ids$/, "").sub(/([^s])$/, "\\1s").to_sym
89
+ else
90
+ name
91
+ end
92
+ end
48
93
  end
49
94
  end
50
95
  end
@@ -21,6 +21,16 @@ module RuboCop
21
21
  (pair (sym :resolver) ...)
22
22
  PATTERN
23
23
 
24
+ # @!method mutation_kwarg?(node)
25
+ def_node_matcher :mutation_kwarg?, <<~PATTERN
26
+ (pair (sym :mutation) ...)
27
+ PATTERN
28
+
29
+ # @!method subscription_kwarg?(node)
30
+ def_node_matcher :subscription_kwarg?, <<~PATTERN
31
+ (pair (sym :subscription) ...)
32
+ PATTERN
33
+
24
34
  # @!method method_kwarg?(node)
25
35
  def_node_matcher :method_kwarg?, <<~PATTERN
26
36
  (pair (sym :method) ...)
@@ -59,6 +69,20 @@ module RuboCop
59
69
  @nodes.find { |kwarg| resolver_kwarg?(kwarg) }
60
70
  end
61
71
 
72
+ def mutation
73
+ @nodes.find { |kwarg| mutation_kwarg?(kwarg) }
74
+ end
75
+
76
+ def subscription
77
+ @nodes.find { |kwarg| subscription_kwarg?(kwarg) }
78
+ end
79
+
80
+ # graphql-ruby builds the field's resolver class out of any one of these
81
+ # options, and the field then inherits configuration from that class.
82
+ def resolver_class
83
+ resolver || mutation || subscription
84
+ end
85
+
62
86
  def method
63
87
  @nodes.find { |kwarg| method_kwarg?(kwarg) }
64
88
  end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module GraphQL
3
- VERSION = "1.7.0".freeze
3
+ VERSION = "1.8.0".freeze
4
4
  end
5
5
  end
@@ -24,4 +24,4 @@ require_relative "rubocop/graphql/field/block"
24
24
  require_relative "rubocop/graphql/field/kwargs"
25
25
  require_relative "rubocop/graphql/schema_member"
26
26
 
27
- require_relative "rubocop/cop/graphql_cops"
27
+ require_relative "rubocop/cop/graphql"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Tsepelev
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-02 00:00:00.000000000 Z
10
+ date: 2026-08-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -71,7 +71,7 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 1.72.1
74
+ version: '1.89'
75
75
  - - "<"
76
76
  - !ruby/object:Gem::Version
77
77
  version: '2'
@@ -81,7 +81,7 @@ dependencies:
81
81
  requirements:
82
82
  - - ">="
83
83
  - !ruby/object:Gem::Version
84
- version: 1.72.1
84
+ version: '1.89'
85
85
  - - "<"
86
86
  - !ruby/object:Gem::Version
87
87
  version: '2'
@@ -97,10 +97,13 @@ files:
97
97
  - config/default.yml
98
98
  - lib/refinements/underscore_string.rb
99
99
  - lib/rubocop-graphql.rb
100
+ - lib/rubocop/cop/graphql.rb
100
101
  - lib/rubocop/cop/graphql/argument_description.rb
101
102
  - lib/rubocop/cop/graphql/argument_name.rb
102
103
  - lib/rubocop/cop/graphql/argument_uniqueness.rb
103
104
  - lib/rubocop/cop/graphql/context_write_in_type.rb
105
+ - lib/rubocop/cop/graphql/default_for_optional_argument.rb
106
+ - lib/rubocop/cop/graphql/disallowed_types.rb
104
107
  - lib/rubocop/cop/graphql/extract_input_type.rb
105
108
  - lib/rubocop/cop/graphql/extract_type.rb
106
109
  - lib/rubocop/cop/graphql/field_definitions.rb
@@ -116,6 +119,7 @@ files:
116
119
  - lib/rubocop/cop/graphql/method_shadowed_by_resolver_method.rb
117
120
  - lib/rubocop/cop/graphql/multiple_field_definitions.rb
118
121
  - lib/rubocop/cop/graphql/not_authorized_node_type.rb
122
+ - lib/rubocop/cop/graphql/nullability_mismatch.rb
119
123
  - lib/rubocop/cop/graphql/object_description.rb
120
124
  - lib/rubocop/cop/graphql/ordered_arguments.rb
121
125
  - lib/rubocop/cop/graphql/ordered_fields.rb