rubocop-graphql 1.6.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: 1a19f13dab5d269042eab2a3dfb06746f70d78ebd512e4656cb384d1e2f0a77c
4
- data.tar.gz: e67b156307473082e114b860097ca80d1ba23c9987359ad6ee6c56d7641552d3
3
+ metadata.gz: f7108c819a363b06725b9de204f6e1eb26a19c0e022d07a51f7cac3570ccc041
4
+ data.tar.gz: 5b5b231155cf6ac19e58df7ab0ded5060517f0a59ed5b3bcef77db04adf9355a
5
5
  SHA512:
6
- metadata.gz: 412c2b4547281975d17d77cbff5a40e737d49a597747bdd85ccba722dfac63c35f30e33027bb252c3ad6939d43348609ac291baeb37f7f4c2f187e4e427635f6
7
- data.tar.gz: '080b77b04d717024d92a79ec83635b1f43e56412240a6a4d6068bec5060a65c5365438158742d579c1fe9d4fd77636ddd19d02055b0003a9d6083c6da15d5d59'
6
+ metadata.gz: 2bd922b4a7234a4ca1315dc42fd6b3adf7fff850bce9ec71abf41df175fd56889cd76be254dcdb4bbec09615dc85a8317a87884778fc62797ab991f63eee04bf
7
+ data.tar.gz: 282acda7b0c3f54e51a7e77245964e55d3d9d0847009490bfa096959cb49d5de72536e0e49b94f148dbfe1c72c40877494ce8edc9d5187c9b8eacdcf33be6129
data/README.md CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  [Rubocop](https://github.com/rubocop-hq/rubocop) extension for enforcing [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) best practices.
4
4
 
5
- You can support my open–source work [here](https://boosty.to/dmitry_tsepelev).
5
+ ## Professional Support
6
+
7
+ Need help with your GraphQL API architecture, schema design, or performance?
8
+ I'm available for consulting — [get in touch](https://dmitrytsepelev.dev/consulting).
6
9
 
7
10
  ## Installation
8
11
 
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'
@@ -117,6 +130,11 @@ GraphQL/MaxDepthSchema:
117
130
  Include:
118
131
  - '**/graphql/**/*_schema.rb'
119
132
 
133
+ GraphQL/MethodShadowedByResolverMethod:
134
+ Enabled: true
135
+ VersionAdded: '1.8.0'
136
+ Description: 'Checks for method definitions shadowed by the field''s effective resolver_method'
137
+
120
138
  GraphQL/MultipleFieldDefinitions:
121
139
  Enabled: true
122
140
  VersionAdded: '0.15.0'
@@ -130,6 +148,11 @@ GraphQL/NotAuthorizedNodeType:
130
148
  - '**/graphql/types/**/*'
131
149
  SafeBaseClasses: []
132
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
+
133
156
  GraphQL/ResolverMethodLength:
134
157
  Enabled: true
135
158
  VersionAdded: '0.1.0'
@@ -143,6 +166,13 @@ GraphQL/ObjectDescription:
143
166
  Enabled: true
144
167
  VersionAdded: '0.3.0'
145
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: []
146
176
  Exclude:
147
177
  - "spec/**/*"
148
178
  - "test/**/*"
@@ -182,3 +212,8 @@ GraphQL/UnnecessaryFieldCamelize:
182
212
  Enabled: true
183
213
  VersionAdded: '0.18.0'
184
214
  Description: "Camelize isn't necessary if the field name doesn't contain underscores"
215
+
216
+ GraphQL/UselessMethodOption:
217
+ Enabled: true
218
+ VersionAdded: '1.8.0'
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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module GraphQL
6
+ # This cop detects method definitions that are never called because
7
+ # the field's effective `resolver_method` points somewhere else.
8
+ #
9
+ # graphql-ruby only ever calls a single method on the type instance:
10
+ # the field's `resolver_method` (which is the resolver class's own
11
+ # method when `resolver:` is set, the explicit `resolver_method:`
12
+ # value when given, or the field name otherwise). Any other
13
+ # same-named method left on the type is unreachable:
14
+ #
15
+ # - When `resolver:` is set, the resolver class handles resolution
16
+ # entirely, so neither the field name, `resolver_method:`, nor
17
+ # `method:` (if also given) is ever dispatched to a method on the type.
18
+ # - When only `resolver_method:` is set, that name is the one
19
+ # actually called -- a leftover method matching the plain field
20
+ # name is never reached.
21
+ #
22
+ # @example
23
+ # # good
24
+ #
25
+ # class Types::PostType < Types::BaseObject
26
+ # field :author, resolver: Resolvers::AuthorResolver
27
+ # end
28
+ #
29
+ # class Types::PostType < Types::BaseObject
30
+ # field :author, String, null: true, resolver_method: :fetch_author
31
+ #
32
+ # def fetch_author
33
+ # object.author
34
+ # end
35
+ # end
36
+ #
37
+ # # bad
38
+ #
39
+ # class Types::PostType < Types::BaseObject
40
+ # field :author, resolver: Resolvers::AuthorResolver
41
+ #
42
+ # def author
43
+ # object.author
44
+ # end
45
+ # end
46
+ #
47
+ # class Types::PostType < Types::BaseObject
48
+ # field :author, String, null: true, resolver_method: :fetch_author
49
+ #
50
+ # def author
51
+ # object.author
52
+ # end
53
+ #
54
+ # def fetch_author
55
+ # object.author
56
+ # end
57
+ # end
58
+ #
59
+ class MethodShadowedByResolverMethod < Base
60
+ include RuboCop::GraphQL::NodePattern
61
+
62
+ RESOLVER_MSG = "Remove this method, it is never called: `resolver: %<resolver>s` " \
63
+ "resolves this field instead."
64
+ RESOLVER_METHOD_MSG = "Remove this method, it is never called: " \
65
+ "`resolver_method: :%<resolver_method>s` is called instead."
66
+ RESTRICT_ON_SEND = %i[field].freeze
67
+
68
+ def on_send(node)
69
+ return unless field_definition?(node)
70
+
71
+ field = RuboCop::GraphQL::Field.new(node)
72
+
73
+ if field.kwargs.resolver
74
+ register_resolver_offenses(field)
75
+ else
76
+ register_resolver_method_offense(field)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Neither `resolver_method:` nor `method:` has any effect once `resolver:` is
83
+ # set (graphql-ruby always calls the resolver class's own resolver_method), so
84
+ # a method matching the field's own name, an explicit `resolver_method:`, or
85
+ # an explicit `method:` is equally unreachable.
86
+ def register_resolver_offenses(field)
87
+ resolver_source = field.kwargs.resolver.value.source
88
+ message = format(RESOLVER_MSG, resolver: resolver_source)
89
+
90
+ candidate_names = [
91
+ field.name, field.kwargs.resolver_method_name, method_kwarg_name(field)
92
+ ].compact.uniq
93
+ candidate_names.each do |method_name|
94
+ shadowed_method = field.schema_member.find_method_definition(method_name)
95
+ add_offense(shadowed_method.loc.name, message: message) if shadowed_method
96
+ end
97
+ end
98
+
99
+ # The `resolver_method:`-named method is the one actually called; only a
100
+ # leftover method matching the plain field name is dead.
101
+ def register_resolver_method_offense(field)
102
+ resolver_method_kwarg = field.kwargs.resolver_method_name
103
+ return unless resolver_method_kwarg && resolver_method_kwarg != field.name
104
+
105
+ shadowed_method = field.schema_member.find_method_definition(field.name)
106
+ return unless shadowed_method
107
+
108
+ message = format(RESOLVER_METHOD_MSG, resolver_method: resolver_method_kwarg)
109
+ add_offense(shadowed_method.loc.name, message: message)
110
+ end
111
+
112
+ def method_kwarg_name(field)
113
+ pair = field.kwargs.method
114
+ return nil unless pair
115
+
116
+ value_node = pair.value
117
+ value_node.value if value_node.sym_type?
118
+ end
119
+ end
120
+ end
121
+ end
122
+ 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