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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +71 -0
- data/Rakefile +9 -0
- data/docs/diagnostics/GQLD101.md +7 -0
- data/docs/diagnostics/GQLD102.md +7 -0
- data/docs/diagnostics/GQLD103.md +7 -0
- data/docs/diagnostics/GQLD104.md +7 -0
- data/docs/diagnostics/GQLD201.md +7 -0
- data/docs/diagnostics/GQLD202.md +7 -0
- data/docs/diagnostics/GQLD203.md +7 -0
- data/docs/diagnostics/GQLD204.md +7 -0
- data/docs/diagnostics/GQLD205.md +7 -0
- data/docs/diagnostics/GQLD301.md +7 -0
- data/docs/diagnostics/GQLD302.md +7 -0
- data/docs/diagnostics/GQLD303.md +7 -0
- data/docs/diagnostics/GQLD305.md +7 -0
- data/docs/diagnostics/GQLD306.md +7 -0
- data/docs/diagnostics/GQLD307.md +7 -0
- data/docs/diagnostics/GQLD401.md +7 -0
- data/docs/diagnostics/README.md +29 -0
- data/exe/graphql-doctor +6 -0
- data/lib/graphql/doctor/cache.rb +47 -0
- data/lib/graphql/doctor/checks/argument_keyword_match.rb +308 -0
- data/lib/graphql/doctor/checks/base.rb +121 -0
- data/lib/graphql/doctor/checks/engine.rb +74 -0
- data/lib/graphql/doctor/checks/resolver_method_presence.rb +68 -0
- data/lib/graphql/doctor/cli.rb +227 -0
- data/lib/graphql/doctor/config.rb +168 -0
- data/lib/graphql/doctor/correlation/correlator.rb +58 -0
- data/lib/graphql/doctor/diagnostic.rb +51 -0
- data/lib/graphql/doctor/ir.rb +30 -0
- data/lib/graphql/doctor/location.rb +52 -0
- data/lib/graphql/doctor/reporters/github.rb +34 -0
- data/lib/graphql/doctor/reporters/json.rb +19 -0
- data/lib/graphql/doctor/reporters/sarif.rb +82 -0
- data/lib/graphql/doctor/reporters/text.rb +47 -0
- data/lib/graphql/doctor/runner.rb +71 -0
- data/lib/graphql/doctor/runtime/dump.rb +137 -0
- data/lib/graphql/doctor/runtime/reflector.rb +313 -0
- data/lib/graphql/doctor/runtime/schema_loader.rb +40 -0
- data/lib/graphql/doctor/source/file_visitor.rb +366 -0
- data/lib/graphql/doctor/source/index.rb +164 -0
- data/lib/graphql/doctor/source/loader.rb +146 -0
- data/lib/graphql/doctor/source/name_mangler.rb +31 -0
- data/lib/graphql/doctor/suppression.rb +39 -0
- data/lib/graphql/doctor/version.rb +7 -0
- data/lib/graphql/doctor.rb +23 -0
- 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
|