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,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