testsift 0.0.1

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 (113) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE +22 -0
  4. data/README.md +572 -0
  5. data/bin/testsift +15 -0
  6. data/lib/testsift/cache/Rakefile +43 -0
  7. data/lib/testsift/cli/blast_radius.rb +318 -0
  8. data/lib/testsift/cli/cache_clear.rb +114 -0
  9. data/lib/testsift/cli/cache_info.rb +106 -0
  10. data/lib/testsift/cli/doctor.rb +307 -0
  11. data/lib/testsift/cli/explain.rb +273 -0
  12. data/lib/testsift/cli/report_open.rb +90 -0
  13. data/lib/testsift/cli/snapshot_helpers.rb +119 -0
  14. data/lib/testsift/cli.rb +219 -0
  15. data/lib/testsift/configuration.rb +1467 -0
  16. data/lib/testsift/defaults.rb +11 -0
  17. data/lib/testsift/engine/snapshot_builder.rb +117 -0
  18. data/lib/testsift/engine.rb +896 -0
  19. data/lib/testsift/example.rb +188 -0
  20. data/lib/testsift/filter.rb +103 -0
  21. data/lib/testsift/line_stub.rb +62 -0
  22. data/lib/testsift/load_config.rb +15 -0
  23. data/lib/testsift/load_default_config.rb +46 -0
  24. data/lib/testsift/load_global_config.rb +33 -0
  25. data/lib/testsift/load_local_config.rb +17 -0
  26. data/lib/testsift/logger.rb +59 -0
  27. data/lib/testsift/rails/README.md +78 -0
  28. data/lib/testsift/rails/i18n_tracking.rb +137 -0
  29. data/lib/testsift/rails/notifications.rb +263 -0
  30. data/lib/testsift/rails/preset.rb +94 -0
  31. data/lib/testsift/rails/railtie.rb +22 -0
  32. data/lib/testsift/rails.rb +15 -0
  33. data/lib/testsift/remote_cache/README.md +140 -0
  34. data/lib/testsift/remote_cache/Rakefile +61 -0
  35. data/lib/testsift/remote_cache/archive.rb +137 -0
  36. data/lib/testsift/remote_cache/backend.rb +73 -0
  37. data/lib/testsift/remote_cache/git_ancestry.rb +241 -0
  38. data/lib/testsift/remote_cache/local_fs_backend.rb +439 -0
  39. data/lib/testsift/remote_cache/redis_backend.rb +554 -0
  40. data/lib/testsift/remote_cache/s3_backend.rb +712 -0
  41. data/lib/testsift/remote_cache/user_tasks.rb +436 -0
  42. data/lib/testsift/remote_cache/validator.rb +50 -0
  43. data/lib/testsift/remote_cache.rb +22 -0
  44. data/lib/testsift/reporters/README.md +103 -0
  45. data/lib/testsift/reporters/base.rb +87 -0
  46. data/lib/testsift/reporters/coverage_json_reporter.rb +338 -0
  47. data/lib/testsift/reporters/html/.gitignore +19 -0
  48. data/lib/testsift/reporters/html/.prettierignore +4 -0
  49. data/lib/testsift/reporters/html/.prettierrc.json +9 -0
  50. data/lib/testsift/reporters/html/README.md +80 -0
  51. data/lib/testsift/reporters/html/dist/assets/index.css +2 -0
  52. data/lib/testsift/reporters/html/dist/assets/index.js +1 -0
  53. data/lib/testsift/reporters/html/dist/index.html +24 -0
  54. data/lib/testsift/reporters/html/eslint.config.js +62 -0
  55. data/lib/testsift/reporters/html/package-lock.json +4910 -0
  56. data/lib/testsift/reporters/html/package.json +29 -0
  57. data/lib/testsift/reporters/html/src/app.jsx +130 -0
  58. data/lib/testsift/reporters/html/src/components/AllExamples.jsx +86 -0
  59. data/lib/testsift/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  60. data/lib/testsift/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  61. data/lib/testsift/reporters/html/src/components/FilesDependency.jsx +72 -0
  62. data/lib/testsift/reporters/html/src/components/FlakyExamples.jsx +42 -0
  63. data/lib/testsift/reporters/html/src/components/ReportTable.jsx +131 -0
  64. data/lib/testsift/reporters/html/src/components/SearchBar.jsx +19 -0
  65. data/lib/testsift/reporters/html/src/index.html +23 -0
  66. data/lib/testsift/reporters/html/src/main.jsx +37 -0
  67. data/lib/testsift/reporters/html/src/styles.css +434 -0
  68. data/lib/testsift/reporters/html/vite.config.js +42 -0
  69. data/lib/testsift/reporters/html_reporter.rb +266 -0
  70. data/lib/testsift/reporters/json_reporter.rb +88 -0
  71. data/lib/testsift/reporters/payload_builder.rb +241 -0
  72. data/lib/testsift/reporters/registry.rb +120 -0
  73. data/lib/testsift/reporters/terminal_reporter.rb +264 -0
  74. data/lib/testsift/rspec/README.md +73 -0
  75. data/lib/testsift/rspec/installation.rb +97 -0
  76. data/lib/testsift/rspec/metadata.rb +96 -0
  77. data/lib/testsift/rspec/parallel_tests.rb +459 -0
  78. data/lib/testsift/rspec/rails_setup.rb +42 -0
  79. data/lib/testsift/rspec/reporter_hook.rb +90 -0
  80. data/lib/testsift/rspec/runner_hook.rb +253 -0
  81. data/lib/testsift/source_file.rb +56 -0
  82. data/lib/testsift/storage/README.md +35 -0
  83. data/lib/testsift/storage/backend.rb +130 -0
  84. data/lib/testsift/storage/json_backend.rb +884 -0
  85. data/lib/testsift/storage/lazy_snapshot.rb +65 -0
  86. data/lib/testsift/storage/schema.rb +50 -0
  87. data/lib/testsift/storage/serializer/json.rb +41 -0
  88. data/lib/testsift/storage/serializer/msgpack.rb +167 -0
  89. data/lib/testsift/storage/snapshot.rb +141 -0
  90. data/lib/testsift/storage/sqlite_backend.rb +709 -0
  91. data/lib/testsift/time_formatter.rb +74 -0
  92. data/lib/testsift/tracker/README.md +36 -0
  93. data/lib/testsift/tracker/coverage_adapter.rb +180 -0
  94. data/lib/testsift/tracker/declared_globs.rb +100 -0
  95. data/lib/testsift/tracker/dependency_graph.rb +134 -0
  96. data/lib/testsift/tracker/env_matcher.rb +127 -0
  97. data/lib/testsift/tracker/env_snapshot.rb +77 -0
  98. data/lib/testsift/tracker/example_registry.rb +153 -0
  99. data/lib/testsift/tracker/file_digest.rb +61 -0
  100. data/lib/testsift/tracker/filter.rb +127 -0
  101. data/lib/testsift/tracker/input.rb +99 -0
  102. data/lib/testsift/tracker/io_hooks/file.rb +55 -0
  103. data/lib/testsift/tracker/io_hooks/io.rb +24 -0
  104. data/lib/testsift/tracker/io_hooks/json.rb +23 -0
  105. data/lib/testsift/tracker/io_hooks/kernel.rb +26 -0
  106. data/lib/testsift/tracker/io_hooks/yaml.rb +38 -0
  107. data/lib/testsift/tracker/io_hooks.rb +195 -0
  108. data/lib/testsift/tracker/loaded_files_tracker.rb +290 -0
  109. data/lib/testsift/tracker/new_file_detector.rb +62 -0
  110. data/lib/testsift/tracker/whole_suite_invalidators.rb +96 -0
  111. data/lib/testsift/version.rb +7 -0
  112. data/lib/testsift.rb +335 -0
  113. metadata +193 -0
