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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Isolation is non-negotiable in native code. Everything that makes one investigation
5
+ # unable to reach another lives here: the rolled-back transaction, the fresh instance,
6
+ # and the leak check that catches what the transaction can't (globals, ENV, class
7
+ # variables -- the state a database rollback never touches).
8
+ module Isolation
9
+ module_function
10
+
11
+ # Whether a database is actually there, and nothing else.
12
+ #
13
+ # An earlier version of this skipped the transaction for the :unit tier, on the theory
14
+ # that a unit test has no database to roll back. That was wrong, and quietly so: the
15
+ # default tier config routes test/cases/models/** to :unit, and Rails model tests are
16
+ # precisely the ones that write rows. Records survived into the next investigation and
17
+ # isolation -- the one guarantee this framework refuses to compromise -- was gone.
18
+ #
19
+ # Not booting the database is the tier's business, decided in case_helper.rb. If a
20
+ # connection exists by the time a test runs, that test gets rolled back. Both things
21
+ # can be true, and only one of them is a promise to the developer.
22
+ def transactional?(_tier = nil)
23
+ return false unless defined?(::ActiveRecord::Base)
24
+
25
+ # `connection_pool.connected?` is false until something has actually checked a
26
+ # connection out, which on the first test of a run is nothing -- so asking it
27
+ # straight leaves the first investigation unwrapped. Checking one out settles the
28
+ # question honestly: it succeeds when there is a database and raises when there
29
+ # isn't.
30
+ ::ActiveRecord::Base.connection_pool.with_connection { true }
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ # Runs the block inside a transaction that is always rolled back, so nothing a test
36
+ # writes survives it. Falls through to a plain yield when there's no database.
37
+ def with_rollback(tier)
38
+ return yield unless transactional?(tier)
39
+
40
+ result = nil
41
+ ::ActiveRecord::Base.transaction(requires_new: true) do
42
+ result = yield
43
+ raise ::ActiveRecord::Rollback
44
+ end
45
+ result
46
+ end
47
+
48
+ # A cheap snapshot of the process-level state a rollback would never restore.
49
+ def snapshot
50
+ {
51
+ globals: global_snapshot,
52
+ env: ENV.to_h,
53
+ class_variables: class_variable_snapshot,
54
+ constants: Object.constants.size
55
+ }
56
+ end
57
+
58
+ # Compares two snapshots and describes what leaked, in the terms a developer can act
59
+ # on. Returns [] when the investigation left the process as it found it.
60
+ def diff(before, after)
61
+ leaks = []
62
+
63
+ added_globals = after[:globals].keys - before[:globals].keys
64
+ changed_globals = (after[:globals].keys & before[:globals].keys).reject do |key|
65
+ after[:globals][key] == before[:globals][key]
66
+ end
67
+ leaks << "set global #{added_globals.join(", ")}" if added_globals.any?
68
+ leaks << "mutated global #{changed_globals.join(", ")}" if changed_globals.any?
69
+
70
+ added_env = after[:env].keys - before[:env].keys
71
+ changed_env = (after[:env].keys & before[:env].keys).reject { |k| after[:env][k] == before[:env][k] }
72
+ leaks << "set ENV #{added_env.join(", ")}" if added_env.any?
73
+ leaks << "mutated ENV #{changed_env.join(", ")}" if changed_env.any?
74
+
75
+ added_cvars = after[:class_variables] - before[:class_variables]
76
+ leaks << "set class variable #{added_cvars.join(", ")}" if added_cvars.any?
77
+
78
+ leaks
79
+ end
80
+
81
+ # $stdout and friends move around legitimately during a run (the reporter captures
82
+ # them), and read-only specials are noise rather than signal. A leak check that cries
83
+ # wolf is worse than no leak check at all: developers learn to scroll past warnings,
84
+ # and then miss the real one.
85
+ # Written as strings rather than %i[] on purpose: a literal "$\\" inside %i[] escapes
86
+ # the following space and silently fuses two entries into one, which quietly drops
87
+ # both from the list.
88
+ IGNORED_GLOBALS = [
89
+ "$stdout", "$stderr", "$stdin", "$stdlog",
90
+ "$!", "$@", "$~", "$&", "$`", "$'", "$+",
91
+ "$1", "$2", "$3", "$4", "$5", "$6", "$7", "$8", "$9",
92
+ "$0", "$PROGRAM_NAME", "$LOAD_PATH", "$LOADED_FEATURES", "$\"", "$:", "$*",
93
+ "$$", "$?", "$,", "$;", "$/", "$\\", "$.", "$_", "$=",
94
+ "$DEBUG", "$VERBOSE", "$FILENAME"
95
+ ].map(&:to_sym).freeze
96
+
97
+ # Every interpreter-flag global ($-I, $-w, $-0 ...) is an alias for something on the
98
+ # list above.
99
+ IGNORED_GLOBAL_PREFIX = "$-"
100
+
101
+ def global_snapshot
102
+ # Reading a global by name needs eval, and Ruby deprecates a few of them ($= among
103
+ # them) loudly enough to bury a test run in warnings that say nothing about the app.
104
+ original_verbose = $VERBOSE
105
+ $VERBOSE = nil
106
+
107
+ watched = global_variables - IGNORED_GLOBALS
108
+ watched.reject! { |name| name.to_s.start_with?(IGNORED_GLOBAL_PREFIX) }
109
+
110
+ watched.each_with_object({}) do |name, out|
111
+ value = begin
112
+ eval(name.to_s) # rubocop:disable Security/Eval -- the only way to read a global by name
113
+ rescue StandardError
114
+ :unreadable
115
+ end
116
+ out[name] = safe_identity(value)
117
+ end
118
+ ensure
119
+ $VERBOSE = original_verbose
120
+ end
121
+
122
+ def class_variable_snapshot
123
+ watched_classes.flat_map do |klass|
124
+ klass.class_variables.map { |cvar| "#{klass}.#{cvar}" }
125
+ rescue StandardError
126
+ []
127
+ end.sort
128
+ end
129
+
130
+ # The set of classes worth watching, worked out once. Ruby's own stdlib lazily
131
+ # initialises class variables the first time you touch it -- Resolv::DNS::Message and
132
+ # URI::RFC3986_Parser both do -- and reporting those as application leaks would train
133
+ # everyone to ignore the section. Only classes defined by files inside the project,
134
+ # and outside its bundled gems, can leak state the developer is able to do anything
135
+ # about.
136
+ def watched_classes
137
+ @watched_classes ||= ObjectSpace.each_object(Class).select { |klass| app_defined?(klass) }
138
+ end
139
+
140
+ def reset_watched_classes!
141
+ @watched_classes = nil
142
+ end
143
+
144
+ def app_defined?(klass)
145
+ return false if klass.singleton_class?
146
+
147
+ name = klass.name
148
+ return false if name.nil?
149
+ return false if name.start_with?("Constable", "Minitest", "RSpec", "ActiveSupport", "ActiveRecord")
150
+
151
+ source = Object.const_source_location(name)&.first
152
+ return false if source.nil?
153
+ return false unless source.start_with?("#{Constable.root}/")
154
+
155
+ !source.include?("/vendor/") && !source.include?("/gems/")
156
+ rescue StandardError
157
+ false
158
+ end
159
+
160
+ # Compares by value where that's cheap and safe, by object identity otherwise, so the
161
+ # check never accidentally deep-freezes or serializes an application object.
162
+ def safe_identity(value)
163
+ case value
164
+ when nil, true, false, Numeric, Symbol then value
165
+ when String then value.dup
166
+ when Array, Hash then value.size
167
+ else value.object_id
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # The docket.
5
+ #
6
+ # Jail answers exactly one question: *does this test block the build?* (Whether the
7
+ # failure was even real is a different question, and warrants answer that one.)
8
+ #
9
+ # A test lands on the docket three ways, and all three land in the same place:
10
+ #
11
+ # 1. A flake-history flip -- it passed, then failed, with no code change. Identity is
12
+ # a content hash of the investigate body, so an unchanged hash that flips result
13
+ # *is* a flake by definition. Nothing else needs to be inferred.
14
+ # 2. A failure during a `--jail` run. The on-ramp for a big red legacy suite: run
15
+ # once in jail mode for a clean baseline, then work the docket down.
16
+ # 3. A parole violation -- a test that was trusted again and immediately let us down.
17
+ #
18
+ # Jailing is not hiding. It swaps "blocks the build" for "tracked and skipped", and a
19
+ # jailed test is always its own summary category -- never folded into passed. Only the
20
+ # `investigate` body is skipped; `briefing` and `witness` still run, so setup rot
21
+ # surfaces on the next ordinary run instead of ambushing whoever finally works the
22
+ # docket. That decision is exposed here as #skip_body?; the Runner does the skipping.
23
+ #
24
+ # The state machine is small: jailed <-> parole -> released.
25
+ #
26
+ # Storage owns the persistence and the counters (see Constable::Storage); this class
27
+ # owns the policy and hands the Runner and the reporter decided Results.
28
+ class Jail
29
+ # Why a test is on the docket. Stored as free text so the blotter stays readable to a
30
+ # human running `constable jail`, keyed by symbol so callers don't retype prose.
31
+ REASONS = {
32
+ flake: "flake history flip -- passed, then failed with no code change",
33
+ jail_mode: "failed during a --jail run",
34
+ parole_violation: "parole violation -- failed while out on parole",
35
+ manual: "jailed by hand"
36
+ }.freeze
37
+
38
+ # One row of the docket, normalized.
39
+ #
40
+ # Adapters hand back symbol-keyed hashes, but this wrapper is deliberately tolerant of
41
+ # string keys and of a couple of column aliases: the blotter is the kind of thing
42
+ # people point third-party adapters at, and a docket entry is not worth a NoMethodError
43
+ # over a spelling.
44
+ class Entry
45
+ attr_reader :identity, :label, :file, :line, :reason, :state, :times_jailed,
46
+ :parole_violations, :parole_day, :jailed_at, :paroled_at, :updated_at, :row
47
+
48
+ def self.wrap(row)
49
+ return nil if row.nil?
50
+ return row if row.is_a?(Entry)
51
+ return nil unless row.respond_to?(:to_h)
52
+
53
+ hash = row.to_h
54
+ hash.empty? ? nil : new(hash)
55
+ end
56
+
57
+ def initialize(row)
58
+ @row = row.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
59
+ @identity = @row[:identity].to_s
60
+ @label = @row[:label]
61
+ @file = @row[:file].to_s
62
+ @line = @row[:line]&.to_i
63
+ @reason = @row[:reason]
64
+ @state = normalize_state(@row[:state] || @row[:status])
65
+ @times_jailed = (@row[:times_jailed] || 1).to_i
66
+ @parole_violations = (@row[:parole_violations] || 0).to_i
67
+ @parole_day = (@row[:parole_clean_runs] || @row[:parole_day] || 0).to_i
68
+ @jailed_at = @row[:jailed_at]
69
+ @paroled_at = @row[:paroled_at]
70
+ @updated_at = @row[:updated_at]
71
+ end
72
+
73
+ def jailed? = @state == :jailed
74
+ def paroled? = @state == :parole
75
+ # An auto-release hands back the entry as it was at the moment the row went away --
76
+ # the only chance the caller gets to report it.
77
+ def released? = @state == :released || @row[:released] == true
78
+
79
+ def location = "#{@file}:#{@line}"
80
+ def to_h = @row.dup
81
+
82
+ private
83
+
84
+ def normalize_state(value)
85
+ case value.to_s
86
+ when "parole", "paroled" then :parole
87
+ when "released" then :released
88
+ else :jailed
89
+ end
90
+ end
91
+ end
92
+
93
+ # What one turn of the parole state machine did. The reporter reads this to say
94
+ # "Failed on day 3 of a 10-run parole" and "this is its 2nd time in jail" without
95
+ # doing arithmetic of its own.
96
+ class Transition
97
+ OUTCOMES = %i[none parole_continues released parole_violation].freeze
98
+
99
+ attr_reader :outcome, :entry, :parole_day, :parole_period, :times_jailed, :parole_violations
100
+
101
+ def initialize(outcome:, entry: nil, parole_day: 0, parole_period: 0,
102
+ times_jailed: nil, parole_violations: nil)
103
+ @outcome = outcome
104
+ @entry = entry
105
+ @parole_day = parole_day.to_i
106
+ @parole_period = parole_period.to_i
107
+ @times_jailed = (times_jailed || entry&.times_jailed).to_i
108
+ @parole_violations = (parole_violations || entry&.parole_violations).to_i
109
+ end
110
+
111
+ def none? = @outcome == :none
112
+ def continuing? = @outcome == :parole_continues
113
+ def released? = @outcome == :released
114
+ def violation? = @outcome == :parole_violation
115
+ end
116
+
117
+ attr_reader :config, :storage
118
+
119
+ def initialize(config: Constable.config, storage: Constable.storage)
120
+ @config = config
121
+ @storage = storage
122
+ end
123
+
124
+ # Consecutive clean runs that earn an automatic release. Floors at 1 -- a period of
125
+ # zero would mean "release on sight", which is not parole.
126
+ def parole_period
127
+ period = @config.respond_to?(:parole_period) ? @config.parole_period.to_i : 0
128
+ period.positive? ? period : 10
129
+ end
130
+
131
+ # --- queries ---------------------------------------------------------------
132
+
133
+ # The whole docket, jailed and paroled alike, newest first. Assembled from both
134
+ # storage lists and deduplicated, so an adapter that returns everything from #jailed
135
+ # is as correct here as one that filters by state.
136
+ def entries
137
+ rows = Array(@storage.jailed) + Array(@storage.paroled)
138
+ rows.filter_map { |row| Entry.wrap(row) }.uniq(&:identity)
139
+ end
140
+
141
+ def entry(identity) = Entry.wrap(@storage.jail_entry(identity.to_s))
142
+
143
+ def jailed = entries.select(&:jailed?)
144
+ def paroled = entries.select(&:paroled?)
145
+
146
+ # Deliberately not Storage#jailed?, which is "on the docket at all" -- a paroled test
147
+ # is on the docket and is emphatically not in jail.
148
+ def jailed?(identity) = entry(identity)&.jailed? || false
149
+ def paroled?(identity) = entry(identity)&.paroled? || false
150
+
151
+ def supervised?(identity) = !entry(identity).nil?
152
+
153
+ # The Runner's question. True means: build the instance, run every briefing and
154
+ # witness, then stop short of the investigate body. Setup rot surfaces immediately;
155
+ # the failing assertion stays locked up.
156
+ def skip_body?(identity) = jailed?(identity)
157
+
158
+ # A test passed, then failed, and its content hash never moved. That is a flake, and
159
+ # it is the one route into jail that needs no flag and no human.
160
+ #
161
+ # Ask this *before* recording the current result to flake history, or the "previous"
162
+ # status will be the one being adjudicated.
163
+ def flake_flip?(result)
164
+ return false unless result.failed?
165
+
166
+ @storage.last_status(result.identity).to_s == "passed"
167
+ end
168
+
169
+ # --- routes in -------------------------------------------------------------
170
+
171
+ # Books a finished Result onto the docket and hands it back decided, so the Runner can
172
+ # keep treating results as its only currency.
173
+ def jail(result, reason: :manual)
174
+ text = reason_text(reason)
175
+ entry = jail_identity(result.identity, label: result.display_label,
176
+ file: result.file, line: result.line, reason: text)
177
+ result.status = :jailed
178
+ result.jail_reason = text
179
+ result.times_jailed = entry&.times_jailed
180
+ result
181
+ end
182
+
183
+ # Route 1. Recorded with its own reason so `constable jail` reads as an explanation
184
+ # rather than a list.
185
+ def jail_for_flake(result) = jail(result, reason: :flake)
186
+
187
+ # Route 2. Jail mode: a failure is tracked and skipped instead of red.
188
+ def jail_failure(result) = jail(result, reason: :jail_mode)
189
+
190
+ # The primitive under all of the above, for the CLI and for anything holding an
191
+ # identity rather than a Result.
192
+ def jail_identity(identity, label:, file:, line:, reason: :manual)
193
+ Entry.wrap(@storage.jail(identity.to_s, label: label, file: file, line: line,
194
+ reason: reason_text(reason))) || entry(identity)
195
+ end
196
+
197
+ # --- the parole state machine ----------------------------------------------
198
+
199
+ # A clean run for a test on parole. `parole_period` of these in a row and it walks --
200
+ # automatically, with no human step, because that is what the period is for.
201
+ def record_pass(identity)
202
+ before = entry(identity)
203
+ return Transition.new(outcome: :none, entry: before, parole_period: parole_period) unless before&.paroled?
204
+
205
+ after = Entry.wrap(@storage.record_parole_pass(identity.to_s))
206
+ day = after&.parole_day&.positive? ? after.parole_day : before.parole_day + 1
207
+
208
+ if after&.released?
209
+ transition(:released, after, day)
210
+ elsif day >= parole_period
211
+ # Belt and braces: an adapter that only counts still gets the release it earned.
212
+ @storage.release(identity.to_s)
213
+ transition(:released, after || before, day)
214
+ else
215
+ transition(:parole_continues, after || before, day)
216
+ end
217
+ end
218
+
219
+ # A paroled test failed. Once is enough -- parole exists precisely because the test
220
+ # had not earned trust yet, so there is no leniency and no second look.
221
+ def record_failure(identity)
222
+ before = entry(identity)
223
+ return Transition.new(outcome: :none, entry: before, parole_period: parole_period) unless before&.paroled?
224
+
225
+ day = before.parole_day + 1 # the run it went down on, not the runs it survived
226
+ after = Entry.wrap(@storage.record_parole_violation(identity.to_s))
227
+ after = force_back_to_jail(before) if after.nil? || after.paroled?
228
+
229
+ Transition.new(
230
+ outcome: :parole_violation, entry: after, parole_day: day, parole_period: parole_period,
231
+ times_jailed: after&.times_jailed || (before.times_jailed + 1),
232
+ parole_violations: after&.parole_violations || (before.parole_violations + 1)
233
+ )
234
+ end
235
+
236
+ # --- human operations ------------------------------------------------------
237
+
238
+ # `constable jail parole PATH:LINE`. Jail -> parole; the clean-run count starts over.
239
+ def parole(identity) = Entry.wrap(@storage.parole(identity.to_s))
240
+
241
+ # `constable jail release PATH:LINE`. Off the docket entirely, no supervision.
242
+ def release(identity) = @storage.release(identity.to_s) ? true : false
243
+
244
+ # `jail run` never auto-releases and never auto-paroles. One green run proves nothing;
245
+ # it only earns a mention. A human reads this list and decides.
246
+ #
247
+ # Pass the raw Results from the jail run -- do *not* route those through #adjudicate,
248
+ # which would book them straight back onto the docket they came from.
249
+ def candidates_for_release(results)
250
+ Array(results).select { |result| passing?(result) }
251
+ .filter_map { |result| entry(result.identity) }
252
+ end
253
+
254
+ # The whole picture after a `jail run`, for the CLI to print.
255
+ def jail_run_report(results)
256
+ results = Array(results)
257
+ {
258
+ candidates: candidates_for_release(results),
259
+ still_failing: results.reject { |r| passing?(r) }.filter_map { |r| entry(r.identity) }
260
+ }
261
+ end
262
+
263
+ # --- the Runner's single entry point ---------------------------------------
264
+
265
+ # One call per finished result. Returns the same Result, decided.
266
+ #
267
+ # Call it *before* writing the result to flake history (the flip check reads the
268
+ # previous status) and *after* Warrants has had its say (a warranted result is not a
269
+ # failure, so it never reaches the docket).
270
+ def adjudicate(result, jail_mode: false)
271
+ return result if result.nil?
272
+
273
+ docket = entry(result.identity)
274
+
275
+ return record_result(result) if docket&.paroled?
276
+ return mark_jailed(result) if docket&.jailed?
277
+ return result unless result.failed?
278
+
279
+ if jail_mode
280
+ jail_failure(result)
281
+ elsif flake_flip?(result)
282
+ jail_for_flake(result)
283
+ else
284
+ result
285
+ end
286
+ end
287
+
288
+ # Drives the parole machine from a Result and stamps the outcome onto it. A parole
289
+ # violation gets its own status -- it is more urgent news than a plain new failure,
290
+ # because somebody deliberately trusted this test again.
291
+ def record_result(result)
292
+ return result unless paroled?(result.identity)
293
+
294
+ if result.failed?
295
+ transition = record_failure(result.identity)
296
+ result.status = :parole_violation
297
+ result.jail_reason = REASONS[:parole_violation]
298
+ result.parole_day = transition.parole_day
299
+ result.times_jailed = transition.times_jailed
300
+ elsif result.passed?
301
+ result.parole_day = record_pass(result.identity).parole_day
302
+ end
303
+
304
+ result
305
+ end
306
+
307
+ # For a test the Runner skipped because it is on the docket: fills in the reason and
308
+ # the repeat-offender count without touching storage. Nothing here is a new offence.
309
+ def mark_jailed(result)
310
+ docket = entry(result.identity)
311
+ result.status = :jailed
312
+ result.jail_reason = docket&.reason
313
+ result.times_jailed = docket&.times_jailed
314
+ result
315
+ end
316
+
317
+ # Headline counts for the summary line: "2 jailed (1 parole violation)".
318
+ def summary_counts(results)
319
+ results = Array(results)
320
+ {
321
+ jailed: results.count { |r| r.status == :jailed },
322
+ parole_violations: results.count { |r| r.status == :parole_violation },
323
+ on_parole: paroled.size
324
+ }
325
+ end
326
+
327
+ # --- PATH:LINE resolution ---------------------------------------------------
328
+
329
+ # The CLI speaks in file:line; the blotter is keyed by content hash. The docket stores
330
+ # both, so it is the first place to look; loaded investigations are the fallback for a
331
+ # test that is not on the docket yet.
332
+ #
333
+ # Returns an identity String, or nil when nothing matches.
334
+ def resolve(target)
335
+ text = target.to_s.strip
336
+ return nil if text.empty?
337
+ return text if identity_like?(text) && entry(text)
338
+
339
+ file, line = self.class.split_target(text)
340
+ return nil if file.empty?
341
+
342
+ matches = entries.select { |e| self.class.same_path?(e.file, file) }
343
+ matches = matches.select { |e| e.line == line } if line
344
+ return matches.first.identity if matches.any?
345
+
346
+ self.class.registry_identity(file, line)
347
+ end
348
+
349
+ def resolve!(target)
350
+ resolve(target) || raise(Constable::Error, "no test found for #{target.inspect} " \
351
+ "(expected PATH:LINE, e.g. test/cases/users_case.rb:12)")
352
+ end
353
+
354
+ # "test/cases/users_case.rb:12" -> ["test/cases/users_case.rb", 12]
355
+ def self.split_target(target)
356
+ text = target.to_s.strip
357
+ match = text.match(/\A(?<file>.+):(?<line>\d+)\z/)
358
+ match ? [match[:file], match[:line].to_i] : [text, nil]
359
+ end
360
+
361
+ def self.normalize_path(path)
362
+ path.to_s.strip.delete_prefix("#{Constable.root}/").delete_prefix("./")
363
+ end
364
+
365
+ def self.same_path?(left, right) = normalize_path(left) == normalize_path(right)
366
+
367
+ # Falls back to whatever the Registry has loaded, so `constable jail parole` works on
368
+ # a test that has never been on the docket.
369
+ def self.registry_identity(file, line)
370
+ investigations = Constable.registry.investigations
371
+ matches = investigations.select { |inv| same_path?(inv.relative_file, file) }
372
+ matches = matches.select { |inv| inv.line.to_i == line } if line
373
+ matches.first&.identity
374
+ rescue StandardError
375
+ nil
376
+ end
377
+
378
+ private
379
+
380
+ def transition(outcome, entry, day)
381
+ Transition.new(outcome: outcome, entry: entry, parole_day: day, parole_period: parole_period)
382
+ end
383
+
384
+ def force_back_to_jail(before)
385
+ jail_identity(before.identity, label: before.label, file: before.file,
386
+ line: before.line, reason: :parole_violation)
387
+ end
388
+
389
+ def reason_text(reason)
390
+ REASONS.fetch(reason) { reason.to_s.empty? ? REASONS[:manual] : reason.to_s }
391
+ end
392
+
393
+ def passing?(result)
394
+ result.respond_to?(:passed?) ? result.passed? : result.to_s == "passed"
395
+ end
396
+
397
+ def identity_like?(text) = text.match?(/\A[0-9a-f]{8,64}\z/)
398
+ end
399
+ end