constable-rails 0.1.0 → 1.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.
@@ -11,6 +11,14 @@ module Constable
11
11
  # chose, since reordering someone's untouched legacy file is exactly the kind of surprise
12
12
  # the cold-case story exists to avoid.
13
13
  class Runner
14
+ # Thresholds for "this run is broken, not these tests" -- see #systemic_failure.
15
+ SYSTEMIC_MINIMUM = 5 # below this it is cheaper to believe the tests
16
+ SYSTEMIC_SHARE = 0.25 # of the whole run
17
+ SYSTEMIC_AGREEMENT = 0.8 # of the failures, failing identically
18
+
19
+ # `failed` rather than `count` or `tally`, both of which override an Enumerable method.
20
+ Systemic = Struct.new(:exception_class, :failed, :total, keyword_init: true)
21
+
14
22
  # One unit of work. Native items are a single investigation; cold items are a whole
15
23
  # file, because their engine owns the granularity inside it.
16
24
  class Item
@@ -56,6 +64,19 @@ module Constable
56
64
  def jail_run? = @jail_run
57
65
  def coverage? = @coverage_requested
58
66
 
67
+ # Loads every case file and hands back the identities the suite actually defines,
68
+ # without running anything. `constable prune` needs this: which tests still exist is
69
+ # only knowable once the whole suite has been loaded.
70
+ def self.identities(config: Constable.config)
71
+ selection = Selection.new([], config: config, root: Constable.root, full: true)
72
+ runner = new(selection: selection, config: config,
73
+ reporter: Reporter.new(io: StringIO.new, config: config, color: false),
74
+ storage: Constable.storage, workers: 1)
75
+ runner.send(:load_suite!)
76
+ Constable.registry.disambiguate_identities!
77
+ Constable.registry.investigations.map(&:identity)
78
+ end
79
+
59
80
  # => Integer exit status (0 clean, 1 failures)
60
81
  def call
61
82
  # Before anything is loaded: Coverage only counts files required after it starts, so
@@ -65,7 +86,11 @@ module Constable
65
86
  Constable::Coverage.start!(config: @config, force: true) if coverage?
66
87
 
67
88
  load_suite!
89
+ # Before anything is keyed on an identity -- selection, the docket, flake history --
90
+ # settle any two tests that happen to share a body.
91
+ Constable.registry.disambiguate_identities!
68
92
  items = build_items
93
+ refuse_empty_selection!(items)
69
94
  ordered = order(items)
70
95
 
71
96
  run_id = @storage.start_run(seed: @seed, mode: mode_label, full: @selection.full?)
@@ -91,6 +116,10 @@ module Constable
91
116
  @results = adjudicate(raw)
92
117
  duration = monotonic - started
93
118
 
119
+ # Cold-case engines hold a live session -- for RSpec that is a configuration
120
+ # carrying `after(:suite)` hooks that have not fired yet. Tear it down before our
121
+ # own after_suite so the engine's cleanup runs inside the suite, not after it.
122
+ ColdCase.reset_engines!
94
123
  Constable.configuration.run_after_suite!
95
124
  @coverage_report = build_coverage_report if coverage?
96
125
 
@@ -127,6 +156,15 @@ module Constable
127
156
  .find { |p| File.exist?(p) }
128
157
  require helper if helper
129
158
 
159
+ # Between requiring the helper and asking the selection anything.
160
+ #
161
+ # Ordering is the whole point. case_helper.rb is where Constable.configure runs, so
162
+ # its settings do not exist until the line above. But Selection memoizes its targets
163
+ # the first time it is asked for them, and `cold_cases` is one of the settings people
164
+ # will most want to set in Ruby -- ask first and the override arrives too late to
165
+ # matter, silently. Requiring the helper needs no selection, so this fits between.
166
+ @config.apply_overrides!(Constable.configuration.overrides)
167
+
130
168
  @selection.native_targets.each { |target| load_case_file(target.path) }
131
169
  helper
132
170
  end
@@ -172,6 +210,17 @@ module Constable
172
210
  end
173
211
  end
174
212
 
213
+ # A run that was *asked* for something specific and found nothing is a usage error, not
214
+ # a pass. `constable test test/cases/typo_case.rb` used to print "0 passed, 0 failed"
215
+ # and exit 0, so a mistyped path in a CI script produced a green build that ran no
216
+ # tests at all. A full run with an empty suite is a different thing and stays quiet.
217
+ def refuse_empty_selection!(items)
218
+ return unless items.empty?
219
+ return unless @selection.explicit?
220
+
221
+ raise Constable::Error, @selection.empty_selection_message
222
+ end
223
+
175
224
  def build_items
