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,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index"
4
+ require_relative "name_mangler"
5
+
6
+ module GraphQL
7
+ module Doctor
8
+ module Source
9
+ class FileVisitor < Prism::Visitor
10
+ LOOP_METHODS = %i[each map collect filter select reject times upto downto].freeze
11
+
12
+ attr_reader :index
13
+
14
+ def initialize(path:, source:, comments: [])
15
+ super()
16
+ @path = path
17
+ @source = source
18
+ @owners = []
19
+ @visibilities = []
20
+ @dynamic_reason = nil
21
+ @current_field = nil
22
+ @forced_visibility = nil
23
+ @singleton_depth = 0
24
+ @index = Index.new(comments: comments.map { |comment| source_comment(comment) })
25
+ end
26
+
27
+ def visit_class_node(node)
28
+ visit_namespace(node, :class)
29
+ end
30
+
31
+ def visit_module_node(node)
32
+ visit_namespace(node, :module)
33
+ end
34
+
35
+ def visit_if_node(node)
36
+ with_dynamic(:conditional) { super }
37
+ end
38
+
39
+ def visit_unless_node(node)
40
+ with_dynamic(:conditional) { super }
41
+ end
42
+
43
+ def visit_case_node(node)
44
+ with_dynamic(:conditional) { super }
45
+ end
46
+
47
+ def visit_while_node(node)
48
+ with_dynamic(:loop) { super }
49
+ end
50
+
51
+ def visit_until_node(node)
52
+ with_dynamic(:loop) { super }
53
+ end
54
+
55
+ def visit_for_node(node)
56
+ with_dynamic(:loop) { super }
57
+ end
58
+
59
+ def visit_singleton_class_node(node)
60
+ return unless owner && node.expression.is_a?(Prism::SelfNode)
61
+
62
+ @singleton_depth += 1
63
+ @visibilities << :public
64
+ begin
65
+ visit(node.body) if node.body
66
+ ensure
67
+ @visibilities.pop
68
+ @singleton_depth -= 1
69
+ end
70
+ end
71
+
72
+ def visit_call_node(node)
73
+ return visit_visibility(node) if %i[private protected public].include?(node.name)
74
+ return visit_class_visibility(node) if %i[private_class_method public_class_method].include?(node.name)
75
+ return with_dynamic(:loop) { super } if LOOP_METHODS.include?(node.name) && node.block
76
+ return visit_schema_dsl(node) if %i[field argument implements].include?(node.name)
77
+
78
+ case node.name
79
+ when :include, :extend then record_dynamic(node, node.name)
80
+ else
81
+ record_dynamic(node, :field) if node.name.to_s.end_with?("_field")
82
+ node.block ? with_dynamic(:metaprogramming) { super } : super
83
+ end
84
+ end
85
+
86
+ def visit_def_node(node)
87
+ parameters = node.parameters
88
+ required = []
89
+ optional = []
90
+ defaults = {}
91
+ parameter_locations = {}
92
+
93
+ Array(parameters&.keywords).each do |parameter|
94
+ name = parameter.name
95
+ parameter_locations[name] = parameter_location(parameter.name_loc)
96
+ if parameter.is_a?(Prism::RequiredKeywordParameterNode)
97
+ required << name
98
+ else
99
+ optional << name
100
+ defaults[name] = parameter.value.slice
101
+ end
102
+ end
103
+
104
+ rest = parameters&.keyword_rest
105
+ @index.method_definitions << IR::MethodDefinition.new(
106
+ owner: owner,
107
+ name: node.name,
108
+ visibility: @forced_visibility || visibility,
109
+ singleton: !node.receiver.nil? || @singleton_depth.positive?,
110
+ required_keywords: required,
111
+ optional_keywords: optional,
112
+ keyword_defaults: defaults,
113
+ accepts_keyword_rest: rest.is_a?(Prism::KeywordRestParameterNode),
114
+ forbids_keywords: rest.is_a?(Prism::NoKeywordsParameterNode),
115
+ positionals: positional_parameters(parameters),
116
+ required_positionals: positional_count(parameters, :requireds) + positional_count(parameters, :posts),
117
+ optional_positionals: positional_count(parameters, :optionals),
118
+ accepts_positional_rest: parameters&.rest.is_a?(Prism::RestParameterNode),
119
+ location: location(node.name_loc),
120
+ parameter_locations: parameter_locations,
121
+ dynamic: !!@dynamic_reason || rest.is_a?(Prism::ForwardingParameterNode)
122
+ )
123
+ end
124
+
125
+ private
126
+
127
+ def visit_schema_dsl(node)
128
+ return visit_children(node) unless dsl_call?(node)
129
+
130
+ case node.name
131
+ when :field then visit_field(node)
132
+ when :argument then visit_argument(node)
133
+ when :implements then visit_implements(node)
134
+ end
135
+ end
136
+
137
+ def visit_namespace(node, kind)
138
+ path = constant_path(node.constant_path)
139
+ previous_singleton_depth = @singleton_depth
140
+ @singleton_depth = 0
141
+ @owners << path
142
+ @visibilities << :public
143
+ @index.types << IR::TypeDefinition.new(
144
+ owner: owner,
145
+ superclass: node.respond_to?(:superclass) ? node.superclass&.slice : nil,
146
+ kind: kind,
147
+ location: location(node.constant_path.location),
148
+ confidence: @dynamic_reason ? :dynamic : :certain
149
+ )
150
+ visit(node.body) if node.body
151
+ ensure
152
+ @visibilities.pop
153
+ @owners.pop
154
+ @singleton_depth = previous_singleton_depth
155
+ end
156
+
157
+ def visit_field(node)
158
+ arguments = Array(node.arguments&.arguments)
159
+ name_node = arguments[0]
160
+ return record_dynamic(node, :field, @dynamic_reason || :non_literal_name) unless literal_name(name_node)
161
+
162
+ field = IR::FieldDefinition.new(
163
+ owner: owner,
164
+ graphql_name: NameMangler.graphql_name(literal_name(name_node)),
165
+ ruby_name: literal_name(name_node).to_sym,
166
+ type_expression: arguments[1]&.slice,
167
+ options: keyword_options(arguments),
168
+ arguments: [],
169
+ block_location: node.block && location(node.block.location),
170
+ definition_location: location(name_node.value_loc),
171
+ confidence: @dynamic_reason ? :dynamic : :certain
172
+ )
173
+ @index.fields << field
174
+ record_dynamic(node, :field) if @dynamic_reason
175
+
176
+ previous = @current_field
177
+ @current_field = field
178
+ visit_children(node)
179
+ field.arguments.concat(@index.arguments.select do |argument|
180
+ argument.owner == field.owner && argument.field_name == field.ruby_name
181
+ end)
182
+ ensure
183
+ @current_field = previous
184
+ end
185
+
186
+ def visit_argument(node)
187
+ arguments = Array(node.arguments&.arguments)
188
+ name_node = arguments[0]
189
+ return record_dynamic(node, :argument, @dynamic_reason || :non_literal_name) unless literal_name(name_node)
190
+
191
+ options = keyword_options(arguments)
192
+ required = options.fetch(:required, true)
193
+ definition = IR::ArgumentDefinition.new(
194
+ owner: owner,
195
+ field_name: @current_field&.ruby_name,
196
+ graphql_name: NameMangler.graphql_name(literal_name(name_node)),
197
+ ruby_name: literal_name(name_node).to_sym,
198
+ type_expression: arguments[1]&.slice,
199
+ required: required,
200
+ default_value: options.fetch(:default_value, :no_default),
201
+ loads: options[:loads],
202
+ as: options[:as],
203
+ prepare: options[:prepare],
204
+ extras: options.fetch(:extras, []),
205
+ definition_location: location(name_node.value_loc),
206
+ confidence: @dynamic_reason ? :dynamic : :certain
207
+ )
208
+ @index.arguments << definition
209
+ record_dynamic(node, :argument) if @dynamic_reason
210
+ visit_children(node)
211
+ end
212
+
213
+ def visit_implements(node)
214
+ Array(node.arguments&.arguments).each do |argument|
215
+ @index.interfaces << IR::InterfaceImplementation.new(
216
+ owner: owner, interface: argument.slice, location: location(argument.location)
217
+ )
218
+ end
219
+ record_dynamic(node, :implements) if @dynamic_reason
220
+ visit_children(node)
221
+ end
222
+
223
+ def visit_visibility(node)
224
+ definitions = Array(node.arguments&.arguments).grep(Prism::DefNode)
225
+ if definitions.any?
226
+ previous = @forced_visibility
227
+ @forced_visibility = node.name
228
+ definitions.each { |definition| visit(definition) }
229
+ @forced_visibility = previous
230
+ return
231
+ end
232
+
233
+ names = Array(node.arguments&.arguments).filter_map { |argument| literal_name(argument)&.to_sym }
234
+ if names.empty?
235
+ @visibilities[-1] = node.name
236
+ else
237
+ @index.method_definitions.each do |method|
238
+ next unless method.owner == owner && method.singleton == @singleton_depth.positive?
239
+
240
+ method.visibility = node.name if names.include?(method.name)
241
+ end
242
+ end
243
+ end
244
+
245
+ def visit_class_visibility(node)
246
+ visibility = node.name == :private_class_method ? :private : :public
247
+ definitions = Array(node.arguments&.arguments).grep(Prism::DefNode)
248
+ if definitions.any?
249
+ previous = @forced_visibility
250
+ @forced_visibility = visibility
251
+ definitions.each { |definition| visit(definition) }
252
+ @forced_visibility = previous
253
+ end
254
+
255
+ names = Array(node.arguments&.arguments).filter_map { |argument| literal_name(argument)&.to_sym }
256
+ @index.method_definitions.each do |method|
257
+ method.visibility = visibility if method.owner == owner && method.singleton && names.include?(method.name)
258
+ end
259
+ end
260
+
261
+ def keyword_options(arguments)
262
+ hash = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
263
+ return {} unless hash
264
+
265
+ hash.elements.each_with_object({}) do |element, values|
266
+ next unless element.is_a?(Prism::AssocNode)
267
+
268
+ key = literal_name(element.key)&.to_sym
269
+ values[key] = literal_value(element.value) if key
270
+ end
271
+ end
272
+
273
+ def literal_value(node)
274
+ case node
275
+ when Prism::SymbolNode then node.unescaped.to_sym
276
+ when Prism::StringNode then node.unescaped
277
+ when Prism::TrueNode then true
278
+ when Prism::FalseNode then false
279
+ when Prism::NilNode then nil
280
+ when Prism::IntegerNode, Prism::FloatNode then node.value
281
+ when Prism::ArrayNode then node.elements.map { |element| literal_value(element) }
282
+ else node.slice
283
+ end
284
+ end
285
+
286
+ def literal_name(node)
287
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
288
+ end
289
+
290
+ def field_receiver?(receiver)
291
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
292
+ end
293
+
294
+ def dsl_call?(node)
295
+ owner && field_receiver?(node.receiver)
296
+ end
297
+
298
+ def positional_parameters(parameters)
299
+ return [] unless parameters
300
+
301
+ (parameters.requireds + parameters.optionals + parameters.posts).map do |parameter|
302
+ parameter.respond_to?(:name) ? parameter.name : parameter.slice
303
+ end
304
+ end
305
+
306
+ def positional_count(parameters, name)
307
+ parameters ? parameters.public_send(name).length : 0
308
+ end
309
+
310
+ def constant_path(node)
311
+ path = node.slice.delete_prefix("::")
312
+ return path if @owners.empty? || node.slice.start_with?("::")
313
+ return path if path == owner || path.start_with?("#{owner}::")
314
+
315
+ owner_parts = owner.split("::")
316
+ if path.split("::").first == owner_parts.last
317
+ return (owner_parts[0...-1] + [path]).reject(&:empty?).join("::")
318
+ end
319
+
320
+ "#{owner}::#{path}"
321
+ end
322
+
323
+ def owner
324
+ @owners.last
325
+ end
326
+
327
+ def visibility
328
+ @visibilities.last || :public
329
+ end
330
+
331
+ def with_dynamic(reason)
332
+ previous = @dynamic_reason
333
+ @dynamic_reason = reason
334
+ yield
335
+ ensure
336
+ @dynamic_reason = previous
337
+ end
338
+
339
+ def record_dynamic(node, kind, reason = @dynamic_reason || :metaprogramming)
340
+ @index.dynamics << IR::DynamicDefinition.new(
341
+ owner: owner, kind: kind, reason: reason, location: location(node.location)
342
+ )
343
+ end
344
+
345
+ def source_comment(comment)
346
+ IR::SourceComment.new(text: comment.slice, location: location(comment.location))
347
+ end
348
+
349
+ def visit_children(node)
350
+ node.each_child_node { |child| visit(child) }
351
+ end
352
+
353
+ def parameter_location(prism_location)
354
+ value = location(prism_location)
355
+ return value unless @source.byteslice(prism_location.end_offset - 1, 1) == ":"
356
+
357
+ Location.new(**value.to_h, end_column: value.end_column - 1, end_offset: value.end_offset - 1)
358
+ end
359
+
360
+ def location(prism_location)
361
+ Location.from_prism(@path, prism_location)
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ir"
4
+
5
+ module GraphQL
6
+ module Doctor
7
+ module Source
8
+ INDEX_COLLECTIONS = %i[
9
+ types fields arguments method_definitions interfaces dynamics comments diagnostics
10
+ ].freeze
11
+ SYMBOL_TAG = "__graphql_doctor_symbol__"
12
+
13
+ Index = Struct.new(
14
+ *INDEX_COLLECTIONS,
15
+ keyword_init: true
16
+ ) do
17
+ def initialize(**attributes)
18
+ super(**INDEX_COLLECTIONS.to_h { |name| [name, Array(attributes[name])] })
19
+ end
20
+
21
+ def merge(other)
22
+ self.class.new(**INDEX_COLLECTIONS.to_h { |name| [name, public_send(name) + other.public_send(name)] })
23
+ end
24
+
25
+ def to_h
26
+ INDEX_COLLECTIONS.to_h { |name| [name, public_send(name).map { |value| serialize(value) }] }
27
+ end
28
+
29
+ def freeze!
30
+ INDEX_COLLECTIONS.each do |name|
31
+ public_send(name).each { |value| deep_freeze(value) }
32
+ public_send(name).freeze
33
+ end
34
+ freeze
35
+ end
36
+
37
+ def self.from_h(hash)
38
+ raise ArgumentError, "Invalid source index" unless serialized_index?(hash)
39
+
40
+ values = hash.transform_keys(&:to_sym)
41
+ new(
42
+ types: Array(values[:types]).map { |value| record(IR::TypeDefinition, value, :location) },
43
+ fields: Array(values[:fields]).map { |value| field(value) },
44
+ arguments: Array(values[:arguments]).map { |value| argument(value) },
45
+ method_definitions: Array(values[:method_definitions]).map { |value| method_definition(value) },
46
+ interfaces: Array(values[:interfaces]).map do |value|
47
+ record(IR::InterfaceImplementation, value, :location)
48
+ end,
49
+ dynamics: Array(values[:dynamics]).map { |value| record(IR::DynamicDefinition, value, :location) },
50
+ comments: Array(values[:comments]).map { |value| record(IR::SourceComment, value, :location) },
51
+ diagnostics: Array(values[:diagnostics]).map { |value| Diagnostic.from_h(value) }
52
+ )
53
+ end
54
+
55
+ class << self
56
+ private
57
+
58
+ def serialized_index?(hash)
59
+ hash.is_a?(Hash) && INDEX_COLLECTIONS.all? do |name|
60
+ key = hash.key?(name) ? name : name.to_s
61
+ hash.key?(key) && hash[key].is_a?(Array)
62
+ end
63
+ end
64
+
65
+ def field(value)
66
+ values = symbolize(value)
67
+ values[:ruby_name] = values[:ruby_name]&.to_sym
68
+ values[:confidence] = values[:confidence]&.to_sym
69
+ values[:options] = symbolize(values[:options] || {})
70
+ values[:arguments] = Array(values[:arguments]).map { |argument_value| argument(argument_value) }
71
+ %i[block_location definition_location].each { |key| values[key] = location(values[key]) }
72
+ IR::FieldDefinition.new(**values)
73
+ end
74
+
75
+ def argument(value)
76
+ values = symbolize(value)
77
+ %i[ruby_name required as confidence].each do |key|
78
+ values[key] = values[key]&.to_sym if values[key].is_a?(String)
79
+ end
80
+ values[:field_name] = values[:field_name]&.to_sym
81
+ values[:definition_location] = location(values[:definition_location])
82
+ IR::ArgumentDefinition.new(**values)
83
+ end
84
+
85
+ def method_definition(value)
86
+ values = symbolize(value)
87
+ %i[name visibility].each { |key| values[key] = values[key]&.to_sym }
88
+ %i[required_keywords optional_keywords].each do |key|
89
+ values[key] = Array(values[key]).map(&:to_sym)
90
+ end
91
+ values[:keyword_defaults] = symbolize(values[:keyword_defaults] || {})
92
+ locations = symbolize(values[:parameter_locations] || {})
93
+ values[:parameter_locations] = locations.transform_values do |location_value|
94
+ location(location_value)
95
+ end
96
+ values[:location] = location(values[:location])
97
+ IR::MethodDefinition.new(**values)
98
+ end
99
+
100
+ def record(type, value, *location_keys)
101
+ values = symbolize(value)
102
+ values[:kind] = values[:kind]&.to_sym if values.key?(:kind)
103
+ values[:confidence] = values[:confidence]&.to_sym if values.key?(:confidence)
104
+ values[:reason] = values[:reason]&.to_sym if values.key?(:reason)
105
+ location_keys.each { |key| values[key] = location(values[key]) }
106
+ type.new(**values)
107
+ end
108
+
109
+ def location(value)
110
+ value && Location.from_h(value)
111
+ end
112
+
113
+ def symbolize(value)
114
+ deserialize(value).transform_keys(&:to_sym)
115
+ end
116
+
117
+ def deserialize(value)
118
+ case value
119
+ when Array then value.map { |item| deserialize(item) }
120
+ when Hash
121
+ return value.fetch(SYMBOL_TAG).to_sym if value.keys == [SYMBOL_TAG]
122
+
123
+ value.transform_values { |item| deserialize(item) }
124
+ else value
125
+ end
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def serialize(value)
132
+ hash = value.to_h
133
+ hash.transform_values { |item| serialize_value(item) }
134
+ end
135
+
136
+ def serialize_value(value)
137
+ case value
138
+ when Location then value.to_h
139
+ when Symbol then {SYMBOL_TAG => value.to_s}
140
+ when Struct then serialize(value)
141
+ when Array then value.map { |item| serialize_value(item) }
142
+ when Hash then value.transform_values { |item| serialize_value(item) }
143
+ else value
144
+ end
145
+ end
146
+
147
+ def deep_freeze(value)
148
+ case value
149
+ when Array
150
+ value.each { |item| deep_freeze(item) }
151
+ when Hash
152
+ value.each do |key, item|
153
+ deep_freeze(key)
154
+ deep_freeze(item)
155
+ end
156
+ when Struct
157
+ value.each_pair { |_name, item| deep_freeze(item) }
158
+ end
159
+ value.freeze
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache"
4
+ require_relative "file_visitor"
5
+
6
+ module GraphQL
7
+ module Doctor
8
+ module Source
9
+ class Loader
10
+ def initialize(root: Dir.pwd, config: Config.new, cache: true)
11
+ @root = File.expand_path(root)
12
+ @config = config
13
+ @cache = cache && Cache.new(directory: File.join(@root, ".graphql-doctor/cache"))
14
+ @config_digest = Digest::SHA256.hexdigest(JSON.generate(config.values))
15
+ end
16
+
17
+ def load(paths = [], jobs: 1)
18
+ files = discover(paths)
19
+ return Index.new.freeze! if files.empty?
20
+
21
+ indexes = parse_files(files, jobs.to_i)
22
+ indexes.reduce(Index.new, &:merge).freeze!
23
+ end
24
+
25
+ def discover(paths = [])
26
+ patterns = paths.empty? ? @config["include"] : paths
27
+ included = patterns.flat_map { |pattern| expand(pattern) }.uniq
28
+ excluded = @config["exclude"].flat_map { |pattern| expand(pattern) }
29
+ (included - excluded).select { |path| File.file?(path) && File.extname(path) == ".rb" }.sort
30
+ end
31
+
32
+ private
33
+
34
+ def parse_files(files, jobs)
35
+ workers = jobs.clamp(1, files.length)
36
+ return files.map { |path| parse(path) } if workers == 1
37
+
38
+ pairs = if Process.respond_to?(:fork) && RUBY_ENGINE == "ruby"
39
+ parse_with_forks(files, workers)
40
+ else
41
+ parse_with_threads(files, workers)
42
+ end
43
+ indexes = pairs.to_h
44
+ files.map { |path| indexes.fetch(path) }
45
+ end
46
+
47
+ def parse_with_threads(files, workers)
48
+ queue = Queue.new
49
+ files.each { |path| queue << path }
50
+ Array.new(workers) do
51
+ Thread.new do
52
+ results = []
53
+ until queue.empty?
54
+ path = queue.pop(true)
55
+ results << [path, parse(path)]
56
+ end
57
+ results
58
+ rescue ThreadError
59
+ results
60
+ end
61
+ end.flat_map(&:value)
62
+ end
63
+
64
+ def parse_with_forks(files, workers)
65
+ chunks = Array.new(workers) { [] }
66
+ files.each_with_index { |path, index| chunks[index % workers] << path }
67
+ children = chunks.map { |chunk| fork_worker(chunk) }
68
+ children.flat_map do |pid, reader|
69
+ response = JSON.parse(reader.read)
70
+ reader.close
71
+ Process.wait(pid)
72
+ raise Error, response["message"] if response["status"] == "error"
73
+
74
+ response.fetch("indexes").map do |entry|
75
+ [entry.fetch("path"), Index.from_h(entry.fetch("index"))]
76
+ end
77
+ end
78
+ end
79
+
80
+ def fork_worker(files)
81
+ reader, writer = IO.pipe
82
+ pid = Process.fork do
83
+ reader.close
84
+ begin
85
+ indexes = files.map { |path| {"path" => path, "index" => parse(path).to_h} }
86
+ writer.write(JSON.generate("status" => "ok", "indexes" => indexes))
87
+ rescue StandardError => e
88
+ writer.write(JSON.generate("status" => "error", "message" => e.message))
89
+ ensure
90
+ writer.close
91
+ end
92
+ exit! 0
93
+ end
94
+ writer.close
95
+ [pid, reader]
96
+ end
97
+
98
+ def expand(pattern)
99
+ path = File.expand_path(pattern, @root)
100
+ return Dir.glob(File.join(path, "**/*.rb")) if File.directory?(path)
101
+ return [path] if File.file?(path)
102
+
103
+ Dir.glob(path)
104
+ end
105
+
106
+ def parse(path)
107
+ return @cache.fetch(path, config_digest: @config_digest) { |source| parse_uncached(path, source) } if @cache
108
+
109
+ parse_uncached(path)
110
+ end
111
+
112
+ def parse_uncached(path, source = nil)
113
+ source ||= File.binread(path)
114
+ result = Prism.parse(source)
115
+ relative_path = path.delete_prefix("#{@root}/")
116
+ return parse_failure(relative_path, result) if result.failure?
117
+
118
+ visitor = FileVisitor.new(path: relative_path, source: source, comments: result.comments)
119
+ visitor.visit(result.value)
120
+ visitor.index
121
+ rescue SystemCallError => e
122
+ Index.new(diagnostics: [diagnostic("GQLD104", e.message, path, 1, 0)])
123
+ end
124
+
125
+ def parse_failure(path, result)
126
+ diagnostics = result.errors.map do |error|
127
+ diagnostic("GQLD104", error.message, path, error.location.start_line, error.location.start_column)
128
+ end
129
+ Index.new(diagnostics: diagnostics)
130
+ end
131
+
132
+ def diagnostic(code, message, path, line, column)
133
+ Diagnostic.new(
134
+ code: code,
135
+ severity: "error",
136
+ message: message,
137
+ location: Location.new(
138
+ path: path, start_line: line, end_line: line, start_column: column,
139
+ end_column: column + 1, start_offset: 0, end_offset: 1
140
+ )
141
+ )
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Doctor
5
+ module Source
6
+ module NameMangler
7
+ module_function
8
+
9
+ def graphql_name(ruby_name)
10
+ string = ruby_name.to_s
11
+ return string if string == "_" || !string.include?("_")
12
+
13
+ camelized = string.split("_").map(&:capitalize).join
14
+ camelized[0] = camelized[0].downcase
15
+ prefix = string[/\A_+/]
16
+ prefix ? "#{prefix}#{camelized}" : camelized
17
+ end
18
+
19
+ def keyword(ruby_name, loads: nil, as: nil)
20
+ return as.to_sym if as
21
+
22
+ name = ruby_name.to_s
23
+ return name.delete_suffix("_ids").sub(/([^s])\z/, "\\1s").to_sym if loads && name.end_with?("_ids")
24
+ return name.delete_suffix("_id").to_sym if loads && name.end_with?("_id")
25
+
26
+ name.to_sym
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end