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,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "set"
5
+ require "yaml"
6
+
7
+ module Constable
8
+ module Importer
9
+ # The default import mode: make an existing RSpec/Minitest suite run under Constable
10
+ # today, verbatim, with zero chance of a broken conversion.
11
+ #
12
+ # Two routes, and the cheaper one wins:
13
+ #
14
+ # 1. **Config path match** -- add a glob to `cold_cases:` in .constable/config.yml.
15
+ # Zero files changed. Preferred whenever a single glob cleanly covers a whole
16
+ # directory, because the smallest diff that achieves the goal is the right diff.
17
+ # 2. **Superclass swap** -- wrap the file in `class LegacyFooSpec < ColdCase::RSpec`.
18
+ # The original bytes are re-emitted untouched between a header line and a final
19
+ # `end`; nothing inside is parsed, reformatted or reindented.
20
+ #
21
+ # Route 2 exists for the leftovers -- one legacy file in a directory that is otherwise
22
+ # already modernized, where a glob would over-reach and silently swallow future files.
23
+ class Reopener
24
+ STRATEGIES = %i[auto config superclass].freeze
25
+
26
+ ENGINES = {
27
+ rspec: {
28
+ glob: "spec/**/*_spec.rb",
29
+ suffix: "_spec.rb",
30
+ roots: ["spec"],
31
+ superclass: "Constable::ColdCase::RSpec"
32
+ },
33
+ minitest: {
34
+ glob: "test/**/*_test.rb",
35
+ suffix: "_test.rb",
36
+ roots: ["test"],
37
+ superclass: "Constable::ColdCase::Minitest"
38
+ }
39
+ }.freeze
40
+
41
+ # A single file the importer proposes to rewrite. Carries both sides so the caller
42
+ # can always say precisely what changed -- we never overwrite blind.
43
+ class Change
44
+ attr_reader :path, :relative_path, :class_name, :before, :after
45
+
46
+ def initialize(path:, relative_path:, class_name:, before:, after:)
47
+ @path = path
48
+ @relative_path = relative_path
49
+ @class_name = class_name
50
+ @before = before
51
+ @after = after
52
+ end
53
+
54
+ def action = :superclass_swap
55
+
56
+ # The rewrite only ever prepends a header and appends `end`, so the diff is exact
57
+ # without needing a real diff algorithm.
58
+ def diff
59
+ added_head = @after.split(@before, 2).first.to_s
60
+ out = "--- a/#{@relative_path}\n+++ b/#{@relative_path}\n"
61
+ added_head.each_line { |line| out << "+#{line.chomp}\n" }
62
+ out << " #{@before.lines.size} unchanged line#{"s" unless @before.lines.size == 1} (byte for byte)\n"
63
+ out << "+end\n"
64
+ out
65
+ end
66
+
67
+ def to_h
68
+ { action: action, path: @relative_path, class_name: @class_name,
69
+ bytes_before: @before.bytesize, bytes_after: @after.bytesize }
70
+ end
71
+ end
72
+
73
+ # What an import run did, or would do. Everything the CLI needs to print a report.
74
+ class Result
75
+ attr_reader :from, :strategy, :root, :config_path, :globs_added, :existing_globs,
76
+ :changes, :covered, :skipped, :errors
77
+
78
+ def initialize(from:, strategy:, root:, config_path:, dry_run:)
79
+ @from = from
80
+ @strategy = strategy
81
+ @root = root
82
+ @config_path = config_path
83
+ @dry_run = dry_run
84
+ @globs_added = []
85
+ @existing_globs = []
86
+ @changes = []
87
+ @covered = []
88
+ @skipped = []
89
+ @errors = []
90
+ @comments_preserved = true
91
+ end
92
+
93
+ attr_accessor :comments_preserved
94
+
95
+ def dry_run? = @dry_run
96
+ def comments_preserved? = @comments_preserved
97
+ def config_changed? = !@globs_added.empty?
98
+ def files_changed = @changes.map(&:relative_path)
99
+ def any_changes? = config_changed? || !@changes.empty?
100
+ def imported_count = @covered.size + @changes.size
101
+
102
+ # Whichever routes were actually used, named honestly.
103
+ def routes
104
+ [(:config_path_match if config_changed?), (:superclass_swap unless @changes.empty?)].compact
105
+ end
106
+
107
+ def to_h
108
+ {
109
+ from: @from, strategy: @strategy, dry_run: @dry_run, routes: routes,
110
+ config_path: @config_path, globs_added: @globs_added,
111
+ comments_preserved: @comments_preserved,
112
+ covered: @covered, changes: @changes.map(&:to_h),
113
+ skipped: @skipped, errors: @errors
114
+ }
115
+ end
116
+
117
+ def summary
118
+ verb = dry_run? ? "would" : "did"
119
+ lines = ["#{@from} import (#{@strategy}) -- #{verb} reopen #{imported_count} file(s)"]
120
+ unless @globs_added.empty?
121
+ lines << " config path match: added #{@globs_added.size} glob(s) to #{@config_path}"
122
+ @globs_added.each { |glob| lines << " - #{glob} (#{covered_by(glob).size} files, 0 file changes)" }
123
+ unless comments_preserved?
124
+ lines << " note: config.yml was rewritten from parsed YAML; comments were not preserved"
125
+ end
126
+ end
127
+ unless @changes.empty?
128
+ lines << " superclass swap: #{@changes.size} file(s) wrapped"
129
+ @changes.each { |c| lines << " - #{c.relative_path} -> class #{c.class_name} < #{superclass_name}" }
130
+ end
131
+ unless @skipped.empty?
132
+ lines << " skipped #{@skipped.size} file(s) already reopened:"
133
+ @skipped.each { |s| lines << " - #{s[:path]} (#{s[:reason]})" }
134
+ end
135
+ @errors.each { |e| lines << " error: #{e[:path]} -- #{e[:message]}" }
136
+ lines.join("\n")
137
+ end
138
+
139
+ def superclass_name = ENGINES.fetch(@from)[:superclass]
140
+
141
+ def covered_by(glob)
142
+ @covered.select { |path| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
143
+ end
144
+ end
145
+
146
+ attr_reader :from, :config, :root, :strategy
147
+
148
+ def initialize(from:, config: Constable.config, root: nil, paths: nil, strategy: :auto, dry_run: false)
149
+ @from = from.to_s.downcase.to_sym
150
+ unless ENGINES.key?(@from)
151
+ raise ArgumentError,
152
+ "unknown import source #{from.inspect} (expected :rspec or :minitest)"
153
+ end
154
+
155
+ @strategy = (strategy || :auto).to_sym
156
+ unless STRATEGIES.include?(@strategy)
157
+ raise ArgumentError,
158
+ "unknown strategy #{strategy.inspect} (expected #{STRATEGIES.join(", ")})"
159
+ end
160
+
161
+ @config = config
162
+ @root = (root || config&.root || Constable.root).to_s
163
+ @paths = paths
164
+ @dry_run = dry_run
165
+ end
166
+
167
+ def engine = ENGINES.fetch(@from)
168
+ def dry_run? = @dry_run
169
+
170
+ def call
171
+ result = Result.new(from: @from, strategy: @strategy, root: @root,
172
+ config_path: Config::CONFIG_PATH, dry_run: @dry_run)
173
+ candidates = discover(result)
174
+ return result if candidates.empty?
175
+
176
+ globs, leftovers = partition(candidates)
177
+ apply_config_globs(globs, result)
178
+ apply_superclass_swaps(leftovers, result)
179
+ result
180
+ end
181
+
182
+ # Every file the engine owns that isn't already running as a cold case.
183
+ def discover(result = nil)
184
+ files = expand_paths.select { |path| File.file?(path) }
185
+ files.sort.filter_map do |path|
186
+ relative = relativize(path)
187
+ if already_reopened?(path)
188
+ result&.skipped&.push(path: relative, reason: "already a #{engine[:superclass]} subclass")
189
+ next
190
+ end
191
+ if @config.respond_to?(:cold_case?) && @config.cold_case?(relative)
192
+ result&.skipped&.push(path: relative, reason: "already matched by a cold_cases glob")
193
+ next
194
+ end
195
+ relative
196
+ end
197
+ end
198
+
199
+ # Derives a valid, collision-free constant from a file path.
200
+ # spec/controllers/users_controller_spec.rb -> LegacyUsersControllerSpec
201
+ #
202
+ # `taken` is both the names already handed out in this run and anything the caller
203
+ # knows is spoken for; on a clash we widen with a parent directory segment rather
204
+ # than appending a number, because "LegacyModelsUserSpec" says something and
205
+ # "LegacyUserSpec2" says nothing.
206
+ def self.class_name_for(relative_path, taken: [], prefix: "Legacy")
207
+ segments = relative_path.to_s.sub(/\.rb\z/, "").split("/")
208
+ segments.shift if %w[spec test].include?(segments.first) && segments.size > 1
209
+ base = camelize(segments.pop.to_s)
210
+ name = "#{prefix}#{base}"
211
+
212
+ while taken.include?(name)
213
+ parent = segments.pop
214
+ break if parent.nil?
215
+
216
+ name = "#{prefix}#{camelize(parent)}#{name.delete_prefix(prefix)}"
217
+ end
218
+
219
+ suffix = 2
220
+ candidate = name
221
+ while taken.include?(candidate)
222
+ candidate = "#{name}#{suffix}"
223
+ suffix += 1
224
+ end
225
+ candidate
226
+ end
227
+
228
+ def self.camelize(string)
229
+ parts = string.to_s.gsub(/[^A-Za-z0-9_]/, "_").split("_").reject(&:empty?)
230
+ camel = parts.map { |part| part.match?(/\A[A-Z]/) ? part : part.capitalize }.join
231
+ camel = "X#{camel}" unless camel.match?(/\A[A-Z]/)
232
+ camel
233
+ end
234
+
235
+ # Wraps a file's contents. The body is emitted byte for byte -- not reindented, not
236
+ # re-encoded, not touched. That is the entire safety argument for this mode.
237
+ def wrap(source, class_name)
238
+ body = source.end_with?("\n") || source.empty? ? source : "#{source}\n"
239
+ "#{banner(class_name)}#{body}end\n"
240
+ end
241
+
242
+ def banner(class_name)
243
+ <<~HEADER
244
+ # Reopened by `constable import --from=#{@from}`. Everything between this line and the
245
+ # final `end` is the original file, byte for byte -- no rewriting was done, so this file
246
+ # still passes or fails exactly as it did before. Run `constable modernize` on it when
247
+ # you want the native DSL; until then it runs through the real #{@from} engine.
248
+ class #{class_name} < #{engine[:superclass]}
249
+ HEADER
250
+ end
251
+
252
+ private
253
+
254
+ def expand_paths
255
+ return Dir.glob(File.join(@root, engine[:glob])) if @paths.nil? || Array(@paths).empty?
256
+
257
+ Array(@paths).flat_map do |entry|
258
+ absolute = File.absolute_path?(entry.to_s) ? entry.to_s : File.join(@root, entry.to_s)
259
+ if File.directory?(absolute)
260
+ Dir.glob(File.join(absolute, "**", "*#{engine[:suffix]}"))
261
+ elsif absolute.include?("*")
262
+ Dir.glob(absolute)
263
+ else
264
+ [absolute]
265
+ end
266
+ end.uniq.sort
267
+ end
268
+
269
+ def relativize(path) = path.to_s.delete_prefix("#{@root}/")
270
+
271
+ # A file that already declares a ColdCase superclass has been imported before.
272
+ def already_reopened?(path)
273
+ head = File.open(path, "r") { |io| io.read(4096).to_s }
274
+ head.include?("Constable::ColdCase")
275
+ rescue StandardError
276
+ false
277
+ end
278
+
279
+ # Decides which files a glob can cleanly cover and which need the per-file wrap.
280
+ def partition(candidates)
281
+ return [[], candidates] if @strategy == :superclass
282
+
283
+ set = candidates.to_set
284
+ globs = []
285
+ remaining = candidates.dup
286
+
287
+ clean_globs(candidates, set).each do |glob|
288
+ matched = remaining.select { |path| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
289
+ next if matched.empty?
290
+
291
+ globs << glob
292
+ remaining -= matched
293
+ end
294
+
295
+ # --strategy=config means "change no source files at all": anything a directory
296
+ # glob couldn't cleanly cover gets listed by its exact path instead.
297
+ if @strategy == :config
298
+ globs.concat(remaining)
299
+ remaining = []
300
+ end
301
+
302
+ [globs, remaining]
303
+ end
304
+
305
+ # A glob is "clean" when every file on disk it matches is one we're importing.
306
+ # Broadest first, so `spec/**/*_spec.rb` beats a pile of per-directory globs.
307
+ def clean_globs(candidates, set)
308
+ directories = candidates.map { |path| File.dirname(path) }.flat_map { |dir| ancestors(dir) }.uniq
309
+ directories
310
+ .map { |dir| dir == "." ? "*#{engine[:suffix]}" : "#{dir}/**/*#{engine[:suffix]}" }
311
+ .uniq
312
+ .select { |glob| clean?(glob, set) }
313
+ .sort_by { |glob| [-candidates.count { |p| File.fnmatch?(glob, p, File::FNM_PATHNAME | File::FNM_EXTGLOB) }, glob] }
314
+ end
315
+
316
+ def ancestors(dir)
317
+ parts = dir.split("/")
318
+ parts.each_index.map { |i| parts[0..i].join("/") }.reverse
319
+ end
320
+
321
+ def clean?(glob, set)
322
+ on_disk = Dir.glob(File.join(@root, glob)).map { |path| relativize(path) }
323
+ !on_disk.empty? && on_disk.all? { |path| set.include?(path) }
324
+ end
325
+
326
+ def apply_config_globs(globs, result)
327
+ return if globs.empty?
328
+
329
+ path = File.join(@root, Config::CONFIG_PATH)
330
+ existing = existing_cold_cases(path)
331
+ result.existing_globs.concat(existing)
332
+ fresh = globs.reject { |glob| existing.include?(glob) }
333
+ result.globs_added.concat(fresh)
334
+ result.covered.concat(covered_files(globs))
335
+
336
+ return if fresh.empty? || dry_run?
337
+
338
+ FileUtils.mkdir_p(File.dirname(path))
339
+ updated, preserved = merged_config_yaml(path, fresh)
340
+ result.comments_preserved = preserved
341
+ File.write(path, updated)
342
+ end
343
+
344
+ def covered_files(globs)
345
+ globs.flat_map { |glob| Dir.glob(File.join(@root, glob)).map { |path| relativize(path) } }.uniq.sort
346
+ end
347
+
348
+ def existing_cold_cases(path)
349
+ return [] unless File.exist?(path)
350
+
351
+ raw = YAML.safe_load_file(path, permitted_classes: [], aliases: true) || {}
352
+ Array(raw["cold_cases"]).map(&:to_s)
353
+ rescue StandardError
354
+ []
355
+ end
356
+
357
+ # Editing YAML as text rather than round-tripping through the parser, because a
358
+ # config file is a document a human wrote and their comments are part of it.
359
+ # If the text edit can't be verified afterwards we fall back to a full dump, which
360
+ # keeps every setting but loses the comments -- and says so in the result.
361
+ def merged_config_yaml(path, globs)
362
+ original = File.exist?(path) ? File.read(path) : nil
363
+ edited = original.nil? ? fresh_config_yaml(globs) : insert_globs(original, globs)
364
+
365
+ parsed = begin
366
+ edited && (YAML.safe_load(edited, permitted_classes: [], aliases: true) || {})
367
+ rescue StandardError
368
+ nil
369
+ end
370
+
371
+ if edited && parsed.is_a?(Hash) && globs.all? { |glob| Array(parsed["cold_cases"]).include?(glob) }
372
+ [edited, true]
373
+ else
374
+ [rewritten_config_yaml(original, globs), false]
375
+ end
376
+ end
377
+
378
+ def fresh_config_yaml(globs)
379
+ <<~YAML
380
+ # Constable settings. See docs/SPEC.md for the full reference.
381
+ cold_cases: # run verbatim through their original engine
382
+ #{globs.map { |glob| " - #{quote(glob)}" }.join("\n")}
383
+ YAML
384
+ end
385
+
386
+ # Last resort: reparse and re-dump. Loses comments, never loses settings.
387
+ def rewritten_config_yaml(original, globs)
388
+ raw = if original
389
+ begin
390
+ YAML.safe_load(original, permitted_classes: [], aliases: true) || {}
391
+ rescue StandardError
392
+ {}
393
+ end
394
+ else
395
+ {}
396
+ end
397
+ raw["cold_cases"] = (Array(raw["cold_cases"]).map(&:to_s) + globs).uniq
398
+ YAML.dump(raw)
399
+ end
400
+
401
+ def insert_globs(original, globs)
402
+ lines = original.lines
403
+ key_index = lines.index { |line| line.match?(/\Acold_cases\s*:/) }
404
+
405
+ return append_block(lines, globs) if key_index.nil?
406
+
407
+ key_line = lines[key_index]
408
+ inline = key_line.split(":", 2)[1].to_s.sub(/#.*\z/, "").strip
409
+
410
+ # `cold_cases: []` and `cold_cases:` both become a block; anything else inline
411
+ # (a populated flow sequence, an anchor) is too exotic to edit safely as text --
412
+ # nil tells the caller to fall back to a full re-dump.
413
+ return nil if !inline.empty? && inline != "[]"
414
+
415
+ lines[key_index] = key_line.sub(/:\s*\[\]/, ":") if inline == "[]"
416
+
417
+ insert_at = key_index
418
+ cursor = key_index + 1
419
+ while cursor < lines.length
420
+ line = lines[cursor]
421
+ break if line.match?(/\A\S/) && !line.start_with?("#")
422
+
423
+ insert_at = cursor if line.match?(/\A\s+-\s/)
424
+ cursor += 1
425
+ end
426
+
427
+ indent = lines[insert_at].match?(/\A(\s+)-\s/) ? lines[insert_at][/\A\s+/] : " "
428
+ lines[insert_at] = "#{lines[insert_at].chomp}\n" unless lines[insert_at].end_with?("\n")
429
+ lines.insert(insert_at + 1, *globs.map { |glob| "#{indent}- #{quote(glob)}\n" })
430
+ lines.join
431
+ end
432
+
433
+ def append_block(lines, globs)
434
+ body = lines.join
435
+ body += "\n" unless body.empty? || body.end_with?("\n")
436
+ body += "\n" unless body.empty? || body.end_with?("\n\n")
437
+ body + "# Added by `constable import` -- these run verbatim as cold cases.\n" \
438
+ "cold_cases:\n#{globs.map { |glob| " - #{quote(glob)}\n" }.join}"
439
+ end
440
+
441
+ # A sentinel the caller turns into the dump fallback -- returning invalid YAML on
442
+ # purpose is clearer than raising through the happy path.
443
+ def rewritten_via_dump_marker = "unparseable"
444
+
445
+ def quote(glob) = glob.match?(%r{\A[A-Za-z0-9_.\-/*\[\]{}]+\z}) ? glob : glob.inspect
446
+
447
+ def apply_superclass_swaps(files, result)
448
+ taken = []
449
+ files.each do |relative|
450
+ absolute = File.join(@root, relative)
451
+ begin
452
+ before = File.read(absolute)
453
+ rescue StandardError => e
454
+ result.errors << { path: relative, message: e.message }
455
+ next
456
+ end
457
+
458
+ class_name = self.class.class_name_for(relative, taken: taken)
459
+ taken << class_name
460
+ change = Change.new(path: absolute, relative_path: relative, class_name: class_name,
461
+ before: before, after: wrap(before, class_name))
462
+ result.changes << change
463
+ File.write(absolute, change.after) unless dry_run?
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Adoption, in two modes with very different risk profiles.
5
+ #
6
+ # **Reopen** is the default and it is verbatim. A file's body is never read, parsed or
7
+ # rewritten -- it is either wrapped in `class Legacy... < Constable::ColdCase::RSpec`
8
+ # (one line above, one line below, original bytes in between) or, better still, matched
9
+ # by a glob in `.constable/config.yml` so no file is touched at all. The reason this is
10
+ # the default and not the fallback: an AST rewrite can silently change what a test
11
+ # asserts and still parse, compile and pass. A superclass swap cannot. The worst case
12
+ # for reopen is a file that runs exactly as it did yesterday.
13
+ #
14
+ # **Modernize** is the opt-in AST rewrite into the native DSL, one file at a time, and
15
+ # it is deliberately partial. Anything it cannot convert with certainty is *flagged* --
16
+ # written into `constable_modernize_report.md` for a human, never guessed at. It writes
17
+ # nothing by default.
18
+ module Importer
19
+ autoload :Modernizer, "constable/importer/modernizer"
20
+ autoload :Reopener, "constable/importer/reopener"
21
+
22
+ module_function
23
+
24
+ # The `constable import --from=rspec` entry point.
25
+ #
26
+ # from: :rspec | :minitest
27
+ # config: a Constable::Config (used for its root and existing cold_cases globs)
28
+ # root: project root; defaults to the config's root
29
+ # paths: explicit files/dirs/globs to import; nil discovers them for the engine
30
+ # strategy: :auto (default) | :config | :superclass
31
+ # dry_run: true reports exactly what would change and writes nothing
32
+ #
33
+ # Returns a Reopener::Result describing every glob added, every file rewritten and
34
+ # every file deliberately skipped.
35
+ def run(from:, config: Constable.config, root: nil, paths: nil, strategy: :auto, dry_run: false)
36
+ Reopener.new(from: from, config: config, root: root, paths: paths,
37
+ strategy: strategy, dry_run: dry_run).call
38
+ end
39
+
40
+ # Same as #run but guaranteed not to touch anything -- the "show me first" call.
41
+ def plan(from:, config: Constable.config, root: nil, paths: nil, strategy: :auto)
42
+ run(from: from, config: config, root: root, paths: paths, strategy: strategy, dry_run: true)
43
+ end
44
+
45
+ # The `constable modernize PATH` entry point. Dry run unless `write:` says otherwise;
46
+ # see Modernizer::WRITE_MODES.
47
+ def modernize(paths, config: Constable.config, root: nil, write: :none, report: true)
48
+ Modernizer.run(paths, config: config, root: root, write: write, report: report)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # One registered test. `investigate` is a registration DSL, not a method definition --
5
+ # each Investigation is run in its own fresh instance of its owning case class, so
6
+ # nothing an investigation does can reach any other one.
7
+ class Investigation
8
+ attr_reader :case_class, :description, :block, :file, :line, :docket_path
9
+ attr_accessor :tier
10
+
11
+ def initialize(case_class:, description:, block:, file:, line:, docket_path: [], tier: nil)
12
+ @case_class = case_class
13
+ @description = description
14
+ @block = block
15
+ @file = file
16
+ @line = line
17
+ @docket_path = docket_path
18
+ @tier = tier
19
+ end
20
+
21
+ # "UsersController::CreatesUserCase" -- the outermost real (non-docket) class.
22
+ def case_name
23
+ @case_name ||=
24
+ if @case_class.respond_to?(:constable_display_name)
25
+ @case_class.constable_display_name
26
+ else
27
+ @case_class.name.to_s
28
+ end
29
+ end
30
+
31
+ # Docket nesting folds into the description the way a reader would say it aloud:
32
+ # docket "as an admin" + investigate "creates a user" => "as an admin creates a user"
33
+ def full_description
34
+ (@docket_path + [@description]).join(" ")
35
+ end
36
+
37
+ def identity
38
+ @identity ||= Identity.for_block(@block)
39
+ end
40
+
41
+ def location
42
+ "#{relative_file}:#{@line}"
43
+ end
44
+
45
+ def relative_file
46
+ @file.to_s.delete_prefix("#{Constable.root}/")
47
+ end
48
+
49
+ def kind = :native
50
+
51
+ def to_h
52
+ {
53
+ identity: identity,
54
+ case_name: case_name,
55
+ description: full_description,
56
+ file: relative_file,
57
+ line: @line,
58
+ tier: @tier,
59
+ kind: kind
60
+ }
61
+ end
62
+
63
+ def display_label
64
+ "#{case_name} \"#{full_description}\""
65
+ end
66
+ end
67
+ end