176
225
  native = native_items
177
226
  cold = @selection.cold_targets_selected.map { |t| Item.new(path: t.path, kind: :cold) }
@@ -188,14 +237,29 @@ module Constable
188
237
 
189
238
  # PATH:LINE means "the investigation at that line" -- but developers point at any line
190
239
  # inside the block, so pick the investigation whose declaration is nearest above it.
240
+ #
241
+ # Bounded by the end of the file. Unbounded, `:999` on a twenty-line file quietly ran
242
+ # the last investigation in it: not the test the user asked for, not an error, and
243
+ # green either way. A line past the end is a typo, and no answer beats a wrong one.
191
244
  def narrow_to_line(investigations, line)
192
245
  exact = investigations.select { |inv| inv.line == line }
193
246
  return exact if exact.any?
247
+ return [] unless line_within_file?(investigations.first, line)
194
248
 
195
249
  nearest = investigations.select { |inv| inv.line <= line }.max_by(&:line)
196
250
  nearest ? [nearest] : []
197
251
  end
198
252
 
253
+ def line_within_file?(investigation, line)
254
+ path = investigation&.file
255
+ return false if path.nil?
256
+
257
+ path = File.join(@config.root, path) unless File.exist?(path)
258
+ return false unless File.exist?(path)
259
+
260
+ line <= File.foreach(path).count
261
+ end
262
+
199
263
  def order(items)
200
264
  native, cold = items.partition(&:native?)
201
265
  [*native.shuffle(random: Random.new(@seed)), *cold]
@@ -205,13 +269,32 @@ module Constable
205
269
  return [] if items.empty?
206
270
 
207
271
  count = worker_count(items)
208
- if count > 1 && forkable?
272
+ if count > 1 && forkable? && parallel_safe?
209
273
  run_parallel(items, count)
210
274
  else
211
275
  run_serial(items)
212
276
  end
213
277
  end
214
278
 
279
+ # Forking is only safe once each worker has a database of its own. Without that,
280
+ # every worker opens the same one: on SQLite the run dissolves into "database is
281
+ # locked", and on a client/server database the tests quietly see each other's rows,
282
+ # which is worse. An app with no ActiveRecord has nothing to shard and is always safe.
283
+ #
284
+ # When we cannot shard, we run serially and say why. Slow is a trade-off; wrong is not.
285
+ def parallel_safe?
286
+ return true unless WorkerDatabases.active_record?
287
+ return true if WorkerDatabases.shardable?
288
+
289
+ Constable.warn!(
290
+ "parallel workers need one database per worker, and this app's ActiveRecord " \
291
+ "cannot provide them (active_record/test_databases did not load). Running " \
292
+ "serially instead -- pass --workers N once that is available.",
293
+ kind: :parallel
294
+ )
295
+ false
296
+ end
297
+
215
298
  def worker_count(items)
216
299
  requested = @workers || @config.parallel_workers
217
300
  requested.to_i.clamp(1, items.size)
@@ -240,7 +323,11 @@ module Constable
240
323
  # driver rightly complains about it.
241
324
  @storage.close
242
325
 
243
- buckets.each do |bucket|
326
+ # Same reasoning for the app's own connections: a child that inherits a live
327
+ # handle can corrupt it. Rails does exactly this before its own fork.
328
+ WorkerDatabases.before_fork!
329
+
330
+ buckets.each_with_index do |bucket, worker_index|
244
331
  reader, writer = IO.pipe
245
332
  # Marshal payloads are binary. Left in text mode, the first byte that isn't valid
246
333
  # UTF-8 takes the worker down with an encoding error.
@@ -248,10 +335,21 @@ module Constable
248
335
  writer.binmode
249
336
  pid = fork do
250
337
  reader.close
338
+
339
+ # Before a single test runs: build this worker's own database and point the
340
+ # process at it. Raises rather than falling back to the shared one, because a
341
+ # silent fallback is the bug we are here to prevent.
342
+ WorkerDatabases.after_fork!(worker_index)
343
+
251
344
  bucket.each do |item|
252
345
  run_item(item).each { |result| write_message(writer, :result, result.to_h) }
253
346
  end
254
347
 
