graphql-modernize 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +40 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +106 -0
  6. data/Rakefile +10 -0
  7. data/docs/migration-guide.md +23 -0
  8. data/docs/research/changelog-survey.md +68 -0
  9. data/docs/rules/GQLM101.md +5 -0
  10. data/docs/rules/GQLM102.md +12 -0
  11. data/docs/rules/GQLM103.md +5 -0
  12. data/docs/rules/GQLM104.md +5 -0
  13. data/docs/rules/GQLM105.md +9 -0
  14. data/docs/rules/GQLM106.md +7 -0
  15. data/docs/rules/GQLM107.md +7 -0
  16. data/docs/rules/GQLM108.md +4 -0
  17. data/docs/rules/GQLM201.md +5 -0
  18. data/docs/rules/GQLM202.md +4 -0
  19. data/docs/rules/GQLM203.md +5 -0
  20. data/docs/rules/GQLM204.md +5 -0
  21. data/docs/rules/GQLM205.md +5 -0
  22. data/docs/rules/GQLM301.md +5 -0
  23. data/docs/rules/GQLM302.md +5 -0
  24. data/docs/rules/GQLM303.md +6 -0
  25. data/docs/rules/GQLM304.md +4 -0
  26. data/docs/rules/GQLM305.md +8 -0
  27. data/docs/rules/GQLM306.md +8 -0
  28. data/docs/rules/GQLM307.md +11 -0
  29. data/docs/rules/GQLM308.md +5 -0
  30. data/docs/rules/GQLM309.md +4 -0
  31. data/docs/rules/GQLM310.md +9 -0
  32. data/docs/rules/GQLM401.md +6 -0
  33. data/docs/rules/GQLM402.md +6 -0
  34. data/docs/rules/GQLM501.md +6 -0
  35. data/docs/rules/GQLM502.md +5 -0
  36. data/docs/rules/README.md +35 -0
  37. data/docs/writing-rules.md +29 -0
  38. data/exe/graphql-modernize +6 -0
  39. data/lib/graphql/modernize/application.rb +172 -0
  40. data/lib/graphql/modernize/class_hierarchy.rb +329 -0
  41. data/lib/graphql/modernize/cli.rb +175 -0
  42. data/lib/graphql/modernize/config.rb +163 -0
  43. data/lib/graphql/modernize/context_builder.rb +78 -0
  44. data/lib/graphql/modernize/file_finder.rb +45 -0
  45. data/lib/graphql/modernize/offense.rb +28 -0
  46. data/lib/graphql/modernize/reporter.rb +103 -0
  47. data/lib/graphql/modernize/rule_registry.rb +26 -0
  48. data/lib/graphql/modernize/rules/base.rb +132 -0
  49. data/lib/graphql/modernize/rules/deprecation.rb +219 -0
  50. data/lib/graphql/modernize/rules/legacy.rb +301 -0
  51. data/lib/graphql/modernize/rules/modernize.rb +69 -0
  52. data/lib/graphql/modernize/rules/relay.rb +74 -0
  53. data/lib/graphql/modernize/rules/schema_config.rb +38 -0
  54. data/lib/graphql/modernize/runner.rb +34 -0
  55. data/lib/graphql/modernize/suppression_index.rb +88 -0
  56. data/lib/graphql/modernize/version.rb +7 -0
  57. data/lib/graphql/modernize.rb +30 -0
  58. metadata +120 -0
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "open3"
5
+
6
+ module GraphQL
7
+ module Modernize
8
+ class Application
9
+ FileResult = Data.define(:source_file, :rewritten, :offenses)
10
+ Result = Data.define(:files, :parse_errors, :iterations)
11
+
12
+ def initialize(config:, rules: RuleRegistry.all, jobs: Etc.nprocessors, explicit_rule_ids: [])
13
+ @config = config
14
+ @rules = rules
15
+ @explicit_rule_ids = explicit_rule_ids.map { |id| id.to_s.upcase }.freeze
16
+ @jobs = Integer(jobs)
17
+ raise Error, "jobs must be an integer greater than or equal to 1" unless @jobs.positive?
18
+ end
19
+
20
+ def run(paths = [])
21
+ files = FileFinder.new(@config).find(paths)
22
+ originals = files.to_h do |path|
23
+ source_file = Astel::SourceFile.parse(path: path, version: @config.ruby_version)
24
+ [path, source_file.source]
25
+ end
26
+ current = originals.dup
27
+ corrected_offenses = Hash.new { |hash, path| hash[path] = [] }
28
+ current_offenses = Hash.new { |hash, path| hash[path] = [] }
29
+ parse_errors = []
30
+ iterations = 0
31
+
32
+ (@config.iterations + 2).times do |iteration|
33
+ iterations = iteration + 1
34
+ source_files = parse_sources(current, parse_errors)
35
+ hierarchy = ClassHierarchy.new(base_classes: @config.base_classes).build(source_files)
36
+ iteration_results = process_files(source_files.map(&:path), current, hierarchy)
37
+ changed = false
38
+
39
+ iteration_results.each do |path, rewritten, found|
40
+ current_offenses[path] = found
41
+ corrected_offenses[path].concat(found.select(&:corrected?))
42
+ changed ||= current[path] != rewritten
43
+ current[path] = rewritten
44
+ end
45
+ break unless changed
46
+
47
+ next unless iterations > @config.iterations + 1
48
+
49
+ rule_ids = iteration_results.flat_map do |_path, _rewritten, found|
50
+ found.filter_map do |offense|
51
+ offense.rule_id if offense.corrected? || offense.conflicting_rule_id
52
+ end
53
+ end.uniq.sort
54
+ detail = rule_ids.empty? ? "" : " (related rules: #{rule_ids.join(', ')})"
55
+ raise Error, "rewrites did not converge after #{@config.iterations} retry iterations#{detail}"
56
+ end
57
+
58
+ results = originals.map do |path, source|
59
+ source_file = Astel::SourceFile.from_string(source, path: path, version: @config.ruby_version)
60
+ unresolved = current_offenses[path].reject(&:corrected?)
61
+ FileResult.new(source_file: source_file, rewritten: current[path],
62
+ offenses: corrected_offenses[path] + unresolved)
63
+ end
64
+ Result.new(files: results, parse_errors: parse_errors.uniq, iterations: iterations)
65
+ end
66
+
67
+ def ensure_write_safe!(paths: [], force: false)
68
+ return if force
69
+
70
+ output, _error, status = Open3.capture3("git", "-C", @config.root, "rev-parse", "--is-inside-work-tree")
71
+ unless status.success? && output.strip == "true"
72
+ raise Error, "--write must run inside a Git working tree (use --force to override)"
73
+ end
74
+
75
+ output, error, status = Open3.capture3("git", "-C", @config.root, "status", "--porcelain")
76
+ raise Error, "Unable to inspect the Git working tree: #{error.strip}" unless status.success?
77
+
78
+ raise Error, "The working tree is not clean (use --force to override)" unless output.empty?
79
+
80
+ output, error, status = Open3.capture3("git", "-C", @config.root, "rev-parse", "--show-toplevel")
81
+ raise Error, "Unable to determine the Git working tree root: #{error.strip}" unless status.success?
82
+
83
+ root = File.realpath(output.strip)
84
+ prefix = "#{root}#{File::SEPARATOR}"
85
+ files = FileFinder.new(@config).find(paths)
86
+ unsafe = files.find do |path|
87
+ !File.realpath(path).start_with?(prefix) || File.lstat(path).symlink?
88
+ end
89
+ raise Error, "A --write target is outside the Git working tree or is a symbolic link: #{unsafe}" if unsafe
90
+
91
+ output, error, status = Open3.capture3("git", "-C", root, "ls-files", "-z")
92
+ raise Error, "Unable to inspect Git-tracked files: #{error.strip}" unless status.success?
93
+
94
+ tracked = output.split("\0").to_h { |path| [File.join(root, path), true] }
95
+ untracked = files.find { |path| !tracked[File.realpath(path)] }
96
+ raise Error, "A --write target is not tracked by Git: #{untracked}" if untracked
97
+ end
98
+
99
+ def write(result)
100
+ result.files.each do |file|
101
+ next if file.source_file.source == file.rewritten
102
+
103
+ File.binwrite(file.source_file.path, file.rewritten)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def parse_sources(contents, parse_errors)
110
+ contents.filter_map do |path, source|
111
+ parsed = Astel::SourceFile.from_string(source, path: path, version: @config.ruby_version)
112
+ if parsed.valid?
113
+ parsed
114
+ else
115
+ parse_errors << [path, parsed.errors.map(&:message)]
116
+ nil
117
+ end
118
+ end
119
+ end
120
+
121
+ def process_files(paths, contents, hierarchy)
122
+ sequential = @jobs == 1 || !Process.respond_to?(:fork)
123
+ return paths.map { |path| process_file(path, contents.fetch(path), hierarchy) } if sequential
124
+
125
+ paths.each_slice(@jobs).flat_map do |batch|
126
+ workers = batch.map do |path|
127
+ reader, writer = IO.pipe
128
+ pid = Process.fork do
129
+ reader.close
130
+ Marshal.dump([true, process_file(path, contents.fetch(path), hierarchy)], writer)
131
+ rescue StandardError => e
132
+ Marshal.dump([false, [e.class.name, e.message]], writer)
133
+ ensure
134
+ writer.close
135
+ exit! 0
136
+ end
137
+ writer.close
138
+ [pid, reader]
139
+ end
140
+ workers.map do |pid, reader|
141
+ success, payload = Marshal.load(reader) # rubocop:disable Security/MarshalLoad -- trusted forked worker only
142
+ Process.wait(pid)
143
+ raise Error, "worker failed: #{payload.join(': ')}" unless success
144
+
145
+ payload
146
+ ensure
147
+ reader.close
148
+ end
149
+ end
150
+ end
151
+
152
+ def process_file(path, source, hierarchy)
153
+ source_file = Astel::SourceFile.from_string(source, path: path, version: @config.ruby_version)
154
+ found, rewriter = Runner.new(hierarchy: hierarchy, config: @config,
155
+ explicit_rule_ids: @explicit_rule_ids).run(source_file, @rules)
156
+ [path, rewriter.rewrite(validate: :parse), found]
157
+ rescue Astel::Rewriter::ValidationError => e
158
+ rules = found ? found.select(&:corrected?).map(&:rule_id).uniq.sort : []
159
+ edits = if rewriter
160
+ rewriter.edits.map do |edit|
161
+ "#{edit.start_offset}...#{edit.end_offset}=#{edit.replacement.inspect}"
162
+ end
163
+ else
164
+ []
165
+ end
166
+ raise Error, "Rewritten source failed syntax validation: #{path}; rules=#{rules.join(',')}; " \
167
+ "edits=[#{edits.join(', ')}]; errors=#{e.parse_errors.map(&:message).join(', ')}; " \
168
+ "Please report an issue with reproduction details."
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Modernize
5
+ ClassDeclaration = Data.define(:path, :start_offset, :end_offset, :name, :superclass, :module_node)
6
+ VisibleDefinition = Data.define(:class_name, :path, :line)
7
+ SchemaCall = Data.define(:name, :first_argument, :path, :start_offset, :direct)
8
+ HierarchyNodes = Data.define(:declarations, :calls, :method_nodes, :singleton_classes, :scopes)
9
+
10
+ module HierarchyScanner
11
+ module_function
12
+
13
+ def call(ast)
14
+ declarations = []
15
+ calls = []
16
+ methods = []
17
+ singleton_classes = []
18
+ scopes = []
19
+ dispatcher = Astel::Dispatcher.new
20
+ dispatcher.on(:class_node) { |node| declarations << [node, false] }
21
+ dispatcher.on(:module_node) { |node| declarations << [node, true] }
22
+ dispatcher.on(:call_node) { |node| calls << node if node.receiver.nil? }
23
+ dispatcher.on(:def_node) do |node|
24
+ methods << node
25
+ scopes << node
26
+ end
27
+ dispatcher.on(:alias_method_node) { |node| methods << node }
28
+ dispatcher.on(:block_node) { |node| scopes << node }
29
+ dispatcher.on(:lambda_node) { |node| scopes << node }
30
+ dispatcher.on(:singleton_class_node) do |node|
31
+ singleton_classes << node if node.expression.is_a?(Prism::SelfNode)
32
+ end
33
+ dispatcher.run(ast)
34
+ HierarchyNodes.new(declarations: declarations, calls: calls, method_nodes: methods,
35
+ singleton_classes: singleton_classes, scopes: scopes)
36
+ end
37
+ end
38
+
39
+ module MethodDeclarations
40
+ module_function
41
+
42
+ def body_statements(nodes)
43
+ {}.compare_by_identity.tap do |result|
44
+ add = lambda do |statements|
45
+ statements&.body&.each do |statement|
46
+ result[statement] = true
47
+ plain_begin = statement.is_a?(Prism::BeginNode) && !statement.rescue_clause &&
48
+ !statement.else_clause && !statement.ensure_clause
49
+ add.call(statement.statements) if plain_begin
50
+ end
51
+ end
52
+ nodes.each { |node| add.call(node.body) }
53
+ end
54
+ end
55
+
56
+ def select(methods, calls, body_statements)
57
+ wrapped = calls.select do |call|
58
+ body_statements.key?(call) && %i[private protected public].include?(call.name)
59
+ end.flat_map { |call| call.arguments&.arguments.to_a }.grep(Prism::DefNode)
60
+ methods.select { |method| body_statements.key?(method) || wrapped.include?(method) }
61
+ end
62
+
63
+ def calls(calls, body_statements)
64
+ direct = calls.select { |call| body_statements.key?(call) }
65
+ wrapped = direct.select { |call| %i[private protected public].include?(call.name) }
66
+ .flat_map { |call| call.arguments&.arguments.to_a }.grep(Prism::CallNode)
67
+ direct.concat(wrapped).to_h { |call| [call, true] }.compare_by_identity
68
+ end
69
+
70
+ def names(call)
71
+ names = call.arguments&.arguments.to_a.filter_map do |argument|
72
+ argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
73
+ end
74
+ return names if %i[attr attr_reader attr_accessor].include?(call.name)
75
+ return names.first(1) if %i[alias_method define_method define_singleton_method].include?(call.name)
76
+
77
+ []
78
+ end
79
+
80
+ def class_method?(call, singleton_classes)
81
+ call.name == :define_singleton_method || singleton_classes.any? do |scope|
82
+ call.location.start_offset > scope.location.start_offset &&
83
+ call.location.end_offset < scope.location.end_offset
84
+ end
85
+ end
86
+ end
87
+
88
+ class ClassHierarchy
89
+ DIRECT_KINDS = {
90
+ "GraphQL::Schema::Object" => :object,
91
+ "GraphQL::Schema::InputObject" => :input_object,
92
+ "GraphQL::Schema::Enum" => :enum,
93
+ "GraphQL::Schema::Union" => :union,
94
+ "GraphQL::Schema::Resolver" => :resolver,
95
+ "GraphQL::Schema::Mutation" => :mutation,
96
+ "GraphQL::Schema::RelayClassicMutation" => :mutation,
97
+ "GraphQL::Schema::Subscription" => :subscription,
98
+ "GraphQL::Schema" => :schema
99
+ }.freeze
100
+ SUFFIX_KINDS = {
101
+ "BaseObject" => :object,
102
+ "BaseInputObject" => :input_object,
103
+ "BaseEnum" => :enum,
104
+ "BaseUnion" => :union,
105
+ "BaseResolver" => :resolver,
106
+ "BaseMutation" => :mutation
107
+ }.freeze
108
+
109
+ attr_reader :declarations
110
+
111
+ def initialize(base_classes: {})
112
+ @configured_kinds = base_classes.each_with_object({}) do |(kind, names), result|
113
+ Array(names).each { |name| result[normalize(name)] = kind.to_sym }
114
+ end
115
+ @declarations = {}
116
+ @declared_names = {}
117
+ @superclasses = {}
118
+ @interface_names = {}
119
+ @visible_definitions = []
120
+ @calls = Hash.new { |hash, name| hash[name] = [] }
121
+ @methods = Hash.new { |hash, name| hash[name] = {} }
122
+ @class_methods = Hash.new { |hash, name| hash[name] = {} }
123
+ end
124
+
125
+ def build(source_files) = tap { source_files.each { |source_file| index(source_file) } }
126
+
127
+ def resolve_kind(class_name)
128
+ name = normalize(class_name)
129
+ seen = {}
130
+ until name.empty? || seen[name]
131
+ seen[name] = true
132
+ kind = direct_kind(name)
133
+ return kind if kind
134
+
135
+ name = resolve_superclass(name)
136
+ end
137
+ nil
138
+ end
139
+
140
+ def calls_for(class_name) = ancestors(class_name).flat_map { |name| @calls[name] }
141
+
142
+ def method_defined?(class_name, method_name) = defined_in?(@methods, class_name, method_name)
143
+
144
+ def class_method_defined?(class_name, method_name) = defined_in?(@class_methods, class_name, method_name)
145
+
146
+ def visible_definitions = @visible_definitions.select { |definition| visible_definition?(definition) }
147
+
148
+ def visibility_plugin?(class_name)
149
+ calls_for(class_name).any? do |call|
150
+ call.direct && call.name == :use &&
151
+ %w[GraphQL::Schema::Visibility GraphQL::Schema::Warden].include?(call.first_argument)
152
+ end
153
+ end
154
+
155
+ def visibility_plugin_ambiguous?(class_name)
156
+ !visibility_plugin?(class_name) && calls_for(class_name).any? do |call|
157
+ call.name == :use && %w[GraphQL::Schema::Visibility GraphQL::Schema::Warden].include?(call.first_argument)
158
+ end
159
+ end
160
+
161
+ def visibility_plugin_target?(class_name)
162
+ return false if visibility_plugin?(class_name)
163
+
164
+ ancestors(class_name).drop(1).none? do |name|
165
+ @declared_names[name] && resolve_kind(name) == :schema && !visibility_plugin?(name)
166
+ end
167
+ end
168
+
169
+ def visibility_plugin_misplaced?(class_name, path, start_offset)
170
+ @calls[normalize(class_name)].any? do |call|
171
+ call.direct && call.path == path && call.start_offset > start_offset &&
172
+ %i[query mutation subscription].include?(call.name)
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def visible_definition?(item) = resolve_kind(item.class_name) && !DIRECT_KINDS.key?(item.class_name)
179
+
180
+ def defined_in?(table, class_name, name) = ancestors(class_name).any? { |ancestor| table[ancestor][name.to_sym] }
181
+
182
+ def index(source_file)
183
+ scan = HierarchyScanner.call(source_file.ast)
184
+ nodes = scan.declarations
185
+ calls = scan.calls
186
+ methods = scan.method_nodes
187
+ singleton_classes = scan.singleton_classes
188
+ includes = calls.select do |node|
189
+ node.name == :include &&
190
+ node.arguments&.arguments&.any? { |arg| constant_name(arg) == "GraphQL::Schema::Interface" }
191
+ end
192
+
193
+ body_statements = MethodDeclarations.body_statements(nodes.map(&:first) + singleton_classes)
194
+ includes.select! { |call| body_statements.key?(call) }
195
+ calls.reject! { |call| scan.scopes.any? { |scope| nested_in?(scope, call) } }
196
+ methods = MethodDeclarations.select(methods, calls, body_statements)
197
+ method_calls = MethodDeclarations.calls(calls, body_statements)
198
+
199
+ name_cache = {}.compare_by_identity
200
+ resolve_name = lambda do |node|
201
+ name_cache[node] ||= begin
202
+ raw_name = constant_name(node.constant_path)
203
+ parent = nodes.filter_map do |candidate, _candidate_module|
204
+ next if candidate.equal?(node)
205
+ next unless candidate.location.start_offset < node.location.start_offset &&
206
+ candidate.location.end_offset >= node.location.end_offset
207
+
208
+ candidate
209
+ end.min_by { |candidate| candidate.location.end_offset - candidate.location.start_offset }
210
+ qualify(raw_name, parent && resolve_name.call(parent))
211
+ end
212
+ end
213
+
214
+ entries = nodes.sort_by { |node, _module_node| node.location.start_offset }.map do |node, module_node|
215
+ superclass = node.respond_to?(:superclass) ? constant_name(node.superclass) : nil
216
+ ClassDeclaration.new(path: source_file.path, start_offset: node.location.start_offset,
217
+ end_offset: node.location.end_offset, name: resolve_name.call(node),
218
+ superclass: superclass, module_node: module_node)
219
+ end
220
+ offset_map = entries.to_h { |entry| [entry.start_offset, entry] }
221
+ @declarations[source_file.path] = offset_map
222
+ index_metadata(source_file, entries, includes, calls, methods, singleton_classes, body_statements, method_calls)
223
+ end
224
+
225
+ def index_metadata(source_file, entries, includes, calls, methods, singleton_classes, body_statements,
226
+ method_calls)
227
+ entries.each do |entry|
228
+ @declared_names[entry.name] = true
229
+ @superclasses[entry.name] = entry.superclass if entry.superclass
230
+ end
231
+ includes.each do |call|
232
+ owner = owner_for(entries, call)
233
+ @interface_names[owner.name] = true if owner&.module_node
234
+ end
235
+ calls.each do |call|
236
+ owner = owner_for(entries, call)
237
+ next unless owner
238
+
239
+ @calls[owner.name] << SchemaCall.new(name: call.name, first_argument: first_argument(call),
240
+ path: source_file.path, start_offset: call.location.start_offset,
241
+ direct: body_statements.key?(call))
242
+ names = MethodDeclarations.names(call)
243
+ if method_calls.key?(call) && !names.empty?
244
+ table = MethodDeclarations.class_method?(call, singleton_classes) ? @class_methods : @methods
245
+ names.each { |name| table[owner.name][name] = true }
246
+ end
247
+ end
248
+ methods.each do |node|
249
+ owner = owner_for(entries, node)
250
+ next unless owner
251
+
252
+ inside_singleton = singleton_classes.any? { |scope| nested_in?(scope, node) }
253
+ if node.is_a?(Prism::AliasMethodNode)
254
+ (inside_singleton ? @class_methods : @methods)[owner.name][node.new_name.unescaped.to_sym] = true
255
+ next
256
+ end
257
+
258
+ next if node.receiver && !node.receiver.is_a?(Prism::SelfNode)
259
+
260
+ class_method = node.receiver.is_a?(Prism::SelfNode) || inside_singleton
261
+ (class_method ? @class_methods : @methods)[owner.name][node.name] = true
262
+ if class_method && node.name == :visible?
263
+ @visible_definitions << VisibleDefinition.new(class_name: owner.name, path: source_file.path,
264
+ line: node.location.start_line)
265
+ end
266
+ end
267
+ end
268
+
269
+ def resolve_superclass(name)
270
+ superclass = @superclasses[name]
271
+ return "" unless superclass
272
+ return normalize(superclass) if superclass.include?("::")
273
+
274
+ namespace = name.split("::")[0...-1]
275
+ while namespace.any?
276
+ candidate = (namespace + [superclass]).join("::")
277
+ return candidate if @superclasses.key?(candidate) || direct_kind(candidate)
278
+
279
+ namespace.pop
280
+ end
281
+ superclass
282
+ end
283
+
284
+ def owner_for(entries, node)
285
+ entries.select do |entry|
286
+ node.location.start_offset > entry.start_offset && node.location.end_offset < entry.end_offset
287
+ end.min_by { |entry| entry.end_offset - entry.start_offset }
288
+ end
289
+
290
+ def nested_in?(scope, node)
291
+ node.location.start_offset > scope.location.start_offset && node.location.end_offset < scope.location.end_offset
292
+ end
293
+
294
+ def first_argument(call) = (arg = call.arguments&.arguments&.first) && (constant_name(arg) || arg.location.slice)
295
+
296
+ def direct_kind(name)
297
+ DIRECT_KINDS[name] || @configured_kinds[name] || (@interface_names[name] && :interface) ||
298
+ SUFFIX_KINDS.find { |suffix, _kind| name.end_with?(suffix) }&.last
299
+ end
300
+
301
+ def ancestors(class_name)
302
+ name = normalize(class_name)
303
+ result = []
304
+ seen = {}
305
+ until name.empty? || seen[name]
306
+ result << name
307
+ seen[name] = true
308
+ name = resolve_superclass(name)
309
+ end
310
+ result
311
+ end
312
+
313
+ def qualify(name, parent_name)
314
+ normalized = normalize(name)
315
+ return normalized if normalized.include?("::") || !parent_name
316
+
317
+ "#{parent_name}::#{normalized}"
318
+ end
319
+
320
+ def constant_name(node)
321
+ return node.name.to_s if node.is_a?(Prism::ConstantReadNode)
322
+
323
+ node.location.slice.delete_prefix("::") if node.is_a?(Prism::ConstantPathNode)
324
+ end
325
+
326
+ def normalize(name) = name.to_s.delete_prefix("::")
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "pathname"
5
+
6
+ module GraphQL
7
+ module Modernize
8
+ class CLI
9
+ COMMANDS = %w[run list explain doctor version].freeze
10
+ PACKAGE_ROOT = File.expand_path("../../..", __dir__)
11
+
12
+ def self.start(arguments = ARGV, out: $stdout, err: $stderr)
13
+ new(arguments.dup, out: out, err: err).start
14
+ end
15
+
16
+ def initialize(arguments, out:, err:)
17
+ @arguments = arguments
18
+ @out = out
19
+ @err = err
20
+ end
21
+
22
+ def start
23
+ command = COMMANDS.include?(@arguments.first) ? @arguments.shift : "run"
24
+ public_send(command)
25
+ rescue StandardError => e
26
+ @err.puts("graphql-modernize: #{e.message}")
27
+ 2
28
+ end
29
+
30
+ def run
31
+ options = { diff: true, format: "text", jobs: Etc.nprocessors, force: false, strict: false }
32
+ parser = run_parser(options)
33
+ paths = catch(:help) { parser.parse(@arguments) }
34
+ return 0 if paths == :help
35
+
36
+ config = Config.load(root: Dir.pwd, overrides: config_overrides(options))
37
+ rules = selected_rules(options, config)
38
+ application = Application.new(config: config, rules: rules, jobs: options[:jobs],
39
+ explicit_rule_ids: options[:only] || [])
40
+ application.ensure_write_safe!(paths: paths, force: options[:force]) if options[:write]
41
+ result = application.run(paths)
42
+ warn_parse_errors(result) unless %w[text json].include?(options[:format])
43
+ output = Reporter.render(options[:format], result, diff: options[:diff])
44
+ if options[:out]
45
+ ensure_output_is_not_source!(options[:out], result)
46
+ File.write(options[:out], output)
47
+ else
48
+ @out.write(output)
49
+ end
50
+ strict_failure = options[:strict] && result.parse_errors.any?
51
+ application.write(result) if options[:write] && !strict_failure
52
+ return 2 if strict_failure
53
+
54
+ unresolved?(result, options[:fail_level]) ? 1 : 0
55
+ end
56
+
57
+ def list
58
+ parse_no_options("list")
59
+ RuleRegistry.all.each do |rule|
60
+ metadata = rule.metadata
61
+ @out.puts("#{metadata.id}\t#{metadata.safety}\t#{metadata.summary}")
62
+ end
63
+ 0
64
+ end
65
+
66
+ def explain
67
+ parser = OptionParser.new { |opts| opts.banner = "Usage: graphql-modernize explain RULE_ID" }
68
+ parser.parse!(@arguments)
69
+ raise Error, "Specify exactly one RULE_ID" unless @arguments.one?
70
+
71
+ metadata = RuleRegistry.fetch(@arguments.first).metadata
72
+ @out.puts("#{metadata.id}: #{metadata.summary}")
73
+ @out.puts("safety: #{metadata.safety}")
74
+ @out.puts("since: #{metadata.since}") if metadata.since
75
+ @out.puts("removed_in: #{metadata.removed_in}") if metadata.removed_in
76
+ docs = metadata.docs && File.expand_path(metadata.docs, PACKAGE_ROOT)
77
+ @out.puts("\n#{File.read(docs)}") if docs && File.file?(docs)
78
+ 0
79
+ end
80
+
81
+ def doctor
82
+ parse_no_options("doctor")
83
+ config = Config.load(root: Dir.pwd)
84
+ files = FileFinder.new(config).find
85
+ @out.puts("configuration: ok")
86
+ @out.puts("ruby: #{RUBY_VERSION}")
87
+ @out.puts("astel: #{Astel::VERSION}")
88
+ @out.puts("graphql: #{config.from_version || 'unknown'} -> #{config.target_version || 'unknown'}")
89
+ @out.puts("files: #{files.length}")
90
+ 0
91
+ end
92
+
93
+ def version
94
+ parse_no_options("version")
95
+ @out.puts(GraphQL::Modernize::VERSION)
96
+ 0
97
+ end
98
+
99
+ private
100
+
101
+ def run_parser(options)
102
+ OptionParser.new do |opts|
103
+ opts.banner = "Usage: graphql-modernize run [PATHS...] [options]"
104
+ opts.on("--write", "Rewrite files") { options[:write] = true }
105
+ opts.on("--[no-]diff", "Print a diff") { |value| options[:diff] = value }
106
+ opts.on("--only IDS", Array, "Run only the specified rules") { |value| options[:only] = value }
107
+ opts.on("--except IDS", Array, "Exclude the specified rules") { |value| options[:except] = value }
108
+ opts.on("--target-version VERSION") { |value| options[:target_version] = value }
109
+ opts.on("--from-version VERSION") { |value| options[:from_version] = value }
110
+ opts.on("--ruby-version VERSION") { |value| options[:ruby_version] = value }
111
+ opts.on("--unsafe", "Apply unsafe corrections") { options[:apply_unsafe] = true }
112
+ opts.on("--iterations N", Integer) { |value| options[:iterations] = value }
113
+ opts.on("--format FORMAT", %w[text diff json github sarif]) { |value| options[:format] = value }
114
+ opts.on("--out PATH") { |value| options[:out] = value }
115
+ opts.on("--jobs N", Integer) { |value| options[:jobs] = value }
116
+ opts.on("--strict") { options[:strict] = true }
117
+ opts.on("--force") { options[:force] = true }
118
+ opts.on("--fail-level LEVEL", %w[any manual unsafe none]) { |value| options[:fail_level] = value }
119
+ opts.on("--no-color") { options[:no_color] = true }
120
+ opts.on("-h", "--help") do
121
+ @out.puts(opts)
122
+ throw :help, :help
123
+ end
124
+ end
125
+ end
126
+
127
+ def config_overrides(options)
128
+ options.slice(:target_version, :from_version, :ruby_version, :apply_unsafe, :iterations)
129
+ end
130
+
131
+ def selected_rules(options, config)
132
+ rules = options[:only] ? options[:only].map { |id| RuleRegistry.fetch(id) }.uniq : RuleRegistry.all
133
+ excluded = Array(options[:except]).map { |id| RuleRegistry.fetch(id).metadata.id }
134
+ rules = rules.reject { |rule| excluded.include?(rule.metadata.id) }
135
+ return rules unless config.target_version
136
+
137
+ target = Gem::Version.new(config.target_version)
138
+ rules.select do |rule|
139
+ boundary = rule.metadata.since || rule.metadata.removed_in
140
+ !boundary || target >= Gem::Version.new(boundary)
141
+ end
142
+ end
143
+
144
+ def ensure_output_is_not_source!(output_path, result)
145
+ output = File.expand_path(output_path)
146
+ conflict = result.files.any? do |file|
147
+ source = File.expand_path(file.source_file.path)
148
+ output == source || (File.exist?(output) && File.identical?(output, source))
149
+ end
150
+ raise Error, "--out must differ from every input file" if conflict
151
+ end
152
+
153
+ def warn_parse_errors(result)
154
+ result.parse_errors.each do |path, errors|
155
+ @err.puts("#{path}: parse error: #{errors.join(', ')}")
156
+ end
157
+ end
158
+
159
+ def unresolved?(result, fail_level)
160
+ offenses = result.files.flat_map(&:offenses).reject(&:corrected?)
161
+ case fail_level || "any"
162
+ when "none" then false
163
+ when "manual" then offenses.any? { |offense| offense.safety == :manual }
164
+ when "unsafe" then offenses.any? { |offense| %i[manual unsafe].include?(offense.safety) }
165
+ else offenses.any?
166
+ end
167
+ end
168
+
169
+ def parse_no_options(command)
170
+ OptionParser.new { |opts| opts.banner = "Usage: graphql-modernize #{command}" }.parse!(@arguments)
171
+ raise Error, "Unexpected arguments: #{@arguments.join(' ')}" unless @arguments.empty?
172
+ end
173
+ end
174
+ end
175
+ end