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,697 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Constable
6
+ # Executes a selection and turns it into results.
7
+ #
8
+ # Ordering is random every run, because a suite that only passes in one order is a suite
9
+ # that will fail the first time anything is added to it. The seed is always printed and
10
+ # always replayable. Cold cases are exempt -- they keep whatever order their own engine
11
+ # chose, since reordering someone's untouched legacy file is exactly the kind of surprise
12
+ # the cold-case story exists to avoid.
13
+ class Runner
14
+ # One unit of work. Native items are a single investigation; cold items are a whole
15
+ # file, because their engine owns the granularity inside it.
16
+ class Item
17
+ attr_reader :investigation, :path, :kind
18
+
19
+ def initialize(investigation: nil, path: nil, kind: :native)
20
+ @investigation = investigation
21
+ @path = path
22
+ @kind = kind
23
+ end
24
+
25
+ def native? = @kind == :native
26
+ def cold? = @kind == :cold
27
+ def identity = native? ? @investigation.identity : Identity.for_cold_case(@path, "file")
28
+ def label = native? ? @investigation.display_label : @path.to_s
29
+ end
30
+
31
+ attr_reader :config, :selection, :reporter, :storage, :seed, :results, :coverage_report
32
+
33
+ def initialize(selection:, config: Constable.config, reporter: nil, storage: nil,
34
+ seed: nil, jail_mode: false, jail_run: false, warrants: nil, coverage: nil,
35
+ workers: nil, verbose: false, io: $stdout)
36
+ @selection = selection
37
+ @config = config
38
+ @storage = storage || Constable.storage
39
+ @seed = (seed || ENV["CONSTABLE_SEED"] || SecureRandom.random_number(10_000)).to_i
40
+ @jail_mode = jail_mode
41
+ @jail_run = jail_run
42
+ @warrants_requested = warrants.nil? ? config.warrants? : warrants
43
+ @coverage_requested = coverage.nil? ? config.coverage? : coverage
44
+ @workers = workers
45
+ @verbose = verbose
46
+ @io = io
47
+ @reporter = reporter || Reporter.new(io: io, config: config)
48
+ @results = []
49
+ @worker_coverage = {}
50
+ @warnings_before = Constable.warnings.size
51
+ end
52
+
53
+ def jail_mode? = @jail_mode
54
+
55
+ # `constable jail run` exists precisely to run the bodies the docket normally skips.
56
+ def jail_run? = @jail_run
57
+ def coverage? = @coverage_requested
58
+
59
+ # => Integer exit status (0 clean, 1 failures)
60
+ def call
61
+ # Before anything is loaded: Coverage only counts files required after it starts, so
62
+ # starting it any later measures an empty application and reports a confident 100%.
63
+ # force:, because #coverage? has already folded --coverage together with the config
64
+ # setting, and asking the config again would throw the flag away.
65
+ Constable::Coverage.start!(config: @config, force: true) if coverage?
66
+
67
+ load_suite!
68
+ items = build_items
69
+ ordered = order(items)
70
+
71
+ run_id = @storage.start_run(seed: @seed, mode: mode_label, full: @selection.full?)
72
+ Constable.configuration.run_before_suite!
73
+
74
+ started = monotonic
75
+ # A cold-case file is one work item but an unknown number of tests until its own
76
+ # engine has run it, so claim a total only when every item is a native investigation.
77
+ announced = ordered.all?(&:native?) ? ordered.size : nil
78
+ @reporter.start(total: announced, seed: @seed)
79
+
80
+ # The "alone" half of the order audit has to happen before the suite has touched
81
+ # anything, so it runs here rather than alongside the results it will be compared to.
82
+ order_audit.record_isolated!(ordered.select(&:native?).map(&:investigation))
83
+
84
+ # Warm everything that reads the blotter while we are still single-process.
85
+ docket_snapshot
86
+ duration_index
87
+ known_before
88
+
89
+ raw = order_audit.audit(execute(ordered)) + load_error_results
90
+
91
+ @results = adjudicate(raw)
92
+ duration = monotonic - started
93
+
94
+ Constable.configuration.run_after_suite!
95
+ @coverage_report = build_coverage_report if coverage?
96
+
97
+ persist(run_id, @results, @coverage_report)
98
+ suggestions = rename_suggestions(@results)
99
+
100
+ @reporter.finish(
101
+ results: @results,
102
+ duration: duration,
103
+ seed: @seed,
104
+ coverage: @coverage_report,
105
+ suggestions: suggestions
106
+ )
107
+
108
+ exit_status(@results, @coverage_report)
109
+ end
110
+
111
+ private
112
+
113
+ def mode_label
114
+ return "jail_run" if jail_run?
115
+ return "jail" if jail_mode?
116
+ return "unsafe" if @selection.unsafe_only?
117
+
118
+ @selection.full? ? "full" : "diff"
119
+ end
120
+
121
+ # test/case_helper.rb is the app's own entry point -- it boots Rails, defines the tier
122
+ # base classes and loads support files. Everything else depends on it having run.
123
+ def load_suite!
124
+ add_suite_dirs_to_load_path!
125
+
126
+ helper = %w[test/case_helper.rb spec/case_helper.rb].map { |p| File.join(Constable.root, p) }
127
+ .find { |p| File.exist?(p) }
128
+ require helper if helper
129
+
130
+ @selection.native_targets.each { |target| load_case_file(target.path) }
131
+ helper
132
+ end
133
+
134
+ # Case files open with `require "case_helper"`, the way an RSpec file opens with
135
+ # `require "rails_helper"`. That only resolves if the suite directory is on the load
136
+ # path, and nothing else puts it there.
137
+ def add_suite_dirs_to_load_path!
138
+ %w[test spec].each do |dir|
139
+ path = File.join(Constable.root, dir)
140
+ $LOAD_PATH.unshift(path) if File.directory?(path) && !$LOAD_PATH.include?(path)
141
+ end
142
+ end
143
+
144
+ def load_case_file(path)
145
+ require path
146
+ rescue StandardError, ScriptError => e
147
+ load_errors << [path, e]
148
+ end
149
+
150
+ def load_errors
151
+ @load_errors ||= []
152
+ end
153
+
154
+ # A case file that will not load is a failure, not a silence. Reporting it as a result
155
+ # puts it in the FAILURES section with its own error, instead of letting a whole file
156
+ # of tests quietly vanish from the run.
157
+ def load_error_results
158
+ load_errors.map do |path, error|
159
+ relative = path.to_s.delete_prefix("#{Constable.root}/")
160
+ result = Result.new(
161
+ identity: Identity.for_source("load-error:#{relative}"),
162
+ case_name: relative,
163
+ description: "could not be loaded",
164
+ file: relative,
165
+ line: 1,
166
+ kind: :native,
167
+ status: :errored,
168
+ failure: Failure.from_exception(error, context: "This file never ran. Nothing in it was tested.")
169
+ )
170
+ result.seed = @seed
171
+ result
172
+ end
173
+ end
174
+
175
+ def build_items
176
+ native = native_items
177
+ cold = @selection.cold_targets_selected.map { |t| Item.new(path: t.path, kind: :cold) }
178
+ native + cold
179
+ end
180
+
181
+ def native_items
182
+ @selection.native_targets.flat_map do |target|
183
+ investigations = Constable.registry.investigations_in(target.path)
184
+ investigations = narrow_to_line(investigations, target.line) if target.line
185
+ investigations.map { |inv| Item.new(investigation: inv) }
186
+ end
187
+ end
188
+
189
+ # PATH:LINE means "the investigation at that line" -- but developers point at any line
190
+ # inside the block, so pick the investigation whose declaration is nearest above it.
191
+ def narrow_to_line(investigations, line)
192
+ exact = investigations.select { |inv| inv.line == line }
193
+ return exact if exact.any?
194
+
195
+ nearest = investigations.select { |inv| inv.line <= line }.max_by(&:line)
196
+ nearest ? [nearest] : []
197
+ end
198
+
199
+ def order(items)
200
+ native, cold = items.partition(&:native?)
201
+ [*native.shuffle(random: Random.new(@seed)), *cold]
202
+ end
203
+
204
+ def execute(items)
205
+ return [] if items.empty?
206
+
207
+ count = worker_count(items)
208
+ if count > 1 && forkable?
209
+ run_parallel(items, count)
210
+ else
211
+ run_serial(items)
212
+ end
213
+ end
214
+
215
+ def worker_count(items)
216
+ requested = @workers || @config.parallel_workers
217
+ requested.to_i.clamp(1, items.size)
218
+ end
219
+
220
+ def forkable?
221
+ Process.respond_to?(:fork) && !@verbose
222
+ end
223
+
224
+ def run_serial(items)
225
+ items.flat_map do |item|
226
+ run_item(item).each { |result| @reporter.record(result) }
227
+ end
228
+ end
229
+
230
+ # Workers never write to the blotter -- they ship results back over a pipe and the
231
+ # parent is the sole writer. That keeps every storage adapter free of cross-process
232
+ # write contention without any locking of its own.
233
+ def run_parallel(items, count)
234
+ buckets = balance(items, count)
235
+ readers = []
236
+ pids = []
237
+
238
+ # Everything that reads the blotter has already been warmed, so the handle can go.
239
+ # A child inheriting a writable SQLite connection is a corruption risk, and the
240
+ # driver rightly complains about it.
241
+ @storage.close
242
+
243
+ buckets.each do |bucket|
244
+ reader, writer = IO.pipe
245
+ # Marshal payloads are binary. Left in text mode, the first byte that isn't valid
246
+ # UTF-8 takes the worker down with an encoding error.
247
+ reader.binmode
248
+ writer.binmode
249
+ pid = fork do
250
+ reader.close
251
+ bucket.each do |item|
252
+ run_item(item).each { |result| write_message(writer, :result, result.to_h) }
253
+ end
254
+
255
+ # Ruby's Coverage counts lines in the process that executed them, so a worker's
256
+ # hits would die with it. They ride home on the same pipe as the results.
257
+ write_message(writer, :coverage, Constable::Coverage.peek_raw) if coverage?
258
+
259
+ writer.close
260
+ exit!(0)
261
+ end
262
+ writer.close
263
+ readers << reader
264
+ pids << pid
265
+ end
266
+
267
+ collected = drain(readers)
268
+ pids.each { |pid| Process.waitpid(pid) rescue nil } # rubocop:disable Style/RescueModifier
269
+
270
+ # A warning raised inside a worker only ever reached that worker's memory, so the
271
+ # results carry them home. Nothing that bends the rules is allowed to go missing
272
+ # just because it happened in a subprocess.
273
+ collected.each { |result| Constable.warnings.concat(Array(result.warnings)) }
274
+ collected
275
+ end
276
+
277
+ # Every message on the pipe is tagged, because results are not the only thing a worker
278
+ # has to send home.
279
+ def write_message(writer, kind, body)
280
+ payload = Marshal.dump([kind, body])
281
+ writer.write([payload.bytesize].pack("N"))
282
+ writer.write(payload)
283
+ writer.flush
284
+ end
285
+
286
+ # Reads from every worker as results arrive, so the glyph stream stays live rather than
287
+ # arriving in one lump when the slowest worker finishes.
288
+ def drain(readers)
289
+ collected = []
290
+ buffers = Hash.new { |h, k| h[k] = String.new(encoding: Encoding::BINARY) }
291
+ open_readers = readers.dup
292
+
293
+ until open_readers.empty?
294
+ ready, = IO.select(open_readers, nil, nil, 1)
295
+ next unless ready
296
+
297
+ ready.each do |reader|
298
+ chunk = begin
299
+ reader.read_nonblock(65_536)
300
+ rescue IOError # EOFError is one of these -- the worker finished and closed its end.
301
+ nil
302
+ rescue IO::WaitReadable
303
+ next
304
+ end
305
+
306
+ if chunk.nil?
307
+ open_readers.delete(reader)
308
+ reader.close unless reader.closed?
309
+ next
310
+ end
311
+
312
+ buffers[reader] << chunk
313
+ extract(buffers[reader]).each do |kind, body|
314
+ case kind
315
+ when :result
316
+ result = Result.from_h(body)
317
+ collected << result
318
+ @reporter.record(result)
319
+ when :coverage
320
+ @worker_coverage = Constable::Coverage.merge_raw(@worker_coverage, body)
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ collected
327
+ end
328
+
329
+ def extract(buffer)
330
+ out = []
331
+ loop do
332
+ break if buffer.bytesize < 4
333
+
334
+ size = buffer.byteslice(0, 4).unpack1("N")
335
+ break if buffer.bytesize < 4 + size
336
+
337
+ payload = buffer.byteslice(4, size)
338
+ rest = buffer.byteslice(4 + size, buffer.bytesize - 4 - size)
339
+ buffer.replace(rest || String.new(encoding: Encoding::BINARY))
340
+ out << Marshal.load(payload) # rubocop:disable Security/MarshalLoad -- our own pipe
341
+ end
342
+ out
343
+ end
344
+
345
+ # Longest-processing-time-first: the slowest tests are handed out before the quick ones,
346
+ # so no worker is left holding a three-second test after everyone else has finished.
347
+ def balance(items, count)
348
+ index = duration_index
349
+ buckets = Array.new(count) { [] }
350
+ loads = Array.new(count, 0.0)
351
+
352
+ items.sort_by { |item| -index.fetch(item.identity, 0.0) }.each do |item|
353
+ slot = loads.index(loads.min)
354
+ buckets[slot] << item
355
+ loads[slot] += index.fetch(item.identity, 0.05)
356
+ end
357
+ buckets.reject(&:empty?)
358
+ end
359
+
360
+ def duration_index
361
+ @duration_index ||= begin
362
+ @storage.duration_index
363
+ rescue StandardError
364
+ {}
365
+ end
366
+ end
367
+
368
+ # --- running one item ------------------------------------------------------
369
+
370
+ def run_item(item)
371
+ item.cold? ? run_cold(item) : [run_native(item)]
372
+ end
373
+
374
+ def run_cold(item)
375
+ warnings_before = Constable.warnings.size
376
+ # The seed goes in, not just onto the results: Minitest randomizes its own method
377
+ # order, so passing it is what makes `constable test PATH --seed N` actually replay.
378
+ results = ColdCase.run_file(item.path, config: @config, seed: @seed)
379
+ raised = Constable.warnings[warnings_before..] || []
380
+
381
+ results.each { |r| r.seed = @seed }
382
+
383
+ # A cold case warns once per file, not once per test, so the warning rides home on
384
+ # the first result rather than being repeated on all of them.
385
+ results.first&.warnings&.concat(raised)
386
+ results
387
+ rescue StandardError => e
388
+ [Result.new(
389
+ identity: Identity.for_cold_case(item.path, "load"),
390
+ case_name: File.basename(item.path),
391
+ description: "failed to load",
392
+ file: item.path.to_s.delete_prefix("#{Constable.root}/"),
393
+ line: 0,
394
+ kind: :cold,
395
+ status: :errored,
396
+ failure: Failure.from_exception(e)
397
+ ).tap { |r| r.seed = @seed }]
398
+ end
399
+
400
+ def run_native(item)
401
+ investigation = item.investigation
402
+ jail_entry = docket_snapshot[investigation.identity]
403
+
404
+ return run_jailed_setup(investigation, jail_entry) if jail_entry&.jailed? && !jail_run?
405
+
406
+ result = execute_investigation(investigation)
407
+ result.seed = @seed
408
+ result
409
+ end
410
+
411
+ # A jailed test still runs its briefing and witnesses -- only the investigate body is
412
+ # skipped -- so setup rot surfaces on the next ordinary run rather than lying in wait
413
+ # until someone gets around to `constable jail run`.
414
+ def run_jailed_setup(investigation, entry)
415
+ started = monotonic
416
+ failure = nil
417
+
418
+ instance = Case.constable_instance_for(investigation)
419
+ begin
420
+ # Setup without a body, but with its teardown -- skipping the body is the point of
421
+ # the jail; skipping the cleanup would just leave the request session, the browser
422
+ # or whatever else a teardown releases open for whatever runs next.
423
+ Isolation.with_rollback(investigation.tier) do
424
+ raised = nil
425
+ begin
426
+ instance.run_setup(investigation)
427
+ rescue StandardError => e
428
+ raised = e
429
+ end
430
+ # On its own line, not behind an ||=: teardown has to run whether or not setup
431
+ # got that far, and the first failure is still the one worth reporting.
432
+ teardown_failure = instance.run_teardown
433
+ raised ||= teardown_failure
434
+ raise raised if raised
435
+ end
436
+ rescue StandardError => e
437
+ failure = Failure.from_exception(e, context: "setup for a jailed test still runs, and it failed")
438
+ ensure
439
+ teardown(instance)
440
+ end
441
+
442
+ result = Result.from_investigation(
443
+ investigation,
444
+ status: :jailed,
445
+ duration: monotonic - started,
446
+ failure: failure
447
+ )
448
+ result.jail_reason = entry.reason
449
+ result.times_jailed = entry.times_jailed
450
+ result.seed = @seed
451
+ result
452
+ end
453
+
454
+ def execute_investigation(investigation)
455
+ started = monotonic
456
+ before = leak_check? ? Isolation.snapshot : nil
457
+ warnings_before = Constable.warnings.size
458
+ failure = nil
459
+ status = :passed
460
+
461
+ instance = Case.constable_instance_for(investigation)
462
+
463
+ begin
464
+ # run_investigation is the whole lifecycle: before_setup, briefings, body,
465
+ # teardowns, after_teardown. The teardown half runs inside the rollback whether or
466
+ # not the body raised, and a teardown that raises never masks the body's failure.
467
+ Isolation.with_rollback(investigation.tier) { instance.run_investigation(investigation) }
468
+ rescue AssertionFailed => e
469
+ status = :failed
470
+ failure = Failure.from_exception(e, context: e.context)
471
+ rescue StandardError => e
472
+ status = :errored
473
+ failure = Failure.from_exception(e)
474
+ ensure
475
+ # Time stays frozen and the network stays blocked only for the life of one
476
+ # investigation. Whatever the outcome, the next one starts from clean ground.
477
+ teardown(instance)
478
+ end
479
+
480
+ duration = monotonic - started
481
+ raised = Constable.warnings[warnings_before..] || []
482
+
483
+ if before
484
+ leaks = Isolation.diff(before, Isolation.snapshot)
485
+ if leaks.any?
486
+ Constable.warn!(
487
+ "state leaked out of this investigation: #{leaks.join("; ")}",
488
+ location: investigation.location,
489
+ kind: :leak
490
+ )
491
+ end
492
+ end
493
+
494
+ Result.from_investigation(
495
+ investigation,
496
+ status: status,
497
+ duration: duration,
498
+ failure: failure,
499
+ warnings: Constable.warnings[warnings_before..] || raised
500
+ )
501
+ end
502
+
503
+ # The leak check walks every user class's class variables, which is worth it per test
504
+ # but not worth it per test in a hot parallel loop on a huge suite.
505
+ def leak_check?
506
+ return @leak_check if defined?(@leak_check)
507
+
508
+ @leak_check = ENV["CONSTABLE_LEAK_CHECK"] != "0"
509
+ end
510
+
511
+ # --- adjudication ----------------------------------------------------------
512
+
513
+ # Turns raw pass/fail into the verdict the build acts on: warrants decide whether a
514
+ # failure is even real, then jail decides whether it blocks.
515
+ def adjudicate(raw)
516
+ raw.map do |result|
517
+ decided = warrants.adjudicate(
518
+ result,
519
+ requested: @warrants_requested,
520
+ subject: investigation_for(result.identity)
521
+ ) { |subject, _attempt| rerun_in_isolation(subject) }
522
+
523
+ jail_run? ? decided : jail.adjudicate(decided, jail_mode: jail_mode?)
524
+ end
525
+ end
526
+
527
+ def investigation_for(identity)
528
+ @investigation_index ||= Constable.registry.investigations.to_h { |inv| [inv.identity, inv] }
529
+ @investigation_index[identity]
530
+ end
531
+
532
+ # One test, run again from scratch, so a warrant's retries measure the test rather than
533
+ # whatever the rest of the suite left lying around.
534
+ def rerun_in_isolation(subject)
535
+ investigation = subject.is_a?(Investigation) ? subject : investigation_for(subject.to_s)
536
+ return nil unless investigation
537
+
538
+ execute_investigation(investigation)
539
+ end
540
+
541
+ def jail
542
+ @jail ||= Jail.new(config: @config, storage: @storage)
543
+ end
544
+
545
+ # The docket, read once in the parent and inherited by every worker through fork.
546
+ # A worker that queried it directly would be reaching into a database handle it does
547
+ # not own -- SQLite is explicit that a connection must not cross a fork -- and the
548
+ # answer cannot change mid-run anyway.
549
+ def docket_snapshot
550
+ @docket_snapshot ||= jail.entries.to_h { |entry| [entry.identity, entry] }
551
+ rescue StandardError
552
+ {}
553
+ end
554
+
555
+ def warrants
556
+ @warrants ||= Warrants.new(config: @config, storage: @storage)
557
+ end
558
+
559
+ def order_audit
560
+ @order_audit ||= OrderAudit.new(config: @config, storage: @storage)
561
+ end
562
+
563
+ # --- persistence and reporting --------------------------------------------
564
+
565
+ def persist(run_id, results, coverage_report)
566
+ results.each do |result|
567
+ @storage.record_result(run_id, result)
568
+ @storage.record_duration(result.identity, result.duration)
569
+ end
570
+
571
+ Constable::Coverage.record!(coverage_report, run_id, storage: @storage) if coverage_report
572
+
573
+ @storage.finish_run(run_id, totals: totals(results))
574
+ rescue StandardError => e
575
+ Constable.warn!("could not write to the blotter: #{e.message}", kind: :storage)
576
+ end
577
+
578
+ def totals(results)
579
+ {
580
+ total: results.size,
581
+ passed: results.count(&:passed?),
582
+ failed: results.count(&:failed?),
583
+ jailed: results.count(&:jailed?),
584
+ warranted: results.count(&:warranted?)
585
+ }
586
+ end
587
+
588
+ # The blotter's identities as they stood before this run wrote anything. Taken up
589
+ # front, because rename detection compares "what we used to know" against "what we
590
+ # just saw", and persisting first would make every test look familiar.
591
+ def known_before
592
+ @known_before ||= Array(@storage.known_identities).map(&:to_s)
593
+ rescue StandardError
594
+ []
595
+ end
596
+
597
+ # A test whose body changed gets a new identity, so an old one vanishing the same run
598
+ # a similar new one appears is usually a rename plus a tweak, not two separate edits.
599
+ # Confirming the relink also carries any jail or warrant entry across, which is what
600
+ # stops a fixed-but-still-jailed test from haunting the docket forever.
601
+ def rename_suggestions(results)
602
+ return [] unless @selection.full?
603
+
604
+ seen = results.map(&:identity)
605
+ vanished = known_before - seen
606
+ fresh = results.reject { |r| known_before.include?(r.identity) }
607
+ return [] if vanished.empty? || fresh.empty?
608
+
609
+ labels = vanished.to_h { |identity| [identity, label_for(identity)] }
610
+
611
+ fresh.filter_map do |result|
612
+ match = vanished.max_by { |old| similarity(labels[old], result.description) }
613
+ next if match.nil?
614
+
615
+ score = similarity(labels[match], result.description)
616
+ next if score < 0.5
617
+
618
+ suggestion = {
619
+ relinked: false, score: score,
620
+ old_identity: match, new_identity: result.identity,
621
+ old_label: labels[match], new_label: result.display_label
622
+ }
623
+
624
+ if @config.auto_relink? && score >= 0.85
625
+ @storage.relink(match, result.identity)
626
+ suggestion[:relinked] = true
627
+ end
628
+
629
+ suggestion
630
+ end
631
+ rescue StandardError
632
+ []
633
+ end
634
+
635
+ # The blotter keeps a display label alongside each identity purely so a vanished test
636
+ # can still be named in a suggestion.
637
+ def label_for(identity)
638
+ row = @storage.history_for(identity, limit: 1).first
639
+ return identity unless row
640
+
641
+ [row[:case_name], row[:description]].compact.join(" ").strip
642
+ rescue StandardError
643
+ identity
644
+ end
645
+
646
+ # Cheap token overlap -- enough to spot "creates a user" vs "creates a user with valid
647
+ # params" without pulling in a Levenshtein dependency for a hint that a human confirms.
648
+ def similarity(left, right)
649
+ a = left.to_s.downcase.scan(/\w+/)
650
+ b = right.to_s.downcase.scan(/\w+/)
651
+ return 0.0 if a.empty? || b.empty?
652
+
653
+ (a & b).size.to_f / [a.size, b.size].max
654
+ end
655
+
656
+ def exit_status(results, coverage_report)
657
+ return 1 if results.any?(&:failed?)
658
+ return 1 if @config.fail_on_warnings? && new_warnings.any?
659
+ return 1 if coverage_report && !coverage_report.meets_threshold?(@config)
660
+
661
+ 0
662
+ end
663
+
664
+ def new_warnings
665
+ Constable.warnings[@warnings_before..] || []
666
+ end
667
+
668
+ # Combines what this process saw with everything the workers sent back. Cold cases
669
+ # count here too -- Coverage works at the process level, so it never knew which engine
670
+ # ran the code.
671
+ def build_coverage_report
672
+ merged = Constable::Coverage.merge_raw(Constable::Coverage.peek_raw, @worker_coverage)
673
+ report = Constable::Coverage.build_report(
674
+ merged,
675
+ config: @config,
676
+ root: Constable.root,
677
+ # Cold cases contribute their numbers but are never held to the diff gate, so a
678
+ # run carrying nothing else must not be gated at all.
679
+ gate: !@selection.unsafe_only?
680
+ )
681
+ Constable::Coverage.abort!
682
+ report
683
+ end
684
+
685
+ # The last thing to happen to an instance, after its own teardowns and after the
686
+ # transaction is gone: the runtime DSL's global state -- a frozen clock, a barred
687
+ # network, a half-open unsafe block -- belongs to the process, not to the test, so it
688
+ # is released once everything that might still depend on it has finished.
689
+ def teardown(instance)
690
+ instance._constable_dsl_teardown if instance.respond_to?(:_constable_dsl_teardown)
691
+ rescue StandardError => e
692
+ Constable.warn!("teardown after an investigation raised: #{e.message}", kind: :teardown)
693
+ end
694
+
695
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
696
+ end
697
+ end