348
+ # A worker owns its own cold-case session, and it dies here. Fire the engine's
349
+ # after(:suite) hooks in the process that ran the before(:suite) half, before
350
+ # coverage is read -- the parent has no hooks to run on its behalf.
351
+ ColdCase.reset_engines!
352
+
255
353
  # Ruby's Coverage counts lines in the process that executed them, so a worker's
256
354
  # hits would die with it. They ride home on the same pipe as the results.
257
355
  write_message(writer, :coverage, Constable::Coverage.peek_raw) if coverage?
@@ -513,6 +611,9 @@ module Constable
513
611
  # Turns raw pass/fail into the verdict the build acts on: warrants decide whether a
514
612
  # failure is even real, then jail decides whether it blocks.
515
613
  def adjudicate(raw)
614
+ @systemic = systemic_failure(raw)
615
+ announce_systemic_failure(@systemic) if @systemic
616
+
516
617
  raw.map do |result|
517
618
  decided = warrants.adjudicate(
518
619
  result,
@@ -520,10 +621,54 @@ module Constable
520
621
  subject: investigation_for(result.identity)
521
622
  ) { |subject, _attempt| rerun_in_isolation(subject) }
522
623
 
523
- jail_run? ? decided : jail.adjudicate(decided, jail_mode: jail_mode?)
624
+ next decided if jail_run?
625
+
626
+ jail.adjudicate(decided, jail_mode: jail_mode?, systemic: systemic?(decided))
524
627
  end
525
628
  end
526
629
 
630
+ # A run is "systemically broken" when a large share of it failed the same way: the
631
+ # database was down, a worker could not start, a shared fixture never loaded. Thirty
632
+ # tests did not each independently go bad in the same second.
633
+ #
634
+ # This matters because flake history reads "passed last run, failed this run" as
635
+ # evidence about a *test*, and jails it. One bad afternoon on CI could therefore
636
+ # quarantine a third of a healthy suite, and the docket -- which is supposed to be a
637
+ # record of tests worth distrusting -- fills up with tests that were never at fault.
638
+ def systemic_failure(results)
639
+ failures = results.select(&:failed?)
640
+ return nil if failures.size < SYSTEMIC_MINIMUM
641
+ return nil if failures.size < results.size * SYSTEMIC_SHARE
642
+
643
+ grouped = failures.group_by { |result| result.failure&.exception_class.to_s }
644
+ grouped.delete("")
645
+ return nil if grouped.empty?
646
+
647
+ exception_class, sharing = grouped.max_by { |_klass, group| group.size }
648
+ return nil if sharing.size < failures.size * SYSTEMIC_AGREEMENT
649
+
650
+ Systemic.new(exception_class: exception_class, failed: sharing.size, total: results.size)
651
+ end
652
+
653
+ # Only the failures that look like the outage are exempt. A genuine failure that
654
+ # happened to land in the same run is still a genuine failure.
655
+ def systemic?(result)
656
+ return false unless @systemic
657
+ return false unless result.failed?
658
+
659
+ result.failure&.exception_class.to_s == @systemic.exception_class
660
+ end
661
+
662
+ def announce_systemic_failure(systemic)
663
+ Constable.warn!(
664
+ "#{systemic.failed} of #{systemic.total} tests failed with the same error " \
665
+ "(#{systemic.exception_class}). That reads as one broken run rather than " \
666
+ "#{systemic.failed} newly flaky tests, so flake history and the jail docket were " \
667
+ "left alone. Fix the cause and run again.",
668
+ kind: :systemic
669
+ )
670
+ end
671
+
527
672
  def investigation_for(identity)
528
673
  @investigation_index ||= Constable.registry.investigations.to_h { |inv| [inv.identity, inv] }
529
674
  @investigation_index[identity]
@@ -564,6 +709,11 @@ module Constable
564
709
 
565
710
  def persist(run_id, results, coverage_report)
566
711
  results.each do |result|
712
+ # The blotter is the evidence file. A result produced by an outage is not
713
+ # evidence about the test, so it is not filed -- otherwise the next run reads
714
+ # "failed, then passed" and draws a conclusion from a power cut.
715
+ next if systemic?(result)
716
+
567
717
  @storage.record_result(run_id, result)
568
718
  @storage.record_duration(result.identity, result.duration)
569
719
  end
@@ -33,10 +33,13 @@ module Constable
33
33
  @root = root.to_s
34
34
  @full = full
35
35
  @unsafe_only = unsafe_only
