constable-rails 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05e7e65ec7f1f096713bc5cb7b891ce2297275876c01cf9151130dc13608befe
4
- data.tar.gz: db0b094a1e70a0052f02bb343cd0d41c53b224e56df089638ad6f5be60025e36
3
+ metadata.gz: c4612ebf00dac193c1557bbd887beccfbd7e7864f157dd88a77386e476897a6e
4
+ data.tar.gz: f208f84658349658dccef504a21f6a62833d27b9b772d1d91ef5f26216b60ae5
5
5
  SHA512:
6
- metadata.gz: 19e12d780b73ec73830eab6290b27d61f1fb8c9cd40894ff0c2832120883f06265e58b405ec107c1dab002d6bf484fb166a99cad273507d1b05e0da1234ae0df
7
- data.tar.gz: 789ba56df5a2720e25ddbadec56d9916776f809c95b812748468addbdf05276292e76ee37686a40d8489c5678121d44d35e41a0b2b09ab6e72af82b016dc682a
6
+ metadata.gz: 296f8609681632e8427d481ecc94a15773b581c90dd7a88fa28c66eb584cd492c1706eec3faebc56aec385709ff0e72320db299c3901cec4659b333d8b94f97a
7
+ data.tar.gz: aeb4696fc4029685e7ffcab4c4b7d3e0287b6a153b86f6c331864353e56546d6b9a9eb0aef640a59dce748ed08e27f6f5e80251b7e521143f1490ed18356d67f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,123 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.1.0]
9
+
10
+ Three things that were documented and did not work, plus the command for a docket
11
+ that has gone stale.
12
+
13
+ ### `Constable.configure` actually configures things now
14
+
15
+ The generated `test/case_helper.rb` told you to write `c.parallel_workers = 4`, explained
16
+ when you would want to, and then **nothing in the codebase ever read it**. Four accessors,
17
+ all inert.
18
+
19
+ They work now, and every setting `.constable/config.yml` understands is settable in Ruby
20
+ alongside them — `cold_cases`, `storage`, `warrants`, `warrant_retries`, `auto_relink`,
21
+ `parole_period`, `coverage`, `coverage_threshold`, `coverage_html`, `fail_on_warnings`,
22
+ `parallel_workers`, `output`, `tiers`. A test asserts the two halves stay in step, so a
23
+ setting cannot be added to one and forgotten in the other.
24
+
25
+ Precedence, and the reasoning:
26
+
27
+ ```
28
+ a CLI flag --workers 4, --expanded one run, most specific
29
+ Constable.configure test/case_helper.rb code you deliberately ran
30
+ .constable/config.yml the project's declared default
31
+ Constable's defaults
32
+ ```
33
+
34
+ Which to use? A setting that differs per machine or per branch belongs in the YAML, where
35
+ it is obvious and greppable. A setting that has to be *computed* belongs in Ruby, because
36
+ YAML cannot do this:
37
+
38
+ ```ruby
39
+ Constable.configure do |c|
40
+ c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
41
+ c.coverage = ENV["CI"] == "true"
42
+ end
43
+ ```
44
+
45
+ Values set in Ruby go through the same clamping as values set in the file, so a typo is no
46
+ more dangerous in one than the other.
47
+
48
+ **`storage` is the one exception, and it now says so.** The blotter is opened before
49
+ `case_helper.rb` loads, so that `constable jail`, `warrants`, `watchlist` and `status` can
50
+ read the docket without booting the app — a broken app should not stop you reading the
51
+ docket. Setting it in Ruby would have been silently ignored, which is the exact failure
52
+ this release exists to stop, so it raises and explains why.
53
+
54
+ The docs now lead with Ruby. The generated `case_helper.rb` lists **every** setting at its
55
+ default in one compact block — a test asserts the list stays complete, and that it never
56
+ offers `storage`. The long-form explanation of each stays in `config.yml`, so the two files
57
+ are a reference and a place to write code rather than two competing references.
58
+
59
+ Neither is `config/initializers/`, and the reason is concrete: initializers run on every
60
+ boot including production, where a test-only gem is not in the bundle, so an initializer
61
+ calling `Constable.configure` takes the app down with a NoMethodError. Same reason RSpec,
62
+ SimpleCov, WebMock and Capybara all configure from the test helper.
63
+
64
+ ### `.constable/config.yml` is now genuinely optional
65
+
66
+ You can delete it. `rails generate constable:install --skip-config` never writes it.
67
+
68
+ The one thing keeping it mandatory was `storage`, which cannot be set in Ruby for the
69
+ ordering reason above. It now reads from the environment as well, which is the right shape
70
+ for CI anyway, where the value is a secret and differs per machine:
71
+
72
+ ```
73
+ CONSTABLE_STORAGE_URL=postgres://user:pass@host/constable_metadata
74
+ CONSTABLE_STORAGE_PATH=/var/lib/constable/blotter.sqlite3
75
+ CONSTABLE_STORAGE_ADAPTER=postgres
76
+ ```
77
+
78
+ An adapter is inferred from the URL scheme when it is not given, and an empty variable
79
+ means unset rather than "connect to the empty string". The `.constable/` **directory**
80
+ still exists to hold the blotter — deliberately not `tmp/`, which `rails tmp:clear` and
81
+ most deploys would wipe, taking weeks of flake history with it.
82
+
83
+ One bug found while checking this end to end, in the override layer added above: settings
84
+ were applied *after* the selection had already been asked for its targets, so
85
+ `c.cold_cases` in Ruby was silently ignored and a suite of 192 ran 35. Overrides are now
86
+ applied between requiring the helper and asking the selection anything.
87
+
88
+ ### Settings added after you install are no longer invisible
89
+
90
+ `.constable/config.yml` doubles as the reference — every key at its default, with the
91
+ reasoning above it — which only works if it stays current. A gem upgrade cannot rewrite it
92
+ without clobbering your settings, and Thor's only other answer is to skip the file, so
93
+ **`output` shipped in 1.0.0 and never appeared in any existing config.** The first anyone
94
+ knew was running `bundle update` and finding the setting missing.
95
+
96
+ `rails generate constable:install` now appends only the settings your file does not
97
+ mention, each with its explanation, under a header saying where they came from. Your
98
+ values and your own comments are never touched. Re-run it after any upgrade.
99
+
100
+ ### `constable prune`
101
+
102
+ A test's key is a content hash of its body, so editing a jailed test gives it a new
103
+ identity and leaves the old row behind — pointing at a `file:line` that may now hold
104
+ something else. That is identity working as designed, and it was the one known limitation
105
+ left open in 1.0.0. This is the broom:
106
+
107
+ ```console
108
+ $ constable prune --dry-run # list what would go
109
+ $ constable prune # forget it
110
+ ```
111
+
112
+ It loads the whole suite first, because which tests still exist is only knowable once
113
+ every case file has been read, and it is deliberately conservative in two directions:
114
+
115
+ - **A known identity is never pruned**, even when the file recorded beside it is gone. The
116
+ path is a display label; the identity is the truth. A test that moved file has an
117
+ out-of-date label, not a missing test.
118
+ - **A cold case is never pruned while its file exists.** Cold-case tests cannot be
119
+ enumerated without running their own engine, so their absence says nothing.
120
+
121
+ Flake history is left alone either way — only the docket and outstanding warrants are
122
+ touched.
123
+
124
+
8
125
  ## [1.0.0]
