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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. 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