36
- @tier = tier&.to_sym
36
+ # Downcased: `--tier UNIT` used to match nothing at all and report a clean run.
37
+ @tier = tier.to_s.strip.downcase.to_sym unless tier.to_s.strip.empty?
37
38
  @reason = nil
38
39
  end
39
40
 
41
+ TIERS = %w[unit integration system].freeze
42
+
40
43
  def full? = @full
41
44
  def unsafe_only? = @unsafe_only
42
45
 
@@ -60,6 +63,25 @@ module Constable
60
63
  end
61
64
  end
62
65
 
66
+ # Did the user ask for something in particular? If so, finding nothing is an error
67
+ # rather than a clean run -- see Runner#refuse_empty_selection!.
68
+ def explicit? = @args.any? { |arg| !arg.to_s.strip.empty? } || !@tier.nil?
69
+
70
+ # Says which part of the request came up empty, because "0 tests" on its own does not
71
+ # tell you whether the path was wrong, the tier was, or both.
72
+ def empty_selection_message
73
+ if @tier && !TIERS.include?(@tier.to_s)
74
+ return "unknown tier #{@tier.inspect} -- expected one of #{TIERS.join(", ")}."
75
+ end
76
+
77
+ described = @args.reject { |arg| arg.to_s.strip.empty? }
78
+ subject = described.empty? ? "this run" : described.join(", ")
79
+ suffix = @tier ? " in the #{@tier} tier" : ""
80
+
81
+ "no tests matched #{subject}#{suffix}. Check the path, the line number, and " \
82
+ "whether the file is a case or a cold case."
83
+ end
84
+
63
85
  def native_targets = targets.select(&:native?)
64
86
  def cold_targets_selected = targets.select(&:cold?)
65
87
  def empty? = targets.empty?
@@ -36,16 +36,36 @@ module Constable
36
36
 
37
37
  def connect!
38
38
  require_driver!
39
+ open_database!
40
+ end
41
+
42
+ # The blotter is the one file Constable owns outright, and it is disposable: it
43
+ # holds flake history, the docket and warrants, never a test. So when it cannot be
44
+ # opened, say that deleting it is a real option -- the raw
45
+ # `SQLite3::NotADatabaseException: file is not a database: PRAGMA journal_mode = WAL`
46
+ # tells a reader nothing about what to do next, and a blotter that got committed to
47
+ # git and then merged is exactly how it ends up unreadable.
48
+ def open_database!
39
49
  FileUtils.mkdir_p(File.dirname(path))
40
50
  @connection = SQLite3::Database.new(path)
41
51
  @connection.results_as_hash = true
42
52
  @connection.busy_timeout = BUSY_TIMEOUT_MS
43
- # Readers never block the writer and the writer never blocks readers.
53
+ # Readers never block the writer and the writer never blocks readers. This is also
54
+ # the first statement to touch the file, so a corrupt blotter surfaces here.
44
55
  @connection.execute("PRAGMA journal_mode = WAL")
45
56
  # WAL + NORMAL is durable across process crashes, which is the only failure that
46
57
  # matters here; a machine losing power mid-run costs us one run's bookkeeping.
47
58
  @connection.execute("PRAGMA synchronous = NORMAL")
48
59
  @connection
60
+ rescue SystemCallError => e
61
+ raise Constable::Error,
62
+ "Constable cannot open its blotter at #{path} (#{e.class}: #{e.message}). " \
63
+ "Point `storage.path` in .constable/config.yml somewhere writable."
64
+ rescue StandardError => e
65
+ raise Constable::Error,
66
+ "Constable's blotter at #{path} is not a readable database " \
67
+ "(#{e.class}). It holds flake history, the jail docket and warrants -- " \
68
+ "never your tests -- so deleting it is safe and starts that record fresh."
49
69
  end
50
70
 
51
71
  def require_driver!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "0.1.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -222,17 +222,68 @@ module Constable
222
222
 
223
223
  # The CLI speaks in file:line, the blotter is keyed by content hash. Warrant rows
224
224
  # carry both; loaded investigations are the fallback.
225
- def resolve(target)
225
+ # Every docket row a target could mean.
226
+ #
227
+ # The interesting case is a bare path. "test/cases/users_case.rb" with three tests
228
+ # on the docket is a question, not an instruction: picking one silently acts on a
229
+ # test the user never named -- and not even the first one, since the order is
230
+ # whatever storage returns. Callers ask for the candidates and refuse to guess.
231
+ def candidates(target)
226
232
  text = target.to_s.strip
