rspec-tracer 1.2.3 → 2.0.0.pre.2

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 +384 -67
  3. data/README.md +454 -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 +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -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 +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  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 +436 -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 +239 -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 +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -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 +231 -491
  104. metadata +94 -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,109 @@
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
+ # 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 UPGRADING `#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
20
104
 
105
+ # Internal constant.
106
+ # @api private
21
107
  LOG_LEVEL = {
22
108
  off: 0,
23
109
  debug: 1,
@@ -26,8 +112,18 @@ module RSpecTracer
26
112
  error: 4
27
113
  }.freeze
28
114
 
115
+ # Internal method on the tracer pipeline.
116
+ # @api private
29
117
  def configure(&)
30
- configurers = caller_locations(1, 2).map(&:path)
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)
31
127
  invalid = configurers.none? do |configurer|
32
128
  ALLOWED_CONFIGURER.any? do |allowed_configurer|
33
129
  configurer.end_with?(allowed_configurer)
@@ -40,8 +136,13 @@ module RSpecTracer
40
136
  RSpecTracer::Configuration.private_instance_methods(false).each do |method_name|
41
137
  alias_method :"_#{method_name}", method_name
42
138
 
43
- define_method method_name do |*args, &block|
44
- send(:"_#{method_name}", *args, &block)
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)
45
146
  end
46
147
  end
47
148
  end
@@ -51,6 +152,16 @@ module RSpecTracer
51
152
 
52
153
  private
53
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
+ # RSpecTracer.configure { root '/path/to/project' }
54
165
  def root(root = nil)
55
166
  return @root if defined?(@root) && root.nil?
56
167
 
@@ -61,6 +172,11 @@ module RSpecTracer
61
172
  @root = File.expand_path(root || Dir.getwd)
62
173
  end
63
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.
64
180
  def project_name(new_name = nil)
65
181
  return @project_name if defined?(@project_name) && @project_name && new_name.nil?
66
182
 
@@ -68,6 +184,14 @@ module RSpecTracer
68
184
  @project_name ||= File.basename(root.split('/').last).capitalize.tr('_', ' ')
69
185
  end
70
186
 
187
+ # Override the on-disk cache directory (default
188
+ # `rspec_tracer_cache`). Also reads `RSPEC_TRACER_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.
71
195
  def cache_dir(dir = nil)
72
196
  return @cache_dir if defined?(@cache_dir) && dir.nil?
73
197
 
@@ -79,6 +203,11 @@ module RSpecTracer
79
203
  end
80
204
  end
81
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.
82
211
  def cache_path
83
212
  @cache_path ||= begin
84
213
  cache_path = File.expand_path(cache_dir, root)
@@ -91,6 +220,11 @@ module RSpecTracer
91
220
  end
92
221
  end
93
222
 
223
+ # Override the on-disk HTML/JSON report directory (default
224
+ # `rspec_tracer_report`). Also reads `RSPEC_TRACER_REPORT_DIR`.
225
+ #
226
+ # @param dir [String, nil] relative-to-root or absolute path.
227
+ # @return [String] configured report directory.
94
228
  def report_dir(dir = nil)
95
229
  return @report_dir if defined?(@report_dir) && dir.nil?
96
230
 
@@ -102,6 +236,11 @@ module RSpecTracer
102
236
  end
103
237
  end
104
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.
105
244
  def report_path
106
245
  @report_path ||= begin
107
246
  report_path = File.expand_path(report_dir, root)
@@ -114,6 +253,11 @@ module RSpecTracer
114
253
  end
115
254
  end
116
255
 
256
+ # Override the coverage output directory (default
257
+ # `rspec_tracer_coverage`). Also reads `RSPEC_TRACER_COVERAGE_DIR`.
258
+ #
259
+ # @param dir [String, nil] relative-to-root or absolute path.
260
+ # @return [String] configured coverage directory.
117
261
  def coverage_dir(dir = nil)
118
262
  return @coverage_dir if defined?(@coverage_dir) && dir.nil?
119
263
 
@@ -125,6 +269,11 @@ module RSpecTracer
125
269
  end
126
270
  end
127
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.
128
277
  def coverage_path
129
278
  @coverage_path ||= begin
130
279
  coverage_path = File.expand_path(coverage_dir, root)
