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