227
- return nil if text.empty?
228
- return text if text.match?(/\A[0-9a-f]{8,64}\z/) && entry(text)
233
+ return [] if text.empty?
234
+
235
+ if text.match?(/\A[0-9a-f]{8,64}\z/) && (row = entry(text))
236
+ return [row]
237
+ end
229
238
 
230
239
  file, line = Jail.split_target(text)
231
- return nil if file.empty?
240
+ return [] if file.empty?
232
241
 
233
242
  matches = entries.select { |e| Jail.same_path?(e.file, file) }
234
243
  matches = matches.select { |e| e.line == line } if line
235
- return matches.first.identity if matches.any?
244
+ matches
245
+ end
246
+
247
+ # Rows whose test no longer exists.
248
+ #
249
+ # A test's key is a content hash of its body, so editing a jailed test gives it a new
250
+ # identity and leaves the old row behind -- pointing at a file:line that may now hold
251
+ # something else entirely. That is content-hash identity working as designed, but over
252
+ # a few months the docket fills with tests nobody can find.
253
+ #
254
+ # `known` is every identity the loaded suite registered, so this is only meaningful
255
+ # after a full load. Two things are deliberately conservative about it:
256
+ #
257
+ # * A known identity is never stale, even when the file recorded beside it is gone.
258
+ # The path is a display label; the identity is the truth. A test that moved file
259
+ # has an out-of-date label, not a missing test.
260
+ # * A cold case is never pruned while its file exists. Cold-case tests cannot be
261
+ # enumerated without running their own engine, so absence from `known` says
262
+ # nothing about them.
263
+ def stale_entries(known, cold_case: nil)
264
+ known = Array(known)
265
+ entries.reject { |entry| known.include?(entry.identity) }
266
+ .select { |entry| gone?(entry, cold_case) }
267
+ end
268
+
269
+ def gone?(entry, cold_case)
270
+ relative = entry.file.to_s
271
+ return true if relative.empty?
272
+
273
+ path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
274
+ return true unless File.exist?(path)
275
+ return false if cold_case&.call(relative)
276
+
277
+ true
278
+ end
279
+
280
+ def resolve(target)
281
+ matches = candidates(target)
282
+ return matches.first.identity if matches.size == 1
283
+ return nil unless matches.empty?
284
+
285
+ file, line = Jail.split_target(target.to_s.strip)
286
+ return nil if file.empty?
236
287
 
237
288
  Jail.registry_identity(file, line)
238
289
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Per-worker databases for parallel runs.
5
+ #
6
+ # Forking N workers that all talk to one database is not a speed/safety trade, it is a
7
+ # correctness bug. On SQLite it shows up immediately and honestly -- every worker
8
+ # contends for the same file and the run dissolves into
9
+ # `SQLite3::BusyException: database is locked` -- and on a client/server database it
10
+ # shows up later and far worse, as tests seeing each other's rows.
11
+ #
12
+ # Rails already solved this for `rails test`: each worker gets its own database, named
13
+ # by appending the worker index, rebuilt from schema. This is the same thing, driven by
14
+ # Constable's runner rather than by ActiveSupport::Testing::Parallelization, because
15
+ # Constable does its own forking.
16
+ #
17
+ # Everything here is defensive. Constable runs in apps with no ActiveRecord at all --
18
+ # that is the whole point of the :unit tier -- so every entry point answers "no" rather
19
+ # than raising when the pieces are missing, and the runner falls back to a serial run.
20
+ module WorkerDatabases
21
+ module_function
22
+
23
+ # Is there an ActiveRecord in this process whose databases would be shared by forks?
24
+ def active_record?
25
+ defined?(::ActiveRecord::Base) ? true : false
26
+ end
27
+
28
+ # Can we actually give each worker its own database? Rails ships the machinery in
29
+ # active_record/test_databases, which is only loaded when someone asks for parallel
30
+ # tests -- so ask for it here rather than assuming.
31
+ def shardable?
32
+ return false unless active_record?
33
+
34
+ load_test_databases!
35
+ defined?(::ActiveRecord::TestDatabases) ? true : false
36
+ end
37
+
38
+ # Parent side, before the fork. A child inheriting a live connection is a corruption
39
+ # risk in exactly the way an inherited SQLite handle is.
40
+ def before_fork!
41
+ return false unless active_record?
42
+
43
+ ::ActiveRecord::Base.connection_handler.clear_all_connections!
44
+ true
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ # Child side, immediately after the fork and before any test runs. Builds
50
+ # `<database>_<index>` from schema and points this process at it.
51
+ #
52
+ # ENV["VERBOSE"] is silenced the way Rails silences it: schema loading is chatty, and
53
+ # stdout belongs to the reporter.
54
+ def after_fork!(index)
55
+ return false unless shardable?
56
+
57
+ ::ActiveRecord::TestDatabases.create_and_load_schema(index, env_name: env_name)
58
+ true
59
+ rescue StandardError => e
60
+ # A worker that cannot build its own database would otherwise silently fall back to
61
+ # sharing the parent's, which is the bug this module exists to prevent. Say so, and
62
+ # let the failure be a real one.
63
+ raise Constable::Error, "worker #{index} could not create its own test database: " \
64
+ "#{e.class}: #{e.message}"
65
+ end
66
+
67
+ def env_name
68
+ if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
69
+ ::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
70
+ else
71
+ ENV["RAILS_ENV"] || "test"
72
+ end
73
+ end
74
+
75
+ def load_test_databases!
76
+ return if defined?(::ActiveRecord::TestDatabases)
77
+
78
+ require "active_record/test_databases"
79
+ rescue LoadError, StandardError
80
+ nil
81
+ end
82
+ end
83
+ end
data/lib/constable.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "constable/version"
4
+ require "constable/worker_databases"
4
5
 
