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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Cold cases -- Tier 1 of the unsafe escape hatch, and the whole adoption story.
5
+ #
6
+ # Philosophy point 3: adoption never requires a rewrite. A cold case is an existing
7
+ # RSpec or Minitest file that keeps its original body *verbatim* and still reports
8
+ # into Constable's summary, flake history and CI gate alongside native cases. Two
9
+ # ways in, and both have to work:
10
+ #
11
+ # 1. One-line superclass swap -- the file's wrapper line changes, nothing else:
12
+ #
13
+ # class LegacyUsersSpec < Constable::ColdCase::RSpec
14
+ # describe UsersController do
15
+ # it "creates a user" do ... end
16
+ # end
17
+ # end
18
+ #
19
+ # 2. Zero file changes -- a `cold_cases:` glob in .constable/config.yml matches the
20
+ # file and Constable wraps it without anyone touching it:
21
+ #
22
+ # cold_cases:
23
+ # - spec/controllers/**/*_spec.rb
24
+ #
25
+ # Nothing here reimplements RSpec or Minitest. The *real* engine runs the file
26
+ # in-process and we translate its own verdicts into Constable::Result objects. That
27
+ # matters for trust: a cold case has to behave exactly as it did before adoption, or
28
+ # "runs untouched from day one" is a lie.
29
+ #
30
+ # Cold cases are exempt from the native rules on purpose, and the exemptions are not
31
+ # oversights:
32
+ #
33
+ # * No shuffling. Native cases get a fresh random order every run because isolation
34
+ # is guaranteed for them. A legacy suite frequently is *not* isolated -- it may
35
+ # lean on before(:all), class-level state or declaration order -- so re-ordering it
36
+ # would manufacture failures that have nothing to do with the code under test.
37
+ # Cold cases keep whatever order their own engine chose.
38
+ # * No transactional wrapper, no state-leak check, no linter. Those enforce rules the
39
+ # file never agreed to.
40
+ #
41
+ # The price of the exemption is visibility (philosophy point 4). Every cold-case file
42
+ # emits exactly one warning, every run, until someone modernizes it -- one per FILE,
43
+ # not per test, because a hundred warnings for one legacy spec is noise, not news.
44
+ module ColdCase
45
+ ENGINES = %i[rspec minitest].freeze
46
+
47
+ # What to require, and which gem the user is missing if it isn't there.
48
+ ENGINE_REQUIRES = { rspec: "rspec/core", minitest: "minitest" }.freeze
49
+ ENGINE_GEMS = { rspec: "rspec-rails", minitest: "minitest" }.freeze
50
+ ENGINE_LABELS = { rspec: "RSpec", minitest: "Minitest" }.freeze
51
+
52
+ # The optional Gemfile group `constable:install` writes. It is optional precisely
53
+ # because these dependencies are temporary: delete the group once the last cold case
54
+ # is gone and both gems drop out of the app.
55
+ GEMFILE_GROUP = <<~RUBY
56
+ group :cold_case do
57
+ gem "rspec-rails"
58
+ gem "minitest"
59
+ end
60
+ RUBY
61
+
62
+ # Base classes a legacy Minitest file is likely to inherit from, used as a last-ditch
63
+ # content sniff when neither the filename nor an explicit ColdCase superclass says.
64
+ MINITEST_SUPERCLASSES = /<\s*(?:::)?(?:Minitest::|ActiveSupport::|ActionDispatch::|ActionController::)/
65
+
66
+ # Raised when a cold case needs an engine the app no longer has installed.
67
+ class EngineMissing < Constable::Error; end
68
+
69
+ class << self
70
+ # ---- public API the Runner calls -------------------------------------------------
71
+
72
+ # Runs one cold-case file through its own engine and returns [Constable::Result].
73
+ # Every result has kind: :cold, so downstream code can tell at a glance that this
74
+ # test is not under native rules.
75
+ def run_file(path, config: Constable.config, seed: nil)
76
+ absolute = absolute_path(path, config: config)
77
+ engine = engine_for(absolute)
78
+
79
+ unless engine
80
+ raise Constable::Error,
81
+ "#{relative_path(absolute, config: config)} is registered as a cold case but " \
82
+ "Constable can't tell whether it is RSpec or Minitest. Name it *_spec.rb or " \
83
+ "*_test.rb, or give it a Constable::ColdCase::RSpec / " \
84
+ "Constable::ColdCase::Minitest superclass."
85
+ end
86
+
87
+ adapter_for(engine).run_file(absolute, config: config, seed: seed)
88
+ end
89
+
90
+ # Convenience for a whole batch. Files run one at a time and in the given order --
91
+ # engine globals are process-wide, so there is no safe way to interleave them.
92
+ def run_files(paths, config: Constable.config, seed: nil)
93
+ Array(paths).flat_map { |path| run_file(path, config: config, seed: seed) }
94
+ end
95
+
96
+ # Every file the `cold_cases:` globs match -- the zero-file-change adoption path.
97
+ # Absolute, de-duplicated, sorted so a run's file order is stable.
98
+ def cold_case_files(config: Constable.config)
99
+ root = config.root.to_s
100
+ matched = config.cold_cases.flat_map do |glob|
101
+ pattern = File.absolute_path?(glob.to_s) ? glob.to_s : File.join(root, glob.to_s)
102
+ Dir.glob(pattern, File::FNM_EXTGLOB)
103
+ end
104
+ matched.select { |path| File.file?(path) }.uniq.sort
105
+ end
106
+
107
+ # :rspec, :minitest, or nil when we genuinely cannot tell.
108
+ #
109
+ # An explicit Constable::ColdCase::* superclass is the strongest signal -- the user
110
+ # said so in the file. After that, filename convention, then a content sniff, then
111
+ # the directory the file lives in.
112
+ def engine_for(path)
113
+ path = path.to_s
114
+ source = head_of(path)
115
+
116
+ return :rspec if source&.match?(/<\s*(?:::)?Constable::ColdCase::RSpec\b/)
117
+ return :minitest if source&.match?(/<\s*(?:::)?Constable::ColdCase::Minitest\b/)
118
+
119
+ return :rspec if path.end_with?("_spec.rb")
120
+ return :minitest if path.end_with?("_test.rb")
121
+
122
+ if source
123
+ return :rspec if source.match?(/^\s*(?:RSpec\.)?(?:describe|feature|context)\b/)
124
+ return :minitest if source.match?(MINITEST_SUPERCLASSES) || source.match?(/^\s*def\s+test_/)
125
+ end
126
+
127
+ segments = path.split(File::SEPARATOR)
128
+ return :rspec if segments.include?("spec")
129
+ return :minitest if segments.include?("test")
130
+
131
+ nil
132
+ end
133
+
134
+ # The adapter module that knows how to drive one engine. Accepts an engine symbol
135
+ # or a path. Requiring is lazy: an app with only RSpec cold cases never loads
136
+ # Minitest, and vice versa.
137
+ def adapter_for(engine_or_path)
138
+ engine = ENGINES.include?(engine_or_path) ? engine_or_path : engine_for(engine_or_path)
139
+ raise Constable::Error, "unknown cold-case engine: #{engine_or_path.inspect}" unless engine
140
+
141
+ load_adapter!(engine)
142
+ end
143
+
144
+ # Drops both engines back to a clean slate. The adapters already restore global
145
+ # state around every file; this is the belt-and-braces version for a long-lived
146
+ # process (our own test suite, an editor plugin) that wants nothing left behind.
147
+ def reset_engines!
148
+ ENGINES.each do |engine|
149
+ adapter = adapter_module(engine)
150
+ adapter.reset_engine! if adapter.respond_to?(:reset_engine!)
151
+ end
152
+ end
153
+
154
+ # ---- lazy engine loading ---------------------------------------------------------
155
+
156
+ # Requires an engine, or explains -- precisely, and with the fix -- why it can't.
157
+ # Constable itself must keep loading without either gem installed; that is the
158
+ # entire point of the optional :cold_case group.
159
+ def require_engine!(engine, path: nil)
160
+ require ENGINE_REQUIRES.fetch(engine)
161
+ true
162
+ rescue ::LoadError => e
163
+ raise EngineMissing, missing_engine_message(engine, error: e, path: path)
164
+ end
165
+
166
+ def missing_engine_message(engine, error: nil, path: nil)
167
+ label = ENGINE_LABELS.fetch(engine)
168
+ where = path ? " in #{relative_path(path)}" : ""
169
+
170
+ <<~MESSAGE.strip
171
+ Cold cases#{where} need #{label}, but `require "#{ENGINE_REQUIRES.fetch(engine)}"` failed#{" (#{error.message})" if error}.
172
+
173
+ #{label} (the `#{ENGINE_GEMS.fetch(engine)}` gem) is only needed while cold cases exist, so
174
+ `rails generate constable:install` puts it in an optional Gemfile group.
175
+ Add the group back and run `bundle install`:
176
+
177
+ #{GEMFILE_GROUP.chomp.gsub(/^/, " ")}
178
+
179
+ Delete the group once the suite is fully modernized -- both dependencies drop out
180
+ on their own, because nothing native depends on them.
181
+ MESSAGE
182
+ end
183
+
184
+ # ---- shared helpers used by both adapters ----------------------------------------
185
+
186
+ # Exactly ONE warning per cold-case file, never per test. The count is the engine's
187
+ # own real example count, so the summary line reads the way SPEC.md shows it:
188
+ #
189
+ # running as a cold case (Constable::ColdCase::RSpec) -- 12 tests not yet under native rules
190
+ def warn_for_file(path, base_class_name, count, config: Constable.config)
191
+ location = relative_path(path, config: config)
192
+ return if warned?(location)
193
+
194
+ Constable.warn!(
195
+ "running as a cold case (#{base_class_name}) — " \
196
+ "#{count} #{count == 1 ? "test" : "tests"} not yet under native rules",
197
+ location: location,
198
+ kind: :cold_case
199
+ )
200
+ end
201
+
202
+ def relative_path(path, config: nil)
203
+ root = (config&.root || Constable.root).to_s
204
+ path.to_s.delete_prefix("#{root}/")
205
+ end
206
+
207
+ def absolute_path(path, config: Constable.config)
208
+ path = path.to_s
209
+ File.absolute_path?(path) ? path : File.expand_path(path, config.root.to_s)
210
+ end
211
+
212
+ # Cold-case classes announce themselves while their file loads, so a Result can be
213
+ # labelled `LegacyUsersSpec` rather than an anonymous example-group description.
214
+ def note_cold_class(klass)
215
+ @declared_classes&.push(klass)
216
+ klass
217
+ end
218
+
219
+ def while_loading(path)
220
+ previous_file = @loading_file
221
+ previous_classes = @declared_classes
222
+ @loading_file = path
223
+ @declared_classes = []
224
+ yield
225
+ ensure
226
+ @loading_file = previous_file
227
+ @declared_classes = previous_classes
228
+ end
229
+
230
+ # The name of the ColdCase subclass declared by the file we just loaded, if any.
231
+ # Anonymous classes are ignored -- they have nothing useful to display.
232
+ def declared_class_name
233
+ @declared_classes&.map { |k| k.name if k.respond_to?(:name) }&.compact&.first
234
+ end
235
+
236
+ attr_reader :loading_file
237
+
238
+ private
239
+
240
+ def warned?(location)
241
+ Constable.warnings.any? { |w| w[:kind] == :cold_case && w[:location] == location }
242
+ end
243
+
244
+ def load_adapter!(engine)
245
+ require_engine!(engine)
246
+ require "constable/cold_case/#{engine}"
247
+ adapter_module(engine)
248
+ end
249
+
250
+ def adapter_module(engine)
251
+ name = engine == :rspec ? :RSpecAdapter : :MinitestAdapter
252
+ const_defined?(name, false) ? const_get(name, false) : nil
253
+ end
254
+
255
+ # Reads just enough of a file to classify it. Cheap, and never blows up on a file
256
+ # that has been deleted between globbing and running.
257
+ def head_of(path, bytes: 8192)
258
+ return nil unless File.file?(path)
259
+
260
+ File.open(path, "rb") { |f| f.read(bytes) }&.force_encoding(Encoding::UTF_8)
261
+ rescue SystemCallError, IOError
262
+ nil
263
+ end
264
+ end
265
+
266
+ # Constable::ColdCase::RSpec and ::Minitest are the user-facing base classes, and
267
+ # referencing one is what triggers the lazy require of the engine behind it. Doing
268
+ # it here rather than with `autoload` lets us turn a bare LoadError into the
269
+ # actionable "add the :cold_case group back" message.
270
+ def self.const_missing(name)
271
+ case name
272
+ when :RSpec, :Minitest
273
+ engine = name == :RSpec ? :rspec : :minitest
274
+ load_adapter!(engine)
275
+ return const_get(name, false) if const_defined?(name, false)
276
+ end
277
+ super
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "etc"
5
+
6
+ module Constable
7
+ # Settings, as opposed to code. Everything here comes from .constable/config.yml
8
+ # and may be overridden per-run by CLI flags. Code-level configuration (matchers,
9
+ # tier base classes, one-time global setup) lives in test/case_helper.rb instead.
10
+ class Config
11
+ DEFAULTS = {
12
+ "cold_cases" => [],
13
+ "storage" => { "adapter" => "sqlite", "path" => ".constable/constable.sqlite3", "url" => nil },
14
+ "warrants" => false,
15
+ "warrant_retries" => 5,
16
+ "auto_relink" => false,
17
+ "parole_period" => 10,
18
+ "coverage" => false,
19
+ "coverage_threshold" => 90,
20
+ "coverage_html" => false,
21
+ "fail_on_warnings" => false,
22
+ "parallel_workers" => "auto",
23
+ "tiers" => {
24
+ "unit" => "test/cases/models/**/*",
25
+ "integration" => "test/cases/controllers/**/*",
26
+ "system" => "test/cases/system/**/*"
27
+ }
28
+ }.freeze
29
+
30
+ CONFIG_PATH = ".constable/config.yml"
31
+
32
+ attr_reader :root, :raw
33
+
34
+ def self.load(root: Constable.root, overrides: {})
35
+ 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)
38
+ end
39
+
40
+ def initialize(raw = {}, root: Constable.root, overrides: {})
41
+ @root = root.to_s
42
+ @raw = deep_merge(DEFAULTS, stringify(raw || {}))
43
+ @raw = deep_merge(@raw, stringify(overrides || {}))
44
+ end
45
+
46
+ def cold_cases = Array(@raw["cold_cases"])
47
+ def warrants? = truthy(@raw["warrants"])
48
+ def warrant_retries = @raw["warrant_retries"].to_i
49
+ def auto_relink? = truthy(@raw["auto_relink"])
50
+ def parole_period = @raw["parole_period"].to_i
51
+ def coverage? = truthy(@raw["coverage"])
52
+ def coverage_threshold = @raw["coverage_threshold"].to_i
53
+ def coverage_html? = truthy(@raw["coverage_html"])
54
+ def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
55
+ def tiers = @raw["tiers"] || {}
56
+ def storage = @raw["storage"] || {}
57
+
58
+ def storage_adapter = (storage["adapter"] || "sqlite").to_s
59
+ def storage_url = storage["url"]
60
+
61
+ def storage_path
62
+ path = storage["path"] || DEFAULTS["storage"]["path"]
63
+ File.absolute_path?(path) ? path : File.join(@root, path)
64
+ end
65
+
66
+ # "auto" resolves to the machine's processor count, minus a little headroom so a
67
+ # developer's laptop stays usable while the suite runs.
68
+ def parallel_workers
69
+ value = @raw["parallel_workers"]
70
+ return [value.to_i, 1].max unless value.nil? || value.to_s == "auto"
71
+
72
+ [Etc.nprocessors - 1, 1].max
73
+ end
74
+
75
+ # Path-based tier inference. This is the *fallback* -- an explicit `tier :unit`
76
+ # macro or a tiered base class always wins.
77
+ def tier_for(path)
78
+ relative = path.to_s.delete_prefix("#{@root}/")
79
+ tiers.each do |tier, glob|
80
+ next unless glob
81
+ return tier.to_sym if File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
82
+ File.fnmatch?(glob.to_s, path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
83
+ end
84
+ nil
85
+ end
86
+
87
+ def cold_case?(path)
88
+ relative = path.to_s.delete_prefix("#{@root}/")
89
+ cold_cases.any? do |glob|
90
+ File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
91
+ File.fnmatch?(glob.to_s, path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
92
+ end
93
+ end
94
+
95
+ def [](key) = @raw[key.to_s]
96
+ def to_h = @raw.dup
97
+
98
+ private
99
+
100
+ def truthy(value)
101
+ return false if value.nil? || value == false
102
+ return false if value.to_s.strip.downcase == "false"
103
+
104
+ true
105
+ end
106
+
107
+ def stringify(hash)
108
+ return hash unless hash.is_a?(Hash)
109
+
110
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v.is_a?(Hash) ? stringify(v) : v }
111
+ end
112
+
113
+ def deep_merge(base, other)
114
+ base.merge(other) do |_key, old, new|
115
+ if old.is_a?(Hash) && new.is_a?(Hash)
116
+ deep_merge(old, new)
117
+ elsif new.nil?
118
+ old
119
+ else
120
+ new
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end