constable-rails 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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
module Importer
|
|
8
|
+
# The opt-in AST rewrite: RSpec/Minitest source into the native Constable DSL.
|
|
9
|
+
#
|
|
10
|
+
# This mode is opt-in, and reopen is the default, for one reason. A rewrite that gets
|
|
11
|
+
# a conversion subtly wrong still parses, still compiles and still runs -- it just
|
|
12
|
+
# asserts something slightly different from what the author wrote, and nothing tells
|
|
13
|
+
# you. A superclass swap has no such failure mode. So everything here is built around
|
|
14
|
+
# refusing to guess:
|
|
15
|
+
#
|
|
16
|
+
# * `Parser::Source::TreeRewriter` edits byte ranges, so every line we don't
|
|
17
|
+
# explicitly convert keeps its exact original formatting.
|
|
18
|
+
# * Anything ambiguous (`before(:all)`, `let!`, `it { is_expected.to ... }`, mocks)
|
|
19
|
+
# is *flagged* and left exactly as it was -- never silently reshaped.
|
|
20
|
+
# * `shared_examples` and custom matcher definitions are left alone and logged.
|
|
21
|
+
# * The rewritten source is re-parsed before it is written anywhere. If it doesn't
|
|
22
|
+
# parse, nothing is written and the file is reported as failed.
|
|
23
|
+
# * Nothing is written at all unless the caller asks (`write:`); the deliverable of
|
|
24
|
+
# a partial conversion is `constable_modernize_report.md`.
|
|
25
|
+
class Modernizer
|
|
26
|
+
REPORT_FILENAME = "constable_modernize_report.md"
|
|
27
|
+
|
|
28
|
+
# :none -- dry run. Report only. The default.
|
|
29
|
+
# :alongside -- write foo_spec.rb's conversion to foo_case.rb, never clobbering.
|
|
30
|
+
# :in_place -- overwrite the original file.
|
|
31
|
+
WRITE_MODES = %i[none alongside in_place].freeze
|
|
32
|
+
|
|
33
|
+
GROUP_METHODS = %i[describe context xdescribe xcontext fdescribe fcontext feature].freeze
|
|
34
|
+
EXAMPLE_METHODS = %i[it specify example scenario].freeze
|
|
35
|
+
SKIPPED_EXAMPLES = %i[xit fit xspecify xexample pending].freeze
|
|
36
|
+
SHARED_DEFINITIONS = %i[shared_examples shared_examples_for shared_context].freeze
|
|
37
|
+
SHARED_USES = %i[it_behaves_like it_should_behave_like include_examples include_context].freeze
|
|
38
|
+
MOCK_METHODS = %i[receive receive_messages have_received receive_message_chain].freeze
|
|
39
|
+
MOCK_ENTRY_POINTS = %i[allow allow_any_instance_of expect_any_instance_of double instance_double
|
|
40
|
+
class_double spy stub_const].freeze
|
|
41
|
+
HOOK_SCOPES_OK = [nil, :each, :example].freeze
|
|
42
|
+
MINITEST_SUPERCLASS = /(?:Test|TestCase)\z/
|
|
43
|
+
SPEC_HELPERS = %w[spec_helper rails_helper test_helper].freeze
|
|
44
|
+
|
|
45
|
+
# One converted file. Responds to #[] and #to_h, so callers can treat it either as
|
|
46
|
+
# `result.source` or as the documented `{ source:, flags:, converted: }` hash.
|
|
47
|
+
Result = Struct.new(
|
|
48
|
+
:path, :relative_path, :dialect, :class_name, :original, :source,
|
|
49
|
+
:converted, :flags, :untouched, :error, :write_mode, :written_to,
|
|
50
|
+
keyword_init: true
|
|
51
|
+
) do
|
|
52
|
+
def ok? = error.nil?
|
|
53
|
+
def changed? = ok? && source != original
|
|
54
|
+
def flagged? = !Array(flags).empty?
|
|
55
|
+
def partial? = flagged? || !Array(untouched).empty?
|
|
56
|
+
def written? = !written_to.nil?
|
|
57
|
+
|
|
58
|
+
def counts
|
|
59
|
+
{ converted: Array(converted).size, flagged: Array(flags).size, untouched: Array(untouched).size }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An aggregate over one `constable modernize` invocation.
|
|
64
|
+
Run = Struct.new(:results, :report, :report_path, :write_mode, keyword_init: true) do
|
|
65
|
+
def ok? = results.all?(&:ok?)
|
|
66
|
+
def failed = results.reject(&:ok?)
|
|
67
|
+
def flagged = results.select(&:flagged?)
|
|
68
|
+
def written = results.select(&:written?)
|
|
69
|
+
def to_h = { write_mode: write_mode, report_path: report_path, results: results.map(&:to_h) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :path, :relative_path, :root, :config
|
|
73
|
+
|
|
74
|
+
def initialize(path, config: Constable.config, root: nil, source: nil)
|
|
75
|
+
@config = config
|
|
76
|
+
@root = (root || config&.root || Constable.root).to_s
|
|
77
|
+
@path = File.absolute_path?(path.to_s) ? path.to_s : File.join(@root, path.to_s)
|
|
78
|
+
@relative_path = @path.delete_prefix("#{@root}/")
|
|
79
|
+
@given_source = source
|
|
80
|
+
@dialect = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Rewrites a single file in memory. Writes nothing, ever -- see .run for that.
|
|
84
|
+
def call
|
|
85
|
+
source = @given_source || read_source
|
|
86
|
+
return failure(source, "file not found") if source.nil?
|
|
87
|
+
|
|
88
|
+
self.class.load_parser!
|
|
89
|
+
reset!(source)
|
|
90
|
+
|
|
91
|
+
ast = parse(source)
|
|
92
|
+
return failure(source, @error) if ast.nil?
|
|
93
|
+
|
|
94
|
+
@dialect = detect_dialect(ast)
|
|
95
|
+
visit(ast, in_case: false)
|
|
96
|
+
rewritten = @rewriter.process
|
|
97
|
+
|
|
98
|
+
if rewritten != source && parse(rewritten).nil?
|
|
99
|
+
# Belt and braces: a rewrite that doesn't parse is a bug in this file, not in
|
|
100
|
+
# the user's spec. Hand back the original and say so rather than writing it.
|
|
101
|
+
return failure(source, "rewritten source did not parse (#{@error}); nothing was changed")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
build_result(source, rewritten)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class << self
|
|
108
|
+
# The CLI entry point. `paths` may be files, directories or globs.
|
|
109
|
+
def run(paths, config: Constable.config, root: nil, write: :none, report: true)
|
|
110
|
+
root = (root || config&.root || Constable.root).to_s
|
|
111
|
+
write = (write || :none).to_sym
|
|
112
|
+
unless WRITE_MODES.include?(write)
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"unknown write mode #{write.inspect} (expected #{WRITE_MODES.join(", ")})"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
results = expand(paths, root).map do |file|
|
|
118
|
+
result = new(file, config: config, root: root).call
|
|
119
|
+
result.write_mode = write
|
|
120
|
+
persist(result, root, write)
|
|
121
|
+
result
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
text = report_for(results, write_mode: write)
|
|
125
|
+
report_path = nil
|
|
126
|
+
if report
|
|
127
|
+
report_path = File.join(root, REPORT_FILENAME)
|
|
128
|
+
File.write(report_path, text)
|
|
129
|
+
end
|
|
130
|
+
Run.new(results: results, report: text, report_path: report_path, write_mode: write)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# `constable modernize` is useless without the parser gem, but Constable itself
|
|
134
|
+
# boots fine without it, so the require is lazy and the failure is a sentence.
|
|
135
|
+
def load_parser!
|
|
136
|
+
return if defined?(::Parser::CurrentRuby)
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
# parser/current prints a version-skew notice for every patch-level mismatch.
|
|
140
|
+
# It's noise on a CLI and there is nothing the user can do about it.
|
|
141
|
+
captured = $stderr
|
|
142
|
+
$stderr = StringIO.new
|
|
143
|
+
require "parser/current"
|
|
144
|
+
ensure
|
|
145
|
+
$stderr = captured
|
|
146
|
+
end
|
|
147
|
+
rescue LoadError
|
|
148
|
+
raise Constable::Error,
|
|
149
|
+
"constable modernize needs the `parser` gem (a runtime dependency of " \
|
|
150
|
+
"constable-rails). Run `bundle install`, or use `constable import` -- " \
|
|
151
|
+
"the default reopen mode needs no parser at all."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def report_for(results, write_mode: :none)
|
|
155
|
+
Report.new(results, write_mode: write_mode).to_markdown
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def expand(paths, root)
|
|
159
|
+
Array(paths).flat_map do |entry|
|
|
160
|
+
absolute = File.absolute_path?(entry.to_s) ? entry.to_s : File.join(root, entry.to_s)
|
|
161
|
+
if File.directory?(absolute)
|
|
162
|
+
Dir.glob(File.join(absolute, "**", "*{_spec,_test}.rb"))
|
|
163
|
+
elsif absolute.include?("*")
|
|
164
|
+
Dir.glob(absolute)
|
|
165
|
+
else
|
|
166
|
+
[absolute]
|
|
167
|
+
end
|
|
168
|
+
end.uniq
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Output path for :alongside -- users_controller_spec.rb -> users_controller_case.rb.
|
|
172
|
+
def alongside_path(path)
|
|
173
|
+
dir = File.dirname(path)
|
|
174
|
+
base = File.basename(path, ".rb").sub(/_(?:spec|test)\z/, "")
|
|
175
|
+
File.join(dir, "#{base}_case.rb")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def persist(result, root, write)
|
|
181
|
+
return unless result.ok? && result.changed?
|
|
182
|
+
|
|
183
|
+
case write
|
|
184
|
+
when :in_place
|
|
185
|
+
File.write(result.path, result.source)
|
|
186
|
+
result.written_to = result.relative_path
|
|
187
|
+
when :alongside
|
|
188
|
+
target = alongside_path(result.path)
|
|
189
|
+
if File.exist?(target)
|
|
190
|
+
result.error = "refusing to overwrite #{target.delete_prefix("#{root}/")}; " \
|
|
191
|
+
"move it aside, or use write: :in_place"
|
|
192
|
+
else
|
|
193
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
194
|
+
File.write(target, result.source)
|
|
195
|
+
result.written_to = target.delete_prefix("#{root}/")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def read_source
|
|
204
|
+
File.read(@path)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def reset!(source)
|
|
210
|
+
@source = source
|
|
211
|
+
@error = nil
|
|
212
|
+
@converted = []
|
|
213
|
+
@flags = []
|
|
214
|
+
@untouched = []
|
|
215
|
+
@class_name = nil
|
|
216
|
+
@buffer = ::Parser::Source::Buffer.new(@relative_path, source: source)
|
|
217
|
+
@rewriter = ::Parser::Source::TreeRewriter.new(@buffer)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def parse(source)
|
|
221
|
+
buffer = ::Parser::Source::Buffer.new(@relative_path, source: source)
|
|
222
|
+
parser = ::Parser::CurrentRuby.new
|
|
223
|
+
parser.diagnostics.all_errors_are_fatal = true
|
|
224
|
+
parser.diagnostics.consumer = ->(_diagnostic) {}
|
|
225
|
+
parser.parse(buffer)
|
|
226
|
+
rescue ::Parser::SyntaxError => e
|
|
227
|
+
@error = "syntax error: #{e.message}"
|
|
228
|
+
nil
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
@error = "could not parse: #{e.message}"
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def failure(source, message)
|
|
235
|
+
Result.new(path: @path, relative_path: @relative_path, dialect: @dialect,
|
|
236
|
+
class_name: nil, original: source, source: source,
|
|
237
|
+
converted: [], flags: [], untouched: [], error: message)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_result(source, rewritten)
|
|
241
|
+
Result.new(
|
|
242
|
+
path: @path, relative_path: @relative_path, dialect: @dialect,
|
|
243
|
+
class_name: @class_name, original: source, source: rewritten,
|
|
244
|
+
converted: @converted.sort_by { |c| c[:line] },
|
|
245
|
+
flags: @flags.sort_by { |f| f[:line] },
|
|
246
|
+
untouched: @untouched.sort_by { |u| u[:line] },
|
|
247
|
+
error: nil
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ---- dialect -----------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def detect_dialect(ast)
|
|
254
|
+
return :rspec if find_node(ast) { |n| n.type == :block && group_call?(n.children[0]) }
|
|
255
|
+
return :minitest if find_node(ast) { |n| n.type == :def && n.children[0].to_s.start_with?("test_") }
|
|
256
|
+
return :minitest if find_node(ast) { |n| n.type == :class && minitest_superclass?(n.children[1]) }
|
|
257
|
+
|
|
258
|
+
:unknown
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def find_node(node, &block)
|
|
262
|
+
return nil unless node.is_a?(::Parser::AST::Node)
|
|
263
|
+
return node if block.call(node)
|
|
264
|
+
|
|
265
|
+
node.children.each do |child|
|
|
266
|
+
found = find_node(child, &block)
|
|
267
|
+
return found if found
|
|
268
|
+
end
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# ---- the walk ----------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def visit(node, in_case:)
|
|
275
|
+
return unless node.is_a?(::Parser::AST::Node)
|
|
276
|
+
|
|
277
|
+
case node.type
|
|
278
|
+
when :block then visit_block(node, in_case: in_case)
|
|
279
|
+
when :class then visit_class(node, in_case: in_case)
|
|
280
|
+
when :def then visit_def(node, in_case: in_case)
|
|
281
|
+
when :send then visit_send(node, in_case: in_case)
|
|
282
|
+
else visit_children(node, in_case: in_case)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def visit_children(node, in_case:)
|
|
287
|
+
node.children.each { |child| visit(child, in_case: in_case) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def visit_block(node, in_case:)
|
|
291
|
+
send_node, block_args, _body = node.children
|
|
292
|
+
unless send_node.is_a?(::Parser::AST::Node) && send_node.type == :send
|
|
293
|
+
return visit_children(node,
|
|
294
|
+
in_case: in_case)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
name = send_node.children[1]
|
|
298
|
+
|
|
299
|
+
return handle_group(node, send_node, in_case: in_case) if group_call?(send_node)
|
|
300
|
+
return handle_shared_definition(node, send_node) if SHARED_DEFINITIONS.include?(name)
|
|
301
|
+
return handle_matcher_definition(node, send_node) if matcher_definition?(send_node)
|
|
302
|
+
if EXAMPLE_METHODS.include?(name) && send_node.children[0].nil?
|
|
303
|
+
return handle_example(node, send_node,
|
|
304
|
+
block_args)
|
|
305
|
+
end
|
|
306
|
+
if SKIPPED_EXAMPLES.include?(name) && send_node.children[0].nil?
|
|
307
|
+
return handle_skipped_example(node,
|
|
308
|
+
send_node)
|
|
309
|
+
end
|
|
310
|
+
return handle_let(node, send_node) if %i[let
|
|
311
|
+
let!].include?(name) && send_node.children[0].nil?
|
|
312
|
+
return handle_subject(node, send_node) if %i[subject
|
|
313
|
+
subject!].include?(name) && send_node.children[0].nil?
|
|
314
|
+
return handle_hook(node, send_node) if %i[before after around append_after
|
|
315
|
+
prepend_before].include?(name) && send_node.children[0].nil?
|
|
316
|
+
return handle_its(node, send_node) if name == :its && send_node.children[0].nil?
|
|
317
|
+
|
|
318
|
+
visit_children(node, in_case: in_case)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# `describe X do` at the top of a file becomes the case class; anything nested
|
|
322
|
+
# inside it becomes a docket, which is the DSL's own grouping construct.
|
|
323
|
+
def handle_group(node, send_node, in_case:)
|
|
324
|
+
if in_case
|
|
325
|
+
convert_to_docket(node, send_node)
|
|
326
|
+
else
|
|
327
|
+
convert_to_case_class(node, send_node)
|
|
328
|
+
end
|
|
329
|
+
visit_children_of_block(node, in_case: true)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def convert_to_case_class(node, send_node)
|
|
333
|
+
args = send_node.children[2..] || []
|
|
334
|
+
@class_name = case_class_name(args.first)
|
|
335
|
+
replace(block_head(node), "class #{@class_name} < Constable::Case")
|
|
336
|
+
close_brace_block(node)
|
|
337
|
+
record_converted(:case_class, node, "#{source_of(send_node)} do", "class #{@class_name} < Constable::Case")
|
|
338
|
+
return if args.size <= 1
|
|
339
|
+
|
|
340
|
+
note_untouched(:describe_metadata, node,
|
|
341
|
+
"extra arguments to `#{send_node.children[1]}` (#{args[1..].map do |a|
|
|
342
|
+
source_of(a)
|
|
343
|
+
end.join(", ")}) " \
|
|
344
|
+
"were dropped -- Constable has no example metadata")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def convert_to_docket(node, send_node)
|
|
348
|
+
args = send_node.children[2..] || []
|
|
349
|
+
description = docket_description(args.first, send_node)
|
|
350
|
+
replace(send_node.loc.expression, "docket #{description}")
|
|
351
|
+
record_converted(:docket, node, source_of(send_node), "docket #{description}")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def handle_example(node, send_node, block_args)
|
|
355
|
+
args = send_node.children[2..] || []
|
|
356
|
+
if args.empty?
|
|
357
|
+
# `it { is_expected.to be_valid }` -- there is no description to carry over and
|
|
358
|
+
# no subject in the native DSL. Naming it for the user would be inventing an
|
|
359
|
+
# assertion's intent, so it stays exactly as written.
|
|
360
|
+
return flag(:one_liner_example, node,
|
|
361
|
+
"`#{send_node.children[1]} { ... }` has no description and relies on an implicit " \
|
|
362
|
+
"subject. Write it as `investigate \"...\" do attest(subject).to ... end`.")
|
|
363
|
+
end
|
|
364
|
+
unless args.size == 1 && args.first.type == :str
|
|
365
|
+
return flag(:example_metadata, node,
|
|
366
|
+
"`#{source_of(send_node)}` carries metadata or a non-literal description; " \
|
|
367
|
+
"Constable's `investigate` takes a plain string only.")
|
|
368
|
+
end
|
|
369
|
+
unless block_args.children.empty?
|
|
370
|
+
return flag(:example_block_args, node,
|
|
371
|
+
"`#{source_of(send_node)}` yields block arguments; `investigate` runs its block " \
|
|
372
|
+
"in a fresh case instance and yields nothing.")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
replace(send_node.loc.selector, "investigate")
|
|
376
|
+
record_converted(:investigate, node, "#{send_node.children[1]} #{source_of(args.first)}",
|
|
377
|
+
"investigate #{source_of(args.first)}")
|
|
378
|
+
visit_children_of_block(node, in_case: true)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def handle_skipped_example(node, send_node)
|
|
382
|
+
flag(:skipped_example, node,
|
|
383
|
+
"`#{send_node.children[1]}` is an RSpec skip/focus marker. Constable has no equivalent -- " \
|
|
384
|
+
"convert it to `investigate` and jail it (`constable test --jail`) if it should not run yet.")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def handle_let(node, send_node)
|
|
388
|
+
name = send_node.children[1]
|
|
389
|
+
args = send_node.children[2..] || []
|
|
390
|
+
|
|
391
|
+
if name == :let!
|
|
392
|
+
# `let!` runs eagerly before every example; `witness` is lazy and memoized
|
|
393
|
+
# per-test. Swapping one for the other changes when the record is created,
|
|
394
|
+
# which is exactly the kind of silent behaviour change this tool won't make.
|
|
395
|
+
return flag(:eager_let, node,
|
|
396
|
+
"`let!` is eager -- it runs before every example whether or not it is referenced. " \
|
|
397
|
+
"`witness` is lazy. Move the side effect into a `briefing` block, then declare the " \
|
|
398
|
+
"value as `witness`.")
|
|
399
|
+
end
|
|
400
|
+
unless args.size == 1 && %i[sym str].include?(args.first.type)
|
|
401
|
+
return flag(:dynamic_let, node, "`#{source_of(send_node)}` does not name a single literal helper.")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
replace(send_node.loc.selector, "witness")
|
|
405
|
+
record_converted(:witness, node, source_of(send_node), "witness(#{source_of(args.first)})")
|
|
406
|
+
visit_children_of_block(node, in_case: true)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def handle_subject(node, send_node)
|
|
410
|
+
name = send_node.children[1]
|
|
411
|
+
args = send_node.children[2..] || []
|
|
412
|
+
|
|
413
|
+
if name == :subject!
|
|
414
|
+
return flag(:eager_subject, node, "`subject!` is eager, like `let!`. Split it into a `briefing` " \
|
|
415
|
+
"side effect plus a lazy `witness`.")
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if args.empty?
|
|
419
|
+
# An anonymous `subject` is just a witness with a well-known name. That is a
|
|
420
|
+
# faithful conversion, so it happens -- and the report says it happened.
|
|
421
|
+
replace(send_node.loc.expression, "witness(:subject)")
|
|
422
|
+
record_converted(:subject, node, "subject", "witness(:subject)",
|
|
423
|
+
note: "an anonymous `subject` became `witness(:subject)`; `is_expected` and " \
|
|
424
|
+
"`should` have no equivalent and are flagged separately")
|
|
425
|
+
elsif args.size == 1 && %i[sym str].include?(args.first.type)
|
|
426
|
+
replace(send_node.loc.selector, "witness")
|
|
427
|
+
record_converted(:subject, node, source_of(send_node), "witness(#{source_of(args.first)})")
|
|
428
|
+
else
|
|
429
|
+
return flag(:dynamic_subject, node, "`#{source_of(send_node)}` does not name a single literal subject.")
|
|
430
|
+
end
|
|
431
|
+
visit_children_of_block(node, in_case: true)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def handle_hook(node, send_node)
|
|
435
|
+
name = send_node.children[1]
|
|
436
|
+
args = send_node.children[2..] || []
|
|
437
|
+
scope = args.first && args.first.type == :sym ? args.first.children[0] : nil
|
|
438
|
+
|
|
439
|
+
if name != :before || args.size > 1 || !HOOK_SCOPES_OK.include?(scope)
|
|
440
|
+
return flag(hook_flag_kind(name, scope), node, hook_flag_reason(name, scope, send_node))
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
replace(send_node.loc.expression, "briefing")
|
|
444
|
+
to_do_end(node)
|
|
445
|
+
record_converted(:briefing, node, "#{source_of(send_node)} #{node.loc.begin.source}",
|
|
446
|
+
"briefing do")
|
|
447
|
+
visit_children_of_block(node, in_case: true)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def hook_flag_kind(name, scope)
|
|
451
|
+
return :before_all if name == :before && %i[all context suite].include?(scope)
|
|
452
|
+
|
|
453
|
+
name == :around ? :around_hook : :"#{name}_hook"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def hook_flag_reason(name, scope, send_node)
|
|
457
|
+
if name == :before && %i[all context suite].include?(scope)
|
|
458
|
+
"`before(:#{scope})` runs once for a whole group and shares its state across examples. " \
|
|
459
|
+
"Constable has no equivalent by design -- isolation is the point. Decide per case whether " \
|
|
460
|
+
"the setup is cheap enough to move into `briefing` (runs per test) or belongs in a fixture."
|
|
461
|
+
elsif name == :after
|
|
462
|
+
"`after` has no `briefing` counterpart. Native cases roll back their transaction and restore " \
|
|
463
|
+
"DSL global state automatically, so most `after` blocks are redundant -- check this one and delete it."
|
|
464
|
+
elsif name == :around
|
|
465
|
+
"`around` wraps an example; Constable owns the wrapping (transaction, isolation, timing) and " \
|
|
466
|
+
"exposes no hook for it. Move the setup half into `briefing`."
|
|
467
|
+
else
|
|
468
|
+
"`#{source_of(send_node)}` is a hook form Constable does not model."
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def handle_its(node, send_node)
|
|
473
|
+
attribute = literal_value(send_node.children[2])
|
|
474
|
+
flag(:its, node, "`its(#{source_of(send_node.children[2])})` is an implicit-subject one-liner. Write it " \
|
|
475
|
+
"as `investigate \"...\" do attest(subject.#{attribute}).to ... end`.")
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def handle_shared_definition(node, send_node)
|
|
479
|
+
note_untouched(:shared_examples, node,
|
|
480
|
+
"`#{source_of(send_node)}` left untouched. Constable has no shared-examples DSL on " \
|
|
481
|
+
"purpose -- shared behaviour is a plain Ruby module in test/support that each case " \
|
|
482
|
+
"`include`s. Extract it by hand.")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def handle_matcher_definition(node, send_node)
|
|
486
|
+
note_untouched(:custom_matcher, node,
|
|
487
|
+
"`#{source_of(send_node)}` left untouched. Port it to " \
|
|
488
|
+
"`Constable::Matchers.define(:name) { |actual, *args| ... }` in test/support.")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def matcher_definition?(send_node)
|
|
492
|
+
receiver, name, * = send_node.children
|
|
493
|
+
return true if name == :define && receiver && source_of(receiver).end_with?("Matchers")
|
|
494
|
+
return true if name == :matcher && receiver.nil?
|
|
495
|
+
|
|
496
|
+
false
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# ---- send-level rewrites -----------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def visit_send(node, in_case:)
|
|
502
|
+
receiver, name, *args = node.children
|
|
503
|
+
|
|
504
|
+
if %i[to not_to to_not].include?(name) && mock_expectation?(args.first)
|
|
505
|
+
return note_untouched(:rspec_mocks, node,
|
|
506
|
+
"`#{first_line(node)}` is an RSpec message expectation. Constable ships no " \
|
|
507
|
+
"mocking library -- keep rspec-mocks via a cold case, or replace it with a " \
|
|
508
|
+
"stub object.")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
if MOCK_ENTRY_POINTS.include?(name) && receiver.nil?
|
|
512
|
+
note_untouched(:rspec_mocks, node,
|
|
513
|
+
"`#{first_line(node)}` uses rspec-mocks. Constable has no equivalent; convert it by hand.")
|
|
514
|
+
return
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
if SHARED_USES.include?(name) && receiver.nil?
|
|
518
|
+
note_untouched(:shared_examples, node,
|
|
519
|
+
"`#{first_line(node)}` pulls in shared examples. Replace with a plain module `include`.")
|
|
520
|
+
return
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
case name
|
|
524
|
+
when :expect
|
|
525
|
+
replace(node.loc.selector, "attest") if receiver.nil?
|
|
526
|
+
record_converted(:attest, node, "expect", "attest") if receiver.nil?
|
|
527
|
+
when :is_expected
|
|
528
|
+
if receiver.nil?
|
|
529
|
+
flag(:is_expected, node,
|
|
530
|
+
"`is_expected` needs RSpec's implicit subject. Use `attest(subject)` -- an anonymous " \
|
|
531
|
+
"`subject` block is converted to `witness(:subject)` for you.")
|
|
532
|
+
end
|
|
533
|
+
when :should, :should_not
|
|
534
|
+
flag(:should_syntax, node, "`#{name}` is RSpec's monkey-patched expectation syntax. Use `attest(...).to`.")
|
|
535
|
+
when :described_class
|
|
536
|
+
if receiver.nil?
|
|
537
|
+
flag(:described_class, node,
|
|
538
|
+
"`described_class` has no meaning once `describe X` is a real class. Name the class directly.")
|
|
539
|
+
end
|
|
540
|
+
when :to_not
|
|
541
|
+
replace(node.loc.selector, "not_to")
|
|
542
|
+
record_converted(:not_to, node, "to_not", "not_to")
|
|
543
|
+
when :require, :require_relative
|
|
544
|
+
convert_helper_require(node, args.first)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
visit_children(node, in_case: in_case)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def convert_helper_require(node, arg)
|
|
551
|
+
return unless arg.is_a?(::Parser::AST::Node) && arg.type == :str
|
|
552
|
+
|
|
553
|
+
value = arg.children[0].to_s
|
|
554
|
+
return unless SPEC_HELPERS.include?(File.basename(value))
|
|
555
|
+
|
|
556
|
+
replaced = value.sub(/#{Regexp.escape(File.basename(value))}\z/, "case_helper")
|
|
557
|
+
replace(arg.loc.expression, replaced.inspect)
|
|
558
|
+
record_converted(:helper_require, node, value, replaced)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def mock_expectation?(node)
|
|
562
|
+
return false unless node.is_a?(::Parser::AST::Node)
|
|
563
|
+
|
|
564
|
+
!!find_node(node) { |n| n.type == :send && MOCK_METHODS.include?(n.children[1]) }
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# ---- Minitest ----------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
def visit_class(node, in_case:)
|
|
570
|
+
name_node, superclass, body = node.children
|
|
571
|
+
|
|
572
|
+
return visit_children(node, in_case: in_case) unless minitest_superclass?(superclass)
|
|
573
|
+
|
|
574
|
+
@class_name = minitest_class_name(name_node)
|
|
575
|
+
replace(name_node.loc.expression, @class_name) if @class_name != source_of(name_node)
|
|
576
|
+
replace(superclass.loc.expression, "Constable::Case")
|
|
577
|
+
record_converted(:case_class, node, "class #{source_of(name_node)} < #{source_of(superclass)}",
|
|
578
|
+
"class #{@class_name} < Constable::Case")
|
|
579
|
+
visit(body, in_case: true)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def minitest_superclass?(node)
|
|
583
|
+
return false unless node.is_a?(::Parser::AST::Node) && %i[const send].include?(node.type)
|
|
584
|
+
|
|
585
|
+
source_of(node).match?(MINITEST_SUPERCLASS)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def minitest_class_name(name_node)
|
|
589
|
+
source_of(name_node).sub(/Test\z/, "Case").then { |n| n.end_with?("Case") ? n : "#{n}Case" }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def visit_def(node, in_case:)
|
|
593
|
+
name, args, body = node.children
|
|
594
|
+
return visit_children(node, in_case: in_case) unless in_case
|
|
595
|
+
|
|
596
|
+
if name.to_s.start_with?("test_")
|
|
597
|
+
convert_test_method(node, name, args, body)
|
|
598
|
+
elsif name == :setup
|
|
599
|
+
convert_setup_method(node, args, body)
|
|
600
|
+
elsif name == :teardown
|
|
601
|
+
flag(:teardown, node,
|
|
602
|
+
"`teardown` has no Constable equivalent -- isolation is restored automatically. " \
|
|
603
|
+
"Delete it, or move anything genuinely needed into the `investigate` body.")
|
|
604
|
+
else
|
|
605
|
+
note_untouched(:helper_method, node,
|
|
606
|
+
"`def #{name}` left as an ordinary instance method -- that works unchanged on a " \
|
|
607
|
+
"`Constable::Case`.")
|
|
608
|
+
visit_children(node, in_case: in_case)
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def convert_test_method(node, name, args, body)
|
|
613
|
+
description = name.to_s.delete_prefix("test_").tr("_", " ").strip
|
|
614
|
+
if node.loc.end.nil?
|
|
615
|
+
return flag(:endless_def, node,
|
|
616
|
+
"`def #{name} = ...` is an endless method; rewrite it as a block first.")
|
|
617
|
+
end
|
|
618
|
+
unless args.children.empty?
|
|
619
|
+
return flag(:test_method_args, node,
|
|
620
|
+
"`def #{name}` takes arguments; `investigate` yields nothing.")
|
|
621
|
+
end
|
|
622
|
+
if calls_super?(body)
|
|
623
|
+
return flag(:super_in_test, node, "`def #{name}` calls `super`; inside an `investigate` block `super` " \
|
|
624
|
+
"would resolve against the block's enclosing scope, not the test.")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
replace(def_head(node), "investigate #{description.inspect} do")
|
|
628
|
+
record_converted(:investigate, node, "def #{name}", "investigate #{description.inspect} do")
|
|
629
|
+
visit(body, in_case: true)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def convert_setup_method(node, args, body)
|
|
633
|
+
if node.loc.end.nil?
|
|
634
|
+
return flag(:endless_def, node,
|
|
635
|
+
"`def setup = ...` is an endless method; rewrite it as a block first.")
|
|
636
|
+
end
|
|
637
|
+
unless args.children.empty?
|
|
638
|
+
return flag(:setup_args, node,
|
|
639
|
+
"`def setup` takes arguments, which `briefing` cannot supply.")
|
|
640
|
+
end
|
|
641
|
+
if calls_super?(body)
|
|
642
|
+
return flag(:super_in_setup, node, "`def setup` calls `super`; `briefing` blocks already chain from " \
|
|
643
|
+
"parent to child, so the `super` call must be removed by hand.")
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
replace(def_head(node), "briefing do")
|
|
647
|
+
record_converted(:briefing, node, "def setup", "briefing do")
|
|
648
|
+
visit(body, in_case: true)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def calls_super?(body)
|
|
652
|
+
!!find_node(body) { |n| %i[super zsuper].include?(n.type) }
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def def_head(node) = range(node.loc.keyword.begin_pos, node.loc.name.end_pos)
|
|
656
|
+
|
|
657
|
+
# ---- rewriting primitives ----------------------------------------------------
|
|
658
|
+
|
|
659
|
+
def replace(range_or_loc, text) = @rewriter.replace(range_or_loc, text)
|
|
660
|
+
|
|
661
|
+
def range(from, to) = ::Parser::Source::Range.new(@buffer, from, to)
|
|
662
|
+
|
|
663
|
+
# `describe X do` -- everything up to and including the block opener.
|
|
664
|
+
def block_head(node) = range(node.loc.expression.begin_pos, node.loc.begin.end_pos)
|
|
665
|
+
|
|
666
|
+
# A `{ }` block whose head we replaced with a `class`/`do` opener needs its closer
|
|
667
|
+
# turned into `end` too.
|
|
668
|
+
def close_brace_block(node)
|
|
669
|
+
replace(node.loc.end, "end") if node.loc.begin.source == "{"
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# `briefing do @seen = [] end` is valid Ruby and nobody writes it. A one-line hook
|
|
673
|
+
# keeps its braces; only a block that already spans lines becomes do/end.
|
|
674
|
+
def to_do_end(node)
|
|
675
|
+
return unless node.loc.begin.source == "{"
|
|
676
|
+
return if node.loc.begin.line == node.loc.end.line
|
|
677
|
+
|
|
678
|
+
replace(node.loc.begin, "do")
|
|
679
|
+
replace(node.loc.end, "end")
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def visit_children_of_block(node, in_case:)
|
|
683
|
+
visit(node.children[2], in_case: in_case)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def group_call?(send_node)
|
|
687
|
+
return false unless send_node.is_a?(::Parser::AST::Node) && send_node.type == :send
|
|
688
|
+
|
|
689
|
+
receiver, name, * = send_node.children
|
|
690
|
+
return false unless GROUP_METHODS.include?(name)
|
|
691
|
+
return true if receiver.nil?
|
|
692
|
+
|
|
693
|
+
receiver.type == :const && receiver.children[1] == :RSpec
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# ---- naming ------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
# `describe UsersController` -> UsersControllerCase, per SPEC.md's `class XCase`.
|
|
699
|
+
# A namespaced constant keeps its namespace: `describe Admin::Users` -> Admin::UsersCase.
|
|
700
|
+
def case_class_name(arg)
|
|
701
|
+
base =
|
|
702
|
+
case arg&.type
|
|
703
|
+
when :const then source_of(arg)
|
|
704
|
+
when :str, :sym then Reopener.camelize(arg.children[0].to_s)
|
|
705
|
+
else Reopener.camelize(File.basename(@relative_path, ".rb").sub(/_(?:spec|test)\z/, ""))
|
|
706
|
+
end
|
|
707
|
+
base = Reopener.camelize(File.basename(@relative_path, ".rb")) if base.to_s.empty?
|
|
708
|
+
base.end_with?("Case") ? base : "#{base}Case"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def docket_description(arg, send_node)
|
|
712
|
+
case arg&.type
|
|
713
|
+
when :str then source_of(arg)
|
|
714
|
+
when :sym then arg.children[0].to_s.inspect
|
|
715
|
+
when nil then send_node.children[1].to_s.inspect
|
|
716
|
+
else source_of(arg).inspect
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def literal_value(node)
|
|
721
|
+
return node.children[0].to_s if node.is_a?(::Parser::AST::Node) && %i[str sym].include?(node.type)
|
|
722
|
+
|
|
723
|
+
source_of(node)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# ---- bookkeeping -------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
def source_of(node)
|
|
729
|
+
return "" unless node.is_a?(::Parser::AST::Node) && node.loc&.expression
|
|
730
|
+
|
|
731
|
+
node.loc.expression.source
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def first_line(node) = source_of(node).lines.first.to_s.strip
|
|
735
|
+
|
|
736
|
+
def line_of(node) = node.loc.expression.line
|
|
737
|
+
|
|
738
|
+
def location(node) = "#{@relative_path}:#{line_of(node)}"
|
|
739
|
+
|
|
740
|
+
def record_converted(kind, node, from, to, note: nil)
|
|
741
|
+
@converted << { kind: kind, location: location(node), line: line_of(node),
|
|
742
|
+
from: from, to: to, note: note }
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def flag(kind, node, reason)
|
|
746
|
+
@flags << { kind: kind, location: location(node), line: line_of(node),
|
|
747
|
+
source: first_line(node), reason: reason }
|
|
748
|
+
nil
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def note_untouched(kind, node, reason)
|
|
752
|
+
@untouched << { kind: kind, location: location(node), line: line_of(node),
|
|
753
|
+
source: first_line(node), reason: reason }
|
|
754
|
+
nil
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Renders constable_modernize_report.md. When a conversion is partial -- and it
|
|
758
|
+
# usually is -- this file, not the rewritten source, is the deliverable.
|
|
759
|
+
class Report
|
|
760
|
+
def initialize(results, write_mode: :none)
|
|
761
|
+
@results = Array(results)
|
|
762
|
+
@write_mode = write_mode
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def to_markdown
|
|
766
|
+
out = +"# Constable modernize report\n\n"
|
|
767
|
+
out << preamble
|
|
768
|
+
out << headline
|
|
769
|
+
@results.each { |result| out << file_section(result) }
|
|
770
|
+
out
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
private
|
|
774
|
+
|
|
775
|
+
def preamble
|
|
776
|
+
<<~TEXT
|
|
777
|
+
`constable modernize` rewrites RSpec/Minitest source into the native Constable DSL
|
|
778
|
+
using an AST rewriter, so every line it does not explicitly convert keeps its exact
|
|
779
|
+
original formatting. It converts only what it can convert faithfully. **Everything
|
|
780
|
+
listed under _Flagged_ below was left exactly as written** -- a rewrite that guesses
|
|
781
|
+
wrong still parses and still passes, which is precisely the failure this tool refuses
|
|
782
|
+
to risk. Work the flags by hand.
|
|
783
|
+
|
|
784
|
+
Nothing here is required. `constable import` reopens these files verbatim as cold
|
|
785
|
+
cases, and cold cases run alongside native ones forever.
|
|
786
|
+
|
|
787
|
+
Write mode: **#{write_mode_label}**
|
|
788
|
+
|
|
789
|
+
TEXT
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def write_mode_label
|
|
793
|
+
case @write_mode
|
|
794
|
+
when :in_place then "in place -- the original files were overwritten"
|
|
795
|
+
when :alongside then "alongside -- conversions were written to `*_case.rb` next to the originals"
|
|
796
|
+
else "dry run -- no source file was written; this report is the only output"
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def headline
|
|
801
|
+
converted = @results.sum { |r| Array(r.converted).size }
|
|
802
|
+
flagged = @results.sum { |r| Array(r.flags).size }
|
|
803
|
+
untouched = @results.sum { |r| Array(r.untouched).size }
|
|
804
|
+
failed = @results.count { |r| !r.ok? }
|
|
805
|
+
|
|
806
|
+
out = +"## Summary\n\n"
|
|
807
|
+
out << "| Files | Converted | Flagged | Left untouched | Failed |\n"
|
|
808
|
+
out << "|---|---|---|---|---|\n"
|
|
809
|
+
out << "| #{@results.size} | #{converted} | #{flagged} | #{untouched} | #{failed} |\n\n"
|
|
810
|
+
out
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def file_section(result)
|
|
814
|
+
out = "## `#{result.relative_path}`\n\n"
|
|
815
|
+
|
|
816
|
+
unless result.ok?
|
|
817
|
+
out << "**Not converted.** #{result.error}\n\n"
|
|
818
|
+
out << "The file was left exactly as it was. Reopen it instead: `constable import`.\n\n"
|
|
819
|
+
return out
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
out << "- dialect: `#{result.dialect}`\n"
|
|
823
|
+
out << "- case class: `#{result.class_name}`\n" if result.class_name
|
|
824
|
+
out << "- written to: #{result.written_to ? "`#{result.written_to}`" : "nothing (dry run)"}\n"
|
|
825
|
+
out << "- status: #{status_line(result)}\n\n"
|
|
826
|
+
|
|
827
|
+
out << list("Converted", result.converted) do |item|
|
|
828
|
+
"`#{squish(item[:from])}` -> `#{squish(item[:to])}`#{" -- #{item[:note]}" if item[:note]}"
|
|
829
|
+
end
|
|
830
|
+
out << list("Flagged -- NOT converted, still as written", result.flags) do |item|
|
|
831
|
+
"**#{item[:kind]}** `#{squish(item[:source])}` -- #{item[:reason]}"
|
|
832
|
+
end
|
|
833
|
+
out << list("Left untouched", result.untouched) do |item|
|
|
834
|
+
"**#{item[:kind]}** `#{squish(item[:source])}` -- #{item[:reason]}"
|
|
835
|
+
end
|
|
836
|
+
out
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def status_line(result)
|
|
840
|
+
return "no change -- nothing in this file needed converting" unless result.changed?
|
|
841
|
+
return "**partial** -- #{Array(result.flags).size} flag(s) need a human decision" if result.flagged?
|
|
842
|
+
|
|
843
|
+
"converted cleanly"
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def list(title, items)
|
|
847
|
+
items = Array(items)
|
|
848
|
+
return "" if items.empty?
|
|
849
|
+
|
|
850
|
+
out = "### #{title} (#{items.size})\n\n"
|
|
851
|
+
items.each { |item| out << "- `#{item[:location]}` #{yield(item)}\n" }
|
|
852
|
+
out << "\n"
|
|
853
|
+
out
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def squish(text) = text.to_s.gsub(/\s+/, " ").strip
|
|
857
|
+
end
|
|
858
|
+
end
|
|
859
|
+
end
|
|
860
|
+
end
|