@@ -137,9 +286,278 @@ module RSpecTracer
137
286
  end
138
287
  end
139
288
 
289
+ # Configure the remote-cache backend used by the
290
+ # `rake rspec_tracer:remote_cache:download` / `:upload` tasks.
291
+ # Single-entry accumulator: a second call raises
292
+ # {InvalidUsageError} so a misconfigured `.rspec-tracer` fails
293
+ # fast.
294
+ #
295
+ # @param name_or_class [Symbol, Class] one of `:s3`, `:local_fs`,
296
+ # `:redis`, OR a custom class implementing
297
+ # {RSpecTracer::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: 'rspec-tracer'
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/rspec-tracer-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 `RSPEC_TRACER_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/rspec-tracer'
354
+ # @example
355
+ # remote_cache_uri 'file:///tmp/rspec-tracer-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 `RSPEC_TRACER_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: 'rspec-tracer')
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('RSPEC_TRACER_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: 'rspec-tracer')
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]
140
552
  def reports_s3_path(s3_path = nil)
141
553
  return @reports_s3_path if defined?(@reports_s3_path) && s3_path.nil?
142
554
 
555
+ warn_once_deprecation(
556
+ :reports_s3_path,
557
+ '`reports_s3_path` / `RSPEC_TRACER_REPORTS_S3_PATH` is deprecated in 2.0; ' \
558
+ "use `remote_cache_uri 's3://bucket/prefix'` or `remote_cache_backend :s3, bucket:, prefix:` instead."
559
+ )
560
+
143
561
  path = if ENV.key?('RSPEC_TRACER_REPORTS_S3_PATH')
144
562
  ENV['RSPEC_TRACER_REPORTS_S3_PATH']
145
563
  else
@@ -149,9 +567,42 @@ module RSpecTracer
149
567
  @reports_s3_path = path if valid_s3_path?(path)
150
568
  end
151
569
 