9
126
 
10
127
  The first release anybody should install.
@@ -241,6 +358,7 @@ Initial release.
241
358
  - Diff-based coverage gate — only lines changed in the current diff are held to the
242
359
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
243
360
 
244
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...HEAD
361
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...HEAD
362
+ [1.1.0]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...v1.1.0
245
363
  [1.0.0]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...v1.0.0
246
364
  [0.1.0]: https://github.com/Ray-Hughes/constable/releases/tag/v0.1.0
data/README.md CHANGED
@@ -399,6 +399,7 @@ worse than one that resets.
399
399
  | `constable status` | How the suite is doing over time |
400
400
  | `constable beat [--html]` | Coverage: overall %, per-file, the unpatrolled list |
401
401
  | `constable history relink OLD NEW` | Carry history across a real body change |
402
+ | `constable prune [--dry-run]` | Forget docket rows and warrants for tests that no longer exist |
402
403
  | `constable import --from=rspec` | Adopt an existing suite as cold cases |
403
404
  | `constable modernize PATH` | Opt-in AST rewrite into the native DSL |
404
405
 
@@ -557,6 +558,50 @@ storage:
557
558
 
558
559
  ### Configuration
559
560
 
561
+ Settings can be written in Ruby, in `test/case_helper.rb` — the same place RSpec puts
562
+ `RSpec.configure` — or in `.constable/config.yml`, or on the command line. The most
563
+ specific wins:
564
+
565
+ ```
566
+ a CLI flag --workers 4, --expanded one run
567
+ Constable.configure test/case_helper.rb code you deliberately ran
568
+ .constable/config.yml the project's declared default
569
+ Constable's defaults
570
+ ```
571
+
572
+ Every key below can be set in either place. Put settings that differ per machine or per
573
+ branch in the YAML, where they are obvious and greppable; put settings that have to be
574
+ *computed* in Ruby, because YAML cannot:
575
+
576
+ ```ruby
577
+ # test/case_helper.rb
578
+ Constable.configure do |c|
579
+ c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
580
+ c.coverage = ENV["CI"] == "true"
581
+ c.output = :expanded
582
+ end
583
+ ```
584
+
585
+ Not `config/initializers/`, which is where a runtime gem like Devise goes. Initializers
586
+ run on **every** boot including production, where a test-only gem is not in the bundle, so
587
+ an initializer calling `Constable.configure` takes the app down. It is the same reason
588
+ RSpec, SimpleCov, WebMock and Capybara all configure from the test helper.
589
+
590
+ `config.yml` is optional, and one setting is the reason it exists: **`storage` can only be
591
+ set there.** The blotter is opened before `case_helper.rb` loads, so that `constable jail`,
592
+ `warrants`, `watchlist` and `status` can read the docket without booting the app — a
593
+ broken app should not stop you reading the docket. Setting it in Ruby raises rather than
594
+ being quietly ignored.
595
+
596
+ Beyond that it is a preference. A settings file is greppable and diffable without running
597
+ anything, which suits values that differ per project or per branch; Ruby suits anything
598
+ computed.
599
+
600
+ `config.yml` doubles as the reference, so re-run
601
+ `rails generate constable:install --skip` after an upgrade: it appends any settings your
602
+ file does not mention and leaves your own values and comments alone. (`--skip` so the
603
+ other generated files, which you have probably edited, are left as they are.)
604
+
560
605
  ```yaml
561
606
  # .constable/config.yml
562
607
  cold_cases:
data/lib/constable/cli.rb CHANGED
@@ -399,6 +399,48 @@ module Constable
399
399
  end
400
400
  end
401
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
+
402
444
  desc "jail SUBCOMMAND", "The jail docket"
403
445
  subcommand "jail", JailCommand
404
446
 
@@ -409,6 +451,16 @@ module Constable
409
451
  subcommand "history", HistoryCommand
410
452
 
411
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
+
412
464
  def load_config
413
465
  Constable.config
414
466
  end
@@ -63,6 +63,16 @@ module Constable
63
63
  @raw = deep_merge(@raw, stringify(overrides || {}))
64
64
  end
65
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
+
66
76
  def cold_cases = Array(@raw["cold_cases"])
67
77
  def warrants? = truthy(@raw["warrants"])
68
78
  # Negative retries are a typo for "off", not an instruction to count backwards.
@@ -103,11 +113,33 @@ module Constable
103
113
  def expanded_output? = output_mode == :expanded
104
114
  def storage = @raw["storage"] || {}
105
115
 
106
- def storage_adapter = (storage["adapter"] || "sqlite").to_s
107
- 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
108
139
 
109
140
  def storage_path
110
- path = storage["path"] || DEFAULTS["storage"]["path"]
141
+ path = env_or("CONSTABLE_STORAGE_PATH", storage["path"]) ||
142
+ DEFAULTS["storage"]["path"]
111
143
  File.absolute_path?(path) ? path : File.join(@root, path)
112
144
  end
113
145
 
@@ -145,6 +177,13 @@ module Constable
145
177
 
146
178
  private
147
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
+
148
187
  def truthy(value)
149
188
  return false if value.nil? || value == false
150
189
  return false if value.to_s.strip.downcase == "false"
@@ -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
  #
@@ -360,6 +365,39 @@ module Constable
360
365
  matches
361
366
  end
362
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
+
363
401
  # An identity String, or nil when nothing matches -- or when more than one does.
364
402
  # Ambiguity is the caller's to report, with the candidates in hand.
365
403
  def resolve(target)
@@ -100,14 +100,16 @@ module Constable
100
100
  0x1F900..0x1F9FF, 0x20000..0x3FFFD
101
101
  ].freeze
102
102
 
103
- attr_reader :io, :config, :seed, :total, :mode
103
+ attr_reader :io, :config, :seed, :total
104
104
 
105
105
  def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST, mode: nil)
106
106
  @io = io
107
107
  @config = config || Constable.config
108
108
  @color = resolve_color(color)
109
109
  @slowest = slowest.to_i
110
- @mode = resolve_mode(mode)
110
+ # Resolved lazily: the reporter is built before case_helper.rb has run, so reading
111
+ # the config now would miss anything Constable.configure sets.
112
+ @requested_mode = mode
111
113
  @io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
112
114
 
113
115
  reset_stream!
@@ -210,7 +212,8 @@ module Constable
210
212
  def failed? = !success?
211
213
  def color? = @color
212
214
  def finished? = @finished
213
- def expanded? = @mode == :expanded
215
+ def mode = @mode ||= resolve_mode(@requested_mode)
216
+ def expanded? = mode == :expanded
214
217
 
215
218
  private
216
219
 
@@ -64,6 +64,19 @@ module Constable
64
64
  def jail_run? = @jail_run
65
65
  def coverage? = @coverage_requested
66
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
+
67
80
  # => Integer exit status (0 clean, 1 failures)
68
81
  def call
69
82
  # Before anything is loaded: Coverage only counts files required after it starts, so
@@ -143,6 +156,15 @@ module Constable
143
156
  .find { |p| File.exist?(p) }
144
157
  require helper if helper
145
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
+
146
168
  @selection.native_targets.each { |target| load_case_file(target.path) }
147
169
  helper
148
170
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -244,6 +244,39 @@ module Constable
244
244
  matches
245
245
  end
246
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
+
247
280
  def resolve(target)
248
281
  matches = candidates(target)
249
282
  return matches.first.identity if matches.size == 1
data/lib/constable.rb CHANGED
@@ -130,14 +130,60 @@ module Constable
130
130
  end
131
131
 
132
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.
133
146
  class Configuration
134
- 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
135
173
 
136
174
  def initialize
137
175
  @before_suite_hooks = []
138
176
  @after_suite_hooks = []
139
177
  end
140
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
+
141
187
  def before_suite(&block) = @before_suite_hooks << block
142
188
  def after_suite(&block) = @after_suite_hooks << block
143
189
 
@@ -45,6 +45,8 @@ module Constable
45
45
  desc: "Don't write the example case under test/cases/"
46
46
  class_option :skip_support, type: :boolean, default: false,
47
47
  desc: "Don't write the example files under test/support/"
48
+ class_option :skip_config, type: :boolean, default: false,
49
+ desc: "Don't write .constable/config.yml -- configure in Ruby instead"
48
50
 
49
51
  COLD_CASE_HEADER = <<~RUBY
50
52
  # Cold cases: your existing RSpec/Minitest files, run verbatim through their own
@@ -72,6 +74,15 @@ module Constable
72
74
  /.constable/*.sqlite3-*
73
75
  TEXT
74
76
 
77
+ NEW_SETTINGS_HEADER = <<~TEXT
78
+ # ---------------------------------------------------------------------------
79
+ # Added by `rails generate constable:install` on a later upgrade. These settings
80
+ # did not exist when this file was written; each is shown at its default, so
81
+ # deleting any of them changes nothing.
82
+ # ---------------------------------------------------------------------------
83
+
84
+ TEXT
85
+
75
86
  RUBOCOP_EXTENSION = "rubocop-constable"
76
87
 
77
88
  def create_case_helper
@@ -85,8 +96,32 @@ module Constable
85
96
  template "authenticatable.rb.tt", "test/support/authenticatable.rb"
86
97
  end
87
98
 
99
+ # The config file doubles as the reference -- every key at its default, with the
100
+ # reasoning above it -- which only works if it stays current. A gem upgrade cannot
101
+ # rewrite it (that would clobber your settings) and Thor's only other answer is to
102
+ # skip the file entirely, so before 1.1.0 a setting added after you installed was
103
+ # invisible: `output` shipped in 1.0.0 and never appeared in an existing config.
104
+ #
105
+ # So: create it if it is missing, and otherwise append only the settings it does not
106
+ # already mention. Your edits and comments are never touched.
88
107
  def create_config
89
- template "config.yml.tt", ".constable/config.yml"
108
+ if options[:skip_config]
109
+ say_status :skip, ".constable/config.yml (configure in test/case_helper.rb instead)", :blue
110
+ return
111
+ end
112
+
113
+ path = File.join(destination_root, ".constable/config.yml")
114
+ return template("config.yml.tt", ".constable/config.yml") unless File.exist?(path)
115
+
116
+ missing = missing_config_blocks(File.read(path))
117
+ if missing.empty?
118
+ say_status :identical, ".constable/config.yml (every setting is documented)", :blue
119
+ return
120
+ end
121
+
122
+ names = missing.flat_map { |block| config_keys_in(block) }
123
+ say_status :append, ".constable/config.yml (#{names.join(", ")})", :green
124
+ append_to_file ".constable/config.yml", "\n#{NEW_SETTINGS_HEADER}#{missing.join("\n\n")}\n"
90
125
  end
91
126
 
92
127
  def create_example_case
@@ -204,6 +239,31 @@ module Constable
204
239
  end
205
240
  end
206
241
 
242
+ # Blocks in the shipped reference whose settings the existing file never mentions.
243
+ # A "block" is a run of lines between blank ones: the comment and the setting it
244
+ # explains travel together, because a bare key with no reasoning is not a reference.
245
+ def missing_config_blocks(existing)
246
+ present = config_keys_in(existing)
247
+
248
+ reference_blocks.select do |block|
249
+ keys = config_keys_in(block)
250
+ keys.any? && (keys - present) == keys
251
+ end
252
+ end
253
+
254
+ def reference_blocks
255
+ File.read(find_in_source_paths("config.yml.tt")).split(/\n{2,}/).map(&:rstrip).reject(&:empty?)
256
+ end
257
+
258
+ # Top-level YAML keys only, ignoring comments and nested ones -- a commented-out
259
+ # example is a suggestion, not a declaration.
260
+ def config_keys_in(text)
261
+ text.lines.filter_map do |line|
262
+ match = line.match(/\A([a-z][a-z0-9_]*):/)
263
+ match && match[1]
264
+ end.uniq
265
+ end
266
+
207
267
  def gemfile_contents
208
268
  File.read(File.join(destination_root, "Gemfile"))
209
269
  end
@@ -139,16 +139,49 @@ end
139
139
  # Code-level configuration.
140
140
  # -----------------------------------------------------------------------------
141
141
 
142
+ # Code only, by default: matchers and suite hooks can be written nowhere else.
143
+ #
144
+ # Settings can be written here too, and this file wins over
145
+ # .constable/config.yml -- a CLI flag wins over both, for one run. Use the file
146
+ # for values that differ per project or per branch, where being greppable
147
+ # without running anything is worth something. Use Ruby when the value has to be
148
+ # computed, which YAML cannot do:
149
+ #
150
+ # c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
151
+ # c.coverage = ENV["CI"] == "true"
152
+ #
153
+ # Every setting, at its default -- uncomment what you want and delete the rest.
154
+ # `.constable/config.yml` explains each one at length and is optional; between
155
+ # the two, this file can be the only place you configure Constable.
156
+ #
157
+ # c.cold_cases = [] # RSpec/Minitest globs to run as cold cases
158
+ # c.parallel_workers = "auto" # or an integer. Each worker gets its own database
159
+ # c.output = :concise # or :expanded -- a line per test, with timings
160
+ # c.fail_on_warnings = false # CI: fail when the warning count is not trending down
161
+ # c.warrants = false # rerun a failure in isolation before believing it
162
+ # c.warrant_retries = 5
163
+ # c.parole_period = 10 # clean runs before a paroled test releases itself
164
+ # c.auto_relink = false # auto-confirm rename detection
165
+ # c.coverage = false
166
+ # c.coverage_threshold = 90 # diff-based: only lines changed in this diff
167
+ # c.coverage_html = false
168
+ # c.tiers = { "unit" => "test/cases/models/**/*" }
169
+ #
170
+ # `storage` is the one setting that cannot go here: the blotter is opened before
171
+ # this file loads, so `constable jail` and `constable status` can read the docket
172
+ # without booting the app. Setting it here raises rather than being ignored. Use
173
+ # .constable/config.yml, or CONSTABLE_STORAGE_URL / _PATH / _ADAPTER.
174
+ #
175
+ # Not config/initializers/: initializers run on every boot including production,
176
+ # where a test-only gem is not in the bundle. Same reason RSpec, SimpleCov and
177
+ # WebMock all configure from the test helper rather than an initializer.
178
+ # -----------------------------------------------------------------------------
179
+
142
180
  Constable.configure do |c|
143
- # Workers default to `auto` -- processor count minus a little headroom, so the
144
- # machine stays usable while the suite runs. Pin it only when a CI container
145
- # lies about its core count.
146
- # c.parallel_workers = 4
147
-
148
- # One-time global setup, run once per process before the whole suite. This is
149
- # for configuring the world -- drivers, adapters, formats -- and deliberately
150
- # not for creating records that tests then share. See the note at the bottom
151
- # of this file about why that distinction is the whole ballgame.
181
+ # One-time global setup, run once per process before the whole suite. For
182
+ # configuring the world -- drivers, adapters, formats -- and deliberately not
183
+ # for creating records that tests then share. See the note at the bottom of
184
+ # this file about why that distinction is the whole ballgame.
152
185
  #
153
186
  # c.before_suite do
154
187
  # Capybara.default_driver = :rack_test
@@ -1,13 +1,29 @@
1
1
  # .constable/config.yml
2
2
  #
3
- # Settings, as opposed to code. Everything here is a number, a flag or a glob;
4
- # anything that is Ruby -- custom matchers, tier base classes, one-time global
5
- # setup -- lives in test/case_helper.rb instead.
3
+ # Optional. Everything here can also be written in Ruby, in test/case_helper.rb:
6
4
  #
7
- # Every key Constable understands is present below at its default value, so this
8
- # file doubles as the complete reference. Delete anything you haven't changed;
9
- # the behavior is identical either way. CLI flags win over this file for the
10
- # duration of a single run.
5
+ # Constable.configure do |c|
6
+ # c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
7
+ # c.output = :expanded
8
+ # end
9
+ #
10
+ # ...which is the better home for anything computed, and the more familiar one if you
11
+ # come from RSpec's spec_helper.rb. Ruby wins over this file; a CLI flag wins over both.
12
+ #
13
+ # Two reasons this file exists at all:
14
+ #
15
+ # 1. `storage` cannot go anywhere else. The blotter is opened before case_helper.rb
16
+ # loads, so that `constable jail`, `warrants`, `watchlist` and `status` can read the
17
+ # docket without booting the app -- a broken app should not stop you reading the
18
+ # docket. Setting it in Ruby raises rather than being quietly ignored.
19
+ #
20
+ # 2. A settings file is greppable and diffable without executing anything, which is
21
+ # what you want for the values that differ per project or per branch.
22
+ #
23
+ # Every key Constable understands is below at its default, so this doubles as the
24
+ # reference. Delete anything you have not changed; the behavior is identical either way.
25
+ # Re-run `rails generate constable:install --skip` after upgrading and any settings added
26
+ # since will be appended here, leaving your own values and comments alone.
11
27
 
12
28
  # Glob paths to run as cold cases: original RSpec/Minitest files, driven verbatim
13
29
  # through their own real engine, with pass/fail/timing fed into Constable's
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes