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