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.
data/lib/constable/cli.rb CHANGED
@@ -17,7 +17,10 @@ module Constable
17
17
  # wrong", and CI needs to. Commands return a status; this turns it into one.
18
18
  def self.dispatch!(argv)
19
19
  start(argv)
20
- rescue Thor::Error => e
20
+ # Thor::Error is a malformed command; Constable::Error is a wrong path, an unknown
21
+ # tier, an unreadable config. Both are the user's mistake rather than a crash, and
22
+ # both deserve one sentence and a usage status instead of a backtrace.
23
+ rescue Thor::Error, Constable::Error => e
21
24
  warn e.message
22
25
  exit(EXIT_USAGE)
23
26
  rescue Interrupt
@@ -42,6 +45,9 @@ module Constable
42
45
  option :workers, type: :numeric, desc: "Parallel workers (default: config, or auto)"
43
46
  option :verbose, type: :boolean, default: false, desc: "Stream log/test.log to stdout"
44
47
  option :tier, type: :string, desc: "Run one tier only: unit, integration or system"
48
+ option :output, type: :string, desc: "Live stream detail: concise (default) or expanded"
49
+ option :expanded, type: :boolean, default: false, desc: "Shorthand for --output=expanded"
50
+ option :concise, type: :boolean, default: false, desc: "Shorthand for --output=concise"
45
51
  def test(*paths)
46
52
  config = load_config
47
53
  LogRouter.route!(verbose: options[:verbose])
@@ -299,7 +305,9 @@ module Constable
299
305
  no_commands do
300
306
  def act(locator)
301
307
  jail = Jail.new(config: Constable.config, storage: Constable.storage)
302
- identity = jail.identity_for(locator)
308
+ refuse_ambiguous(locator, jail.candidates(locator), "on the docket")
309
+
310
+ identity = jail.resolve(locator)
303
311
  unless identity
304
312
  warn "Nothing on the docket at #{locator}"
305
313
  exit(EXIT_USAGE)
@@ -307,6 +315,18 @@ module Constable
307
315
  yield jail, identity
308
316
  end
309
317
 
318
+ # A bare path naming several tests is a question. Answer it with the list rather
319
+ # than acting on whichever row the database happened to return first.
320
+ def refuse_ambiguous(locator, candidates, noun)
321
+ return if candidates.size <= 1
322
+
323
+ warn "#{locator} matches #{candidates.size} tests #{noun}. Name one:"
324
+ candidates.sort_by { |entry| entry.line.to_i }.each do |entry|
325
+ warn " #{entry.location} #{entry.label}"
326
+ end
327
+ exit(EXIT_USAGE)
328
+ end
329
+
310
330
  def repeat_note(entry)
311
331
  count = entry.times_jailed
312
332
  count > 1 ? " (this is its #{Reporter.ordinalize(count)} time in jail)" : ""
@@ -344,12 +364,14 @@ module Constable
344
364
  desc "release PATH:LINE", "Clear a warrant by hand"
345
365
  def release(locator)
346
366
  warrants = Warrants.new(config: Constable.config, storage: Constable.storage)
347
- identity = warrants.identity_for(locator)
367
+ JailCommand.new.send(:refuse_ambiguous, locator, warrants.candidates(locator), "under warrant")
368
+
369
+ identity = warrants.resolve(locator)
348
370
  unless identity
349
371
  warn "No warrant at #{locator}"
350
372
  exit(EXIT_USAGE)
351
373
  end
352
- warrants.clear(identity)
374
+ warrants.release(identity)
353
375
  say "Warrant cleared."
354
376
  end
355
377
  end
@@ -377,6 +399,48 @@ module Constable
377
399
  end
378
400
  end
379
401
 