570
+ # Probe-path predicate. True when the user explicitly set
571
+ # `reports_s3_path` via the DSL OR the `RSPEC_TRACER_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 rspec_tracer: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?('RSPEC_TRACER_REPORTS_S3_PATH')
584
+
585
+ env_value = ENV.fetch('RSPEC_TRACER_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]
152
597
  def use_local_aws(new_flag = nil)
153
598
  return @use_local_aws if defined?(@use_local_aws) && new_flag.nil?
154
599
 
600
+ warn_once_deprecation(
601
+ :use_local_aws,
602
+ '`use_local_aws` / `RSPEC_TRACER_USE_LOCAL_AWS` is deprecated in 2.0; ' \
603
+ 'use `remote_cache_backend :s3, ..., local: true` instead.'
604
+ )
605
+
155
606
  @use_local_aws = if ENV.key?('RSPEC_TRACER_USE_LOCAL_AWS')
156
607
  ENV['RSPEC_TRACER_USE_LOCAL_AWS'] == 'true'
157
608
  else
@@ -159,6 +610,22 @@ module RSpecTracer
159
610
  end
160
611
  end
161
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("rspec-tracer deprecation: #{message}")
621
+ end
622
+
623
+ # Allow remote-cache uploads from non-CI environments (default
624
+ # `false`). Reads `RSPEC_TRACER_UPLOAD_NON_CI_REPORTS=true` as
625
+ # an alternate enable.
626
+ #
627
+ # @param new_flag [Boolean, nil]
628
+ # @return [Boolean]
162
629
  def upload_non_ci_reports(new_flag = nil)
163
630
  return @upload_non_ci_reports if defined?(@upload_non_ci_reports) && new_flag.nil?
164
631
 
@@ -169,6 +636,13 @@ module RSpecTracer
169
636
  end
170
637
  end
171
638
 
639
+ # Force every example to run (default `false` — the tracer's
640
+ # whole point is to skip unaffected examples). Reads
641
+ # `RSPEC_TRACER_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]
172
646
  def run_all_examples(new_flag = nil)
173
647
  return @run_all_examples if defined?(@run_all_examples) && new_flag.nil?
174
648
 
@@ -179,6 +653,13 @@ module RSpecTracer
179
653
  end
180
654
  end
181
655
 
656
+ # Exit with non-zero when duplicate-identity examples are
657
+ # detected (default `true` — duplicates break per-example
658
+ # attribution). Reads `RSPEC_TRACER_FAIL_ON_DUPLICATES=false` as
659
+ # an alternate disable.
660
+ #
661
+ # @param new_flag [Boolean, nil]
662
+ # @return [Boolean]
182
663
  def fail_on_duplicates(new_flag = nil)
183
664
  return @fail_on_duplicates if defined?(@fail_on_duplicates) && new_flag.nil?
184
665
 
@@ -189,6 +670,90 @@ module RSpecTracer
189
670
  end
190
671
  end
191
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?('RSPEC_TRACER_TRANSITIVE_LOAD_TRACKING')
690
+ ENV['RSPEC_TRACER_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 `.rspec-tracer` 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 `RSPEC_TRACER_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
+ # `RSPEC_TRACER_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['RSPEC_TRACER_AR_SCHEMA_NOTIFICATIONS'] == 'true' if ENV.key?('RSPEC_TRACER_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
+ # `rspec_tracer.lock`). Reads `RSPEC_TRACER_LOCK_FILE`.
754
+ #
755
+ # @param new_file [String, nil]
756
+ # @return [String]
192
757
  def lock_file(new_file = nil)
193
758
  return @lock_file if defined?(@lock_file) && @lock_file && new_file.nil?
194
759
 
@@ -199,6 +764,11 @@ module RSpecTracer
199
764
  end
200
765
  end
201
766
 
767
+ # Set the tracer's log level. Reads `RSPEC_TRACER_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).
202
772
  def log_level(new_level = nil)
203
773
  return @log_level if defined?(@log_level) && @log_level && new_level.nil?
204
774
 
@@ -212,42 +782,596 @@ module RSpecTracer
212
782
  @log_level = LOG_LEVEL[level.to_s.to_sym].to_i
213
783
  end
214
784
 
785
+ # Lazy-initialized {RSpecTracer::Logger} instance honoring
786
+ # {#log_level}.
787
+ #
788
+ # @return [RSpecTracer::Logger]
215
789
  def logger
216
790
  @logger ||= RSpecTracer::Logger.new(log_level)
217
791
  end
218
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]
219
799
  def coverage_track_files(glob)
220
800
  @coverage_track_files = glob
221
801
  end
222
802
 
803
+ # @return [String, nil] the configured {#coverage_track_files} value.
223
804
  def coverage_tracked_files
224
805
  @coverage_track_files if defined?(@coverage_track_files)
225
806
  end
226
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
+ # `RSpecTracer::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 = RSpecTracer::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 rspec-tracer 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 rspec-tracer'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
+ # RSPEC_TRACER_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
+ # `rspec_tracer: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?('RSPEC_TRACER_CACHE_RETENTION_LOCAL_COUNT')
1001
+ env_value = ENV['RSPEC_TRACER_CACHE_RETENTION_LOCAL_COUNT']
1002
+ unless env_value.match?(/\A\d+\z/)
1003
+ raise InvalidUsageError,
1004
+ "RSPEC_TRACER_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 would wrap the helper as a
1027
+ # public `_name` DSL surface (see
1028
+ # feedback_configure_dsl_private_leak).
1029
+ # rubocop:disable Metrics/PerceivedComplexity
1030
+ def cache_size_warn_per_file_mb(size_mb = nil)
1031
+ return @cache_size_warn_per_file_mb if defined?(@cache_size_warn_per_file_mb) && size_mb.nil?
1032
+
1033
+ if ENV.key?('RSPEC_TRACER_CACHE_SIZE_WARN_PER_FILE_MB')
1034
+ env_value = ENV['RSPEC_TRACER_CACHE_SIZE_WARN_PER_FILE_MB']
1035
+ unless env_value.match?(/\A\d+\z/)
1036
+ raise InvalidUsageError,
1037
+ "RSPEC_TRACER_CACHE_SIZE_WARN_PER_FILE_MB must be a non-negative integer, got #{env_value.inspect}"
1038
+ end
1039
+ @cache_size_warn_per_file_mb = env_value.to_i
1040
+ elsif size_mb.nil?
1041
+ @cache_size_warn_per_file_mb = DEFAULT_CACHE_SIZE_WARN_PER_FILE_MB
1042
+ elsif size_mb.is_a?(::Integer) && size_mb >= 0
1043
+ @cache_size_warn_per_file_mb = size_mb
1044
+ else
1045
+ raise InvalidUsageError,
1046
+ "cache_size_warn_per_file_mb must be a non-negative integer, got #{size_mb.inspect}"
1047
+ end
1048
+
1049
+ @cache_size_warn_per_file_mb
1050
+ end
1051
+
1052
+ # Internal method on the tracer pipeline.
1053
+ # @api private
1054
+ def cache_size_warn_total_mb(size_mb = nil)
1055
+ return @cache_size_warn_total_mb if defined?(@cache_size_warn_total_mb) && size_mb.nil?
1056
+
1057
+ if ENV.key?('RSPEC_TRACER_CACHE_SIZE_WARN_TOTAL_MB')
1058
+ env_value = ENV['RSPEC_TRACER_CACHE_SIZE_WARN_TOTAL_MB']
1059
+ unless env_value.match?(/\A\d+\z/)
1060
+ raise InvalidUsageError,
1061
+ "RSPEC_TRACER_CACHE_SIZE_WARN_TOTAL_MB must be a non-negative integer, got #{env_value.inspect}"
1062
+ end
1063
+ @cache_size_warn_total_mb = env_value.to_i
1064
+ elsif size_mb.nil?
1065
+ @cache_size_warn_total_mb = DEFAULT_CACHE_SIZE_WARN_TOTAL_MB
1066
+ elsif size_mb.is_a?(::Integer) && size_mb >= 0
1067
+ @cache_size_warn_total_mb = size_mb
1068
+ else
1069
+ raise InvalidUsageError,
1070
+ "cache_size_warn_total_mb must be a non-negative integer, got #{size_mb.inspect}"
1071
+ end
1072
+
1073
+ @cache_size_warn_total_mb
1074
+ end
1075
+ # rubocop:enable Metrics/PerceivedComplexity
1076
+
1077
+ # Storage backend selector with optional kwarg opts hash.
1078
+ # Symbol form: `storage_backend :json` or
1079
+ # `storage_backend :sqlite`. ENV `RSPEC_TRACER_STORAGE` wins over
1080
+ # the DSL argument, matching the `cache_dir` / `coverage_dir`
1081
+ # precedence convention so CI can swap backends without editing
1082
+ # `.rspec-tracer`.
1083
+ #
1084
+ # Options (:json only):
1085
+ # serializer: :json | :msgpack on-disk payload format;
1086
+ # ENV RSPEC_TRACER_STORAGE_SERIALIZER
1087
+ # wins over the opt.
1088
+ #
1089
+ # :sqlite does not accept any opts - its storage layout is a
1090
+ # single sqlite3 file with a normalized schema; serializer
1091
+ # substitution does not apply.
1092
+ # Configure the on-disk storage backend. Reads
1093
+ # `RSPEC_TRACER_STORAGE` for env-based override.
1094
+ #
1095
+ # @param name [Symbol, nil] one of `:json` (default; preserves
1096
+ # 1.x layout) or `:sqlite` (single-file DB; MRI 3.2+ only;
1097
+ # JRuby auto-falls-back to `:json` with a one-time warn).
1098
+ # @param opts [Hash] backend-specific opts. `:json` accepts
1099
+ # `serializer:` (`:json` default or `:msgpack`); `:sqlite`
1100
+ # accepts no opts.
1101
+ # @return [Symbol] the resolved backend name.
1102
+ # @raise [InvalidUsageError] for unknown backend names or
1103
+ # invalid opts.
1104
+ # @example JSON (default)
1105
+ # storage_backend :json
1106
+ # @example SQLite (faster cold reads above ~5,000 examples)
1107
+ # storage_backend :sqlite
1108
+ # @example JSON with msgpack serializer
1109
+ # storage_backend :json, serializer: :msgpack
1110
+ def storage_backend(name = nil, **opts)
1111
+ if defined?(@storage_backend_name) && @storage_backend_name && name.nil? && opts.empty?
1112
+ return @storage_backend_name
1113
+ end
1114
+
1115
+ env = ENV.fetch('RSPEC_TRACER_STORAGE', nil)
1116
+ resolved = (env || name || DEFAULT_STORAGE_BACKEND).to_sym
1117
+
1118
+ unless STORAGE_BACKEND_NAMES.include?(resolved)
1119
+ raise InvalidUsageError,
1120
+ "unknown storage backend: #{resolved.inspect}; allowed: #{STORAGE_BACKEND_NAMES.inspect}"
1121
+ end
1122
+
1123
+ @storage_backend_name = resolved
1124
+ @storage_backend_opts = validate_and_resolve_storage_opts(resolved, opts).freeze
1125
+ @storage_backend_name
1126
+ end
1127
+
1128
+ # Accessor for the opts hash passed to `storage_backend`.
1129
+ # Returns a frozen empty Hash when the DSL was called without
1130
+ # opts (or not called at all) so the backend dispatch can splat
1131
+ # `**storage_backend_opts` unconditionally.
1132
+ def storage_backend_opts
1133
+ return EMPTY_STORAGE_BACKEND_OPTS unless defined?(@storage_backend_opts) && @storage_backend_opts
1134
+
1135
+ @storage_backend_opts
1136
+ end
1137
+
1138
+ # Internal constant.
1139
+ # @api private
1140
+ EMPTY_STORAGE_BACKEND_OPTS = {}.freeze
1141
+ private_constant :EMPTY_STORAGE_BACKEND_OPTS
1142
+
1143
+ # Configure which Ruby `Coverage` modes rspec-tracer enables on
1144
+ # the standalone path (when SimpleCov is not running). Pass a
1145
+ # Symbol Array drawn from {COVERAGE_MODES}; `coverage_modes [:lines,
1146
+ # :branches]` translates to `Coverage.start(lines: true, branches:
1147
+ # true)`.
1148
+ #
1149
+ # When SimpleCov is loaded and running at `RSpecTracer.start`
1150
+ # time, the DSL is inert — SimpleCov owns `Coverage.start` and
1151
+ # controls modes via its own `enable_coverage :branch` etc. (the
1152
+ # documented load-order contract; see UPGRADING `#SimpleCov branch
1153
+ # coverage now works`).
1154
+ #
1155
+ # The default `[:lines]` matches the pre-#195 bare `Coverage.start`
1156
+ # semantics so existing configs continue to emit the lines-only
1157
+ # `coverage.json` shape. The `coverage.json` artifact itself stays
1158
+ # on the 1.x `Array<Integer|nil>` per-file format regardless of
1159
+ # modes (storage-format stability). Branches / methods / etc.
1160
+ # data is available to SimpleCov via the documented interop and
1161
+ # to downstream consumers via `Coverage.peek_result` directly.
1162
+ #
1163
+ # @param modes [Array<Symbol>, Symbol, nil] one or more entries
1164
+ # from {COVERAGE_MODES} (`:lines`, `:branches`, `:methods`,
1165
+ # `:oneshot_lines`, `:eval`); a single Symbol is wrapped to
1166
+ # an Array.
1167
+ # @return [Array<Symbol>] the resolved (frozen) modes array. The
1168
+ # no-arg call returns the current setting (or
1169
+ # {DEFAULT_COVERAGE_MODES} if unset).
1170
+ # @raise [InvalidUsageError] when `modes` is empty or contains an
1171
+ # unknown mode.
1172
+ # @example Branch coverage on top of the default lines
1173
+ # coverage_modes [:lines, :branches]
1174
+ # @example Methods coverage in addition (for downstream tooling)
1175
+ # coverage_modes [:lines, :methods]
1176
+ def coverage_modes(*modes)
1177
+ return read_coverage_modes if modes.empty?
1178
+
1179
+ modes_array = Array(modes.length == 1 ? modes.first : modes).flatten.map(&:to_sym)
1180
+ raise InvalidUsageError, 'coverage_modes requires at least one mode (e.g. [:lines])' if modes_array.empty?
1181
+
1182
+ unknown = modes_array - COVERAGE_MODES
1183
+ unless unknown.empty?
1184
+ raise InvalidUsageError,
1185
+ "unknown coverage modes: #{unknown.inspect}; allowed: #{COVERAGE_MODES.inspect}"
1186
+ end
1187
+
1188
+ @coverage_modes = modes_array.uniq.freeze
1189
+ end
1190
+
1191
+ # Hash form of {#coverage_modes} for splatting into
1192
+ # `Coverage.start`. `[:lines, :branches]` becomes
1193
+ # `{lines: true, branches: true}`.
1194
+ def coverage_modes_for_start
1195
+ coverage_modes.to_h { |m| [m, true] }.freeze
1196
+ end
1197
+
1198
+ # Internal method on the tracer pipeline.
1199
+ # @api private
1200
+ def read_coverage_modes
1201
+ return @coverage_modes if defined?(@coverage_modes) && @coverage_modes
1202
+
1203
+ DEFAULT_COVERAGE_MODES
1204
+ end
1205
+ private :read_coverage_modes
1206
+
1207
+ # Internal method on the tracer pipeline.
1208
+ # @api private
1209
+ def validate_and_resolve_storage_opts(backend, opts)
1210
+ unknown = opts.keys - STORAGE_BACKEND_OPT_KEYS
1211
+ unless unknown.empty?
1212
+ raise InvalidUsageError,
1213
+ "unknown storage_backend options: #{unknown.inspect}; allowed: #{STORAGE_BACKEND_OPT_KEYS.inspect}"
1214
+ end
1215
+
1216
+ if backend == :sqlite && !opts.empty?
1217
+ raise InvalidUsageError,
1218
+ "storage_backend :sqlite does not accept options (got #{opts.keys.inspect})"
1219
+ end
1220
+
1221
+ return {} unless backend == :json
1222
+
1223
+ env_ser = ENV.fetch('RSPEC_TRACER_STORAGE_SERIALIZER', nil)
1224
+ serializer = (env_ser || opts[:serializer] || :json).to_sym
1225
+
1226
+ unless STORAGE_BACKEND_SERIALIZERS.include?(serializer)
1227
+ raise InvalidUsageError,
1228
+ "unknown storage serializer: #{serializer.inspect}; allowed: #{STORAGE_BACKEND_SERIALIZERS.inspect}"
1229
+ end
1230
+
1231
+ { serializer: serializer }
1232
+ end
1233
+
1234
+ # Reporter DSL. Accumulates each call onto an internal list of
1235
+ # `[name_or_class, opts]` pairs that `Reporters::Registry` walks
1236
+ # at finalize-time. Matches the `track_files` accumulator shape.
1237
+ #
1238
+ # Shapes:
1239
+ # add_reporter :terminal
1240
+ # add_reporter :json
1241
+ # add_reporter MyCustomReporter, color: false
1242
+ #
1243
+ # Symbol names must match `Reporters::Registry::BUILT_INS.keys`
1244
+ # (`:terminal`, `:json`, `:html`). Class values are
1245
+ # trusted - they must subclass / duck-type `Reporters::Base` but
1246
+ # validation is deferred to initialize-time so custom reporters
1247
+ # defined in user code don't have to exist at DSL-validate time.
1248
+ #
1249
+ # If the user never calls `add_reporter`, `Registry.emit_all`
1250
+ # falls back to `Registry::DEFAULTS` (`[:terminal, :json]`).
1251
+ def add_reporter(name_or_class, **opts)
1252
+ validate_reporter_entry(name_or_class)
1253
+ @reporters ||= []
1254
+ @reporters << [name_or_class, opts]
1255
+ @reporters.dup.freeze
1256
+ end
1257
+
1258
+ # Readonly access to the accumulated reporter entries. Returns
1259
+ # `nil` when no `add_reporter` calls were made - the Registry
1260
+ # distinguishes nil (fall back to defaults) from `[]` (user
1261
+ # opted out of every reporter).
1262
+ def reporters
1263
+ return nil unless defined?(@reporters)
1264
+
1265
+ @reporters.dup.freeze
1266
+ end
1267
+
1268
+ # Internal method on the tracer pipeline.
1269
+ # @api private
1270
+ def validate_reporter_entry(name_or_class)
1271
+ case name_or_class
1272
+ when ::Symbol
1273
+ allowed = reporter_builtins_keys
1274
+ return if allowed.include?(name_or_class)
1275
+
1276
+ raise InvalidUsageError,
1277
+ "unknown reporter: #{name_or_class.inspect}; allowed: #{allowed.inspect}"
1278
+ when ::Class
1279
+ nil
1280
+ else
1281
+ raise InvalidUsageError,
1282
+ "add_reporter: expected Symbol or Class, got #{name_or_class.class}"
1283
+ end
1284
+ end
1285
+
1286
+ # Internal method on the tracer pipeline.
1287
+ # @api private
1288
+ def reporter_builtins_keys
1289
+ return [] unless defined?(RSpecTracer::Reporters::Registry)
1290
+
1291
+ RSpecTracer::Reporters::Registry::BUILT_INS.keys
1292
+ end
1293
+
1294
+ # Add a filter to exclude files from the tracer's per-example
1295
+ # dependency graph. Files matching the filter are not registered
1296
+ # as deps, so changes to them don't trigger re-runs.
1297
+ #
1298
+ # The filter applies uniformly to both fresh per-example
1299
+ # attributions AND prior-snapshot carry-forward (warm-run path).
1300
+ # Adding a filter between runs immediately drops matching paths
1301
+ # from the next warm run's `@all_files` and dependency graph;
1302
+ # no cold run is required.
1303
+ #
1304
+ # The default filter list (loaded by `lib/rspec_tracer/load_default_config.rb`
1305
+ # before the user's `.rspec-tracer` runs) excludes Ruby toolchain
1306
+ # paths (`/vendor/bundle/`, `/usr/local/bundle/`, rbenv / asdf / rvm)
1307
+ # AND rspec-tracer's own output dirs (`/rspec_tracer_cache/`,
1308
+ # `/rspec_tracer_coverage/`, `/rspec_tracer_report/`,
1309
+ # `rspec_tracer.lock`). Use `filters.clear` in `.rspec-tracer`
1310
+ # before adding your own if you need to start from a blank list,
1311
+ # but be aware you'll then need to re-add the toolchain + tracer-
1312
+ # output exclusions yourself.
1313
+ #
1314
+ # @param filter [String, Regexp, Array, RSpecTracer::Filter, nil]
1315
+ # filter spec. Nil + a block also accepted; the block receives
1316
+ # a `source_file` Hash (`:name` / `:file_path`) and returns
1317
+ # true to exclude.
1318
+ # @return [Array] the updated filters list.
1319
+ # @example String match (substring)
1320
+ # add_filter '/vendor/bundle/'
1321
+ # @example Regex match
1322
+ # add_filter %r{^/helpers/}
1323
+ # @example Block
1324
+ # add_filter { |source_file| source_file[:file_path].include?('/helpers/') }
1325
+ # @example Array of mixed types
1326
+ # add_filter ['/helpers/', %r{^/utils/}]
227
1327
  def add_filter(filter = nil, &)
228
1328
  filters << parse_filter(filter, &)
229
1329
  end
230
1330
 
1331
+ # @return [Array] the currently-registered dep-graph filters.
1332
+ # Use `filters.clear` to reset.
231
1333
  def filters
232
1334
  @filters ||= []
233
1335
  end
234
1336
 
1337
+ # Bulk filters assignment is intentionally rejected — use
1338
+ # {#filters} `.clear` + repeated {#add_filter} instead so each
1339
+ # entry routes through the same parser.
1340
+ #
1341
+ # @raise [NotImplementedError]
235
1342
  def filters=(new_filteres)
236
1343
  raise NotImplementedError
237
1344
  end
238
1345
 
1346
+ # Add a filter to exclude files from coverage reporting (NOT
1347
+ # from the tracer's dep graph). Use {#add_filter} for the
1348
+ # dep graph; this controls SimpleCov-style coverage output only.
1349
+ #
1350
+ # @param filter [String, Regexp, Array, nil] same shape as
1351
+ # {#add_filter}'s filter arg.
1352
+ # @return [Array]
1353
+ # @example
1354
+ # add_coverage_filter %w[/spec/ /test/ /vendor/bundle/]
239
1355
  def add_coverage_filter(filter = nil, &)
240
1356
  coverage_filters << parse_filter(filter, &)
241
1357
  end
242
1358
 
1359
+ # @return [Array] the currently-registered coverage filters.
1360
+ # Use `coverage_filters.clear` to reset.
243
1361
  def coverage_filters
244
1362
  @coverage_filters ||= []
245
1363
  end
246
1364
 
1365
+ # Bulk coverage_filters assignment intentionally rejected — see
1366
+ # {#filters=}.
1367
+ #
1368
+ # @raise [NotImplementedError]
247
1369
  def coverage_filters=(new_filteres)
248
1370
  raise NotImplementedError
249
1371
  end
250
1372
 
1373
+ # Internal method on the tracer pipeline.
1374
+ # @api private
251
1375
  def valid_s3_path?(s3_path)
252
1376
  uri = URI.parse(s3_path)
253
1377
 
@@ -256,6 +1380,8 @@ module RSpecTracer
256
1380
  false
257
1381
  end
258
1382
 
1383
+ # Internal method on the tracer pipeline.
1384
+ # @api private
259
1385
  def parallel_tests_id
260
1386
  if ParallelTests.first_process?
261
1387
  'parallel_tests_1'
@@ -264,6 +1390,8 @@ module RSpecTracer
264
1390
  end
265
1391
  end
266
1392
 
1393
+ # Internal method on the tracer pipeline.
1394
+ # @api private
267
1395
  def parse_filter(filter = nil, &block)
268
1396
  arg = filter || block
269
1397
 
@@ -271,5 +1399,70 @@ module RSpecTracer
271
1399
 
272
1400
  RSpecTracer::Filter.register(arg)
273
1401
  end
1402
+
1403
+ public
1404
+
1405
+ # Catches typos in `.rspec-tracer` config files. When the user
1406
+ # mistypes a DSL name (e.g. `track_files_glob` for `track_files`),
1407
+ # bare `NoMethodError` puts a Ruby backtrace in their face. This
1408
+ # surface raises `InvalidUsageError` with a stdlib `DidYouMean`
1409
+ # suggestion when the typo is close to a known DSL method;
1410
+ # otherwise it falls through to NoMethodError so internal
1411
+ # respond_to? probes / non-DSL undefined-method usage retains
1412
+ # standard Ruby semantics.
1413
+ def method_missing(name, *args, **kwargs, &)
1414
+ return super if name.to_s.end_with?('=')
1415
+
1416
+ candidates = RSpecTracer::Configuration::DslTypoSuggester.candidates
1417
+ suggestion = candidates.empty? ? nil : RSpecTracer::Configuration::DslTypoSuggester.nearest(name.to_s, candidates)
1418
+ return super if suggestion.nil?
1419
+
1420
+ raise InvalidUsageError,
1421
+ "unknown .rspec-tracer DSL method #{name.inspect}; did you mean #{suggestion.inspect}?"
1422
+ end
1423
+
1424
+ # Internal method on the tracer pipeline.
1425
+ # @api private
1426
+ def respond_to_missing?(name, include_private = false)
1427
+ super
1428
+ end
1429
+
1430
+ # Helpers for the DSL-typo `did you mean` surface. Lives in a
1431
+ # dedicated module so its methods stay outside the configure-time
1432
+ # `alias_method :"_#{name}"` wrapper loop in `Configuration#configure`
1433
+ # (which iterates Configuration's `private_instance_methods(false)`
1434
+ # twice during `load_default_config` + `load_local_config` and would
1435
+ # double-alias any helper instance methods we kept here).
1436
+ module DslTypoSuggester
1437
+ # Internal helper for the tracer pipeline.
1438
+ # @api private
1439
+ def self.candidates
1440
+ # Strip one or more leading `_` to canonicalize through the
1441
+ # configure wrapper's aliasing levels (single underscore after
1442
+ # one configure, double after two, etc.).
1443
+ RSpecTracer.private_methods(true).each_with_object([]) do |m, acc|
1444
+ s = m.to_s
1445
+ next unless s.start_with?('_')
1446
+ next if s.end_with?('=')
1447
+
1448
+ acc << s.sub(/\A_+/, '')
1449
+ end.uniq
1450
+ end
1451
+
1452
+ # Internal helper for the tracer pipeline.
1453
+ # @api private
1454
+ def self.nearest(typed, candidates)
1455
+ prefix_match = candidates
1456
+ .select { |c| typed.start_with?(c) || c.start_with?(typed) }
1457
+ .max_by(&:length)
1458
+ return prefix_match if prefix_match
1459
+
1460
+ require 'did_you_mean'
1461
+ ::DidYouMean::SpellChecker.new(dictionary: candidates).correct(typed).first
1462
+ rescue LoadError
1463
+ nil
1464
+ end
1465
+ end
274
1466
  end
1467
+ # rubocop:enable Metrics/ModuleLength
275
1468
  end