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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Modernize
5
+ module Rules
6
+ module DeprecatedPluginRule
7
+ private
8
+
9
+ def remove_plugin(node, constant)
10
+ return unless inside?(node, :schema)
11
+
12
+ arguments = node.arguments&.arguments
13
+ first_argument = arguments&.first
14
+ return unless constant_source(first_argument) == constant
15
+
16
+ if !arguments.one? || node.block || !standalone_line?(node)
17
+ return add_offense(node.location, safety: :manual,
18
+ message: "Remove `use #{constant}` manually and preserve side effects")
19
+ end
20
+
21
+ add_offense(node.location, message: "Remove `use #{constant}` because it is now the default") do |rw|
22
+ rw.remove_line(node)
23
+ end
24
+ end
25
+ end
26
+
27
+ class InterpreterPlugin < Base
28
+ include DeprecatedPluginRule
29
+
30
+ rule id: "GQLM301", safety: :safe, summary: "Remove the now-default Interpreter plugin",
31
+ since: "1.12.0", docs: "docs/rules/GQLM301.md"
32
+ node_pattern :call_node, "(call_node receiver: nil name: :use)"
33
+
34
+ def on_match(node, _captures)
35
+ remove_plugin(node, "GraphQL::Execution::Interpreter")
36
+ end
37
+ end
38
+
39
+ class AnalysisAstPlugin < Base
40
+ include DeprecatedPluginRule
41
+
42
+ rule id: "GQLM302", safety: :safe, summary: "Remove the now-default Analysis::AST plugin",
43
+ since: "1.12.0", docs: "docs/rules/GQLM302.md"
44
+ node_pattern :call_node, "(call_node receiver: nil name: :use)"
45
+
46
+ def on_match(node, _captures)
47
+ remove_plugin(node, "GraphQL::Analysis::AST")
48
+ end
49
+ end
50
+
51
+ class ConnectionsPlugin < Base
52
+ include DeprecatedPluginRule
53
+
54
+ rule id: "GQLM303", safety: :safe, summary: "Remove the now-default Connections plugin",
55
+ since: "1.12.0", docs: "docs/rules/GQLM303.md"
56
+ node_pattern :call_node, "(call_node receiver: nil name: :use)"
57
+
58
+ def on_match(node, _captures)
59
+ remove_plugin(node, "GraphQL::Pagination::Connections")
60
+ end
61
+ end
62
+
63
+ class ErrorBubbling < Base
64
+ rule id: "GQLM304", safety: :manual, summary: "Review error_bubbling(true)",
65
+ since: "2.2.7", docs: "docs/rules/GQLM304.md"
66
+ node_pattern :call_node, "(call_node receiver: nil name: :error_bubbling)"
67
+
68
+ def on_match(node, _captures)
69
+ return unless inside?(node, :schema)
70
+ return unless node.arguments&.arguments&.first.is_a?(Prism::TrueNode)
71
+
72
+ add_offense(node.location, message: "`error_bubbling(true)` is deprecated without a replacement")
73
+ end
74
+ end
75
+
76
+ module RemovedPaginationSetting
77
+ private
78
+
79
+ def remove_setting(node, name)
80
+ return unless node.name == name
81
+
82
+ receiver = constant_source(node.receiver)
83
+ return unless receiver == "GraphQL::Relay::ConnectionType"
84
+
85
+ label = node.name.to_s.delete_suffix("=")
86
+ value = node.arguments&.arguments&.first
87
+ unless @context.top_level_statement?(node) && standalone_line?(node) && value && safe_to_discard?(value)
88
+ return add_offense(node.location, safety: :manual,
89
+ message: "Remove `#{label}` manually without discarding side effects")
90
+ end
91
+
92
+ add_offense(node.location, message: "Remove the obsolete `#{label}` setting") { |rw| rw.remove_line(node) }
93
+ end
94
+ end
95
+
96
+ class BidirectionalPagination < Base
97
+ include RemovedPaginationSetting
98
+
99
+ rule id: "GQLM305", safety: :safe, summary: "Remove the legacy pagination setting",
100
+ since: "1.13.10", docs: "docs/rules/GQLM305.md"
101
+ node_pattern :call_node, "(call_node)"
102
+
103
+ def on_match(node, _captures)
104
+ remove_setting(node, :bidirectional_pagination=)
105
+ end
106
+ end
107
+
108
+ class DefaultNodesField < Base
109
+ include RemovedPaginationSetting
110
+
111
+ rule id: "GQLM306", safety: :safe, summary: "Remove the legacy default_nodes_field setting",
112
+ since: "1.13.10", docs: "docs/rules/GQLM306.md"
113
+ node_pattern :call_node, "(call_node)"
114
+
115
+ def on_match(node, _captures)
116
+ remove_setting(node, :default_nodes_field=)
117
+ end
118
+ end
119
+
120
+ class VisibilityPlugin < Base
121
+ rule id: "GQLM307", safety: :unsafe, summary: "Add the Visibility plugin required by visible?",
122
+ since: "2.4.0", docs: "docs/rules/GQLM307.md"
123
+ node_pattern :class_node, "(class_node)"
124
+ node_pattern :call_node, "(call_node receiver: nil name: :use)", handler: :on_visibility_call
125
+
126
+ def on_match(node, _captures)
127
+ context = context_for(node)
128
+ return unless inside?(node, :schema)
129
+ return if hierarchy.visible_definitions.empty? || !hierarchy.visibility_plugin_target?(context.class_name)
130
+
131
+ return if hierarchy.visibility_plugin_ambiguous?(context.class_name)
132
+
133
+ definition = hierarchy.visible_definitions.first
134
+ message = "visible? (#{definition.path}:#{definition.line}) requires a Visibility plugin"
135
+ migration_errors = config.rule_options(self.class.metadata.id).fetch("migration_errors", true)
136
+ plugin = "use GraphQL::Schema::Visibility, migration_errors: #{migration_errors}"
137
+ add_offense(node.location, message: message) { |rw| rw.insert_into_body(node, plugin, position: :end) }
138
+ end
139
+
140
+ def on_visibility_call(node, _captures)
141
+ context = context_for(node)
142
+ return unless inside?(node, :schema)
143
+
144
+ plugin = constant_source(node.arguments&.arguments&.first)
145
+ return unless %w[GraphQL::Schema::Visibility GraphQL::Schema::Warden].include?(plugin)
146
+ if hierarchy.visibility_plugin_ambiguous?(context.class_name)
147
+ return add_offense(node.location, safety: :manual,
148
+ message: "Resolve conditional Visibility plugin configuration manually")
149
+ end
150
+ return unless plugin == "GraphQL::Schema::Visibility"
151
+ return unless hierarchy.visibility_plugin_misplaced?(context.class_name, rewriter.source_file.path,
152
+ node.location.start_offset)
153
+
154
+ message = "Move `use GraphQL::Schema::Visibility` after root type configuration"
155
+ line_end = rewriter.source_file.line_end(node.location.end_offset - 1)
156
+ trailing = rewriter.source_file.source.byteslice(node.location.end_offset,
157
+ line_end - node.location.end_offset).strip
158
+ if node.block || !standalone_line?(node) || !trailing.empty?
159
+ return add_offense(node.location, safety: :manual, message: message)
160
+ end
161
+
162
+ source = rewriter.dedent(node.location.slice)
163
+ add_offense(node.location, message: message) do |rw|
164
+ rw.remove_line(node)
165
+ rw.insert_into_body(context.class_node, source, position: :end)
166
+ end
167
+ end
168
+ end
169
+
170
+ class TypeIndexKeys < Base
171
+ rule id: "GQLM308", safety: :manual, summary: "Update for possible_types and references_to key changes",
172
+ since: "2.3.5", docs: "docs/rules/GQLM308.md"
173
+ node_pattern :call_node, "(call_node)"
174
+
175
+ def on_match(node, _captures)
176
+ receiver = node.receiver&.location&.slice
177
+ return unless %i[possible_types references_to].include?(node.name)
178
+ return unless (receiver == "self" && inside?(node, :schema)) || hierarchy.resolve_kind(receiver) == :schema
179
+
180
+ add_offense(node.location, message: "Return keys changed from type-name strings to type classes")
181
+ end
182
+ end
183
+
184
+ class RescueFrom < Base
185
+ rule id: "GQLM309", safety: :manual, summary: "Review rescue_from placement and handler signatures",
186
+ docs: "docs/rules/GQLM309.md"
187
+ node_pattern :call_node, "(call_node receiver: nil name: :rescue_from)"
188
+
189
+ def on_match(node, _captures)
190
+ return unless inside?(node, :schema)
191
+
192
+ add_offense(node.location, message: "Review `rescue_from` placement and its handler signature")
193
+ end
194
+ end
195
+
196
+ class EmptyUnionSelections < Base
197
+ rule id: "GQLM310", safety: :manual, summary: "Choose a migration policy for empty Union selections",
198
+ since: "2.5.3", docs: "docs/rules/GQLM310.md"
199
+ node_pattern :class_node, "(class_node)"
200
+
201
+ def on_match(node, _captures)
202
+ context = context_for(node)
203
+ return unless inside?(node, :schema)
204
+
205
+ configured = hierarchy.calls_for(context.class_name).any? do |call|
206
+ call.direct && call.name == :allow_legacy_invalid_empty_selections_on_union
207
+ end
208
+ configured ||= %i[legacy_invalid_empty_selections_on_union
209
+ legacy_invalid_empty_selections_on_union_with_type].any? do |name|
210
+ hierarchy.class_method_defined?(context.class_name, name)
211
+ end
212
+ return if configured
213
+
214
+ add_offense(node.location, message: "Choose a policy for `allow_legacy_invalid_empty_selections_on_union`")
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Modernize
5
+ module Rules
6
+ class DefineDsl < Base
7
+ rule id: "GQLM101", safety: :manual, summary: "Migrate the `.define` DSL to the class-based API",
8
+ since: "1.10.0", removed_in: "2.0.0", docs: "docs/rules/GQLM101.md"
9
+ node_pattern :call_node, "(call_node name: :define)"
10
+
11
+ def on_match(node, _captures)
12
+ receiver = constant_source(node.receiver)
13
+ return unless receiver&.match?(/\AGraphQL::(?:Object|InputObject|Interface|Enum|Scalar|Union)Type\z/) ||
14
+ receiver == "GraphQL::Relay::Mutation"
15
+
16
+ add_offense(node.message_loc, message: "Migrate the `.define` DSL to the class-based API manually")
17
+ end
18
+ end
19
+
20
+ class ResolveFieldOption < Base
21
+ RESERVED_METHOD_NAMES = %i[context initialize object].freeze
22
+ UNSUPPORTED_NODES = [
23
+ Prism::BlockNode, Prism::LambdaNode, Prism::DefNode, Prism::ClassNode, Prism::ModuleNode,
24
+ Prism::SelfNode, Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
25
+ Prism::ClassVariableReadNode, Prism::ClassVariableWriteNode,
26
+ Prism::GlobalVariableReadNode, Prism::GlobalVariableWriteNode,
27
+ Prism::SuperNode, Prism::ForwardingSuperNode, Prism::YieldNode,
28
+ Prism::NextNode, Prism::BreakNode, Prism::RedoNode, Prism::RetryNode
29
+ ].freeze
30
+
31
+ rule id: "GQLM102", safety: :unsafe, summary: "Move `resolve:` field options to resolver methods",
32
+ removed_in: "2.0.1", docs: "docs/rules/GQLM102.md"
33
+ node_pattern :call_node, "(call_node receiver: nil name: :field)"
34
+
35
+ def on_match(node, _captures)
36
+ return unless inside?(node, :object, :interface)
37
+
38
+ arguments = keyword_arguments(node, :resolve)
39
+ return if arguments.empty?
40
+
41
+ argument = arguments.first
42
+ if arguments.length > 1
43
+ return add_offense(argument.location, safety: :manual,
44
+ message: "Resolve duplicate `resolve:` options manually")
45
+ end
46
+
47
+ method_name = field_method_name(node)
48
+ lambda_node = argument.value
49
+ unless convertible?(node, lambda_node, method_name)
50
+ return add_offense(argument.location, safety: :manual,
51
+ message: "Move `resolve:` logic to a resolver method manually")
52
+ end
53
+
54
+ @generated_method_names ||= {}
55
+ if @generated_method_names[method_name] || hierarchy.method_defined?(context_for(node).class_name,
56
+ method_name)
57
+ return add_offense(argument.location, safety: :manual,
58
+ message: "`#{method_name}` already exists; migrate manually")
59
+ end
60
+ @generated_method_names[method_name] = true
61
+
62
+ body = resolver_body(lambda_node)
63
+ method_source = "def #{method_name}(**)\n#{indent_body(body)}\nend"
64
+ add_offense(argument.location, message: "Move `resolve:` to the `#{method_name}` method") do |rw|
65
+ rw.remove_keyword_argument(node, :resolve)
66
+ rw.insert_into_body(context_for(node).class_node, method_source, position: :before_private)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def field_method_name(node)
73
+ field_name = node.arguments&.arguments&.first
74
+ name = case field_name
75
+ when Prism::SymbolNode, Prism::StringNode then field_name.unescaped
76
+ end
77
+ name if name&.match?(/\A[a-z_]\w*[!?]?\z/) && !RESERVED_METHOD_NAMES.include?(name.to_sym)
78
+ end
79
+
80
+ def convertible?(field, lambda_node, method_name)
81
+ return false unless method_name && lambda_node.is_a?(Prism::LambdaNode) && lambda_node.body
82
+ return false unless body_boundaries_are_whitespace?(lambda_node)
83
+ return false if dynamic_keyword_arguments?(field)
84
+ return false if %i[method resolver_method hash_key].any? do |name|
85
+ rewriter.find_keyword_argument(field, name)
86
+ end
87
+
88
+ parameters = lambda_node.parameters&.parameters
89
+ return false unless simple_parameters?(parameters)
90
+
91
+ names = parameters.requireds.map(&:name)
92
+ safe_body?(lambda_node.body, names)
93
+ end
94
+
95
+ def simple_parameters?(parameters)
96
+ parameters && parameters.requireds.length == 3 && parameters.optionals.empty? && !parameters.rest &&
97
+ parameters.posts.empty? && parameters.keywords.empty? && !parameters.keyword_rest && !parameters.block
98
+ end
99
+
100
+ def safe_body?(body, names)
101
+ nodes = descendants(body)
102
+ return false if nodes.any? { |child| UNSUPPORTED_NODES.any? { |type| child.is_a?(type) } }
103
+
104
+ assignment_names = names.values_at(0, 2) + %i[object context]
105
+ return false if nodes.any? do |child|
106
+ child.respond_to?(:name) && assignment_names.include?(child.name) &&
107
+ (child.is_a?(Prism::LocalVariableTargetNode) || child.class.name.end_with?("WriteNode"))
108
+ end
109
+ return false if nodes.grep(Prism::LocalVariableReadNode).any? { |read| read.depth.positive? }
110
+ return false if nodes.grep(Prism::LocalVariableReadNode).any? { |read| read.name == names[1] }
111
+ return false if nodes.grep(Prism::CallNode).any? { |call| call.receiver.nil? }
112
+
113
+ true
114
+ end
115
+
116
+ def body_boundaries_are_whitespace?(lambda_node)
117
+ source = rewriter.source_file.source
118
+ prefix = source.byteslice(lambda_node.opening_loc.end_offset,
119
+ lambda_node.body.location.start_offset - lambda_node.opening_loc.end_offset)
120
+ suffix = source.byteslice(lambda_node.body.location.end_offset,
121
+ lambda_node.closing_loc.start_offset - lambda_node.body.location.end_offset)
122
+ prefix.strip.empty? && suffix.strip.empty?
123
+ end
124
+
125
+ def resolver_body(lambda_node)
126
+ body = lambda_node.body.location.slice.dup
127
+ parameters = lambda_node.parameters.parameters.requireds.map(&:name)
128
+ replacements = descendants(lambda_node.body).grep(Prism::LocalVariableReadNode).filter_map do |read|
129
+ replacement = "object" if read.name == parameters[0]
130
+ replacement ||= "context" if read.name == parameters[2]
131
+ next unless replacement
132
+
133
+ [read.location.start_offset - lambda_node.body.location.start_offset,
134
+ read.location.end_offset - lambda_node.body.location.start_offset, replacement]
135
+ end
136
+ replacements.sort_by(&:first).reverse_each { |from, to, replacement| body.bytesplice(from...to, replacement) }
137
+ rewriter.dedent(body)
138
+ end
139
+
140
+ def indent_body(body)
141
+ rewriter.indent(body).delete_suffix(rewriter.source_file.newline)
142
+ end
143
+
144
+ def descendants(node)
145
+ node.compact_child_nodes.flat_map { |child| [child, *descendants(child)] }
146
+ end
147
+ end
148
+
149
+ class FunctionSuperclass < Base
150
+ rule id: "GQLM103", safety: :unsafe, summary: "Migrate GraphQL::Function to Resolver",
151
+ removed_in: "2.0.0", docs: "docs/rules/GQLM103.md"
152
+ node_pattern :class_node, "(class_node)"
153
+
154
+ def on_match(node, _captures)
155
+ return unless constant_source(node.superclass) == "GraphQL::Function"
156
+
157
+ add_offense(node.superclass.location, message: "Migrate `GraphQL::Function` to Resolver") do |rw|
158
+ replace_constant(rw, node.superclass, "GraphQL::Schema::Resolver")
159
+ end
160
+ end
161
+ end
162
+
163
+ class DeprecatedDefinitionCall < Base
164
+ rule id: "GQLM104", safety: :manual, summary: "Remove legacy definition conversion calls",
165
+ removed_in: "2.0.0", docs: "docs/rules/GQLM104.md"
166
+ node_pattern :call_node, "(call_node)"
167
+
168
+ def on_match(node, _captures)
169
+ return unless %i[to_graphql graphql_definition].include?(node.name)
170
+
171
+ context = context_for(node)
172
+ return if context && ClassHierarchy::DIRECT_KINDS.key?(context.class_name)
173
+
174
+ add_offense(node.message_loc, message: "`#{node.name}` was removed; review this call manually")
175
+ end
176
+ end
177
+
178
+ class QueryArgumentsConstant < Base
179
+ rule id: "GQLM108", safety: :manual, summary: "Migrate GraphQL::Query::Arguments references",
180
+ removed_in: "2.0.0", docs: "docs/rules/GQLM108.md"
181
+ node_pattern :constant_path_node, "(constant_path_node name: :Arguments)"
182
+
183
+ def on_match(node, _captures)
184
+ return unless constant_source(node) == "GraphQL::Query::Arguments"
185
+
186
+ add_offense(node.location, message: "Migrate this use of `GraphQL::Query::Arguments` manually")
187
+ end
188
+ end
189
+
190
+ class TypesShortcut < Base
191
+ TYPE_DSL_CALLS = %i[argument field input_field type].freeze
192
+ REPLACEMENTS = {
193
+ "Boolean" => "GraphQL::Types::Boolean",
194
+ "Float" => "Float",
195
+ "ID" => "GraphQL::Types::ID",
196
+ "Int" => "Integer",
197
+ "String" => "String"
198
+ }.freeze
199
+
200
+ rule id: "GQLM105", safety: :safe, summary: "Replace `types.X` with class references",
201
+ removed_in: "2.0.0", docs: "docs/rules/GQLM105.md"
202
+ node_pattern :call_node, "(call_node receiver: (call_node receiver: nil name: :types))"
203
+
204
+ def on_match(node, _captures)
205
+ return unless inside?(node, :object, :input_object, :interface, :resolver, :mutation, :subscription)
206
+ return if node.block || node.arguments&.arguments&.any?
207
+
208
+ name = node.message_loc.slice
209
+ replacement = REPLACEMENTS[name]
210
+ return unless replacement
211
+
212
+ if @context.call_receiver?(node)
213
+ return add_offense(node.location, safety: :manual,
214
+ message: "Migrate chained `types.#{name}` to the class API manually")
215
+ end
216
+ unless type_dsl_argument?(node)
217
+ return add_offense(node.location, safety: :manual,
218
+ message: "Review `types.#{name}` outside the type DSL manually")
219
+ end
220
+
221
+ add_offense(node.location, message: "Replace `types.#{name}` with `#{replacement}`") do |rw|
222
+ rw.replace(node.location, replacement)
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ def type_dsl_argument?(node)
229
+ call = @context.argument_call(node)
230
+ return false unless call
231
+ return true if call.receiver.nil? && TYPE_DSL_CALLS.include?(call.name)
232
+
233
+ call.name == :[] && call.receiver.is_a?(Prism::CallNode) && call.receiver.receiver.nil? &&
234
+ call.receiver.name == :types
235
+ end
236
+ end
237
+
238
+ class PropertyOption < Base
239
+ rule id: "GQLM106", safety: :safe, summary: "Replace `property:` with `method:`",
240
+ removed_in: "2.0.0", docs: "docs/rules/GQLM106.md"
241
+ node_pattern :call_node, "(call_node receiver: nil name: :field)"
242
+
243
+ def on_match(node, _captures)
244
+ return unless inside?(node, :object, :interface)
245
+
246
+ arguments = keyword_arguments(node, :property)
247
+ return if arguments.empty?
248
+
249
+ argument = arguments.first
250
+
251
+ if arguments.length > 1 || keyword_arguments(node, :method).any? || dynamic_keyword_arguments?(node)
252
+ return add_offense(argument.location, safety: :manual,
253
+ message: "Resolve duplicate `property:` and `method:` manually")
254
+ end
255
+
256
+ add_offense(argument.key.location, message: "Replace `property:` with `method:`") do |rw|
257
+ rw.replace_keyword_key(argument, :method)
258
+ end
259
+ end
260
+ end
261
+
262
+ class RemovedFieldOptions < Base
263
+ TARGETS = %i[field function].freeze
264
+
265
+ rule id: "GQLM107", safety: :safe, summary: "Remove deleted field options",
266
+ removed_in: "2.0.1", docs: "docs/rules/GQLM107.md"
267
+ node_pattern :call_node, "(call_node receiver: nil name: :field)"
268
+
269
+ def on_match(node, _captures)
270
+ return unless inside?(node, :object, :interface)
271
+
272
+ keyword_hash = node.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
273
+ return unless keyword_hash
274
+
275
+ arguments = keyword_hash.elements.select do |argument|
276
+ argument.is_a?(Prism::AssocNode) && argument.key.is_a?(Prism::SymbolNode) &&
277
+ TARGETS.include?(argument.key.unescaped.to_sym)
278
+ end
279
+ return if arguments.empty?
280
+
281
+ unless arguments.all? { |argument| safe_to_discard?(argument.value) } && !dynamic_keyword_arguments?(node)
282
+ message = "Remove `field:` and `function:` manually; preserve side effects"
283
+ return add_offense(arguments.first.location, safety: :manual, message: message)
284
+ end
285
+
286
+ add_offense(arguments.first.location, message: "Remove the deleted `field:` and `function:` options") do |rw|
287
+ if arguments.length == keyword_hash.elements.length
288
+ if node.arguments.arguments.one? && !node.opening_loc
289
+ rw.remove(node.message_loc.end_offset...keyword_hash.location.end_offset)
290
+ else
291
+ rw.remove_list_element(node.arguments, keyword_hash)
292
+ end
293
+ else
294
+ rw.remove_list_elements(keyword_hash, arguments)
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Modernize
5
+ module Rules
6
+ class RedundantRequiredTrue < Base
7
+ rule id: "GQLM401", safety: :safe, summary: "Remove the default `required: true` option",
8
+ since: "1.13.0", docs: "docs/rules/GQLM401.md", default_enabled: false
9
+ node_pattern :call_node, "(call_node receiver: nil name: :argument)"
10
+
11
+ def on_match(node, _captures)
12
+ return unless inside?(node, :object, :input_object, :interface, :resolver, :mutation, :subscription)
13
+
14
+ arguments = keyword_arguments(node, :required)
15
+ return if arguments.empty?
16
+
17
+ if arguments.length > 1
18
+ return unless arguments.any? { |argument| argument.value.is_a?(Prism::TrueNode) }
19
+
20
+ return add_offense(arguments.first.location, safety: :manual,
21
+ message: "Resolve duplicate `required:` options manually")
22
+ end
23
+ return unless arguments.first.value.is_a?(Prism::TrueNode)
24
+
25
+ argument = arguments.first
26
+ if dynamic_keyword_arguments?(node)
27
+ return add_offense(argument.location, safety: :manual,
28
+ message: "Review `**` before removing `required: true` manually")
29
+ end
30
+
31
+ add_offense(argument.location, message: "Remove the default `required: true` option") do |rw|
32
+ rw.remove_keyword_argument(node, :required)
33
+ end
34
+ end
35
+ end
36
+
37
+ class RedundantNullTrue < Base
38
+ rule id: "GQLM402", safety: :safe, summary: "Remove the default `null: true` option",
39
+ since: "1.13.0", docs: "docs/rules/GQLM402.md", default_enabled: false
40
+ node_pattern :call_node, "(call_node receiver: nil name: :field)"
41
+
42
+ def on_match(node, _captures)
43
+ return unless inside?(node, :object, :interface)
44
+
45
+ arguments = keyword_arguments(node, :null)
46
+ return if arguments.empty?
47
+
48
+ if arguments.length > 1
49
+ return unless arguments.any? { |argument| argument.value.is_a?(Prism::TrueNode) }
50
+
51
+ return add_offense(arguments.first.location, safety: :manual,
52
+ message: "Resolve duplicate `null:` options manually")
53
+ end
54
+ return unless arguments.first.value.is_a?(Prism::TrueNode)
55
+
56
+ argument = arguments.first
57
+ if dynamic_keyword_arguments?(node)
58
+ return add_offense(argument.location, safety: :manual,
59
+ message: "Review `**` expansion and resolve `null: true` manually")
60
+ end
61
+
62
+ add_offense(argument.location, message: "Remove the default `null: true` option") do |rw|
63
+ rw.remove_keyword_argument(node, :null)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Modernize
5
+ module Rules
6
+ class RelayMutationSuperclass < Base
7
+ rule id: "GQLM201", safety: :unsafe, summary: "Migrate Relay::Mutation to RelayClassicMutation",
8
+ removed_in: "2.0.0", docs: "docs/rules/GQLM201.md"
9
+ node_pattern :class_node, "(class_node)"
10
+
11
+ def on_match(node, _captures)
12
+ return unless constant_source(node.superclass) == "GraphQL::Relay::Mutation"
13
+
14
+ add_offense(node.superclass.location, message: "Migrate to RelayClassicMutation") do |rw|
15
+ replace_constant(rw, node.superclass, "GraphQL::Schema::RelayClassicMutation")
16
+ end
17
+ end
18
+ end
19
+
20
+ class RelayConnectionType < Base
21
+ rule id: "GQLM202", safety: :manual, summary: "Migrate ConnectionType.create_type to connection_type",
22
+ removed_in: "2.0.0", docs: "docs/rules/GQLM202.md"
23
+ node_pattern :call_node, "(call_node name: :create_type)"
24
+
25
+ def on_match(node, _captures)
26
+ return unless constant_source(node.receiver) == "GraphQL::Relay::ConnectionType"
27
+
28
+ add_offense(node.location, message: "Migrate to the generated type's `connection_type` manually")
29
+ end
30
+ end
31
+
32
+ class RelayNodeField < Base
33
+ rule id: "GQLM203", safety: :unsafe, summary: "Migrate Relay::Node.field to the Relay node interface",
34
+ removed_in: "2.0.0", docs: "docs/rules/GQLM203.md"
35
+ node_pattern :call_node, "(call_node name: :field)"
36
+
37
+ def on_match(node, _captures)
38
+ return unless constant_source(node.receiver) == "GraphQL::Relay::Node"
39
+
40
+ add_offense(node.location, message: "Migrate to `implements GraphQL::Types::Relay::Node`")
41
+ end
42
+ end
43
+
44
+ class DirectBaseConnection < Base
45
+ rule id: "GQLM204", safety: :unsafe, summary: "Inherit from the generated BaseConnection",
46
+ docs: "docs/rules/GQLM204.md"
47
+ node_pattern :class_node, "(class_node)"
48
+
49
+ def on_match(node, _captures)
50
+ return unless constant_source(node.superclass) == "GraphQL::Types::Relay::BaseConnection"
51
+
52
+ add_offense(node.superclass.location, message: "Inherit from the application's `Types::BaseConnection`")
53
+ end
54
+ end
55
+
56
+ class LoadApplicationObjectFailedError < Base
57
+ OLD_NAME = "GraphQL::Schema::Resolver::LoadApplicationObjectFailedError"
58
+ NEW_NAME = "GraphQL::LoadApplicationObjectFailedError"
59
+
60
+ rule id: "GQLM205", safety: :safe, summary: "Update the LoadApplicationObjectFailedError constant path",
61
+ since: "1.9.4", docs: "docs/rules/GQLM205.md"
62
+ node_pattern :constant_path_node, "(constant_path_node name: :LoadApplicationObjectFailedError)"
63
+
64
+ def on_match(node, _captures)
65
+ return unless constant_source(node) == OLD_NAME
66
+
67
+ add_offense(node.location, message: "Replace `#{OLD_NAME}` with `#{NEW_NAME}`") do |rw|
68
+ replace_constant(rw, node, NEW_NAME)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end