402
+ desc "prune", "Forget docket rows and warrants for tests that no longer exist"
403
+ long_desc <<~DESC
404
+ A test's key is a content hash of its body, so editing a jailed test gives it a new
405
+ identity and leaves the old row behind -- pointing at a file:line that may now hold
406
+ something else. That is identity working as designed; this is the broom.
407
+
408
+ Loads the whole suite first, because which tests still exist is only knowable once
409
+ every case file has been read. Cold cases are never pruned while their file exists:
410
+ their tests cannot be enumerated without running their own engine, so silence about
411
+ them means nothing.
412
+ DESC
413
+ option :dry_run, type: :boolean, default: false, desc: "List what would go, change nothing"
414
+ def prune
415
+ config = load_config
416
+ known = Runner.identities(config: config)
417
+ cold = ->(path) { config.cold_case?(path) }
418
+
419
+ jail = Jail.new(config: config, storage: Constable.storage)
420
+ warrants = Warrants.new(config: config, storage: Constable.storage)
421
+
422
+ stale_rows = jail.stale_entries(known, cold_case: cold)
423
+ stale_warrants = warrants.stale_entries(known, cold_case: cold)
424
+
425
+ if stale_rows.empty? && stale_warrants.empty?
426
+ say "Nothing to prune -- every row on the docket still names a test that exists."
427
+ return 0
428
+ end
429
+
430
+ report_prunable("docket", stale_rows)
431
+ report_prunable("warrants", stale_warrants)
432
+
433
+ if options[:dry_run]
434
+ say "Dry run: nothing was changed."
435
+ return 0
436
+ end
437
+
438
+ stale_rows.each { |entry| jail.forget(entry.identity) }
439
+ stale_warrants.each { |entry| warrants.release(entry.identity) }
440
+ say "Pruned #{stale_rows.size + stale_warrants.size} row(s). Flake history is left alone."
441
+ 0
442
+ end
443
+
380
444
  desc "jail SUBCOMMAND", "The jail docket"
381
445
  subcommand "jail", JailCommand
382
446
 
@@ -387,6 +451,16 @@ module Constable
387
451
  subcommand "history", HistoryCommand
388
452
 
389
453
  no_commands do
454
+ def report_prunable(heading, entries)
455
+ return if entries.empty?
456
+
457
+ say "#{heading} (#{entries.size}):"
458
+ entries.sort_by { |entry| [entry.file.to_s, entry.line.to_i] }.each do |entry|
459
+ say " #{entry.location} #{entry.label}"
460
+ end
461
+ say ""
462
+ end
463
+
390
464
  def load_config
391
465
  Constable.config
392
466
  end
@@ -400,7 +474,18 @@ module Constable
400
474
  end
401
475
 
402
476
  def reporter(config)
403
- Reporter.new(io: $stdout, config: config, color: color?)
477
+ Reporter.new(io: $stdout, config: config, color: color?, mode: output_mode)
478
+ end
479
+
480
+ # nil means "the config file decides". The explicit --output wins over the two
481
+ # shorthands, and --concise wins over --expanded if somebody passes both -- the
482
+ # quieter of two contradictory instructions is the safer one to obey.
483
+ def output_mode
484
+ return options[:output] if options[:output]
485
+ return :concise if options[:concise]
486
+ return :expanded if options[:expanded]
487
+
488
+ nil
404
489
  end
405
490
 
406
491
  def say_table(heading, entries)
@@ -120,7 +120,12 @@ module Constable
120
120
  with_engine do
121
121
  collector = Collector.new
122
122
  load_error = capture_load(path)
123
- run_world(collector) unless load_error
123
+ unless load_error
124
+ # After the file has loaded -- that load is what registers them -- and
125
+ # before its examples run. See #run_pending_before_suite_hooks.
126
+ run_pending_before_suite_hooks(::RSpec.configuration)
127
+ run_world(collector)
128
+ end
124
129
 
125
130
  ColdCase.warn_for_file(path, base_class_name, collector.examples.size, config: config)
