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.
- checksums.yaml +7 -0
- data/.rubocop.yml +40 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +106 -0
- data/Rakefile +10 -0
- data/docs/migration-guide.md +23 -0
- data/docs/research/changelog-survey.md +68 -0
- data/docs/rules/GQLM101.md +5 -0
- data/docs/rules/GQLM102.md +12 -0
- data/docs/rules/GQLM103.md +5 -0
- data/docs/rules/GQLM104.md +5 -0
- data/docs/rules/GQLM105.md +9 -0
- data/docs/rules/GQLM106.md +7 -0
- data/docs/rules/GQLM107.md +7 -0
- data/docs/rules/GQLM108.md +4 -0
- data/docs/rules/GQLM201.md +5 -0
- data/docs/rules/GQLM202.md +4 -0
- data/docs/rules/GQLM203.md +5 -0
- data/docs/rules/GQLM204.md +5 -0
- data/docs/rules/GQLM205.md +5 -0
- data/docs/rules/GQLM301.md +5 -0
- data/docs/rules/GQLM302.md +5 -0
- data/docs/rules/GQLM303.md +6 -0
- data/docs/rules/GQLM304.md +4 -0
- data/docs/rules/GQLM305.md +8 -0
- data/docs/rules/GQLM306.md +8 -0
- data/docs/rules/GQLM307.md +11 -0
- data/docs/rules/GQLM308.md +5 -0
- data/docs/rules/GQLM309.md +4 -0
- data/docs/rules/GQLM310.md +9 -0
- data/docs/rules/GQLM401.md +6 -0
- data/docs/rules/GQLM402.md +6 -0
- data/docs/rules/GQLM501.md +6 -0
- data/docs/rules/GQLM502.md +5 -0
- data/docs/rules/README.md +35 -0
- data/docs/writing-rules.md +29 -0
- data/exe/graphql-modernize +6 -0
- data/lib/graphql/modernize/application.rb +172 -0
- data/lib/graphql/modernize/class_hierarchy.rb +329 -0
- data/lib/graphql/modernize/cli.rb +175 -0
- data/lib/graphql/modernize/config.rb +163 -0
- data/lib/graphql/modernize/context_builder.rb +78 -0
- data/lib/graphql/modernize/file_finder.rb +45 -0
- data/lib/graphql/modernize/offense.rb +28 -0
- data/lib/graphql/modernize/reporter.rb +103 -0
- data/lib/graphql/modernize/rule_registry.rb +26 -0
- data/lib/graphql/modernize/rules/base.rb +132 -0
- data/lib/graphql/modernize/rules/deprecation.rb +219 -0
- data/lib/graphql/modernize/rules/legacy.rb +301 -0
- data/lib/graphql/modernize/rules/modernize.rb +69 -0
- data/lib/graphql/modernize/rules/relay.rb +74 -0
- data/lib/graphql/modernize/rules/schema_config.rb +38 -0
- data/lib/graphql/modernize/runner.rb +34 -0
- data/lib/graphql/modernize/suppression_index.rb +88 -0
- data/lib/graphql/modernize/version.rb +7 -0
- data/lib/graphql/modernize.rb +30 -0
- 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
|