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