126
131
  results = build_results(path, collector.examples, config: config, seed: seed,
@@ -134,14 +139,101 @@ module Constable
134
139
  # configuration -- which also means any RSpec.configure hooks a rails_helper
135
140
  # installed are gone, so this is a teardown call, not a between-files call.
136
141
  def reset_engine!
142
+ # Inside the global-state swap, not outside it. Building a SuiteHookContext
143
+ # makes rspec-core lazily construct a world and a configuration, so running
144
+ # these hooks bare would leave both behind in a host process that had none --
145
+ # exactly the leak #with_engine exists to prevent.
146
+ with_engine { run_after_suite_hooks! } if after_suite_hooks_pending?
147
+
137
148
  @session_world = nil
138
149
  @session_configuration = nil
139
150
  @session_prepared = false
151
+ @ran_before_suite_hooks = nil
140
152
  nil
141
153
  end
142
154
 
143
155
  private
144
156
 
157
+ # --- Suite hooks ------------------------------------------------------------
158
+ #
159
+ # RSpec's own Runner wraps its group loop in `configuration.with_suite_hooks`.
160
+ # We drive the groups directly (see #run_world), so without this a cold case
161
+ # never fires `before(:suite)` -- and that is exactly where webmock/rspec calls
162
+ # `WebMock.enable!`, where VCR and DatabaseCleaner install themselves, and where
163
+ # SimpleCov starts. Skipping them fails *open*: a spec that stubs HTTP opens a
164
+ # real socket instead of erroring, which is the worst direction for a testing
165
+ # tool to be wrong in.
166
+ #
167
+ # `with_suite_hooks` itself is the wrong shape here. It is a bracket around one
168
+ # block, but "suite" means the whole run rather than one file: wrapping each file
169
+ # would fire `after(:suite)` after file one and hand file two the wreckage. Nor
170
+ # can the hooks all be run up front, because a legacy file's own
171
+ # `require "rails_helper"` is what registers them -- before the first load there
172
+ # is nothing to run.
173
+ #
174
+ # So each hook runs exactly once, the first time we see it: after a file has
175
+ # loaded, before its examples. `after(:suite)` runs once, from #reset_engine!.
176
+ def run_pending_before_suite_hooks(configuration)
177
+ pending = suite_hooks(configuration, :@before_suite_hooks) - ran_before_suite_hooks
178
+ return if pending.empty?
179
+
180
+ ran_before_suite_hooks.concat(pending)
181
+ invoke_suite_hooks(configuration, "a `before(:suite)` hook", pending,
182
+ scope: :before_suite_hook)
183
+ end
184
+
185
+ # Only worth running if we ever ran the matching before(:suite) half -- otherwise
186
+ # this is a teardown for setup that never happened.
187
+ def run_after_suite_hooks!
188
+ configuration = @session_configuration
189
+ return if configuration.nil? || ran_before_suite_hooks.empty?
190
+
191
+ hooks = suite_hooks(configuration, :@after_suite_hooks)
192
+ return if hooks.empty?
193
+
194
+ invoke_suite_hooks(configuration, "an `after(:suite)` hook", hooks,
195
+ scope: :after_suite_hook)
196
+ end
197
+
198
+ def ran_before_suite_hooks
199
+ @ran_before_suite_hooks ||= []
200
+ end
201
+
202
+ # Nothing was set up, so there is nothing to tear down -- and no reason to build
203
+ # an RSpec world to discover that.
204
+ def after_suite_hooks_pending?
205
+ !@session_configuration.nil? &&
206
+ ran_before_suite_hooks.any? &&
207
+ suite_hooks(@session_configuration, :@after_suite_hooks).any?
208
+ end
209
+
210
+ # RSpec keeps these in plain ivars with no public reader. Read them defensively:
211
+ # a missing ivar means a version that stores them elsewhere, and running no suite
212
+ # hooks is the behavior we already had.
213
+ def suite_hooks(configuration, ivar)
214
+ return [] unless configuration.respond_to?(:instance_variable_defined?)
215
+ return [] unless configuration.instance_variable_defined?(ivar)
216
+
217
+ Array(configuration.instance_variable_get(ivar))
218
+ end
219
+
220
+ # `run_suite_hooks` is private on Configuration, and it is the part that builds a
221
+ # SuiteHookContext and keeps one failing before-hook from running the rest. Use it
222
+ # when it is there, and fall back to driving the hooks ourselves when it is not.
223
+ def invoke_suite_hooks(configuration, description, hooks, scope:)
224
+ previous = ::RSpec.current_scope if ::RSpec.respond_to?(:current_scope)
225
+ ::RSpec.current_scope = scope if ::RSpec.respond_to?(:current_scope=)
226
+
227
+ if configuration.respond_to?(:run_suite_hooks, true)
228
+ configuration.send(:run_suite_hooks, description, hooks)
229
+ else
230
+ context = ::RSpec::Core::SuiteHookContext.new(description, configuration.reporter)
231
+ hooks.each { |hook| hook.run(context) }
232
+ end
233
+ ensure
234
+ ::RSpec.current_scope = previous if previous && ::RSpec.respond_to?(:current_scope=)
235
+ end
236
+
145
237
  # Running RSpec in-process is a global-state problem: RSpec.world holds every
146
238
  # registered example group and RSpec.configuration holds every hook. We swap in a
147
239
  # session world/configuration for the duration of a file and put whatever was
@@ -19,6 +19,7 @@ module Constable
19
19
  "coverage_threshold" => 90,
20
20
  "coverage_html" => false,
21
21
  "fail_on_warnings" => false,
22
+ "output" => "concise",
22
23
  "parallel_workers" => "auto",
23
24
  "tiers" => {
24
25
  "unit" => "test/cases/models/**/*",
@@ -33,8 +34,27 @@ module Constable
33
34
 
34
35
  def self.load(root: Constable.root, overrides: {})
35
36
  path = File.join(root.to_s, CONFIG_PATH)
36
- raw = File.exist?(path) ? (YAML.safe_load_file(path, permitted_classes: [], aliases: true) || {}) : {}
37
- new(raw, root: root, overrides: overrides)
37
+ new(read_file(path), root: root, overrides: overrides)
38
+ end
39
+
40
+ # A typo in config.yml used to surface as a raw Psych::SyntaxError, or -- for a file
41
+ # that parsed but wasn't a mapping -- as "no implicit conversion of Array into Hash"
42
+ # from somewhere deep in the merge. Neither says which file to open.
43
+ def self.read_file(path)
44
+ return {} unless File.exist?(path)
45
+
46
+ loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: true)
47
+ return {} if loaded.nil?
48
+
49
+ unless loaded.is_a?(Hash)
50
+ raise Constable::Error,
51
+ "#{CONFIG_PATH} must be a mapping of settings, but it parsed as " \
52
+ "#{loaded.class.name.downcase}. Check the indentation."
53
+ end
54
+
55
+ loaded
56
+ rescue Psych::SyntaxError => e
57
+ raise Constable::Error, "#{CONFIG_PATH} is not valid YAML: #{e.problem} at line #{e.line}."
38
58
  end
39
59
 
40
60
  def initialize(raw = {}, root: Constable.root, overrides: {})
@@ -43,23 +63,83 @@ module Constable
43
63
  @raw = deep_merge(@raw, stringify(overrides || {}))
44
64
  end
45
65
 
66
+ # Merges values set in Ruby (Constable.configure) over the ones read from the file.
67
+ # Called once, after case_helper.rb has been loaded -- which is the first moment those
68
+ # values exist.
69
+ def apply_overrides!(overrides)
70
+ return self if overrides.nil? || overrides.empty?
71
+
72
+ @raw = deep_merge(@raw, stringify(overrides))
73
+ self
74
+ end
75
+
46
76
  def cold_cases = Array(@raw["cold_cases"])
47
77
  def warrants? = truthy(@raw["warrants"])
48
- def warrant_retries = @raw["warrant_retries"].to_i
78
+ # Negative retries are a typo for "off", not an instruction to count backwards.
79
+ def warrant_retries = [@raw["warrant_retries"].to_i, 0].max
49
80
  def auto_relink? = truthy(@raw["auto_relink"])
50
- def parole_period = @raw["parole_period"].to_i
81
+
82
+ # Clamped here rather than at each call site: Jail already refused a period of zero
83
+ # ("release on sight" is not parole), but the reporter read the raw value and would
84
+ # cheerfully print "Day 1 of 0 -- 0 clean runs to go" while the docket waited for 10.
85
+ def parole_period
86
+ period = @raw["parole_period"].to_i
87
+ period.positive? ? period : DEFAULTS["parole_period"]
88
+ end
89
+
51
90
  def coverage? = truthy(@raw["coverage"])
52
- def coverage_threshold = @raw["coverage_threshold"].to_i
91
+ # Clamped: a threshold above 100 is a build that can never go green, and a negative
92
+ # one is a gate that can never fail. Both are typos rather than intentions.
93
+ def coverage_threshold = @raw["coverage_threshold"].to_i.clamp(0, 100)
53
94
  def coverage_html? = truthy(@raw["coverage_html"])
54
95
  def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
55
96
  def tiers = @raw["tiers"] || {}
97
+
98
+ # How much the live stream says while the suite runs.
99
+ #
100
+ # concise one glyph per test, grouped into a run per case. The default: a
101
+ # 1,000-test suite stays inside one screen.
102
+ # expanded a line per test -- glyph, name, duration. Slower to read in bulk,
103
+ # but you can see which test is hanging without waiting for the summary.
104
+ #
105
+ # The summary itself is identical either way. This only affects the live stream.
106
+ OUTPUT_MODES = %i[concise expanded].freeze
107
+
108
+ def output_mode
109
+ mode = @raw["output"].to_s.strip.downcase.to_sym
110
+ OUTPUT_MODES.include?(mode) ? mode : :concise
111
+ end
112
+
113
+ def expanded_output? = output_mode == :expanded
56
114
  def storage = @raw["storage"] || {}
57
115
 
58
- def storage_adapter = (storage["adapter"] || "sqlite").to_s
59
- def storage_url = storage["url"]
116
+ # Storage is the one setting that cannot be written in Ruby -- the blotter is opened
117
+ # before test/case_helper.rb loads, so `constable jail` and `constable status` can read
118
+ # the docket without booting the app. That would leave .constable/config.yml mandatory
119
+ # for anyone not on the default SQLite, so the environment can say it instead:
120
+ #
121
+ # CONSTABLE_STORAGE_URL=postgres://user:pass@host/constable_metadata
122
+ # CONSTABLE_STORAGE_PATH=/var/lib/constable/blotter.sqlite3
123
+ #
124
+ # Which is also the right shape for CI, where the value is a secret and differs per
125
+ # machine. The environment wins over the file, as an environment usually should.
126
+ def storage_url = env_or("CONSTABLE_STORAGE_URL", storage["url"])
127
+
128
+ def storage_adapter
129
+ explicit = env_or("CONSTABLE_STORAGE_ADAPTER", storage["adapter"])
130
+ return explicit.to_s if explicit
131
+
132
+ # A URL with no adapter names its own: postgres://... can only mean postgres.
133
+ scheme = storage_url.to_s[%r{\A([a-z][a-z0-9+.-]*)://}, 1]
134
+ return "postgres" if %w[postgres postgresql].include?(scheme)
135
+ return "mysql" if %w[mysql mysql2].include?(scheme)
136
+
137
+ "sqlite"
138
+ end
60
139
 
61
140
  def storage_path
62
- path = storage["path"] || DEFAULTS["storage"]["path"]
141
+ path = env_or("CONSTABLE_STORAGE_PATH", storage["path"]) ||
142
+ DEFAULTS["storage"]["path"]
63
143
  File.absolute_path?(path) ? path : File.join(@root, path)
64
144
  end
65
145
 
@@ -97,6 +177,13 @@ module Constable
97
177
 
98
178
  private
99
179
 
180
+ # An empty environment variable is not a value. `CONSTABLE_STORAGE_URL=` in a CI
181
+ # config means "unset", not "connect to the empty string".
182
+ def env_or(name, fallback)
183
+ value = ENV.fetch(name, nil)
184
+ value.nil? || value.strip.empty? ? fallback : value.strip
185
+ end
186
+
100
187
  def truthy(value)
101
188
  return false if value.nil? || value == false
102
189
  return false if value.to_s.strip.downcase == "false"
@@ -33,6 +33,25 @@ module Constable
33
33
  digest("cold:#{relative}:#{description}")
34
34
  end
35
35
 
36
+ # Two investigations with byte-identical bodies hash to the same key, which would
37
+ # make them one test as far as the blotter is concerned: jail one and the other goes
38
+ # with it, and their flake histories merge into a single misleading record.
39
+ #
40
+ # Bodies repeat more often than the "content hash" idea suggests --
41
+ # `attest(build(:thing, name: nil)).not_to be_valid` is the same handful of tokens in
42
+ # every model case, and the model generator writes an identical first investigation
43
+ # into every file it touches. So the collision is routine, not theoretical.
44
+ #
45
+ # When it happens, the colliding tests are re-keyed on the body *plus* their class
46
+ # and description. The rename-survival promise is weaker for exactly those tests --
47
+ # rewording one of them starts its history over -- which is the right trade: a
48
+ # history that belongs to two tests at once is worse than one that resets.
49
+ def disambiguate(base, case_name:, description:, ordinal: nil)
50
+ key = "#{base}:#{case_name}:#{description}"
51
+ key = "#{key}:#{ordinal}" unless ordinal.nil?
52
+ digest(key)
53
+ end
54
+
36
55
  def digest(string)
37
56
  Digest::SHA256.hexdigest(string)[0, LENGTH]
38
57
  end
@@ -508,6 +508,8 @@ module Constable
508
508
  "stub object.")
509
509
  end
510
510
 
511
+ flag_unknown_matcher(node, args.first) if %i[to not_to to_not].include?(name)
512
+
511
513
  if MOCK_ENTRY_POINTS.include?(name) && receiver.nil?
512
514
  note_untouched(:rspec_mocks, node,
513
515
  "`#{first_line(node)}` uses rspec-mocks. Constable has no equivalent; convert it by hand.")
@@ -532,6 +534,14 @@ module Constable
532
534
  end
533
535
  when :should, :should_not
534
536
  flag(:should_syntax, node, "`#{name}` is RSpec's monkey-patched expectation syntax. Use `attest(...).to`.")
537
+ when :helper
538
+ if receiver.nil? && args.empty?
539
+ flag(:rspec_helper_object, node,
540
+ "`helper` is RSpec's helper-spec proxy and has no Constable equivalent. " \
541
+ "A helper is a plain module: `include YourHelper` in the case and call " \
542
+ "the method directly -- which is exactly what `rails generate helper` " \
543
+ "writes.")
544
+ end
535
545
  when :described_class
536
546
  if receiver.nil?
537
547
  flag(:described_class, node,
@@ -558,6 +568,36 @@ module Constable
558
568
  record_converted(:helper_require, node, value, replaced)
559
569
  end
560
570
 
571
+ # Constable's matcher set is deliberately smaller than RSpec's, and the rewrite
572
+ # carries any matcher name straight across. Without this check the first anyone
573
+ # hears about it is a NoMethodError at runtime, naming the matcher -- or worse, an
574
+ # internal deferred class -- rather than the line that needs a decision.
575
+ def flag_unknown_matcher(node, matcher_node)
576
+ name = root_matcher_name(matcher_node)
577
+ return if name.nil?
578
+ return if Constable::Matchers.matcher_name?(name)
579
+
580
+ flag(:unknown_matcher, node,
581
+ "`#{name}` is not one of Constable's matchers. Define it in " \
582
+ "test/support/matchers.rb with `Constable::Matchers.define(:#{name})`, or " \
583
+ "rewrite the assertion.")
584
+ end
585
+
586
+ # `contain_exactly(1, 2)` -> :contain_exactly. `be_within(0.5).of(10)` -> :be_within.
587
+ # `change { x }.by(1)` -> :change. Anything that is not ultimately a bare method
588
+ # call -- a local variable holding a matcher, a constant -- returns nil and is left
589
+ # alone, because we cannot know what it is.
590
+ def root_matcher_name(node)
591
+ return nil unless node.is_a?(::Parser::AST::Node)
592
+
593
+ current = node
594
+ current = current.children.first while current.type == :send && current.children.first
595
+
596
+ return nil unless current.type == :send && current.children.first.nil?
597
+
598
+ current.children[1]
599
+ end
600
+
561
601
  def mock_expectation?(node)
562
602
  return false unless node.is_a?(::Parser::AST::Node)
563
603
 
@@ -38,6 +38,16 @@ module Constable
38
38
  @identity ||= Identity.for_block(@block)
39
39
  end
40
40
 
41
+ # Re-keys this investigation because another one has the same body. Called by the
42
+ # registry once the whole suite is loaded, which is the first moment a collision can
43
+ # be seen. See Identity.disambiguate.
44
+ def disambiguate!(ordinal: nil)
45
+ @identity = Identity.disambiguate(identity, case_name: case_name,
46
+ description: full_description,
47
+ ordinal: ordinal)
48
+ self
49
+ end
50
+
41
51
  def location
42
52
  "#{relative_file}:#{@line}"
43
53
  end
@@ -241,6 +241,11 @@ module Constable
241
241
  # `constable jail release PATH:LINE`. Off the docket entirely, no supervision.
242
242
  def release(identity) = @storage.release(identity.to_s) ? true : false
243
243
 
244
+ # Pruning is not release. Release says "this test is trusted again"; prune says "this
245
+ # row is about a test that no longer exists". The distinction matters because release
246
+ # is a judgement someone made and prune is bookkeeping.
247
+ def forget(identity) = @storage.release(identity.to_s) ? true : false
248
+
244
249
  # `jail run` never auto-releases and never auto-paroles. One green run proves nothing;
245
250
  # it only earns a mention. A human reads this list and decides.
246
251
  #
@@ -267,13 +272,20 @@ module Constable
267
272
  # Call it *before* writing the result to flake history (the flip check reads the
268
273
  # previous status) and *after* Warrants has had its say (a warranted result is not a
269
274
  # failure, so it never reaches the docket).
270
- def adjudicate(result, jail_mode: false)
275
+ # `systemic:` says this run failed for a reason that has nothing to do with any
276
+ # individual test -- see Runner#systemic_failure. The failures still stand and the
277
+ # build still goes red, but they are not *evidence*: nothing moves through the state
278
+ # machine, because "the database was locked for the whole run" is not a fact about a
279
+ # test and must not put one on the docket.
280
+ def adjudicate(result, jail_mode: false, systemic: false)
271
281
  return result if result.nil?
272
282
 
273
283
  docket = entry(result.identity)
274
284
 
285
+ return mark_jailed(result) if docket&.jailed?
286
+ return result if systemic
287
+
275
288
  return record_result(result) if docket&.paroled?
276
- return mark_jailed(result) if docket&.jailed?
277
289
  return result unless result.failed?
278
290
 
279
291
  if jail_mode
@@ -331,17 +343,70 @@ module Constable
331
343
  # test that is not on the docket yet.
332
344
  #
333
345
  # Returns an identity String, or nil when nothing matches.
334
- def resolve(target)
346
+ # Every docket row a target could mean.
347
+ #
348
+ # The interesting case is a bare path. "test/cases/users_case.rb" with three tests
349
+ # on the docket is a question, not an instruction: picking one silently acts on a
350
+ # test the user never named -- and not even the first one, since the order is
351
+ # whatever storage returns. Callers ask for the candidates and refuse to guess.
352
+ def candidates(target)
335
353
  text = target.to_s.strip
336
- return nil if text.empty?
337
- return text if identity_like?(text) && entry(text)
354
+ return [] if text.empty?
355
+
356
+ if identity_like?(text) && (row = entry(text))
357
+ return [row]
358
+ end
338
359
 
339
360
  file, line = self.class.split_target(text)
340
- return nil if file.empty?
361
+ return [] if file.empty?
341
362
 
342
363
  matches = entries.select { |e| self.class.same_path?(e.file, file) }
343
364
  matches = matches.select { |e| e.line == line } if line
344
- return matches.first.identity if matches.any?
365
+ matches
366
+ end
367
+
368
+ # Rows whose test no longer exists.
369
+ #
370
+ # A test's key is a content hash of its body, so editing a jailed test gives it a new
371
+ # identity and leaves the old row behind -- pointing at a file:line that may now hold
372
+ # something else entirely. That is content-hash identity working as designed, but over
373
+ # a few months the docket fills with tests nobody can find.
374
+ #
375
+ # `known` is every identity the loaded suite registered, so this is only meaningful
376
+ # after a full load. Two things are deliberately conservative about it:
377
+ #
378
+ # * A known identity is never stale, even when the file recorded beside it is gone.
379
+ # The path is a display label; the identity is the truth. A test that moved file
380
+ # has an out-of-date label, not a missing test.
381
+ # * A cold case is never pruned while its file exists. Cold-case tests cannot be
382
+ # enumerated without running their own engine, so absence from `known` says
383
+ # nothing about them.
384
+ def stale_entries(known, cold_case: nil)
385
+ known = Array(known)
386
+ entries.reject { |entry| known.include?(entry.identity) }
387
+ .select { |entry| gone?(entry, cold_case) }
388
+ end
389
+
390
+ def gone?(entry, cold_case)
391
+ relative = entry.file.to_s
392
+ return true if relative.empty?
393
+
394
+ path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
395
+ return true unless File.exist?(path)
396
+ return false if cold_case&.call(relative)
397
+
398
+ true
399
+ end
400
+
401
+ # An identity String, or nil when nothing matches -- or when more than one does.
402
+ # Ambiguity is the caller's to report, with the candidates in hand.
403
+ def resolve(target)
404
+ matches = candidates(target)
405
+ return matches.first.identity if matches.size == 1
406
+ return nil unless matches.empty?
407
+
408
+ file, line = self.class.split_target(target.to_s.strip)
409
+ return nil if file.empty?
345
410
 
346
411
  self.class.registry_identity(file, line)
347
412
  end