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,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Modernize
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_INCLUDE = ["app/graphql/**/*.rb", "lib/graphql/**/*.rb"].freeze
|
|
9
|
+
DEFAULT_EXCLUDE = ["vendor/**/*", "tmp/**/*"].freeze
|
|
10
|
+
BASE_CLASS_KINDS = %w[object input_object enum interface union resolver mutation subscription schema].freeze
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"include" => DEFAULT_INCLUDE,
|
|
13
|
+
"exclude" => DEFAULT_EXCLUDE,
|
|
14
|
+
"base_classes" => {},
|
|
15
|
+
"rules" => {},
|
|
16
|
+
"apply_unsafe" => false,
|
|
17
|
+
"require_suppression_reason" => false,
|
|
18
|
+
"iterations" => 3
|
|
19
|
+
}.freeze
|
|
20
|
+
KEYS = (DEFAULTS.keys + %w[from_version target_version ruby_version]).freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :root, :include_patterns, :exclude_patterns, :base_classes, :rules,
|
|
23
|
+
:ruby_version, :apply_unsafe, :from_version, :target_version,
|
|
24
|
+
:require_suppression_reason, :iterations
|
|
25
|
+
|
|
26
|
+
def self.load(root: Dir.pwd, path: nil, overrides: {})
|
|
27
|
+
root = File.expand_path(root)
|
|
28
|
+
path ||= File.join(root, ".graphql-modernize.yml")
|
|
29
|
+
file_options = File.file?(path) ? YAML.safe_load_file(path, aliases: false) : {}
|
|
30
|
+
raise Error, "The configuration root must be a mapping" unless file_options.nil? || file_options.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
options = DEFAULTS.merge(file_options || {}).merge(overrides.compact.transform_keys(&:to_s))
|
|
33
|
+
unknown = options.keys - KEYS
|
|
34
|
+
raise Error, "Unknown configuration keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
35
|
+
|
|
36
|
+
new(root: root, include_patterns: options["include"], exclude_patterns: options["exclude"],
|
|
37
|
+
base_classes: options["base_classes"], rules: options["rules"], ruby_version: options["ruby_version"],
|
|
38
|
+
apply_unsafe: options["apply_unsafe"], from_version: options["from_version"],
|
|
39
|
+
target_version: options["target_version"],
|
|
40
|
+
require_suppression_reason: options["require_suppression_reason"], iterations: options["iterations"])
|
|
41
|
+
rescue Psych::Exception => e
|
|
42
|
+
raise Error, "Unable to load the configuration file: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(root: Dir.pwd, include_patterns: DEFAULT_INCLUDE, exclude_patterns: DEFAULT_EXCLUDE,
|
|
46
|
+
base_classes: {}, rules: {}, ruby_version: nil, apply_unsafe: false, from_version: nil,
|
|
47
|
+
target_version: nil, require_suppression_reason: false, iterations: 3)
|
|
48
|
+
validate!(include_patterns, exclude_patterns, base_classes, rules, apply_unsafe,
|
|
49
|
+
require_suppression_reason, iterations, ruby_version, from_version, target_version)
|
|
50
|
+
|
|
51
|
+
@root = File.expand_path(root)
|
|
52
|
+
@include_patterns = Array(include_patterns).freeze
|
|
53
|
+
@exclude_patterns = Array(exclude_patterns).freeze
|
|
54
|
+
@base_classes = base_classes.transform_keys(&:to_sym).transform_values { |names| Array(names) }.freeze
|
|
55
|
+
@rules = rules.to_h { |id, options| [id.to_s.upcase, options.transform_keys(&:to_s).freeze] }.freeze
|
|
56
|
+
@ruby_version = ruby_version
|
|
57
|
+
@apply_unsafe = apply_unsafe
|
|
58
|
+
@from_version = from_version || locked_graphql_version
|
|
59
|
+
@target_version = target_version || locked_graphql_version
|
|
60
|
+
if @from_version && @target_version && Gem::Version.new(@from_version) > Gem::Version.new(@target_version)
|
|
61
|
+
raise Error, "from_version must be less than or equal to target_version"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@require_suppression_reason = require_suppression_reason
|
|
65
|
+
@iterations = iterations
|
|
66
|
+
rescue ArgumentError => e
|
|
67
|
+
raise Error, "Invalid version configuration: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rule_enabled?(metadata)
|
|
71
|
+
configured = rules.fetch(metadata.id, {})
|
|
72
|
+
configured.fetch("enabled", metadata.default_enabled)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rule_options(rule_id)
|
|
76
|
+
rules.fetch(rule_id, {})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def validate!(include_patterns, exclude_patterns, base_classes, rules, apply_unsafe,
|
|
82
|
+
require_suppression_reason, iterations, ruby_version, from_version, target_version)
|
|
83
|
+
unless iterations.is_a?(Integer) && iterations.positive?
|
|
84
|
+
raise Error, "iterations must be an integer greater than or equal to 1"
|
|
85
|
+
end
|
|
86
|
+
raise Error, "base_classes must be a mapping" unless base_classes.is_a?(Hash)
|
|
87
|
+
raise Error, "rules must be a mapping" unless rules.is_a?(Hash)
|
|
88
|
+
unless [true, false].include?(apply_unsafe) && [true, false].include?(require_suppression_reason)
|
|
89
|
+
raise Error, "Boolean settings must be true or false"
|
|
90
|
+
end
|
|
91
|
+
unless include_patterns.is_a?(Array) && exclude_patterns.is_a?(Array) &&
|
|
92
|
+
include_patterns.all?(String) && exclude_patterns.all?(String)
|
|
93
|
+
raise Error, "include and exclude must be arrays of strings"
|
|
94
|
+
end
|
|
95
|
+
raise Error, "Each rule configuration must be a mapping" unless rules.values.all?(Hash)
|
|
96
|
+
unless base_classes.values.all? { |names| names.is_a?(Array) && names.all?(String) }
|
|
97
|
+
raise Error, "Each base_classes value must be an array of class-name strings"
|
|
98
|
+
end
|
|
99
|
+
if base_classes.values.flatten.any? { |name| name.strip.empty? }
|
|
100
|
+
raise Error, "base_classes class names must not be empty"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
unknown_kinds = base_classes.keys.map(&:to_s) - BASE_CLASS_KINDS
|
|
104
|
+
raise Error, "Unknown base_classes kinds: #{unknown_kinds.join(', ')}" unless unknown_kinds.empty?
|
|
105
|
+
|
|
106
|
+
validate_rules!(rules)
|
|
107
|
+
validate_versions!(ruby_version, from_version, target_version)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_rules!(rules)
|
|
111
|
+
known_rules = RuleRegistry.all.map { |rule| rule.metadata.id }
|
|
112
|
+
unknown_rules = rules.keys.map { |id| id.to_s.upcase } - known_rules
|
|
113
|
+
raise Error, "Unknown rule IDs: #{unknown_rules.join(', ')}" unless unknown_rules.empty?
|
|
114
|
+
|
|
115
|
+
rules.each do |id, options|
|
|
116
|
+
allowed = id.to_s.upcase == "GQLM307" ? %w[enabled migration_errors] : %w[enabled]
|
|
117
|
+
unknown_options = options.keys.map(&:to_s) - allowed
|
|
118
|
+
raise Error, "Unknown settings for #{id}: #{unknown_options.join(', ')}" unless unknown_options.empty?
|
|
119
|
+
end
|
|
120
|
+
validate_rule_values!(rules)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_rule_values!(rules)
|
|
124
|
+
valid_enabled = rules.values.all? do |options|
|
|
125
|
+
key = options.key?("enabled") ? "enabled" : :enabled
|
|
126
|
+
!options.key?(key) || [true, false].include?(options[key])
|
|
127
|
+
end
|
|
128
|
+
raise Error, "A rule's enabled setting must be true or false" unless valid_enabled
|
|
129
|
+
|
|
130
|
+
visibility_options = rules.find { |id, _options| id.to_s.upcase == "GQLM307" }&.last || {}
|
|
131
|
+
migration_errors = visibility_options.fetch("migration_errors",
|
|
132
|
+
visibility_options.fetch(:migration_errors, true))
|
|
133
|
+
return if [true, false].include?(migration_errors)
|
|
134
|
+
|
|
135
|
+
raise Error, "GQLM307 migration_errors must be true or false"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_versions!(ruby_version, from_version, target_version)
|
|
139
|
+
if ruby_version && (!ruby_version.is_a?(String) || !ruby_version.match?(/\A\d+\.\d+(?:\.\d+)?\z/))
|
|
140
|
+
raise Error, "ruby_version must use the X.Y format"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
[from_version, target_version].compact.each do |version|
|
|
144
|
+
raise Error, "versions must be strings" unless version.is_a?(String)
|
|
145
|
+
raise Error, "versions must not be empty" if version.strip.empty?
|
|
146
|
+
|
|
147
|
+
Gem::Version.new(version)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def locked_graphql_version
|
|
152
|
+
lockfile = File.join(root, "Gemfile.lock")
|
|
153
|
+
return unless File.file?(lockfile)
|
|
154
|
+
|
|
155
|
+
File.foreach(lockfile) do |line|
|
|
156
|
+
match = line.match(/^ graphql \((\d+(?:\.\d+)+)/)
|
|
157
|
+
return match[1] if match
|
|
158
|
+
end
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Modernize
|
|
5
|
+
Context = Data.define(:class_stack, :superclass, :graphql_kind, :class_name, :class_node)
|
|
6
|
+
|
|
7
|
+
class ContextBuilder
|
|
8
|
+
def initialize(source_file, hierarchy)
|
|
9
|
+
nodes = {}
|
|
10
|
+
scopes = []
|
|
11
|
+
@call_receivers = {}.compare_by_identity
|
|
12
|
+
@argument_calls = {}.compare_by_identity
|
|
13
|
+
@top_level_statements = {}.compare_by_identity
|
|
14
|
+
source_file.ast.statements.body.each { |node| @top_level_statements[node] = true }
|
|
15
|
+
# ponytail: nested calls rescan descendants; add a parent index only if deep DSLs become a bottleneck.
|
|
16
|
+
record_arguments = lambda do |call|
|
|
17
|
+
stack = call.arguments&.arguments&.dup || []
|
|
18
|
+
until stack.empty?
|
|
19
|
+
argument = stack.pop
|
|
20
|
+
@argument_calls[argument] = call
|
|
21
|
+
stack.concat(argument.compact_child_nodes)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
dispatcher = Astel::Dispatcher.new
|
|
25
|
+
callback = ->(node) { nodes[node.location.start_offset] = node }
|
|
26
|
+
dispatcher.on(:class_node, callback)
|
|
27
|
+
dispatcher.on(:module_node, callback)
|
|
28
|
+
dispatcher.on(:def_node) { |node| scopes << node }
|
|
29
|
+
dispatcher.on(:lambda_node) { |node| scopes << node }
|
|
30
|
+
dispatcher.on(:call_node) do |node|
|
|
31
|
+
@call_receivers[node.receiver] = true if node.receiver
|
|
32
|
+
record_arguments.call(node)
|
|
33
|
+
scopes << node.block if node.block && (node.receiver || node.name != :field)
|
|
34
|
+
end
|
|
35
|
+
dispatcher.run(source_file.ast)
|
|
36
|
+
@executable_scopes = scopes.map do |scope|
|
|
37
|
+
[scope.location.start_offset, scope.location.end_offset]
|
|
38
|
+
end.sort_by(&:first)
|
|
39
|
+
|
|
40
|
+
@ranges = hierarchy.declarations.fetch(source_file.path, {}).values.map do |declaration|
|
|
41
|
+
context = Context.new(
|
|
42
|
+
class_stack: declaration.name.split("::"),
|
|
43
|
+
superclass: declaration.superclass,
|
|
44
|
+
graphql_kind: hierarchy.resolve_kind(declaration.name),
|
|
45
|
+
class_name: declaration.name,
|
|
46
|
+
class_node: nodes[declaration.start_offset]
|
|
47
|
+
)
|
|
48
|
+
[declaration.start_offset, declaration.end_offset, context]
|
|
49
|
+
end.sort_by(&:first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def context_at(byte_offset)
|
|
53
|
+
index = @ranges.bsearch_index { |start_offset, _end_offset, _context| start_offset > byte_offset }
|
|
54
|
+
index = index ? index - 1 : @ranges.length - 1
|
|
55
|
+
index.downto(0) do |candidate|
|
|
56
|
+
start_offset, end_offset, context = @ranges[candidate]
|
|
57
|
+
return context if byte_offset >= start_offset && byte_offset < end_offset
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def executable_scope_at?(byte_offset)
|
|
63
|
+
index = @executable_scopes.bsearch_index { |start_offset, _end_offset| start_offset >= byte_offset }
|
|
64
|
+
index = index ? index - 1 : @executable_scopes.length - 1
|
|
65
|
+
index.downto(0).any? do |candidate|
|
|
66
|
+
start_offset, end_offset = @executable_scopes[candidate]
|
|
67
|
+
byte_offset > start_offset && byte_offset < end_offset
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def call_receiver?(node) = @call_receivers.key?(node)
|
|
72
|
+
|
|
73
|
+
def argument_call(node) = @argument_calls[node]
|
|
74
|
+
|
|
75
|
+
def top_level_statement?(node) = @top_level_statements.key?(node)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Modernize
|
|
7
|
+
class FileFinder
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def find(paths = [])
|
|
13
|
+
candidates = paths.empty? ? configured_files : explicit_files(paths)
|
|
14
|
+
candidates.select { |path| File.file?(path) && path.end_with?(".rb") }
|
|
15
|
+
.reject { |path| excluded?(path) }.uniq.sort
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
def configured_files
|
|
23
|
+
config.include_patterns.flat_map { |pattern| Dir.glob(File.join(config.root, pattern)) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def explicit_files(paths)
|
|
27
|
+
paths.flat_map do |path|
|
|
28
|
+
absolute = File.expand_path(path, config.root)
|
|
29
|
+
if File.directory?(absolute)
|
|
30
|
+
Dir.glob(File.join(absolute, "**/*.rb"))
|
|
31
|
+
elsif File.file?(absolute) && absolute.end_with?(".rb")
|
|
32
|
+
absolute
|
|
33
|
+
else
|
|
34
|
+
raise Error, "Input path does not exist or is not a Ruby file: #{path}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def excluded?(path)
|
|
40
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(config.root)).to_s
|
|
41
|
+
config.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Modernize
|
|
5
|
+
class Offense
|
|
6
|
+
attr_reader :rule_id, :location, :message, :safety, :path
|
|
7
|
+
attr_accessor :corrected, :conflicting_rule_id
|
|
8
|
+
|
|
9
|
+
def initialize(rule_id:, location:, message:, safety:, path:, correctable:)
|
|
10
|
+
@rule_id = rule_id
|
|
11
|
+
@location = location
|
|
12
|
+
@message = message
|
|
13
|
+
@safety = safety
|
|
14
|
+
@path = path
|
|
15
|
+
@correctable = correctable
|
|
16
|
+
@corrected = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def correctable?
|
|
20
|
+
@correctable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def corrected?
|
|
24
|
+
corrected
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Modernize
|
|
8
|
+
module Reporter
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def render(format, result, diff: true)
|
|
12
|
+
case format.to_s
|
|
13
|
+
when "text" then text(result, diff: diff)
|
|
14
|
+
when "diff" then diffs(result)
|
|
15
|
+
when "json" then JSON.pretty_generate(payload(result)) << "\n"
|
|
16
|
+
when "github" then github(result)
|
|
17
|
+
when "sarif" then JSON.pretty_generate(sarif_payload(result)) << "\n"
|
|
18
|
+
else raise Error, "Unknown output format: #{format}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def text(result, diff: true)
|
|
23
|
+
offenses = result.files.flat_map(&:offenses)
|
|
24
|
+
output = result.parse_errors.map do |path, errors|
|
|
25
|
+
"#{path}: parse error: #{errors.join(', ')}"
|
|
26
|
+
end.join("\n")
|
|
27
|
+
output << "\n" unless output.empty?
|
|
28
|
+
offense_output = offenses.map do |offense|
|
|
29
|
+
status = offense.corrected? ? " corrected" : ""
|
|
30
|
+
conflict = offense.conflicting_rule_id ? " conflicts_with=#{offense.conflicting_rule_id}" : ""
|
|
31
|
+
"#{offense.path}:#{offense.location.start_line}:#{offense.location.start_column + 1}: " \
|
|
32
|
+
"[#{offense.rule_id}]#{status}#{conflict} #{offense.message}"
|
|
33
|
+
end.join("\n")
|
|
34
|
+
output << offense_output
|
|
35
|
+
output << "\n" unless output.empty?
|
|
36
|
+
output << diffs(result) if diff
|
|
37
|
+
changed = result.files.count { |file| file.source_file.source != file.rewritten }
|
|
38
|
+
output << "#{result.files.length} files inspected, #{offenses.length} offenses, #{changed} files changed\n"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def diffs(result)
|
|
42
|
+
result.files.filter_map do |file|
|
|
43
|
+
next if file.source_file.source == file.rewritten
|
|
44
|
+
|
|
45
|
+
Astel::Diff.unified(file.source_file.source, file.rewritten, path: relative_path(file.source_file.path))
|
|
46
|
+
end.join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def github(result)
|
|
50
|
+
result.files.flat_map(&:offenses).map do |offense|
|
|
51
|
+
message = escape(offense.message)
|
|
52
|
+
"::warning file=#{escape(offense.path)},line=#{offense.location.start_line}," \
|
|
53
|
+
"col=#{offense.location.start_column + 1},title=#{offense.rule_id}::#{message}"
|
|
54
|
+
end.join("\n") << (result.files.any? { |file| file.offenses.any? } ? "\n" : "")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def payload(result)
|
|
58
|
+
{
|
|
59
|
+
files: result.files.length,
|
|
60
|
+
iterations: result.iterations,
|
|
61
|
+
parse_errors: result.parse_errors.map { |path, errors| { path: path, errors: errors } },
|
|
62
|
+
offenses: result.files.flat_map(&:offenses).map { |offense| offense_payload(offense) }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sarif_payload(result)
|
|
67
|
+
offenses = result.files.flat_map(&:offenses)
|
|
68
|
+
rules = offenses.uniq(&:rule_id).map do |offense|
|
|
69
|
+
{ id: offense.rule_id, shortDescription: { text: offense.message } }
|
|
70
|
+
end
|
|
71
|
+
results = offenses.map do |offense|
|
|
72
|
+
{
|
|
73
|
+
ruleId: offense.rule_id,
|
|
74
|
+
message: { text: offense.message },
|
|
75
|
+
locations: [{ physicalLocation: {
|
|
76
|
+
artifactLocation: { uri: offense.path },
|
|
77
|
+
region: { startLine: offense.location.start_line, startColumn: offense.location.start_column + 1 }
|
|
78
|
+
} }]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
{ version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
82
|
+
runs: [{ tool: { driver: { name: "graphql-modernize", rules: rules } }, results: results }] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def offense_payload(offense)
|
|
86
|
+
{ rule_id: offense.rule_id, path: offense.path, line: offense.location.start_line,
|
|
87
|
+
column: offense.location.start_column + 1, message: offense.message, safety: offense.safety,
|
|
88
|
+
correctable: offense.correctable?, corrected: offense.corrected?,
|
|
89
|
+
conflicting_rule_id: offense.conflicting_rule_id }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def relative_path(path)
|
|
93
|
+
Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
path
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def escape(value)
|
|
99
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A").gsub(":", "%3A").gsub(",", "%2C")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Modernize
|
|
5
|
+
module RuleRegistry
|
|
6
|
+
@rules = {}
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def register(rule_class)
|
|
11
|
+
id = rule_class.metadata.id
|
|
12
|
+
raise ArgumentError, "duplicate rule id: #{id}" if @rules.key?(id)
|
|
13
|
+
|
|
14
|
+
@rules[id] = rule_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def all
|
|
18
|
+
@rules.values.sort_by { |rule_class| rule_class.metadata.id }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(id)
|
|
22
|
+
@rules.fetch(id.to_s.upcase) { raise ArgumentError, "unknown rule: #{id}" }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Modernize
|
|
5
|
+
module Rules
|
|
6
|
+
Metadata = Data.define(:id, :safety, :summary, :since, :removed_in, :docs, :default_enabled)
|
|
7
|
+
Pattern = Data.define(:node_type, :compiled, :handler)
|
|
8
|
+
|
|
9
|
+
class Base
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :metadata, :patterns
|
|
12
|
+
|
|
13
|
+
def rule(id:, safety:, summary:, since: nil, removed_in: nil, docs: nil, default_enabled: true)
|
|
14
|
+
raise ArgumentError, "invalid rule id: #{id}" unless id.match?(/\AGQLM\d{3}\z/)
|
|
15
|
+
raise ArgumentError, "invalid safety: #{safety}" unless %i[safe unsafe manual].include?(safety)
|
|
16
|
+
|
|
17
|
+
@metadata = Metadata.new(id: id, safety: safety, summary: summary, since: since,
|
|
18
|
+
removed_in: removed_in, docs: docs, default_enabled: default_enabled)
|
|
19
|
+
RuleRegistry.register(self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def node_pattern(node_type, pattern_source, handler: :on_match)
|
|
23
|
+
@patterns ||= []
|
|
24
|
+
@patterns << Pattern.new(node_type: node_type.to_sym,
|
|
25
|
+
compiled: Astel::NodePattern.compile(pattern_source), handler: handler)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def node_types
|
|
29
|
+
(@patterns || []).map(&:node_type).uniq
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :offenses
|
|
34
|
+
|
|
35
|
+
def initialize(context:, rewriter:, config:, suppression: nil, hierarchy: nil, edit_rules: {})
|
|
36
|
+
@context = context
|
|
37
|
+
@rewriter = rewriter
|
|
38
|
+
@config = config
|
|
39
|
+
@suppression = suppression
|
|
40
|
+
@hierarchy = hierarchy
|
|
41
|
+
@edit_rules = edit_rules
|
|
42
|
+
@offenses = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def visit(node)
|
|
46
|
+
self.class.patterns.each do |pattern|
|
|
47
|
+
next unless pattern.node_type == Astel::NodeType.from_class_name(node.class.name.split("::").last)
|
|
48
|
+
|
|
49
|
+
captures = pattern.compiled.match(node)
|
|
50
|
+
send(pattern.handler, node, captures) if captures
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :rewriter, :config, :hierarchy
|
|
57
|
+
|
|
58
|
+
def add_offense(location, message:, safety: self.class.metadata.safety, &correction)
|
|
59
|
+
return if @suppression&.suppressed?(self.class.metadata.id, location)
|
|
60
|
+
|
|
61
|
+
offense = Offense.new(rule_id: self.class.metadata.id, location: location, message: message,
|
|
62
|
+
safety: safety, path: rewriter.source_file.path,
|
|
63
|
+
correctable: !correction.nil?)
|
|
64
|
+
@offenses << offense
|
|
65
|
+
return offense unless correction && correction_enabled?(safety)
|
|
66
|
+
|
|
67
|
+
edit_count = rewriter.edits.length
|
|
68
|
+
offense.corrected = rewriter.transaction(raise_on_conflict: true) { |rw| correction.call(rw) }
|
|
69
|
+
rewriter.edits.drop(edit_count).each { |edit| @edit_rules[edit] = self.class.metadata.id }
|
|
70
|
+
offense
|
|
71
|
+
rescue Astel::Rewriter::ConflictError => e
|
|
72
|
+
offense.conflicting_rule_id = @edit_rules.fetch(e.conflicting_edit, self.class.metadata.id)
|
|
73
|
+
offense
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def correction_enabled?(safety)
|
|
77
|
+
safety == :safe || (safety == :unsafe && config.apply_unsafe)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def context_for(node)
|
|
81
|
+
@context.context_at(node.location.start_offset)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def graphql_kind(node)
|
|
85
|
+
context_for(node)&.graphql_kind
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inside?(node, *kinds)
|
|
89
|
+
context = context_for(node)
|
|
90
|
+
kinds.include?(context&.graphql_kind) && !ClassHierarchy::DIRECT_KINDS.key?(context.class_name) &&
|
|
91
|
+
!@context.executable_scope_at?(node.location.start_offset)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def standalone_line?(node)
|
|
95
|
+
source = rewriter.source_file
|
|
96
|
+
line_start = source.line_start(node.location.start_offset)
|
|
97
|
+
line_end = source.line_end([node.location.end_offset - 1, node.location.start_offset].max)
|
|
98
|
+
before = source.source.byteslice(line_start, node.location.start_offset - line_start)
|
|
99
|
+
after = source.source.byteslice(node.location.end_offset, line_end - node.location.end_offset).lstrip
|
|
100
|
+
before.strip.empty? && after.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def safe_to_discard?(node)
|
|
104
|
+
node.static_literal?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def keyword_arguments(node, name)
|
|
108
|
+
keyword_hashes(node).flat_map(&:elements).select do |argument|
|
|
109
|
+
argument.is_a?(Prism::AssocNode) && argument.key.is_a?(Prism::SymbolNode) &&
|
|
110
|
+
argument.key.unescaped.to_sym == name
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dynamic_keyword_arguments?(node)
|
|
115
|
+
keyword_hashes(node).any? { |hash| hash.elements.any?(Prism::AssocSplatNode) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def keyword_hashes(node) = node.arguments&.arguments&.grep(Prism::KeywordHashNode).to_a
|
|
119
|
+
|
|
120
|
+
def constant_source(node)
|
|
121
|
+
source = node&.location&.slice
|
|
122
|
+
source&.delete_prefix("::")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def replace_constant(rewriter, node, replacement)
|
|
126
|
+
prefix = node.location.slice.start_with?("::") ? "::" : ""
|
|
127
|
+
rewriter.replace(node.location, "#{prefix}#{replacement}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|