@@ -0,0 +1,1467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative 'filter'
6
+ require_relative 'logger'
7
+
8
+ module TestSift
9
+ # The user-facing configuration DSL. Mixed into `TestSift` itself,
10
+ # so calls inside a `.testsift` (or `~/.testsift`) file appear
11
+ # against the top-level module:
12
+ #
13
+ # @example A typical `.testsift`
14
+ # TestSift.configure do
15
+ # project_name 'My App'
16
+ # track_files 'config/locales/**/*.yml', 'db/schema.rb', 'Gemfile.lock'
17
+ # track_env 'AUTH_TOKEN', 'DATABASE_URL', 'RAILS_*'
18
+ # track_rails_defaults
19
+ #
20
+ # storage_backend :sqlite
21
+ # remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'testsift'
22
+ #
23
+ # add_filter '/vendor/'
24
+ # add_coverage_filter %w[/spec/ /test/]
25
+ # end
26
+ #
27
+ # The DSL methods are technically `private` in Ruby; the `configure`
28
+ # block uses Docile to expose them as if public. Calling them
29
+ # outside a `.testsift` file raises `InvalidUsageError`. The
30
+ # configuration loader allowlist enforces this gate (see
31
+ # {ALLOWED_CONFIGURER}).
32
+ #
33
+ # See also:
34
+ # - {file:README.md} — user guide.
35
+ # - {file:UPGRADING.md} — moving from rspec-tracer.
36
+ # - {file:ARCHITECTURE.md} — input taxonomy + layer structure.
37
+ # - {file:COOKBOOK.md} — recipes for common scenarios.
38
+ #
39
+ # rubocop:disable Metrics/ModuleLength
40
+ module Configuration
41
+ # Raised when the configuration DSL is invoked outside a
42
+ # `.testsift` / `~/.testsift` loader, when a typo'd DSL
43
+ # method is called (with a `did_you_mean?` suggestion when one
44
+ # exists), or when an option value fails validation.
45
+ class InvalidUsageError < StandardError; end
46
+
47
+ # Internal constant.
48
+ # @api private
49
+ ALLOWED_CONFIGURER = %w[
50
+ lib/testsift/load_default_config.rb
51
+ lib/testsift/load_global_config.rb
52
+ lib/testsift/load_local_config.rb
53
+ ].freeze
54
+
55
+ # Internal constant.
56
+ # @api private
57
+ DEFAULT_CACHE_DIR = 'testsift_cache'
58
+ # Internal constant.
59
+ # @api private
60
+ DEFAULT_COVERAGE_DIR = 'testsift_coverage'
61
+ # Internal constant.
62
+ # @api private
63
+ DEFAULT_REPORT_DIR = 'testsift_report'
64
+ # Internal constant.
65
+ # @api private
66
+ DEFAULT_LOCK_FILE = 'testsift.lock'
67
+ # Internal constant.
68
+ # @api private
69
+ DEFAULT_STORAGE_BACKEND = :json
70
+ # :sqlite opt-in: MRI >= 3.2 only (sqlite3 2.x gem requirement).
71
+ # Kept closed so typos raise early.
72
+ STORAGE_BACKEND_NAMES = %i[json sqlite].freeze
73
+ # Keys allowed in `storage_backend`'s opts hash. `:serializer` is
74
+ # accepted only for the :json backend (see validate_storage_opts!).
75
+ STORAGE_BACKEND_OPT_KEYS = %i[serializer].freeze
76
+ # Internal constant.
77
+ # @api private
78
+ STORAGE_BACKEND_SERIALIZERS = %i[json msgpack].freeze
79
+ # Default Ruby `Coverage` modes when the user has not called the
80
+ # `coverage_modes` DSL. Matches bare `Coverage.start` semantics so
81
+ # legacy configs continue to emit lines-only `coverage.json` shape.
82
+ DEFAULT_COVERAGE_MODES = %i[lines].freeze
83
+ # Allowed Ruby `Coverage` modes accepted by the `coverage_modes`
84
+ # DSL. The set tracks Ruby 3.1+ `Coverage.start` keyword args.
85
+ # `coverage.json` ships the lines-only `Array<Integer|nil>` shape
86
+ # per file regardless — branches / methods / oneshot_lines / eval
87
+ # data is collected by Ruby but the user-facing artifact stays on
88
+ # the documented 1.x shape (see docs/upstream/UPGRADING.md `#SimpleCov branch
89
+ # coverage now works` for SimpleCov interop).
90
+ COVERAGE_MODES = %i[lines branches methods oneshot_lines eval].freeze
91
+ # Per-save retention on the local cache's run-id directories.
92
+ # 5 keeps enough history for rollback debugging without letting
93
+ # the cache grow unbounded (issue #20). 0 opts out entirely.
94
+ DEFAULT_CACHE_RETENTION_LOCAL_COUNT = 5
95
+ # Size budgets (MiB). Warn at save time when any single cache
96
+ # file exceeds the per-file threshold or when the cache total
97
+ # exceeds the aggregate threshold. Surfaces dependency.json
98
+ # ballooning past the few-MB range while the user can still
99
+ # act on them. Set to 0 to disable either individually.
100
+ DEFAULT_CACHE_SIZE_WARN_PER_FILE_MB = 50
101
+ # Internal constant.
102
+ # @api private
103
+ DEFAULT_CACHE_SIZE_WARN_TOTAL_MB = 500
104
+
105
+ # Internal constant.
106
+ # @api private
107
+ LOG_LEVEL = {
108
+ off: 0,
109
+ debug: 1,
110
+ info: 2,
111
+ warn: 3,
112
+ error: 4
113
+ }.freeze
114
+
115
+ # Internal method on the tracer pipeline.
116
+ # @api private
117
+ def configure(&)
118
+ # Scan the full caller chain (not just the immediate two frames):
119
+ # MRI / JRuby place the load_*_config.rb loader at depth 2, but
120
+ # TruffleRuby's `load` interposes an extra runtime frame, pushing
121
+ # the loader past the 2-frame window and tripping the InvalidUsage
122
+ # raise. Path-suffix match against the well-known loader filenames
123
+ # keeps the gate safe regardless of stack depth - user code would
124
+ # have to copy one of those filenames verbatim into a path it
125
+ # `load`s to get a false positive, which we treat as wilful.
126
+ configurers = caller_locations(1).map(&:path)
127
+ invalid = configurers.none? do |configurer|
128
+ ALLOWED_CONFIGURER.any? do |allowed_configurer|
129
+ configurer.end_with?(allowed_configurer)
130
+ end
131
+ end
132
+
133
+ raise InvalidUsageError, 'You must define configurations in a .testsift file' if invalid
134
+
135
+ TestSift::Configuration.module_exec do
136
+ TestSift::Configuration.private_instance_methods(false).each do |method_name|
137
+ alias_method :"_#{method_name}", method_name
138
+
139
+ # Forward `**kwargs` too so DSL methods can accept Ruby 3+
140
+ # keyword args (e.g. `track_rails_defaults except: [:views]`,
141
+ # `storage_backend :json, serializer: :msgpack`). Earlier
142
+ # forms of this wrapper forwarded `*args, &block` only and
143
+ # silently stripped kwargs.
144
+ define_method method_name do |*args, **kwargs, &block|
145
+ send(:"_#{method_name}", *args, **kwargs, &block)
146
+ end
147
+ end
148
+ end
149
+
150
+ Docile.dsl_eval(self, &)
151
+ end
152
+
153
+ private
154
+
155
+ # Set the project root directory. Defaults to `Dir.getwd`.
156
+ # Affects how all other path-based DSL methods (`cache_dir`,
157
+ # `report_dir`, `coverage_dir`, `track_files` globs) are
158
+ # resolved.
159
+ #
160
+ # @param root [String, nil] absolute or relative path; nil
161
+ # returns the current value (or default).
162
+ # @return [String] absolute project root path.
163
+ # @example
164
+ # TestSift.configure { root '/path/to/project' }
165
+ def root(root = nil)
166
+ return @root if defined?(@root) && root.nil?
167
+
168
+ @cache_path = nil
169
+ @report_path = nil
170
+ @coverage_path = nil
171
+
172
+ @root = File.expand_path(root || Dir.getwd)
173
+ end
174
+
175
+ # Set the project's display name; appears in HTML reports.
176
+ # Defaults to a humanized form of the project root's basename.
177
+ #
178
+ # @param new_name [String, nil] human-readable project name.
179
+ # @return [String] the configured (or derived) project name.
180
+ def project_name(new_name = nil)
181
+ return @project_name if defined?(@project_name) && @project_name && new_name.nil?
182
+
183
+ @project_name = new_name if new_name.is_a?(String)
184
+ @project_name ||= File.basename(root.split('/').last).capitalize.tr('_', ' ')
185
+ end
186
+
187
+ # Override the on-disk cache directory (default
188
+ # `testsift_cache`). Also reads `TESTSIFT_CACHE_DIR`
189
+ # from env when set. The directory ships in the canonical
190
+ # `.gitignore` recipe; renaming silently leaks cache into
191
+ # source control.
192
+ #
193
+ # @param dir [String, nil] relative-to-root or absolute path.
194
+ # @return [String] configured cache directory.
195
+ def cache_dir(dir = nil)
196
+ return @cache_dir if defined?(@cache_dir) && dir.nil?
197
+
198
+ @cache_path = nil
199
+ @cache_dir = if ENV.key?('TESTSIFT_CACHE_DIR')
200
+ ENV['TESTSIFT_CACHE_DIR']
201
+ else
202
+ dir || DEFAULT_CACHE_DIR
203
+ end
204
+ end
205
+
206
+ # Resolve the absolute cache path; expanded against {#root} and
207
+ # scoped per `TEST_SUITE_ID` / `parallel_tests` worker. Creates
208
+ # the directory on first access.
209
+ #
210
+ # @return [String] absolute cache path.
211
+ def cache_path
212
+ @cache_path ||= begin
213
+ cache_path = File.expand_path(cache_dir, root)
214
+ cache_path = File.join(cache_path, ENV['TEST_SUITE_ID'].to_s) if ENV['TEST_SUITE_ID']
215
+ cache_path = File.join(cache_path, parallel_tests_id) if TestSift.parallel_tests?
216
+
217
+ FileUtils.mkdir_p(cache_path)
218
+
219
+ cache_path
220
+ end
221
+ end
222
+
223
+ # Override the on-disk HTML/JSON report directory (default
224
+ # `testsift_report`). Also reads `TESTSIFT_REPORT_DIR`.
225
+ #
226
+ # @param dir [String, nil] relative-to-root or absolute path.
227
+ # @return [String] configured report directory.
228
+ def report_dir(dir = nil)
229
+ return @report_dir if defined?(@report_dir) && dir.nil?
230
+
231
+ @report_path = nil
232
+ @report_dir = if ENV.key?('TESTSIFT_REPORT_DIR')
233
+ ENV['TESTSIFT_REPORT_DIR']
234
+ else
235
+ dir || DEFAULT_REPORT_DIR
236
+ end
237
+ end
238
+
239
+ # Resolve the absolute report path; expanded against {#root} and
240
+ # scoped per `TEST_SUITE_ID` / `parallel_tests` worker. Creates
241
+ # the directory on first access.
242
+ #
243
+ # @return [String] absolute report path.
244
+ def report_path
245
+ @report_path ||= begin
246
+ report_path = File.expand_path(report_dir, root)
247
+ report_path = File.join(report_path, ENV['TEST_SUITE_ID'].to_s) if ENV['TEST_SUITE_ID']
248
+ report_path = File.join(report_path, parallel_tests_id) if TestSift.parallel_tests?
249
+
250
+ FileUtils.mkdir_p(report_path)
251
+
252
+ report_path
253
+ end
254
+ end
255
+
256
+ # Override the coverage output directory (default
257
+ # `testsift_coverage`). Also reads `TESTSIFT_COVERAGE_DIR`.
258
+ #
259
+ # @param dir [String, nil] relative-to-root or absolute path.
260
+ # @return [String] configured coverage directory.
261
+ def coverage_dir(dir = nil)
262
+ return @coverage_dir if defined?(@coverage_dir) && dir.nil?
263
+
264
+ @coverage_path = nil
265
+ @coverage_dir = if ENV.key?('TESTSIFT_COVERAGE_DIR')
266
+ ENV['TESTSIFT_COVERAGE_DIR']
267
+ else
268
+ dir || DEFAULT_COVERAGE_DIR
269
+ end
270
+ end
271
+
272
+ # Resolve the absolute coverage path; expanded against {#root}
273
+ # and scoped per `TEST_SUITE_ID` / `parallel_tests` worker.
274
+ # Creates the directory on first access.
275
+ #
276
+ # @return [String] absolute coverage path.
277
+ def coverage_path
278
+ @coverage_path ||= begin
279
+ coverage_path = File.expand_path(coverage_dir, root)
280
+ coverage_path = File.join(coverage_path, ENV['TEST_SUITE_ID'].to_s) if ENV['TEST_SUITE_ID']
281
+ coverage_path = File.join(coverage_path, parallel_tests_id) if TestSift.parallel_tests?
282
+
283
+ FileUtils.mkdir_p(coverage_path)
284
+
285
+ coverage_path
286
+ end
287
+ end
288
+
289
+ # Configure the remote-cache backend used by the
290
+ # `rake testsift:remote_cache:download` / `:upload` tasks.
291
+ # Single-entry accumulator: a second call raises
292
+ # {InvalidUsageError} so a misconfigured `.testsift` fails
293
+ # fast.
294
+ #
295
+ # @param name_or_class [Symbol, Class] one of `:s3`, `:local_fs`,
296
+ # `:redis`, OR a custom class implementing
297
+ # {TestSift::RemoteCache::Backend}.
298
+ # @param opts [Hash] backend-specific keyword args (e.g. `bucket:`
299
+ # `prefix:` for `:s3`; `root:` for `:local_fs`; `url:` `ttl:`
300
+ # for `:redis`).
301
+ # @return [Array(Symbol, Hash)] the persisted entry pair.
302
+ # @example S3 (preserves the 1.x layout)
303
+ # remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'testsift'
304
+ # @example LocalStack / awslocal (development)
305
+ # remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'x', local: true
306
+ # @example Filesystem-backed (no S3 needed)
307
+ # remote_cache_backend :local_fs, root: '/tmp/testsift-cache'
308
+ # @example Redis (with TTL + PR-branch tracking sidecar)
309
+ # remote_cache_backend :redis, url: ENV['REDIS_URL'], ttl: 7 * 86_400
310
+ # @example Custom backend class
311
+ # remote_cache_backend MyCustomBackend, custom_opt: 'value'
312
+ def remote_cache_backend(name_or_class, **opts)
313
+ if defined?(@remote_cache_backend_entry) && @remote_cache_backend_entry
314
+ raise InvalidUsageError,
315
+ 'remote_cache already configured. `remote_cache_backend` and ' \
316
+ '`remote_cache_uri` are alternative DSLs — call one exactly once.'
317
+ end
318
+
319
+ validate_remote_cache_backend(name_or_class)
320
+ @remote_cache_backend_entry = [name_or_class, opts.dup.freeze].freeze
321
+ end
322
+
323
+ # Reader: returns the [name_or_class, opts] pair or nil. Called
324
+ # from `RemoteCache::UserTasks` at task dispatch time.
325
+ def remote_cache_backend_entry
326
+ return nil unless defined?(@remote_cache_backend_entry)
327
+
328
+ @remote_cache_backend_entry
329
+ end
330
+
331
+ # Internal method on the tracer pipeline.
332
+ # @api private
333
+ def validate_remote_cache_backend(name_or_class)
334
+ case name_or_class
335
+ when ::Symbol, ::Class
336
+ nil
337
+ else
338
+ raise InvalidUsageError,
339
+ "remote_cache_backend: expected Symbol or Class, got #{name_or_class.class}"
340
+ end
341
+ end
342
+
343
+ # Convenience DSL accepting a single URI string and dispatching
344
+ # to {#remote_cache_backend} with the parsed bucket / prefix /
345
+ # host / path. Also reads `TESTSIFT_REMOTE_CACHE_URI` from env.
346
+ #
347
+ # @param uri [String, nil] one of `s3://bucket/prefix`,
348
+ # `file:///abs/path`, `redis://host:port/db`. Pass nil to read
349
+ # the env var (or return the cached value if set).
350
+ # @return [String, nil] the configured URI, or nil when neither
351
+ # arg nor env is set.
352
+ # @example
353
+ # remote_cache_uri 's3://my-bucket/testsift'
354
+ # @example
355
+ # remote_cache_uri 'file:///tmp/testsift-cache'
356
+ # @example
357
+ # remote_cache_uri 'redis://localhost:6379/0'
358
+ #
359
+ # Convenience DSL: `remote_cache_uri '<scheme>://...'` parses the
360
+ # URI and calls `remote_cache_backend` with the right backend +
361
+ # connection params. Also accepts ENV `TESTSIFT_REMOTE_CACHE_URI`.
362
+ # Supported schemes:
363
+ # s3://<bucket>/<prefix> -> S3Backend
364
+ # file:///absolute/path -> LocalFsBackend (root:)
365
+ # redis://[user:pass@]host:port/<db> -> RedisBackend (prefix: 'testsift')
366
+ # Users who need finer control (custom Redis prefix, LocalFs on a
367
+ # relative path) use `remote_cache_backend` directly.
368
+ def remote_cache_uri(uri = nil)
369
+ return @remote_cache_uri if defined?(@remote_cache_uri) && uri.nil?
370
+
371
+ value = ENV.fetch('TESTSIFT_REMOTE_CACHE_URI', uri)
372
+ return nil if value.nil?
373
+
374
+ parsed = parse_remote_cache_uri(value)
375
+ dispatch_remote_cache_uri(parsed)
376
+
377
+ @remote_cache_uri = value
378
+ end
379
+
380
+ # Internal method on the tracer pipeline.
381
+ # @api private
382
+ def dispatch_remote_cache_uri(parsed)
383
+ case parsed.scheme
384
+ when 's3'
385
+ prefix = parsed.path.to_s.sub(%r{^/}, '')
386
+ remote_cache_backend(:s3, bucket: parsed.host, prefix: prefix)
387
+ when 'file'
388
+ remote_cache_backend(:local_fs, root: parsed.path.to_s)
389
+ when 'redis', 'rediss'
390
+ remote_cache_backend(:redis, url: parsed.to_s, prefix: 'testsift')
391
+ else
392
+ raise InvalidUsageError,
393
+ "unsupported remote_cache_uri scheme: #{parsed.scheme.inspect} " \
394
+ "(supported: 's3', 'file', 'redis', 'rediss')"
395
+ end
396
+ end
397
+
398
+ # Validate a parsed URI per the active scheme. Host presence is the
399
+ # common structural signal for S3 / Redis; `file://` uses path
400
+ # instead (host is optional and usually empty).
401
+ def parse_remote_cache_uri(value)
402
+ parsed = URI.parse(value)
403
+ raise InvalidUsageError, "invalid remote_cache_uri: #{value.inspect}" unless valid_remote_cache_uri?(parsed)
404
+
405
+ parsed
406
+ rescue URI::InvalidURIError => e
407
+ raise InvalidUsageError, "invalid remote_cache_uri: #{value.inspect} (#{e.message})"
408
+ end
409
+
410
+ # Internal method on the tracer pipeline.
411
+ # @api private
412
+ def valid_remote_cache_uri?(parsed)
413
+ return false if parsed.scheme.nil?
414
+
415
+ case parsed.scheme
416
+ when 'file'
417
+ !parsed.path.to_s.empty?
418
+ else
419
+ !parsed.host.nil? && !parsed.host.empty?
420
+ end
421
+ end
422
+
423
+ # Retention knobs (closes issue #20). Mutually exclusive: count
424
+ # and duration bound the main tier from different axes. PR tier
425
+ # uses `cache_retention_pr_branch_ttl` independently.
426
+ # Cap remote-cache main-tier refs to a count. Mutually exclusive
427
+ # with {#cache_retention_duration}; setting both raises.
428
+ #
429
+ # @param count [Integer, nil] positive integer; nil reads current
430
+ # value.
431
+ # @return [Integer, nil] configured count.
432
+ # @raise [InvalidUsageError] when count is non-positive or when
433
+ # {#cache_retention_duration} is already set.
434
+ def cache_retention_count(count = nil)
435
+ return @cache_retention_count if defined?(@cache_retention_count) && count.nil?
436
+ return nil if count.nil?
437
+
438
+ raise_if_retention_conflict(:cache_retention_duration)
439
+ unless count.is_a?(::Integer) && count.positive?
440
+ raise InvalidUsageError, "cache_retention_count must be a positive integer, got #{count.inspect}"
441
+ end
442
+
443
+ @cache_retention_count = count
444
+ end
445
+
446
+ # Cap remote-cache main-tier refs by age. Mutually exclusive with
447
+ # {#cache_retention_count}; setting both raises.
448
+ #
449
+ # @param spec [String, Integer, nil] duration string (e.g.
450
+ # `'7d'`, `'48h'`, `'30m'`) OR seconds as integer. nil reads
451
+ # current.
452
+ # @return [String, Integer, nil] configured raw spec.
453
+ # @raise [InvalidUsageError] when spec is unparseable or when
454
+ # {#cache_retention_count} is already set.
455
+ def cache_retention_duration(spec = nil)
456
+ return @cache_retention_duration_raw if defined?(@cache_retention_duration_raw) && spec.nil?
457
+ return nil if spec.nil?
458
+
459
+ raise_if_retention_conflict(:cache_retention_count)
460
+ seconds = parse_retention_duration_seconds(spec)
461
+ @cache_retention_duration_raw = spec
462
+ @cache_retention_duration_seconds = seconds
463
+ end
464
+
465
+ # @return [Integer, nil] {#cache_retention_duration} expressed
466
+ # in seconds, or nil if not set.
467
+ def cache_retention_duration_seconds
468
+ return nil unless defined?(@cache_retention_duration_seconds)
469
+
470
+ @cache_retention_duration_seconds
471
+ end
472
+
473
+ # Cap remote-cache PR-tier refs by age. Independent of main-tier
474
+ # retention (PR branches typically need shorter TTLs than the
475
+ # historical main).
476
+ #
477
+ # @param spec [String, Integer, nil] duration string or seconds.
478
+ # @return [String, Integer, nil] configured raw spec.
479
+ def cache_retention_pr_branch_ttl(spec = nil)
480
+ return @cache_retention_pr_branch_ttl_raw if defined?(@cache_retention_pr_branch_ttl_raw) && spec.nil?
481
+ return nil if spec.nil?
482
+
483
+ seconds = parse_retention_duration_seconds(spec)
484
+ @cache_retention_pr_branch_ttl_raw = spec
485
+ @cache_retention_pr_branch_ttl_seconds = seconds
486
+ end
487
+
488
+ # @return [Integer, nil] {#cache_retention_pr_branch_ttl}
489
+ # expressed in seconds.
490
+ def cache_retention_pr_branch_ttl_seconds
491
+ return nil unless defined?(@cache_retention_pr_branch_ttl_seconds)
492
+
493
+ @cache_retention_pr_branch_ttl_seconds
494
+ end
495
+
496
+ # Internal method on the tracer pipeline.
497
+ # @api private
498
+ def raise_if_retention_conflict(other_method)
499
+ other_ivar =
500
+ case other_method
501
+ when :cache_retention_count then :@cache_retention_count
502
+ when :cache_retention_duration then :@cache_retention_duration_seconds
503
+ end
504
+ return unless instance_variable_defined?(other_ivar) && instance_variable_get(other_ivar)
505
+
506
+ raise InvalidUsageError,
507
+ 'cache_retention_count and cache_retention_duration are mutually exclusive'
508
+ end
509
+
510
+ # Internal method on the tracer pipeline.
511
+ # @api private
512
+ def parse_retention_duration_seconds(spec)
513
+ case spec
514
+ when ::Integer
515
+ raise InvalidUsageError, 'retention duration must be positive' unless spec.positive?
516
+
517
+ spec
518
+ when ::String
519
+ parse_retention_duration_from_string(spec)
520
+ else
521
+ raise InvalidUsageError,
522
+ "invalid retention duration: #{spec.inspect} (expected Integer seconds or String like '30 days')"
523
+ end
524
+ end
525
+
526
+ # Internal method on the tracer pipeline.
527
+ # @api private
528
+ def parse_retention_duration_from_string(spec)
529
+ match = spec.strip.match(/\A(\d+)\s+(second|minute|hour|day|week)s?\z/i)
530
+ unless match
531
+ raise InvalidUsageError,
532
+ "invalid retention duration: #{spec.inspect} (expected e.g. '30 days', '2 weeks', '1 hour')"
533
+ end
534
+
535
+ units = { 'second' => 1, 'minute' => 60, 'hour' => 3600, 'day' => 86_400, 'week' => 604_800 }
536
+ count = match[1].to_i
537
+ raise InvalidUsageError, 'retention duration must be positive' unless count.positive?
538
+
539
+ count * units[match[2].downcase]
540
+ end
541
+
542
+ # Deprecated in 2.0. Kept per USER_FACING_SURFACE.md section 3 "deprecated
543
+ # options keep working with one-time warnings." Migration target:
544
+ # `remote_cache_uri` (for the URI form) or
545
+ # `remote_cache_backend :s3, bucket:, prefix:` (for structured form).
546
+ # @deprecated Use {#remote_cache_uri} or {#remote_cache_backend}.
547
+ # Pre-2.0 1.x compatibility shim. Fires a one-time `logger.warn`
548
+ # at first use; the value still resolves so 1.x configs keep
549
+ # working until the 3.0 removal.
550
+ # @param s3_path [String, nil] `s3://bucket/prefix`.
551
+ # @return [String, nil]
552
+ def reports_s3_path(s3_path = nil)
553
+ return @reports_s3_path if defined?(@reports_s3_path) && s3_path.nil?
554
+
555
+ warn_once_deprecation(
556
+ :reports_s3_path,
557
+ '`reports_s3_path` / `TESTSIFT_REPORTS_S3_PATH` is deprecated; ' \
558
+ "use `remote_cache_uri 's3://bucket/prefix'` or `remote_cache_backend :s3, bucket:, prefix:` instead."
559
+ )
560
+
561
+ path = if ENV.key?('TESTSIFT_REPORTS_S3_PATH')
562
+ ENV['TESTSIFT_REPORTS_S3_PATH']
563
+ else
564
+ s3_path
565
+ end
566
+
567
+ @reports_s3_path = path if valid_s3_path?(path)
568
+ end
569
+
570
+ # Probe-path predicate. True when the user explicitly set
571
+ # `reports_s3_path` via the DSL OR the `TESTSIFT_REPORTS_S3_PATH`
572
+ # environment variable. Distinct from {#reports_s3_path} (the
573
+ # getter), which fires a one-time deprecation warning on every
574
+ # call from an unconfigured state — including the
575
+ # `RemoteCache::UserTasks#derive_from_legacy_dsl` probe that runs
576
+ # whenever any `rake testsift:remote_cache:*` task fires.
577
+ # Callers that need to detect whether the legacy DSL was actually
578
+ # used (vs probing) should read this predicate first.
579
+ #
580
+ # @return [Boolean]
581
+ def reports_s3_path_set?
582
+ return true if defined?(@reports_s3_path) && @reports_s3_path
583
+ return false unless ENV.key?('TESTSIFT_REPORTS_S3_PATH')
584
+
585
+ env_value = ENV.fetch('TESTSIFT_REPORTS_S3_PATH', nil)
586
+ !env_value.nil? && !env_value.empty?
587
+ end
588
+
589
+ # Deprecated in 2.0. Migration: `remote_cache_backend :s3, ...,
590
+ # local: true`. Kept for backward compat; UserTasks derives the
591
+ # `local:` opt from this when `remote_cache_backend` is absent.
592
+ # @deprecated Use `remote_cache_backend :s3, ..., local: true`.
593
+ # Pre-2.0 1.x compatibility shim for `awslocal` / LocalStack.
594
+ # Fires a one-time `logger.warn` at first use.
595
+ # @param new_flag [Boolean, nil]
596
+ # @return [Boolean]
597
+ def use_local_aws(new_flag = nil)
598
+ return @use_local_aws if defined?(@use_local_aws) && new_flag.nil?
599
+
600
+ warn_once_deprecation(
601
+ :use_local_aws,
602
+ '`use_local_aws` / `TESTSIFT_USE_LOCAL_AWS` is deprecated; ' \
603
+ 'use `remote_cache_backend :s3, ..., local: true` instead.'
604
+ )
605
+
606
+ @use_local_aws = if ENV.key?('TESTSIFT_USE_LOCAL_AWS')
607
+ ENV['TESTSIFT_USE_LOCAL_AWS'] == 'true'
608
+ else
609
+ new_flag == true
610
+ end
611
+ end
612
+
613
+ # Internal method on the tracer pipeline.
614
+ # @api private
615
+ def warn_once_deprecation(key, message)
616
+ @_deprecation_warnings ||= {}
617
+ return if @_deprecation_warnings.key?(key)
618
+
619
+ @_deprecation_warnings[key] = true
620
+ logger.warn("testsift deprecation: #{message}")
621
+ end
622
+
623
+ # Allow remote-cache uploads from non-CI environments (default
624
+ # `false`). Reads `TESTSIFT_UPLOAD_NON_CI_REPORTS=true` as
625
+ # an alternate enable.
626
+ #
627
+ # @param new_flag [Boolean, nil]
628
+ # @return [Boolean]
629
+ def upload_non_ci_reports(new_flag = nil)
630
+ return @upload_non_ci_reports if defined?(@upload_non_ci_reports) && new_flag.nil?
631
+
632
+ @upload_non_ci_reports = if ENV.key?('TESTSIFT_UPLOAD_NON_CI_REPORTS')
633
+ ENV['TESTSIFT_UPLOAD_NON_CI_REPORTS'] == 'true'
634
+ else
635
+ new_flag == true
636
+ end
637
+ end
638
+
639
+ # Force every example to run (default `false` — the tracer's
640
+ # whole point is to skip unaffected examples). Reads
641
+ # `TESTSIFT_RUN_ALL_EXAMPLES=true` as an alternate enable.
642
+ # Useful for one-off "rebaseline the cache" runs.
643
+ #
644
+ # @param new_flag [Boolean, nil]
645
+ # @return [Boolean]
646
+ def run_all_examples(new_flag = nil)
647
+ return @run_all_examples if defined?(@run_all_examples) && new_flag.nil?
648
+
649
+ @run_all_examples = if ENV.key?('TESTSIFT_RUN_ALL_EXAMPLES')
650
+ ENV['TESTSIFT_RUN_ALL_EXAMPLES'] == 'true'
651
+ else
652
+ new_flag == true
653
+ end
654
+ end
655
+
656
+ # Exit with non-zero when duplicate-identity examples are
657
+ # detected (default `true` — duplicates break per-example
658
+ # attribution). Reads `TESTSIFT_FAIL_ON_DUPLICATES=false` as
659
+ # an alternate disable.
660
+ #
661
+ # @param new_flag [Boolean, nil]
662
+ # @return [Boolean]
663
+ def fail_on_duplicates(new_flag = nil)
664
+ return @fail_on_duplicates if defined?(@fail_on_duplicates) && new_flag.nil?
665
+
666
+ @fail_on_duplicates = if ENV.key?('TESTSIFT_FAIL_ON_DUPLICATES')
667
+ ENV['TESTSIFT_FAIL_ON_DUPLICATES'] == 'true'
668
+ else
669
+ new_flag == true
670
+ end
671
+ end
672
+
673
+ # Transitive-load attribution (closes the constants blind
674
+ # spot). Default `true` - the tracker observes files loaded during
675
+ # the process and attributes them as transitive deps of every
676
+ # subsequent example, so a change to a constants-defining file
677
+ # correctly invalidates tests that only reference its constants.
678
+ #
679
+ # Setting to `false` restores 1.x behavior (pure coverage-diff).
680
+ # Teams who explicitly accept the blind spot as a speed tradeoff
681
+ # can opt out; the cost is silent staleness on constants edits.
682
+ #
683
+ # Default-true breaks the `defined? && new_flag.nil?` memo shape
684
+ # (first read returns nil instead of true). Explicit ternary
685
+ # handles first-call / ENV / explicit-arg cases uniformly.
686
+ def transitive_load_tracking(new_flag = nil)
687
+ return @transitive_load_tracking if defined?(@transitive_load_tracking) && new_flag.nil?
688
+
689
+ @transitive_load_tracking = if ENV.key?('TESTSIFT_TRANSITIVE_LOAD_TRACKING')
690
+ ENV['TESTSIFT_TRANSITIVE_LOAD_TRACKING'] != 'false'
691
+ elsif new_flag.nil?
692
+ true
693
+ else
694
+ new_flag == true
695
+ end
696
+ end
697
+
698
+ # Opt-in for narrow schema attribution. Default `false` - the
699
+ # Preset `:schema` category already declared-glob-tracks db/schema.rb
700
+ # + db/structure.sql as a safe over-approximation (any schema change
701
+ # re-runs every example). Calling this method opts in to an
702
+ # `sql.active_record` subscriber that emits schema inputs only for
703
+ # examples that actually touched AR during the run, narrowing the
704
+ # re-run set to DB-touching examples.
705
+ #
706
+ # Shape: setter-only DSL (like `track_rails_defaults`, `track_files`).
707
+ # Bare `track_ar_schema_notifications` in `.testsift` enables;
708
+ # explicit `track_ar_schema_notifications(false)` disables for tests
709
+ # or config overrides. Read the resulting state via the `?` variant,
710
+ # which layers the `TESTSIFT_AR_SCHEMA_NOTIFICATIONS` ENV
711
+ # override on top of the DSL value.
712
+ #
713
+ # To use the narrower behavior, pair with `track_rails_defaults
714
+ # except: [:schema]` so the declared-glob path doesn't dominate the
715
+ # notification signal at graph registration. Leaving the Preset's
716
+ # :schema category on alongside this flag is a no-op in terms of
717
+ # the final re-run set - declared-glob attaches schema to every
718
+ # example regardless of what this subscriber emits.
719
+ # Setter DSL — bare call enables, explicit `(false)` disables.
720
+ # Any other positional value coerces to disabled (defensive for
721
+ # typos). Read the resulting state via {#track_ar_schema_notifications?}.
722
+ #
723
+ # @param args [Array] positional args (bare = enable; `false` =
724
+ # disable; anything else = disable defensively).
725
+ # @return [Boolean]
726
+ # @note **Common Rails setups widen this to whole-suite-on-schema-
727
+ # change.** Per-example AR cleanup mechanisms
728
+ # (`use_transactional_fixtures = true`, DatabaseCleaner
729
+ # `:truncation` / `:deletion` / `:transaction`) fire
730
+ # `sql.active_record` inside the per-example bucket, attributing
731
+ # `db/schema.rb` to every AR-touching example. A boot-time warn
732
+ # fires when the precondition isn't met. See README "Narrow
733
+ # AR-schema attribution".
734
+ def track_ar_schema_notifications(*args)
735
+ # Setter DSL: bare call enables, explicit `(false)` disables,
736
+ # any other positional coerces to false (defensive for typos).
737
+ @track_ar_schema_notifications = args.empty? || args.first == true
738
+ end
739
+
740
+ # Reads the resolved AR-schema-notifications state. The
741
+ # `TESTSIFT_AR_SCHEMA_NOTIFICATIONS` env var overrides the
742
+ # DSL value when set.
743
+ #
744
+ # @return [Boolean]
745
+ def track_ar_schema_notifications?
746
+ return ENV['TESTSIFT_AR_SCHEMA_NOTIFICATIONS'] == 'true' if ENV.key?('TESTSIFT_AR_SCHEMA_NOTIFICATIONS')
747
+ return false unless defined?(@track_ar_schema_notifications)
748
+
749
+ @track_ar_schema_notifications == true
750
+ end
751
+
752
+ # Override the parallel_tests lock-file path (default
753
+ # `testsift.lock`). Reads `TESTSIFT_LOCK_FILE`.
754
+ #
755
+ # @param new_file [String, nil]
756
+ # @return [String]
757
+ def lock_file(new_file = nil)
758
+ return @lock_file if defined?(@lock_file) && @lock_file && new_file.nil?
759
+
760
+ @lock_file = if ENV.key?('TESTSIFT_LOCK_FILE')
761
+ ENV['TESTSIFT_LOCK_FILE']
762
+ else
763
+ new_file || DEFAULT_LOCK_FILE
764
+ end
765
+ end
766
+
767
+ # Set the tracer's log level. Reads `TESTSIFT_LOG_LEVEL`.
768
+ #
769
+ # @param new_level [Symbol, String, nil] one of `:off`, `:debug`,
770
+ # `:info`, `:warn`, `:error`. Default `:info`.
771
+ # @return [Integer] numeric level (LOG_LEVEL constant value).
772
+ def log_level(new_level = nil)
773
+ return @log_level if defined?(@log_level) && @log_level && new_level.nil?
774
+
775
+ level = if ENV.key?('TESTSIFT_LOG_LEVEL')
776
+ ENV['TESTSIFT_LOG_LEVEL']
777
+ else
778
+ new_level
779
+ end
780
+
781
+ @logger = nil
782
+ @log_level = LOG_LEVEL[level.to_s.to_sym].to_i
783
+ end
784
+
785
+ # Lazy-initialized {TestSift::Logger} instance honoring
786
+ # {#log_level}.
787
+ #
788
+ # @return [TestSift::Logger]
789
+ def logger
790
+ @logger ||= TestSift::Logger.new(log_level)
791
+ end
792
+
793
+ # Track files for SimpleCov coverage emission only (NOT for the
794
+ # tracer's per-example dependency graph). Use {#track_files} for
795
+ # the dependency graph.
796
+ #
797
+ # @param glob [String] file glob pattern.
798
+ # @return [String]
799
+ def coverage_track_files(glob)
800
+ @coverage_track_files = glob
801
+ end
802
+
803
+ # @return [String, nil] the configured {#coverage_track_files} value.
804
+ def coverage_tracked_files
805
+ @coverage_track_files if defined?(@coverage_track_files)
806
+ end
807
+
808
+ # Declare globs of files every example depends on (e.g.
809
+ # `Gemfile.lock`, `db/schema.rb`, `config/locales/**/*.yml`).
810
+ # Globs accumulate across calls; the tracker resolves matched
811
+ # files at boot and attaches them as `:declared`-kind inputs
812
+ # to every example. For files only specific examples depend on,
813
+ # use the per-example `tracks: { files: '...' }` metadata DSL.
814
+ #
815
+ # @param globs [Array<String>] file glob patterns (each matched
816
+ # via `Dir.glob` with `FNM_PATHNAME | FNM_EXTGLOB`).
817
+ # @return [Array<String>] the accumulated glob list.
818
+ # @raise [InvalidUsageError] if called after the tracker started.
819
+ # @example
820
+ # track_files 'config/locales/**/*.yml', 'db/schema.rb', 'Gemfile.lock'
821
+ def track_files(*globs)
822
+ if defined?(@declared_globs_frozen) && @declared_globs_frozen
823
+ raise InvalidUsageError,
824
+ 'track_files cannot be called after the tracker has started'
825
+ end
826
+
827
+ @track_files_globs ||= []
828
+ @track_files_globs.concat(globs.flatten.compact.map(&:to_s))
829
+ @track_files_globs
830
+ end
831
+
832
+ # Consolidated read-only view over `track_files` + legacy
833
+ # `coverage_track_files`. Order: user-declared first (preserves
834
+ # DSL call order), legacy value last. De-duplicated; frozen so
835
+ # downstream consumers treat it as immutable.
836
+ def declared_globs
837
+ all = []
838
+ all.concat(@track_files_globs) if defined?(@track_files_globs) && @track_files_globs
839
+ all << @coverage_track_files if defined?(@coverage_track_files) && @coverage_track_files
840
+ all.uniq.freeze
841
+ end
842
+
843
+ # Rails preset — attaches the common Rails-side declared globs in
844
+ # one DSL call. Expands to the glob set defined in
845
+ # `TestSift::Rails::Preset` (views, helpers, locales, config,
846
+ # schema, factories, fixtures).
847
+ #
848
+ # @param except [Array<Symbol>] categories to opt out of (so a
849
+ # per-example subscriber attributes them instead). Common
850
+ # recipe: `track_rails_defaults except: [:views, :schema]`
851
+ # pairs with {#track_ar_schema_notifications} for narrow
852
+ # per-example schema attribution.
853
+ # @return [Array<String>] the resulting accumulated glob list.
854
+ # @example Default — all categories on
855
+ # track_rails_defaults
856
+ # @example Opt out of views + schema for per-example subscribers
857
+ # track_rails_defaults except: [:views, :schema]
858
+ # track_ar_schema_notifications
859
+ def track_rails_defaults(except: [])
860
+ require_relative 'rails/preset'
861
+
862
+ globs = TestSift::Rails::Preset.globs(except: except)
863
+ track_files(*globs)
864
+ end
865
+
866
+ # One-way latch. Tracker.setup flips it so a stray
867
+ # `track_files` later in the boot sequence raises instead of
868
+ # silently accumulating into state that has already been read.
869
+ def freeze_declared_globs!
870
+ @declared_globs_frozen = true
871
+ end
872
+
873
+ # Config-level env-tracking DSL. Accumulates names across
874
+ # calls; bare entries flow into `Engine#setup`, are wildcard-
875
+ # expanded via `Tracker::EnvMatcher`, and attach to every
876
+ # previously-seen example (parallel to `track_files`'s "declared
877
+ # globs attach to every example" semantics).
878
+ #
879
+ # Wildcards: single trailing (`PREFIX_*`) or single leading
880
+ # (`*_SUFFIX`) only; `*` alone matches every env key. Multi-`*`,
881
+ # character classes, `?`, `!`, `\\` raise ArgumentError at run
882
+ # start (Engine#setup) so users see config errors immediately.
883
+ #
884
+ # Returns a frozen Array view (mirrors `ignore_spec_files` /
885
+ # `add_reporter` shape — defensive against callers mutating an
886
+ # internal accumulator). Setter raises if called after
887
+ # `freeze_declared_globs!` flipped the latch (same "tracker has
888
+ # started, no more declarations" contract as `track_files`).
889
+ # Declare env vars every example depends on. Wildcards expand
890
+ # against the live ENV at config load. For env vars only specific
891
+ # examples branch on, use the per-example
892
+ # `tracks: { env: '...' }` metadata DSL.
893
+ #
894
+ # @param names [Array<String, Symbol>] literal env names OR
895
+ # single-wildcard patterns (`'PREFIX_*'`, `'*_SUFFIX'`, `'*'`).
896
+ # @return [Array<String>] frozen accumulated names list.
897
+ # @raise [InvalidUsageError] if called after the tracker started,
898
+ # or if a pattern is malformed (multi-segment wildcards like
899
+ # `'A_*_B'`, character classes, etc. are rejected).
900
+ # @example
901
+ # track_env 'AUTH_TOKEN', 'DATABASE_URL', 'RAILS_*', '*_API_KEY'
902
+ def track_env(*names)
903
+ if defined?(@declared_globs_frozen) && @declared_globs_frozen
904
+ raise InvalidUsageError,
905
+ 'track_env cannot be called after the tracker has started'
906
+ end
907
+
908
+ @track_env_names ||= []
909
+ @track_env_names.concat(names.flatten.compact.map(&:to_s))
910
+ @track_env_names.dup.freeze
911
+ end
912
+
913
+ # Reader for the accumulated config-level env names. Returns a
914
+ # frozen Array (`[].freeze` when never set), parallel to
915
+ # `declared_globs`'s shape.
916
+ def tracked_env_names
917
+ return EMPTY_TRACKED_ENV_NAMES unless defined?(@track_env_names) && @track_env_names
918
+
919
+ @track_env_names.dup.freeze
920
+ end
921
+
922
+ # Internal constant.
923
+ # @api private
924
+ EMPTY_TRACKED_ENV_NAMES = [].freeze
925
+ private_constant :EMPTY_TRACKED_ENV_NAMES
926
+
927
+ # Per-spec-file exclusion (closes upstream #41). Accumulates
928
+ # glob patterns that match test files testsift should leave
929
+ # alone: matching examples pass through RSpec unchanged, but the
930
+ # tracer does not compute an identity hash, does not run
931
+ # duplicate detection, and does not include them in any filter
932
+ # decision. Distinct from `add_filter` - that excludes *source*
933
+ # files from dependency attribution; `ignore_spec_files` excludes
934
+ # *spec* files from tracer visibility entirely.
935
+ #
936
+ # Typical use: a smoke-test spec with intentionally duplicated
937
+ # descriptions that testsift's `fail_on_duplicates=true`
938
+ # gate would otherwise reject.
939
+ #
940
+ # Shape mirrors `track_files`: bare call returns the current
941
+ # frozen array, any arg call accumulates.
942
+ def ignore_spec_files(*globs)
943
+ @ignore_spec_files_globs ||= []
944
+ @ignore_spec_files_globs.concat(globs.flatten.compact.map(&:to_s)) unless globs.empty?
945
+ @ignore_spec_files_globs.dup.freeze
946
+ end
947
+
948
+ # Runtime matcher consumed by RunnerHook. Normalizes `file_path`
949
+ # to three candidate forms - the raw RSpec-emitted path (often
950
+ # `./spec/foo_spec.rb`), the same with `./` stripped, and the
951
+ # root-relative form - then compares each against each configured
952
+ # glob via `File.fnmatch?`. Public because the RSpec hook layer
953
+ # calls it from outside the configure block.
954
+ def ignore_spec_file?(file_path)
955
+ return false if file_path.nil? || file_path.empty?
956
+
957
+ globs = defined?(@ignore_spec_files_globs) ? @ignore_spec_files_globs : nil
958
+ return false if globs.nil? || globs.empty?
959
+
960
+ candidates = _ignore_spec_file_candidates(file_path)
961
+ globs.any? do |glob|
962
+ candidates.any? { |candidate| File.fnmatch?(glob, candidate, File::FNM_PATHNAME) }
963
+ end
964
+ end
965
+
966
+ # Internal method on the tracer pipeline.
967
+ # @api private
968
+ def _ignore_spec_file_candidates(file_path)
969
+ candidates = [file_path]
970
+ stripped = file_path.delete_prefix('./')
971
+ candidates << stripped if stripped != file_path
972
+ relative = _relative_file_path(file_path)
973
+ candidates << relative if relative != file_path
974
+ candidates
975
+ end
976
+
977
+ # Internal method on the tracer pipeline.
978
+ # @api private
979
+ def _relative_file_path(file_path)
980
+ return file_path unless defined?(@root) && @root && file_path.start_with?("#{@root}/")
981
+
982
+ file_path[(@root.length + 1)..]
983
+ end
984
+
985
+ # Local-cache run-id retention. Default 5 keeps enough
986
+ # history for rollback debugging without letting the cache grow
987
+ # unbounded on long-lived machines (issue #20). 0 opts out (1.x
988
+ # behavior - dirs accumulate forever). ENV
989
+ # TESTSIFT_CACHE_RETENTION_LOCAL_COUNT wins over the DSL
990
+ # argument, matching the other cache_* precedence conventions.
991
+ #
992
+ # Prune-on-save is handled by JsonBackend; this DSL just carries
993
+ # the configured value so the engine can pass it through on
994
+ # backend construction. One-off cleanup lives in the
995
+ # `testsift:cache:gc` Rake task.
996
+ # rubocop:disable Metrics/PerceivedComplexity
997
+ def cache_retention_local_count(count = nil)
998
+ return @cache_retention_local_count if defined?(@cache_retention_local_count) && count.nil?
999
+
1000
+ if ENV.key?('TESTSIFT_CACHE_RETENTION_LOCAL_COUNT')
1001
+ env_value = ENV['TESTSIFT_CACHE_RETENTION_LOCAL_COUNT']
1002
+ unless env_value.match?(/\A\d+\z/)
1003
+ raise InvalidUsageError,
1004
+ "TESTSIFT_CACHE_RETENTION_LOCAL_COUNT must be a non-negative integer, got #{env_value.inspect}"
1005
+ end
1006
+ @cache_retention_local_count = env_value.to_i
1007
+ elsif count.nil?
1008
+ @cache_retention_local_count = DEFAULT_CACHE_RETENTION_LOCAL_COUNT
1009
+ elsif count.is_a?(::Integer) && count >= 0
1010
+ @cache_retention_local_count = count
1011
+ else
1012
+ raise InvalidUsageError,
1013
+ "cache_retention_local_count must be a non-negative integer, got #{count.inspect}"
1014
+ end
1015
+
1016
+ @cache_retention_local_count
1017
+ end
1018
+ # rubocop:enable Metrics/PerceivedComplexity
1019
+
1020
+ # Size budgets. Both return non-negative Integer MiB.
1021
+ # Shape identical to cache_retention_local_count:
1022
+ # defined-and-nil-arg returns memo, ENV wins, then DSL arg, then
1023
+ # module default. 0 disables the respective check.
1024
+ #
1025
+ # Duplicated body rather than factored to a private helper
1026
+ # because configure's alias loop wraps every instance method --
1027
+ # private helpers included -- as a public `_name` DSL surface.
1028
+ # rubocop:disable Metrics/PerceivedComplexity
1029
+ def cache_size_warn_per_file_mb(size_mb = nil)
1030
+ return @cache_size_warn_per_file_mb if defined?(@cache_size_warn_per_file_mb) && size_mb.nil?
1031
+
1032
+ if ENV.key?('TESTSIFT_CACHE_SIZE_WARN_PER_FILE_MB')
1033
+ env_value = ENV['TESTSIFT_CACHE_SIZE_WARN_PER_FILE_MB']
1034
+ unless env_value.match?(/\A\d+\z/)
1035
+ raise InvalidUsageError,
1036
+ "TESTSIFT_CACHE_SIZE_WARN_PER_FILE_MB must be a non-negative integer, got #{env_value.inspect}"
1037
+ end
1038
+ @cache_size_warn_per_file_mb = env_value.to_i
1039
+ elsif size_mb.nil?
1040
+ @cache_size_warn_per_file_mb = DEFAULT_CACHE_SIZE_WARN_PER_FILE_MB
1041
+ elsif size_mb.is_a?(::Integer) && size_mb >= 0
1042
+ @cache_size_warn_per_file_mb = size_mb
1043
+ else
1044
+ raise InvalidUsageError,
1045
+ "cache_size_warn_per_file_mb must be a non-negative integer, got #{size_mb.inspect}"
1046
+ end
1047
+
1048
+ @cache_size_warn_per_file_mb
1049
+ end
1050
+
1051
+ # Internal method on the tracer pipeline.
1052
+ # @api private
1053
+ def cache_size_warn_total_mb(size_mb = nil)
1054
+ return @cache_size_warn_total_mb if defined?(@cache_size_warn_total_mb) && size_mb.nil?
1055
+
1056
+ if ENV.key?('TESTSIFT_CACHE_SIZE_WARN_TOTAL_MB')
1057
+ env_value = ENV['TESTSIFT_CACHE_SIZE_WARN_TOTAL_MB']
1058
+ unless env_value.match?(/\A\d+\z/)
1059
+ raise InvalidUsageError,
1060
+ "TESTSIFT_CACHE_SIZE_WARN_TOTAL_MB must be a non-negative integer, got #{env_value.inspect}"
1061
+ end
1062
+ @cache_size_warn_total_mb = env_value.to_i
1063
+ elsif size_mb.nil?
1064
+ @cache_size_warn_total_mb = DEFAULT_CACHE_SIZE_WARN_TOTAL_MB
1065
+ elsif size_mb.is_a?(::Integer) && size_mb >= 0
1066
+ @cache_size_warn_total_mb = size_mb
1067
+ else
1068
+ raise InvalidUsageError,
1069
+ "cache_size_warn_total_mb must be a non-negative integer, got #{size_mb.inspect}"
1070
+ end
1071
+
1072
+ @cache_size_warn_total_mb
1073
+ end
1074
+ # rubocop:enable Metrics/PerceivedComplexity
1075
+
1076
+ # Storage backend selector with optional kwarg opts hash.
1077
+ # Symbol form: `storage_backend :json` or
1078
+ # `storage_backend :sqlite`. ENV `TESTSIFT_STORAGE` wins over
1079
+ # the DSL argument, matching the `cache_dir` / `coverage_dir`
1080
+ # precedence convention so CI can swap backends without editing
1081
+ # `.testsift`.
1082
+ #
1083
+ # Options (:json only):
1084
+ # serializer: :json | :msgpack on-disk payload format;
1085
+ # ENV TESTSIFT_STORAGE_SERIALIZER
1086
+ # wins over the opt.
1087
+ #
1088
+ # :sqlite does not accept any opts - its storage layout is a
1089
+ # single sqlite3 file with a normalized schema; serializer
1090
+ # substitution does not apply.
1091
+ # Configure the on-disk storage backend. Reads
1092
+ # `TESTSIFT_STORAGE` for env-based override.
1093
+ #
1094
+ # @param name [Symbol, nil] one of `:json` (default; preserves
1095
+ # 1.x layout) or `:sqlite` (single-file DB; MRI 3.2+ only;
1096
+ # JRuby auto-falls-back to `:json` with a one-time warn).
1097
+ # @param opts [Hash] backend-specific opts. `:json` accepts
1098
+ # `serializer:` (`:json` default or `:msgpack`); `:sqlite`
1099
+ # accepts no opts.
1100
+ # @return [Symbol] the resolved backend name.
1101
+ # @raise [InvalidUsageError] for unknown backend names or
1102
+ # invalid opts.
1103
+ # @example JSON (default)
1104
+ # storage_backend :json
1105
+ # @example SQLite (faster cold reads above ~5,000 examples)
1106
+ # storage_backend :sqlite
1107
+ # @example JSON with msgpack serializer
1108
+ # storage_backend :json, serializer: :msgpack
1109
+ def storage_backend(name = nil, **opts)
1110
+ if defined?(@storage_backend_name) && @storage_backend_name && name.nil? && opts.empty?
1111
+ return @storage_backend_name
1112
+ end
1113
+
1114
+ env = ENV.fetch('TESTSIFT_STORAGE', nil)
1115
+ resolved = (env || name || DEFAULT_STORAGE_BACKEND).to_sym
1116
+
1117
+ unless STORAGE_BACKEND_NAMES.include?(resolved)
1118
+ raise InvalidUsageError,
1119
+ "unknown storage backend: #{resolved.inspect}; allowed: #{STORAGE_BACKEND_NAMES.inspect}"
1120
+ end
1121
+
1122
+ @storage_backend_name = resolved
1123
+ @storage_backend_opts = validate_and_resolve_storage_opts(resolved, opts).freeze
1124
+ @storage_backend_name
1125
+ end
1126
+
1127
+ # Accessor for the opts hash passed to `storage_backend`.
1128
+ # Returns a frozen empty Hash when the DSL was called without
1129
+ # opts (or not called at all) so the backend dispatch can splat
1130
+ # `**storage_backend_opts` unconditionally.
1131
+ def storage_backend_opts
1132
+ return EMPTY_STORAGE_BACKEND_OPTS unless defined?(@storage_backend_opts) && @storage_backend_opts
1133
+
1134
+ @storage_backend_opts
1135
+ end
1136
+
1137
+ # Internal constant.
1138
+ # @api private
1139
+ EMPTY_STORAGE_BACKEND_OPTS = {}.freeze
1140
+ private_constant :EMPTY_STORAGE_BACKEND_OPTS
1141
+
1142
+ # Configure which Ruby `Coverage` modes testsift enables on
1143
+ # the standalone path (when SimpleCov is not running). Pass a
1144
+ # Symbol Array drawn from {COVERAGE_MODES}; `coverage_modes [:lines,
1145
+ # :branches]` translates to `Coverage.start(lines: true, branches:
1146
+ # true)`.
1147
+ #
1148
+ # When SimpleCov is loaded and running at `TestSift.start`
1149
+ # time, the DSL is inert — SimpleCov owns `Coverage.start` and
1150
+ # controls modes via its own `enable_coverage :branch` etc. (the
1151
+ # documented load-order contract; see docs/upstream/UPGRADING.md `#SimpleCov branch
1152
+ # coverage now works`).
1153
+ #
1154
+ # The default `[:lines]` matches the pre-#195 bare `Coverage.start`
1155
+ # semantics so existing configs continue to emit the lines-only
1156
+ # `coverage.json` shape. The `coverage.json` artifact itself stays
1157
+ # on the 1.x `Array<Integer|nil>` per-file format regardless of
1158
+ # modes (storage-format stability). Branches / methods / etc.
1159
+ # data is available to SimpleCov via the documented interop and
1160
+ # to downstream consumers via `Coverage.peek_result` directly.
1161
+ #
1162
+ # @param modes [Array<Symbol>, Symbol, nil] one or more entries
1163
+ # from {COVERAGE_MODES} (`:lines`, `:branches`, `:methods`,
1164
+ # `:oneshot_lines`, `:eval`); a single Symbol is wrapped to
1165
+ # an Array.
1166
+ # @return [Array<Symbol>] the resolved (frozen) modes array. The
1167
+ # no-arg call returns the current setting (or
1168
+ # {DEFAULT_COVERAGE_MODES} if unset).
1169
+ # @raise [InvalidUsageError] when `modes` is empty or contains an
1170
+ # unknown mode.
1171
+ # @example Branch coverage on top of the default lines
1172
+ # coverage_modes [:lines, :branches]
1173
+ # @example Methods coverage in addition (for downstream tooling)
1174
+ # coverage_modes [:lines, :methods]
1175
+ def coverage_modes(*modes)
1176
+ return read_coverage_modes if modes.empty?
1177
+
1178
+ modes_array = Array(modes.length == 1 ? modes.first : modes).flatten.map(&:to_sym)
1179
+ raise InvalidUsageError, 'coverage_modes requires at least one mode (e.g. [:lines])' if modes_array.empty?
1180
+
1181
+ unknown = modes_array - COVERAGE_MODES
1182
+ unless unknown.empty?
1183
+ raise InvalidUsageError,
1184
+ "unknown coverage modes: #{unknown.inspect}; allowed: #{COVERAGE_MODES.inspect}"
1185
+ end
1186
+
1187
+ @coverage_modes = modes_array.uniq.freeze
1188
+ end
1189
+
1190
+ # Hash form of {#coverage_modes} for splatting into
1191
+ # `Coverage.start`. `[:lines, :branches]` becomes
1192
+ # `{lines: true, branches: true}`.
1193
+ def coverage_modes_for_start
1194
+ coverage_modes.to_h { |m| [m, true] }.freeze
1195
+ end
1196
+
1197
+ # Internal method on the tracer pipeline.
1198
+ # @api private
1199
+ def read_coverage_modes
1200
+ return @coverage_modes if defined?(@coverage_modes) && @coverage_modes
1201
+
1202
+ DEFAULT_COVERAGE_MODES
1203
+ end
1204
+ private :read_coverage_modes
1205
+
1206
+ # Internal method on the tracer pipeline.
1207
+ # @api private
1208
+ def validate_and_resolve_storage_opts(backend, opts)
1209
+ unknown = opts.keys - STORAGE_BACKEND_OPT_KEYS
1210
+ unless unknown.empty?
1211
+ raise InvalidUsageError,
1212
+ "unknown storage_backend options: #{unknown.inspect}; allowed: #{STORAGE_BACKEND_OPT_KEYS.inspect}"
1213
+ end
1214
+
1215
+ if backend == :sqlite && !opts.empty?
1216
+ raise InvalidUsageError,
1217
+ "storage_backend :sqlite does not accept options (got #{opts.keys.inspect})"
1218
+ end
1219
+
1220
+ return {} unless backend == :json
1221
+
1222
+ env_ser = ENV.fetch('TESTSIFT_STORAGE_SERIALIZER', nil)
1223
+ serializer = (env_ser || opts[:serializer] || :json).to_sym
1224
+
1225
+ unless STORAGE_BACKEND_SERIALIZERS.include?(serializer)
1226
+ raise InvalidUsageError,
1227
+ "unknown storage serializer: #{serializer.inspect}; allowed: #{STORAGE_BACKEND_SERIALIZERS.inspect}"
1228
+ end
1229
+
1230
+ { serializer: serializer }
1231
+ end
1232
+
1233
+ # Reporter DSL. Accumulates each call onto an internal list of
1234
+ # `[name_or_class, opts]` pairs that `Reporters::Registry` walks
1235
+ # at finalize-time. Matches the `track_files` accumulator shape.
1236
+ #
1237
+ # Shapes:
1238
+ # add_reporter :terminal
1239
+ # add_reporter :json
1240
+ # add_reporter MyCustomReporter, color: false
1241
+ #
1242
+ # Symbol names must match `Reporters::Registry::BUILT_INS.keys`
1243
+ # (`:terminal`, `:json`, `:html`). Class values are
1244
+ # trusted - they must subclass / duck-type `Reporters::Base` but
1245
+ # validation is deferred to initialize-time so custom reporters
1246
+ # defined in user code don't have to exist at DSL-validate time.
1247
+ #
1248
+ # If the user never calls `add_reporter`, `Registry.emit_all`
1249
+ # falls back to `Registry::DEFAULTS` (`[:terminal, :json]`).
1250
+ def add_reporter(name_or_class, **opts)
1251
+ validate_reporter_entry(name_or_class)
1252
+ @reporters ||= []
1253
+ @reporters << [name_or_class, opts]
1254
+ @reporters.dup.freeze
1255
+ end
1256
+
1257
+ # Readonly access to the accumulated reporter entries. Returns
1258
+ # `nil` when no `add_reporter` calls were made - the Registry
1259
+ # distinguishes nil (fall back to defaults) from `[]` (user
1260
+ # opted out of every reporter).
1261
+ def reporters
1262
+ return nil unless defined?(@reporters)
1263
+
1264
+ @reporters.dup.freeze
1265
+ end
1266
+
1267
+ # Internal method on the tracer pipeline.
1268
+ # @api private
1269
+ def validate_reporter_entry(name_or_class)
1270
+ case name_or_class
1271
+ when ::Symbol
1272
+ allowed = reporter_builtins_keys
1273
+ return if allowed.include?(name_or_class)
1274
+
1275
+ raise InvalidUsageError,
1276
+ "unknown reporter: #{name_or_class.inspect}; allowed: #{allowed.inspect}"
1277
+ when ::Class
1278
+ nil
1279
+ else
1280
+ raise InvalidUsageError,
1281
+ "add_reporter: expected Symbol or Class, got #{name_or_class.class}"
1282
+ end
1283
+ end
1284
+
1285
+ # Internal method on the tracer pipeline.
1286
+ # @api private
1287
+ def reporter_builtins_keys
1288
+ return [] unless defined?(TestSift::Reporters::Registry)
1289
+
1290
+ TestSift::Reporters::Registry::BUILT_INS.keys
1291
+ end
1292
+
1293
+ # Add a filter to exclude files from the tracer's per-example
1294
+ # dependency graph. Files matching the filter are not registered
1295
+ # as deps, so changes to them don't trigger re-runs.
1296
+ #
1297
+ # The filter applies uniformly to both fresh per-example
1298
+ # attributions AND prior-snapshot carry-forward (warm-run path).
1299
+ # Adding a filter between runs immediately drops matching paths
1300
+ # from the next warm run's `@all_files` and dependency graph;
1301
+ # no cold run is required.
1302
+ #
1303
+ # The default filter list (loaded by `lib/testsift/load_default_config.rb`
1304
+ # before the user's `.testsift` runs) excludes Ruby toolchain
1305
+ # paths (`/vendor/bundle/`, `/usr/local/bundle/`, rbenv / asdf / rvm)
1306
+ # AND testsift's own output dirs (`/testsift_cache/`,
1307
+ # `/testsift_coverage/`, `/testsift_report/`,
1308
+ # `testsift.lock`). Use `filters.clear` in `.testsift`
1309
+ # before adding your own if you need to start from a blank list,
1310
+ # but be aware you'll then need to re-add the toolchain + tracer-
1311
+ # output exclusions yourself.
1312
+ #
1313
+ # @param filter [String, Regexp, Array, TestSift::Filter, nil]
1314
+ # filter spec. Nil + a block also accepted; the block receives
1315
+ # a `source_file` Hash (`:name` / `:file_path`) and returns
1316
+ # true to exclude.
1317
+ # @return [Array] the updated filters list.
1318
+ # @example String match (substring)
1319
+ # add_filter '/vendor/bundle/'
1320
+ # @example Regex match
1321
+ # add_filter %r{^/helpers/}
1322
+ # @example Block
1323
+ # add_filter { |source_file| source_file[:file_path].include?('/helpers/') }
1324
+ # @example Array of mixed types
1325
+ # add_filter ['/helpers/', %r{^/utils/}]
1326
+ def add_filter(filter = nil, &)
1327
+ filters << parse_filter(filter, &)
1328
+ end
1329
+
1330
+ # @return [Array] the currently-registered dep-graph filters.
1331
+ # Use `filters.clear` to reset.
1332
+ def filters
1333
+ @filters ||= []
1334
+ end
1335
+
1336
+ # Bulk filters assignment is intentionally rejected — use
1337
+ # {#filters} `.clear` + repeated {#add_filter} instead so each
1338
+ # entry routes through the same parser.
1339
+ #
1340
+ # @raise [NotImplementedError]
1341
+ def filters=(new_filteres)
1342
+ raise NotImplementedError
1343
+ end
1344
+
1345
+ # Add a filter to exclude files from coverage reporting (NOT
1346
+ # from the tracer's dep graph). Use {#add_filter} for the
1347
+ # dep graph; this controls SimpleCov-style coverage output only.
1348
+ #
1349
+ # @param filter [String, Regexp, Array, nil] same shape as
1350
+ # {#add_filter}'s filter arg.
1351
+ # @return [Array]
1352
+ # @example
1353
+ # add_coverage_filter %w[/spec/ /test/ /vendor/bundle/]
1354
+ def add_coverage_filter(filter = nil, &)
1355
+ coverage_filters << parse_filter(filter, &)
1356
+ end
1357
+
1358
+ # @return [Array] the currently-registered coverage filters.
1359
+ # Use `coverage_filters.clear` to reset.
1360
+ def coverage_filters
1361
+ @coverage_filters ||= []
1362
+ end
1363
+
1364
+ # Bulk coverage_filters assignment intentionally rejected — see
1365
+ # {#filters=}.
1366
+ #
1367
+ # @raise [NotImplementedError]
1368
+ def coverage_filters=(new_filteres)
1369
+ raise NotImplementedError
1370
+ end
1371
+
1372
+ # Internal method on the tracer pipeline.
1373
+ # @api private
1374
+ def valid_s3_path?(s3_path)
1375
+ uri = URI.parse(s3_path)
1376
+
1377
+ uri.scheme == 's3' && !uri.host.empty?
1378
+ rescue URI::InvalidURIError => _e
1379
+ false
1380
+ end
1381
+
1382
+ # Internal method on the tracer pipeline.
1383
+ # @api private
1384
+ def parallel_tests_id
1385
+ if ParallelTests.first_process?
1386
+ 'parallel_tests_1'
1387
+ else
1388
+ "parallel_tests_#{ENV.fetch('TEST_ENV_NUMBER', nil)}"
1389
+ end
1390
+ end
1391
+
1392
+ # Internal method on the tracer pipeline.
1393
+ # @api private
1394
+ def parse_filter(filter = nil, &block)
1395
+ arg = filter || block
1396
+
1397
+ raise ArgumentError, 'Either a filter or a block required' if arg.nil?
1398
+
1399
+ TestSift::Filter.register(arg)
1400
+ end
1401
+
1402
+ public
1403
+
1404
+ # Catches typos in `.testsift` config files. When the user
1405
+ # mistypes a DSL name (e.g. `track_files_glob` for `track_files`),
1406
+ # bare `NoMethodError` puts a Ruby backtrace in their face. This
1407
+ # surface raises `InvalidUsageError` with a stdlib `DidYouMean`
1408
+ # suggestion when the typo is close to a known DSL method;
1409
+ # otherwise it falls through to NoMethodError so internal
1410
+ # respond_to? probes / non-DSL undefined-method usage retains
1411
+ # standard Ruby semantics.
1412
+ def method_missing(name, *args, **kwargs, &)
1413
+ return super if name.to_s.end_with?('=')
1414
+
1415
+ candidates = TestSift::Configuration::DslTypoSuggester.candidates
1416
+ suggestion = candidates.empty? ? nil : TestSift::Configuration::DslTypoSuggester.nearest(name.to_s, candidates)
1417
+ return super if suggestion.nil?
1418
+
1419
+ raise InvalidUsageError,
1420
+ "unknown .testsift DSL method #{name.inspect}; did you mean #{suggestion.inspect}?"
1421
+ end
1422
+
1423
+ # Internal method on the tracer pipeline.
1424
+ # @api private
1425
+ def respond_to_missing?(name, include_private = false)
1426
+ super
1427
+ end
1428
+
1429
+ # Helpers for the DSL-typo `did you mean` surface. Lives in a
1430
+ # dedicated module so its methods stay outside the configure-time
1431
+ # `alias_method :"_#{name}"` wrapper loop in `Configuration#configure`
1432
+ # (which iterates Configuration's `private_instance_methods(false)`
1433
+ # twice during `load_default_config` + `load_local_config` and would
1434
+ # double-alias any helper instance methods we kept here).
1435
+ module DslTypoSuggester
1436
+ # Internal helper for the tracer pipeline.
1437
+ # @api private
1438
+ def self.candidates
1439
+ # Strip one or more leading `_` to canonicalize through the
1440
+ # configure wrapper's aliasing levels (single underscore after
1441
+ # one configure, double after two, etc.).
1442
+ TestSift.private_methods(true).each_with_object([]) do |m, acc|
1443
+ s = m.to_s
1444
+ next unless s.start_with?('_')
1445
+ next if s.end_with?('=')
1446
+
1447
+ acc << s.sub(/\A_+/, '')
1448
+ end.uniq
1449
+ end
1450
+
1451
+ # Internal helper for the tracer pipeline.
1452
+ # @api private
1453
+ def self.nearest(typed, candidates)
1454
+ prefix_match = candidates
1455
+ .select { |c| typed.start_with?(c) || c.start_with?(typed) }
1456
+ .max_by(&:length)
1457
+ return prefix_match if prefix_match
1458
+
1459
+ require 'did_you_mean'
1460
+ ::DidYouMean::SpellChecker.new(dictionary: candidates).correct(typed).first
1461
+ rescue LoadError
1462
+ nil
1463
+ end
1464
+ end
1465
+ end
1466
+ # rubocop:enable Metrics/ModuleLength
1467
+ end