graphql-doctor 0.1.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +71 -0
  4. data/Rakefile +9 -0
  5. data/docs/diagnostics/GQLD101.md +7 -0
  6. data/docs/diagnostics/GQLD102.md +7 -0
  7. data/docs/diagnostics/GQLD103.md +7 -0
  8. data/docs/diagnostics/GQLD104.md +7 -0
  9. data/docs/diagnostics/GQLD201.md +7 -0
  10. data/docs/diagnostics/GQLD202.md +7 -0
  11. data/docs/diagnostics/GQLD203.md +7 -0
  12. data/docs/diagnostics/GQLD204.md +7 -0
  13. data/docs/diagnostics/GQLD205.md +7 -0
  14. data/docs/diagnostics/GQLD301.md +7 -0
  15. data/docs/diagnostics/GQLD302.md +7 -0
  16. data/docs/diagnostics/GQLD303.md +7 -0
  17. data/docs/diagnostics/GQLD305.md +7 -0
  18. data/docs/diagnostics/GQLD306.md +7 -0
  19. data/docs/diagnostics/GQLD307.md +7 -0
  20. data/docs/diagnostics/GQLD401.md +7 -0
  21. data/docs/diagnostics/README.md +29 -0
  22. data/exe/graphql-doctor +6 -0
  23. data/lib/graphql/doctor/cache.rb +47 -0
  24. data/lib/graphql/doctor/checks/argument_keyword_match.rb +308 -0
  25. data/lib/graphql/doctor/checks/base.rb +121 -0
  26. data/lib/graphql/doctor/checks/engine.rb +74 -0
  27. data/lib/graphql/doctor/checks/resolver_method_presence.rb +68 -0
  28. data/lib/graphql/doctor/cli.rb +227 -0
  29. data/lib/graphql/doctor/config.rb +168 -0
  30. data/lib/graphql/doctor/correlation/correlator.rb +58 -0
  31. data/lib/graphql/doctor/diagnostic.rb +51 -0
  32. data/lib/graphql/doctor/ir.rb +30 -0
  33. data/lib/graphql/doctor/location.rb +52 -0
  34. data/lib/graphql/doctor/reporters/github.rb +34 -0
  35. data/lib/graphql/doctor/reporters/json.rb +19 -0
  36. data/lib/graphql/doctor/reporters/sarif.rb +82 -0
  37. data/lib/graphql/doctor/reporters/text.rb +47 -0
  38. data/lib/graphql/doctor/runner.rb +71 -0
  39. data/lib/graphql/doctor/runtime/dump.rb +137 -0
  40. data/lib/graphql/doctor/runtime/reflector.rb +313 -0
  41. data/lib/graphql/doctor/runtime/schema_loader.rb +40 -0
  42. data/lib/graphql/doctor/source/file_visitor.rb +366 -0
  43. data/lib/graphql/doctor/source/index.rb +164 -0
  44. data/lib/graphql/doctor/source/loader.rb +146 -0
  45. data/lib/graphql/doctor/source/name_mangler.rb +31 -0
  46. data/lib/graphql/doctor/suppression.rb +39 -0
  47. data/lib/graphql/doctor/version.rb +7 -0
  48. data/lib/graphql/doctor.rb +23 -0
  49. metadata +105 -0
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/version"
4
+
5
+ module GraphQL
6
+ module Doctor
7
+ module Runtime
8
+ class Reflector
9
+ OWNER_PREPARE_VERSION = Gem::Version.new("2.1.8")
10
+
11
+ def initialize(schema, source_index: nil, abstract_classes: [])
12
+ @schema = schema
13
+ @source_index = source_index
14
+ @abstract_classes = abstract_classes
15
+ end
16
+
17
+ def call
18
+ {
19
+ "version" => 1,
20
+ "graphql_ruby_version" => GraphQL::VERSION,
21
+ "schema" => @schema.name,
22
+ "types" => reflected_types,
23
+ "orphan_members" => orphan_members
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def reflected_types
30
+ @reflected_types ||= @schema.types.values.filter_map do |type|
31
+ next unless reflectable_type?(type)
32
+
33
+ fields = (type.respond_to?(:fields) ? type.fields.values : []).filter_map do |field|
34
+ reflect_field(field) if owned_field?(field, type)
35
+ end
36
+ next if fields.empty? && built_in?(type)
37
+
38
+ {
39
+ "name" => type.graphql_name,
40
+ "kind" => type_kind(type),
41
+ "ruby_class" => type.name,
42
+ "interfaces" => type.respond_to?(:interfaces) ? type.interfaces.map(&:name) : [],
43
+ "source_location" => constant_source_location(type.name),
44
+ "fields" => fields
45
+ }
46
+ end
47
+ end
48
+
49
+ def reflect_field(field)
50
+ owner = field.owner
51
+ return if owner.name.nil?
52
+
53
+ resolver_owner = field.resolver || owner
54
+ resolver_method = field.resolver ? field.resolver.resolve_method : field.resolver_method
55
+ source_options = source_field_options(owner.name, field.graphql_name)
56
+ object_class = underlying_object_class(owner)
57
+ {
58
+ "graphql_name" => field.graphql_name,
59
+ "owner" => owner.name,
60
+ "method_sym" => field.method_sym.to_s,
61
+ "resolver_method" => resolver_method.to_s,
62
+ "hash_key" => json_value(configured_hash_key(member_value(field, :hash_key)) || source_options[:hash_key]),
63
+ "dig_keys" => json_value(member_value(field, :dig_keys) || source_options[:dig]),
64
+ "resolver_class" => field.resolver&.name,
65
+ "type" => field.type.to_type_signature,
66
+ "extras" => field.extras.map(&:to_s),
67
+ "extensions" => field.extensions.map { |extension| extension.class.name },
68
+ **method_metadata(resolver_owner, resolver_method, field.resolver),
69
+ "underlying_object_class" => object_class&.name,
70
+ "underlying_method_defined" => object_class&.public_method_defined?(field.method_sym),
71
+ "arguments" => field_arguments(field).map { |argument| reflect_argument(argument, field) }
72
+ }
73
+ end
74
+
75
+ def reflect_argument(argument, field)
76
+ prepare = prepare_value(argument.prepare)
77
+ method, dispatch = prepare_method(prepare, field)
78
+ {
79
+ "graphql_name" => argument.graphql_name,
80
+ "keyword" => argument.keyword.to_s,
81
+ "type" => argument.type.to_type_signature,
82
+ "non_null" => argument.type.non_null?,
83
+ "required" => argument.type.non_null? || required_nullable?(argument),
84
+ "has_default" => argument.default_value?,
85
+ "default_value" => argument.default_value? ? json_value(argument.default_value) : nil,
86
+ "loads" => constant_name(argument.loads),
87
+ "as" => member_value(argument, :as)&.to_s,
88
+ "prepare" => prepare,
89
+ "prepare_source_location" => method&.source_location,
90
+ "prepare_method_valid" => method && prepare_method_valid?(method, dispatch == "resolver" ? 2 : 1),
91
+ "prepare_method_dispatch" => dispatch
92
+ }
93
+ end
94
+
95
+ def prepare_method(prepare, field)
96
+ return [nil, nil] unless prepare
97
+
98
+ object_method = public_instance_method(field.owner, prepare)
99
+ return [object_method, "object"] if object_method
100
+ return [nil, nil] unless field.resolver && Gem::Version.new(GraphQL::VERSION) >= OWNER_PREPARE_VERSION
101
+
102
+ [field.resolver.public_method(prepare), "resolver"]
103
+ rescue NameError
104
+ [nil, nil]
105
+ end
106
+
107
+ def required_nullable?(argument)
108
+ owner = argument.owner
109
+ return false unless owner.respond_to?(:validators)
110
+
111
+ owner.validators.any? do |validator|
112
+ next false unless validator.is_a?(GraphQL::Schema::Validator::RequiredValidator)
113
+
114
+ names = Array(validator.instance_variable_get(:@one_of))
115
+ names.one? && GraphQL::Schema::Member::BuildType.camelize(names.first.to_s) == argument.graphql_name
116
+ end
117
+ end
118
+
119
+ def public_instance_method(owner, name)
120
+ owner.public_instance_method(name)
121
+ rescue NameError
122
+ nil
123
+ end
124
+
125
+ def prepare_method_valid?(method, positional_count)
126
+ parameters = method.parameters
127
+ required = parameters.count { |kind, _name| kind == :req }
128
+ optional = parameters.count { |kind, _name| kind == :opt }
129
+ accepts_rest = parameters.any? { |kind, _name| kind == :rest }
130
+ required_keywords = parameters.any? { |kind, _name| kind == :keyreq }
131
+ maximum_valid = accepts_rest || positional_count <= required + optional
132
+ !required_keywords && required <= positional_count && maximum_valid
133
+ end
134
+
135
+ def callback_methods(resolver)
136
+ return {} unless resolver
137
+
138
+ %i[ready? authorized?].to_h { |name| [name.to_s, instance_method(resolver, name)] }.compact
139
+ end
140
+
141
+ def method_metadata(owner, name, resolver_class)
142
+ resolver = instance_method(owner, name)
143
+ callbacks = callback_methods(resolver_class)
144
+ {
145
+ "resolver_source_location" => resolver&.source_location,
146
+ "resolver_signature" => method_signature(owner, name, resolver),
147
+ "callback_source_locations" => callbacks.transform_values(&:source_location),
148
+ "callback_signatures" => callbacks.transform_values do |method|
149
+ method_signature(resolver_class, method.name, method)
150
+ end
151
+ }
152
+ end
153
+
154
+ def method_signature(owner, name, method)
155
+ return unless method
156
+
157
+ parameters = method.parameters
158
+ {
159
+ "visibility" => method_visibility(owner, name).to_s,
160
+ "required_keywords" => parameters.filter_map { |kind, key| key.to_s if kind == :keyreq },
161
+ "optional_keywords" => parameters.filter_map { |kind, key| key.to_s if kind == :key },
162
+ "accepts_keyword_rest" => parameters.any? { |kind, _key| kind == :keyrest },
163
+ "required_positionals" => parameters.count { |kind, _key| kind == :req },
164
+ "optional_positionals" => parameters.count { |kind, _key| kind == :opt },
165
+ "accepts_positional_rest" => parameters.any? { |kind, _key| kind == :rest }
166
+ }
167
+ end
168
+
169
+ def method_visibility(owner, name)
170
+ return :public if owner.public_method_defined?(name)
171
+ return :protected if owner.protected_method_defined?(name)
172
+
173
+ :private
174
+ end
175
+
176
+ def field_arguments(field)
177
+ resolver = field.resolver
178
+ if defined?(GraphQL::Schema::RelayClassicMutation) && resolver && resolver < GraphQL::Schema::RelayClassicMutation
179
+ return resolver.all_argument_definitions.reject { |argument| argument.keyword == :client_mutation_id }
180
+ end
181
+
182
+ field.arguments.values
183
+ end
184
+
185
+ def orphan_members
186
+ return [] unless @source_index
187
+
188
+ referenced = reflected_types.flat_map do |type|
189
+ type["fields"].filter_map { |field| field["resolver_class"] }
190
+ end
191
+ candidates = @source_index.types.filter_map do |definition|
192
+ next if definition.confidence == :dynamic
193
+ next unless resolver_namespace?(definition.owner)
194
+ next unless resolver_class?(definition.owner)
195
+
196
+ definition.owner
197
+ end
198
+ candidates = candidates.uniq
199
+ candidates.reject! { |name| abstract_with_subclass?(name, candidates) }
200
+ candidates - referenced.uniq
201
+ end
202
+
203
+ def resolver_class?(name)
204
+ constant = constantize(name)
205
+ constant < GraphQL::Schema::Resolver || constant < GraphQL::Schema::Mutation
206
+ rescue NameError
207
+ false
208
+ end
209
+
210
+ def resolver_namespace?(name)
211
+ name.match?(/(?:\A|::)(?:Mutations|Resolvers)::/)
212
+ end
213
+
214
+ def abstract_with_subclass?(name, candidates)
215
+ return false unless @abstract_classes.include?(name)
216
+
217
+ parent = constantize(name)
218
+ candidates.any? { |candidate| candidate != name && constantize(candidate) < parent }
219
+ rescue NameError
220
+ false
221
+ end
222
+
223
+ def constantize(name)
224
+ name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
225
+ end
226
+
227
+ def instance_method(owner, method_name)
228
+ owner.instance_method(method_name)
229
+ rescue NameError
230
+ nil
231
+ end
232
+
233
+ def configured_hash_key(value)
234
+ value if value.is_a?(String) || value.is_a?(Symbol)
235
+ end
236
+
237
+ def json_value(value)
238
+ case value
239
+ when Hash then value.to_h { |key, item| [key.to_s, json_value(item)] }
240
+ when Array then value.map { |item| json_value(item) }
241
+ when Float then value.finite? ? value : value.to_s
242
+ when String, Integer, TrueClass, FalseClass, NilClass then value
243
+ else value.to_s
244
+ end
245
+ end
246
+
247
+ def source_field_options(owner, graphql_name)
248
+ return {} unless @source_index
249
+
250
+ field = @source_index.fields.find do |definition|
251
+ definition.owner == owner && definition.graphql_name == graphql_name
252
+ end
253
+ field ? field.options : {}
254
+ end
255
+
256
+ def member_value(member, name)
257
+ return member.public_send(name) if member.respond_to?(name)
258
+
259
+ member.instance_variable_get("@#{name}")
260
+ end
261
+
262
+ def constant_source_location(name)
263
+ return unless name
264
+
265
+ Object.const_source_location(name)
266
+ rescue NameError
267
+ nil
268
+ end
269
+
270
+ def constant_name(value)
271
+ value.respond_to?(:name) ? value.name : value&.to_s
272
+ end
273
+
274
+ def underlying_object_class(owner)
275
+ return unless owner.is_a?(Class) && owner < GraphQL::Schema::Object && owner.respond_to?(:object_class)
276
+
277
+ object_class = owner.object_class
278
+ object_class if object_class.is_a?(Class) && object_class.name && !(object_class <= Hash)
279
+ rescue StandardError
280
+ nil
281
+ end
282
+
283
+ def prepare_value(value)
284
+ value.is_a?(Symbol) ? value.to_s : (value if value.is_a?(String))
285
+ end
286
+
287
+ def built_in?(type)
288
+ type.name&.start_with?("GraphQL::")
289
+ end
290
+
291
+ def type_kind(type)
292
+ type.kind.name
293
+ rescue NoMethodError
294
+ type.class.name.split("::").last.upcase
295
+ end
296
+
297
+ def supported_type?(type)
298
+ %w[OBJECT INTERFACE INPUT_OBJECT UNION ENUM].include?(type_kind(type))
299
+ end
300
+
301
+ def reflectable_type?(type)
302
+ supported_type?(type) && !type.graphql_name.start_with?("__")
303
+ end
304
+
305
+ def owned_field?(field, type)
306
+ return true if field.owner == type
307
+
308
+ field.owner < GraphQL::Schema::Mutation && !built_in?(field.owner)
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Doctor
5
+ module Runtime
6
+ class SchemaLoader
7
+ def self.load(config, root: Dir.pwd)
8
+ boot_environment(config["require"], root)
9
+
10
+ name = config["schema"]
11
+ raise Error, "Configuration key `schema` is required" unless name && !name.strip.empty?
12
+
13
+ schema = resolve_schema(name)
14
+ raise Error, "Configured schema `#{name}` is not a GraphQL::Schema subclass" unless graphql_schema?(schema)
15
+
16
+ schema
17
+ rescue StandardError, LoadError, SyntaxError => e
18
+ raise Error, "GQLD101: Failed to boot schema: #{e.message}"
19
+ end
20
+
21
+ def self.boot_environment(require_path, root)
22
+ require File.expand_path(require_path, root) if require_path && !require_path.empty?
23
+ Rails.application.eager_load! if defined?(Rails.application) && Rails.application.respond_to?(:eager_load!)
24
+ end
25
+
26
+ def self.resolve_schema(name)
27
+ name.split("::").reject(&:empty?).reduce(Object) do |namespace, constant|
28
+ namespace.const_get(constant, false)
29
+ end
30
+ end
31
+
32
+ def self.graphql_schema?(schema)
33
+ defined?(GraphQL::Schema) && schema.is_a?(Class) && schema < GraphQL::Schema
34
+ end
35
+
36
+ private_class_method :boot_environment, :resolve_schema, :graphql_schema?
37
+ end
38
+ end
39
+ end
40
+ end