5
6
  # Constable -- an opinionated, strict Rails testing framework.
6
7
  #
@@ -129,14 +130,60 @@ module Constable
129
130
  end
130
131
 
131
132
  # Code-level configuration set from test/case_helper.rb.
133
+ # The Ruby half of configuration, set from test/case_helper.rb.
134
+ #
135
+ # Anything that is *code* -- custom matchers, tier base classes, one-time global setup --
136
+ # can only live here. Everything that is merely a *setting* can live in either place, and
137
+ # this is the one that wins:
138
+ #
139
+ # a CLI flag for one run
140
+ # Constable.configure in case_helper.rb, because it is code and ran deliberately
141
+ # .constable/config.yml the declared default for the project
142
+ # Constable's defaults
143
+ #
144
+ # Until 1.1.0 the four accessors here were read by nothing at all: `c.parallel_workers = 4`
145
+ # was in the generated case_helper.rb, documented, and silently ignored.
132
146
  class Configuration
133
- attr_accessor :parallel_workers, :seed, :coverage, :warrants
147
+ # Every setting .constable/config.yml understands, settable in Ruby as well. A nil is
148
+ # "not set here" rather than "set to nothing", so leaving one alone defers to the file.
149
+ SETTINGS = %i[
150
+ cold_cases warrants warrant_retries auto_relink parole_period
151
+ coverage coverage_threshold coverage_html fail_on_warnings parallel_workers
152
+ output tiers
153
+ ].freeze
154
+
155
+ # `storage` is the one setting that cannot live here, and the reason is ordering, not
156
+ # preference: the blotter handle is opened before test/case_helper.rb is loaded,
157
+ # because `constable jail`, `warrants`, `watchlist` and `status` all read the docket
158
+ # without booting the app at all. By the time this block runs, it is already open.
159
+ #
160
+ # Raising beats accepting the value and quietly using the old path -- silently
161
+ # ignoring a setting somebody wrote is the failure mode this whole class was fixed for.
162
+ SETTINGS_ONLY_IN_YAML = %i[storage].freeze
163
+
164
+ attr_accessor(*SETTINGS, :seed)
165
+
166
+ def storage=(_value)
167
+ raise ConfigurationError,
168
+ "storage must be set in .constable/config.yml, not Constable.configure. The " \
169
+ "blotter is opened before case_helper.rb loads, so that `constable jail` and " \
170
+ "`constable status` can read the docket without booting the app -- by the " \
171
+ "time this block runs the connection is already open."
172
+ end
134
173
 
135
174
  def initialize
136
175
  @before_suite_hooks = []
137
176
  @after_suite_hooks = []
138
177
  end
139
178
 
179
+ # Only the settings actually assigned, ready to merge over the file's values.
180
+ def overrides
181
+ SETTINGS.each_with_object({}) do |name, out|
182
+ value = public_send(name)
183
+ out[name.to_s] = value unless value.nil?
184
+ end
185
+ end
186
+
140
187
  def before_suite(&block) = @before_suite_hooks << block
141
188
  def after_suite(&block) = @after_suite_hooks << block
142
189