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
@@ -0,0 +1,1076 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'set'
7
+
8
+ require_relative 'tracker/coverage_adapter'
9
+ require_relative 'tracker/declared_globs'
10
+ require_relative 'tracker/dependency_graph'
11
+ require_relative 'tracker/env_matcher'
12
+ require_relative 'tracker/env_snapshot'
13
+ require_relative 'tracker/example_registry'
14
+ require_relative 'tracker/file_digest'
15
+ require_relative 'tracker/filter'
16
+ require_relative 'tracker/input'
17
+ require_relative 'tracker/io_hooks'
18
+ require_relative 'tracker/loaded_files_tracker'
19
+ require_relative 'tracker/new_file_detector'
20
+ require_relative 'tracker/whole_suite_invalidators'
21
+ require_relative 'storage/json_backend'
22
+ require_relative 'storage/schema'
23
+ require_relative 'storage/snapshot'
24
+ require_relative 'storage/sqlite_backend'
25
+
26
+ module RSpecTracer
27
+ # Top-level coordinator for the v2 core engine. Wires
28
+ # CoverageAdapter + IOHooks + DeclaredGlobs + NewFileDetector +
29
+ # WholeSuiteInvalidators + LoadedFilesTracker + ExampleRegistry +
30
+ # DependencyGraph + Storage into a single pipeline.
31
+ #
32
+ # Named `Engine` rather than `Tracker` because the `Tracker`
33
+ # namespace is already taken by the sub-module that houses the
34
+ # leaf observers (`Tracker::CoverageAdapter`, `Tracker::IOHooks`,
35
+ # etc.). `RSpecTracer.engine` is the public accessor the RSpec
36
+ # hooks dispatch through during a run.
37
+ #
38
+ # Lifecycle (driven by RSpec hooks in `lib/rspec_tracer.rb`):
39
+ #
40
+ # engine = Engine.new(configuration: RSpecTracer)
41
+ # engine.setup # install hooks, load cache,
42
+ # # compute filter decisions
43
+ # engine.run_example?(id) # per-example filter (from cache)
44
+ # engine.register_example(example) # record metadata + duplicates
45
+ # engine.example_started # peek baseline + open bucket
46
+ # # ... example body runs, IOHooks record into bucket ...
47
+ # engine.example_finished(id) # diff coverage, attribute, close
48
+ # engine.on_example_{passed,failed,pending,skipped}(id, result)
49
+ # engine.finalize # persist snapshot + coverage
50
+ #
51
+ # Per-example coverage delta map: peek baseline at example_started,
52
+ # peek again at example_finished, store the per-line strength delta
53
+ # under `@examples_coverage[id][file_path][line]`. Reporters::
54
+ # CoverageJsonReporter consumes the cumulative coverage at finalize
55
+ # via Tracker::CoverageAdapter#peek_unfiltered + the engine's
56
+ # `merge_skipped_coverage` algorithm.
57
+ #
58
+ # Cache parity: `finalize` builds a Snapshot with file-name-keyed
59
+ # dependency / reverse_dependency / all_files maps (matching the
60
+ # 1.x on-disk convention - root-stripped file names with a leading
61
+ # "/") and hands it to Storage::JsonBackend. The 2.0 schema bump
62
+ # adds `boot_set`; everything else mirrors the 1.x cache layout
63
+ # byte-for-byte.
64
+ # rubocop:disable Metrics/ClassLength
65
+ class Engine
66
+ # Internal constant.
67
+ # @api private
68
+ EXAMPLE_RUN_REASON = {
69
+ explicit_run: 'Explicit run',
70
+ no_cache: 'No cache',
71
+ interrupted: 'Interrupted previously',
72
+ flaky_example: 'Flaky example',
73
+ failed_example: 'Failed previously',
74
+ pending_example: 'Pending previously',
75
+ files_changed: 'Files changed',
76
+ whole_suite_invalidator: 'Whole-suite invalidator changed',
77
+ env_changed: 'Environment changed'
78
+ }.freeze
79
+
80
+ # Map from Filter#select reasons to the legacy-shaped strings
81
+ # users see in test output ("foo (Files changed)"). Keeps the
82
+ # user surface unchanged under v2.
83
+ FILTER_REASON_STRINGS = {
84
+ whole_suite_invalidator: EXAMPLE_RUN_REASON[:whole_suite_invalidator],
85
+ interrupted: EXAMPLE_RUN_REASON[:interrupted],
86
+ flaky_example: EXAMPLE_RUN_REASON[:flaky_example],
87
+ failed_example: EXAMPLE_RUN_REASON[:failed_example],
88
+ pending_example: EXAMPLE_RUN_REASON[:pending_example],
89
+ no_cache: EXAMPLE_RUN_REASON[:no_cache],
90
+ files_changed: EXAMPLE_RUN_REASON[:files_changed],
91
+ env_changed: EXAMPLE_RUN_REASON[:env_changed]
92
+ }.freeze
93
+
94
+ # Internal attribute.
95
+ # @api private
96
+ attr_reader :registry, :graph, :loaded_files_tracker, :coverage_adapter,
97
+ :declared_globs, :whole_suite_invalidators, :new_file_detector,
98
+ :env_snapshot, :storage_backend, :all_examples, :duplicate_examples,
99
+ :examples_coverage, :all_files
100
+
101
+ # Internal method on the tracer pipeline.
102
+ # @api private
103
+ def initialize(configuration: RSpecTracer)
104
+ @configuration = configuration
105
+ @filtered_examples = {}
106
+ @all_examples = {}
107
+ @duplicate_examples = {}
108
+ @examples_coverage = {}
109
+ @all_files = {}
110
+ @tracks_files = Hash.new { |h, id| h[id] = Set.new } # id => Set<abs_path>
111
+ @tracks_env = Hash.new { |h, id| h[id] = Set.new } # id => Set<env_name>
112
+ @tracked_env_names = Set.new
113
+ @config_tracked_env_names = Set.new # config-level subset (post-expansion)
114
+ @previous_snapshot = nil
115
+ @run_id = nil
116
+ @before_peek = nil
117
+ end
118
+
119
+ # Internal method on the tracer pipeline.
120
+ # @api private
121
+ def setup
122
+ @configuration.freeze_declared_globs!
123
+
124
+ build_observers
125
+ install_io_hooks
126
+ install_rails_observers
127
+ ensure_coverage_started
128
+
129
+ @loaded_files_tracker.capture_boot_set!
130
+ @declared_globs.walk
131
+
132
+ @previous_snapshot = load_previous_snapshot
133
+ seed_state_from_previous(@previous_snapshot) if @previous_snapshot
134
+ register_config_tracked_env_names
135
+ compute_filter_decisions
136
+ self
137
+ end
138
+
139
+ # --- filter-phase surface (mirrors legacy Runner) --------------
140
+
141
+ def run_example?(example_id)
142
+ return true if @configuration.run_all_examples
143
+
144
+ previously_seen = @previous_snapshot&.all_examples&.key?(example_id)
145
+ !previously_seen || @filtered_examples.key?(example_id)
146
+ end
147
+
148
+ # Internal method on the tracer pipeline.
149
+ # @api private
150
+ def run_example_reason(example_id)
151
+ return EXAMPLE_RUN_REASON[:explicit_run] if @configuration.run_all_examples
152
+
153
+ @filtered_examples[example_id] || EXAMPLE_RUN_REASON[:no_cache]
154
+ end
155
+
156
+ # Internal method on the tracer pipeline.
157
+ # @api private
158
+ def register_example(example)
159
+ example_id = example[:example_id]
160
+ @registry.register(example_id, metadata: example, identity_hash: example_id)
161
+ @all_examples[example_id] ||= example
162
+ @duplicate_examples[example_id] ||= []
163
+ @duplicate_examples[example_id] << example
164
+ self
165
+ end
166
+
167
+ # Per-example tracking DSL hook. Called from RunnerHook with the
168
+ # normalized `{files: Set<String>, env: Set<String>}` that
169
+ # `RSpec::Metadata.tracks_for(example)` produced. Resolves the
170
+ # file globs against the project root once per distinct glob
171
+ # string (memoized) and unions the matching Inputs into this
172
+ # example's dependency set. Env names are accumulated into
173
+ # `@tracked_env_names` so the finalize snapshot covers every key
174
+ # the run cared about.
175
+ #
176
+ # Per-example env entries may carry wildcard patterns (`tracks:
177
+ # { env: 'RAILS_*' }`). `EnvMatcher.expand` is the single funnel
178
+ # - literals pass through, wildcards expand against the live ENV,
179
+ # and unsupported syntax raises ArgumentError at this point
180
+ # (RunnerHook Pass 1, before any example body runs).
181
+ # rubocop:disable Metrics/PerceivedComplexity
182
+ def register_tracks(example_id, tracks)
183
+ files = tracks[:files] || tracks['files'] || Set.new
184
+ envs = tracks[:env] || tracks['env'] || Set.new
185
+
186
+ files.each { |glob| @tracks_files[example_id].merge(resolved_glob_inputs(glob)) } unless files.empty?
187
+ return self if envs.empty?
188
+
189
+ expanded = RSpecTracer::Tracker::EnvMatcher.expand(envs.map(&:to_s))
190
+ @tracks_env[example_id].merge(expanded)
191
+ @tracked_env_names.merge(expanded)
192
+ self
193
+ end
194
+ # rubocop:enable Metrics/PerceivedComplexity
195
+
196
+ # Called from RunnerHook AFTER the filter-decision pre-walk has
197
+ # populated `@tracks_env` / `@tracked_env_names` for every
198
+ # example. Compares each declared env key against the previous
199
+ # snapshot's `env_snapshot` via Tracker::EnvSnapshot; marks any
200
+ # example whose tracked-env set intersects the invalidated set
201
+ # as re-runnable. Strictly additive vs other filter reasons - if
202
+ # the example was already in `@filtered_examples` for a stronger
203
+ # reason (files_changed / whole_suite_invalidator /
204
+ # failed_example / ...), env_changed does NOT overwrite.
205
+ #
206
+ # Config-level path: when an invalidated key intersects
207
+ # `@config_tracked_env_names` (the post-expansion config-level
208
+ # set), every previously-seen example re-runs - mirrors the
209
+ # `track_files` "declared globs attach to every example"
210
+ # semantics. New examples (not in @previous_snapshot.all_examples)
211
+ # already run via the no_cache path; no special-casing needed.
212
+ def apply_env_filter_decisions
213
+ return self if @previous_snapshot.nil?
214
+ return self if @tracked_env_names.empty?
215
+
216
+ invalidated = @env_snapshot.invalidated_keys(
217
+ @previous_snapshot.env_snapshot, @tracked_env_names
218
+ )
219
+ return self if invalidated.empty?
220
+
221
+ reason = FILTER_REASON_STRINGS.fetch(:env_changed)
222
+ mark_all_prev_examples(reason) if invalidated.intersect?(@config_tracked_env_names)
223
+ mark_per_example_intersections(invalidated, reason)
224
+ self
225
+ end
226
+
227
+ # Internal method on the tracer pipeline.
228
+ # @api private
229
+ def deregister_duplicate_examples
230
+ @duplicate_examples.select! { |_, entries| entries.count > 1 }
231
+ return if @duplicate_examples.empty?
232
+
233
+ @all_examples.reject! { |id, _| @duplicate_examples.key?(id) }
234
+ self
235
+ end
236
+
237
+ # --- per-example surface --------------------------------------
238
+
239
+ # Internal method on the tracer pipeline.
240
+ # @api private
241
+ def example_started
242
+ @before_peek = @coverage_adapter.peek
243
+ @current_bucket = {}
244
+ @current_rails_bucket = {}
245
+ RSpecTracer::Tracker::IOHooks.set_bucket(@current_bucket)
246
+ set_rails_bucket(@current_rails_bucket)
247
+ self
248
+ end
249
+
250
+ # Internal method on the tracer pipeline.
251
+ # @api private
252
+ def example_finished(example_id)
253
+ after_peek = @coverage_adapter.peek
254
+ record_coverage_delta(example_id, @before_peek, after_peek)
255
+ io_inputs = @current_bucket.values
256
+ rails_inputs = @current_rails_bucket ? @current_rails_bucket.values : []
257
+ RSpecTracer::Tracker::IOHooks.clear_bucket
258
+ clear_rails_bucket
259
+
260
+ transitive_inputs = @loaded_files_tracker.loaded_set_inputs |
261
+ @loaded_files_tracker.stop_example(example_id)
262
+ coverage_inputs = @coverage_adapter.compute_diff(@before_peek, after_peek)
263
+ declared_inputs = @declared_globs.walk
264
+ tracks_inputs = per_example_tracks_inputs(example_id)
265
+ attribute_to_example(
266
+ example_id,
267
+ coverage_inputs | transitive_inputs | io_inputs.to_set |
268
+ rails_inputs.to_set | declared_inputs | tracks_inputs
269
+ )
270
+
271
+ @before_peek = nil
272
+ @current_bucket = nil
273
+ @current_rails_bucket = nil
274
+ self
275
+ end
276
+
277
+ # Internal method on the tracer pipeline.
278
+ # @api private
279
+ def on_example_skipped(example_id)
280
+ @registry.register(example_id) unless @registry.registered?(example_id)
281
+ @registry.update_status(example_id, :skipped)
282
+ self
283
+ end
284
+
285
+ # Internal method on the tracer pipeline.
286
+ # @api private
287
+ def on_example_passed(example_id, result)
288
+ return if @duplicate_examples[example_id]&.count.to_i > 1
289
+
290
+ @registry.update_status(example_id, :passed)
291
+ record_execution_result(example_id, result)
292
+ self
293
+ end
294
+
295
+ # Internal method on the tracer pipeline.
296
+ # @api private
297
+ def on_example_failed(example_id, result)
298
+ return if @duplicate_examples[example_id]&.count.to_i > 1
299
+
300
+ @registry.update_status(example_id, :failed)
301
+ record_execution_result(example_id, result)
302
+ self
303
+ end
304
+
305
+ # Internal method on the tracer pipeline.
306
+ # @api private
307
+ def on_example_pending(example_id, result)
308
+ return if @duplicate_examples[example_id]&.count.to_i > 1
309
+
310
+ @registry.update_status(example_id, :pending)
311
+ record_execution_result(example_id, result)
312
+ self
313
+ end
314
+
315
+ # --- finalize ------------------------------------------------
316
+
317
+ def finalize
318
+ @registry.all_example_ids.each do |id|
319
+ next if @registry.status_of(id)
320
+
321
+ @registry.update_status(id, :interrupted)
322
+ end
323
+
324
+ snapshot = build_snapshot
325
+ @storage_backend.save_graph(snapshot, schema_version: RSpecTracer::Storage::Schema::CURRENT)
326
+ uninstall_rails_observers
327
+ snapshot
328
+ end
329
+
330
+ # For every previously-skipped example id, accumulate per-line
331
+ # coverage strengths from the previous run's per-example coverage
332
+ # map into the missed_coverage return value. Deleted files /
333
+ # missing entries are skipped silently. Consumed by
334
+ # Reporters::CoverageJsonReporter at finalize time so coverage.json
335
+ # carries forward the contribution of skipped examples.
336
+ #
337
+ # Returns Hash[file_path => Hash[line_number => cumulative_strength]].
338
+ def merge_skipped_coverage(skipped_ids, previous_examples_coverage = nil)
339
+ source = previous_examples_coverage || @previous_snapshot&.examples_coverage || {}
340
+ missed = Hash.new { |h, f| h[f] = Hash.new(0) }
341
+
342
+ skipped_ids.each do |example_id|
343
+ example_coverage = source[example_id]
344
+ next if example_coverage.nil?
345
+
346
+ example_coverage.each do |file_path, line_coverage|
347
+ accumulate_line_coverage(missed[file_path], line_coverage)
348
+ end
349
+ end
350
+
351
+ missed
352
+ end
353
+
354
+ # --- accessors used by specs ---------------------------------
355
+
356
+ def filtered_example_ids
357
+ @filtered_examples.keys
358
+ end
359
+
360
+ # Internal method on the tracer pipeline.
361
+ # @api private
362
+ def previous_snapshot_loaded?
363
+ !@previous_snapshot.nil?
364
+ end
365
+
366
+ private
367
+
368
+ # Read the config-level `track_env(*names)` accumulator,
369
+ # expand any wildcard patterns against the live ENV via
370
+ # EnvMatcher.expand (which raises ArgumentError on unsupported
371
+ # syntax - intentionally surfaces config errors at run start),
372
+ # and seed both `@config_tracked_env_names` (for the global
373
+ # mark-every-example branch in apply_env_filter_decisions) and
374
+ # `@tracked_env_names` (so the finalize snapshot includes every
375
+ # config-level key alongside per-example keys).
376
+ def register_config_tracked_env_names
377
+ patterns = @configuration.tracked_env_names
378
+ return if patterns.nil? || patterns.empty?
379
+
380
+ expanded = RSpecTracer::Tracker::EnvMatcher.expand(patterns)
381
+ @config_tracked_env_names.merge(expanded)
382
+ @tracked_env_names.merge(expanded)
383
+ end
384
+
385
+ # Mark every previously-seen example for re-run. Called
386
+ # when a config-level env key flips between runs.
387
+ def mark_all_prev_examples(reason)
388
+ @previous_snapshot.all_examples.each_key do |example_id|
389
+ next if @filtered_examples.key?(example_id)
390
+
391
+ @filtered_examples[example_id] = reason
392
+ end
393
+ end
394
+
395
+ # Per-example env-changed mark. Walks @tracks_env,
396
+ # marks examples whose declared env set intersects invalidated.
397
+ # Additive vs other filter reasons (won't overwrite).
398
+ def mark_per_example_intersections(invalidated, reason)
399
+ @tracks_env.each do |example_id, envs|
400
+ next unless envs.intersect?(invalidated)
401
+ next if @filtered_examples.key?(example_id)
402
+
403
+ @filtered_examples[example_id] = reason
404
+ end
405
+ end
406
+
407
+ # Internal method on the tracer pipeline.
408
+ # @api private
409
+ def build_observers
410
+ @registry = RSpecTracer::Tracker::ExampleRegistry.new
411
+ @graph = RSpecTracer::Tracker::DependencyGraph.new
412
+ @coverage_adapter = RSpecTracer::Tracker::CoverageAdapter.new(
413
+ root: @configuration.root, filters: @configuration.filters
414
+ )
415
+ @declared_globs = RSpecTracer::Tracker::DeclaredGlobs.new(
416
+ root: @configuration.root, globs: @configuration.declared_globs
417
+ )
418
+ @whole_suite_invalidators = RSpecTracer::Tracker::WholeSuiteInvalidators.new(
419
+ root: @configuration.root
420
+ )
421
+ @env_snapshot = RSpecTracer::Tracker::EnvSnapshot.new
422
+ @new_file_detector = RSpecTracer::Tracker::NewFileDetector.new(
423
+ root: @configuration.root, declared_globs: @configuration.declared_globs
424
+ )
425
+ @loaded_files_tracker = RSpecTracer::Tracker::LoadedFilesTracker.new(
426
+ root: @configuration.root, enabled: @configuration.transitive_load_tracking
427
+ )
428
+ @storage_backend = build_storage_backend(@configuration.cache_path)
429
+ end
430
+
431
+ # Resolve the configured storage backend to a concrete instance.
432
+ # JsonBackend threads the retention / size-budget knobs and the
433
+ # serializer opt; SqliteBackend ignores them (single-file,
434
+ # latest-run only). A missing sqlite3 gem or JRuby / MRI < 3.2
435
+ # host raises SqliteBackendError; we warn and fall back to the
436
+ # default :json backend so the user's suite still runs - same
437
+ # graceful-degradation contract as the remote cache backends.
438
+ def build_storage_backend(cache_path)
439
+ case @configuration.storage_backend
440
+ when :sqlite
441
+ build_sqlite_backend(cache_path)
442
+ else
443
+ build_json_backend(cache_path)
444
+ end
445
+ end
446
+
447
+ # Internal method on the tracer pipeline.
448
+ # @api private
449
+ def build_json_backend(cache_path)
450
+ RSpecTracer::Storage::JsonBackend.new(
451
+ cache_path: cache_path,
452
+ logger: @configuration.logger,
453
+ retention_local_count: @configuration.cache_retention_local_count,
454
+ warn_per_file_mb: @configuration.cache_size_warn_per_file_mb,
455
+ warn_total_mb: @configuration.cache_size_warn_total_mb,
456
+ serializer: @configuration.storage_backend_opts[:serializer] || :json
457
+ )
458
+ end
459
+
460
+ # Internal method on the tracer pipeline.
461
+ # @api private
462
+ def build_sqlite_backend(cache_path)
463
+ RSpecTracer::Storage::SqliteBackend.new(
464
+ cache_path: cache_path, logger: @configuration.logger
465
+ )
466
+ rescue RSpecTracer::Storage::SqliteBackend::SqliteBackendError => e
467
+ @configuration.logger.warn(
468
+ "rspec-tracer: sqlite backend unavailable (#{e.message}); falling back to :json"
469
+ )
470
+ build_json_backend(cache_path)
471
+ end
472
+
473
+ # Internal method on the tracer pipeline.
474
+ # @api private
475
+ def install_io_hooks
476
+ declared = @declared_globs
477
+ RSpecTracer::Tracker::IOHooks.install(
478
+ root: @configuration.root,
479
+ filter: ->(path) { !declared.covers?(path) }
480
+ )
481
+ end
482
+
483
+ # Install the Rails-observer family (ActionView notifications +
484
+ # I18n backend prepend) when Rails is detected in the process.
485
+ # Runs at setup time so the subscribers are attached before the
486
+ # first example fires. Errors here never propagate - the tracer
487
+ # gracefully degrades to IOHooks-only behavior.
488
+ def install_rails_observers
489
+ return unless @configuration.rails?
490
+
491
+ require_relative 'rails/notifications'
492
+ require_relative 'rails/i18n_tracking'
493
+
494
+ declared = @declared_globs
495
+ filter = ->(path) { !declared.covers?(path) }
496
+ ar_paths = ar_schema_notifications_enabled? ? ar_schema_path_probes : []
497
+
498
+ RSpecTracer::Rails::Notifications.install(
499
+ root: @configuration.root, filter: filter, ar_schema_paths: ar_paths
500
+ )
501
+ RSpecTracer::Rails::I18nTracking.install(
502
+ root: @configuration.root, filter: filter
503
+ )
504
+
505
+ arm_ar_schema_setup_warn if ar_schema_notifications_enabled?
506
+
507
+ @rails_observers_installed = true
508
+ rescue StandardError => e
509
+ @configuration.logger.warn(
510
+ "rspec-tracer: Rails observer install failed (#{e.class}: #{e.message})"
511
+ )
512
+ @rails_observers_installed = false
513
+ end
514
+
515
+ # `track_ar_schema_notifications` promises per-example attribution
516
+ # of `db/schema.rb` via the `sql.active_record` subscriber. That
517
+ # narrow promise only holds when no per-example AR cleanup
518
+ # mechanism fires queries inside the rspec-tracer per-example
519
+ # bucket window. Common Rails setups trip this:
520
+ #
521
+ # - `use_transactional_fixtures = true` (Rails default): per-
522
+ # example BEGIN/COMMIT fires sql.active_record -> every
523
+ # example gets schema attributed -> any schema mutation re-
524
+ # runs every example (safe but wide).
525
+ # - DatabaseCleaner :truncation / :deletion / :transaction in
526
+ # around hooks: same outcome.
527
+ #
528
+ # Defer the check to before(:suite): at engine.setup the user has
529
+ # not run their RSpec.configure block yet, so
530
+ # `use_transactional_fixtures` is unset.
531
+ def arm_ar_schema_setup_warn
532
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configure)
533
+
534
+ logger = @configuration.logger
535
+ ::RSpec.configure do |config|
536
+ config.before(:suite) do
537
+ next unless ::RSpec.configuration.respond_to?(:use_transactional_fixtures)
538
+ next if ::RSpec.configuration.use_transactional_fixtures == false
539
+
540
+ logger.warn(
541
+ 'rspec-tracer: track_ar_schema_notifications is enabled but ' \
542
+ 'use_transactional_fixtures defaults to true; per-example ' \
543
+ 'BEGIN/COMMIT fires sql.active_record so db/schema.rb gets ' \
544
+ 'attributed to every AR-touching example (safe, but widens ' \
545
+ 'invalidation). For narrow attribution, set ' \
546
+ 'use_transactional_fixtures = false and use sequence-based ' \
547
+ 'factories (or another non-AR cleanup mechanism). See ' \
548
+ 'README section "Narrow AR-schema attribution".'
549
+ )
550
+ end
551
+ end
552
+ rescue StandardError => e
553
+ @configuration.logger.warn(
554
+ "rspec-tracer: ar-schema warn install failed (#{e.class}: #{e.message})"
555
+ )
556
+ end
557
+
558
+ # Internal method on the tracer pipeline.
559
+ # @api private
560
+ def uninstall_rails_observers
561
+ return unless rails_observers_installed?
562
+
563
+ RSpecTracer::Rails::Notifications.uninstall
564
+ RSpecTracer::Rails::I18nTracking.uninstall
565
+ @rails_observers_installed = false
566
+ rescue StandardError
567
+ @rails_observers_installed = false
568
+ end
569
+
570
+ # Internal method on the tracer pipeline.
571
+ # @api private
572
+ def rails_observers_installed?
573
+ defined?(@rails_observers_installed) && @rails_observers_installed == true
574
+ end
575
+
576
+ # rubocop:disable Naming/AccessorMethodName
577
+ def set_rails_bucket(bucket)
578
+ return unless rails_observers_installed?
579
+
580
+ RSpecTracer::Rails::Notifications.set_bucket(bucket)
581
+ end
582
+ # rubocop:enable Naming/AccessorMethodName
583
+
584
+ def clear_rails_bucket
585
+ return unless rails_observers_installed?
586
+
587
+ RSpecTracer::Rails::Notifications.clear_bucket
588
+ end
589
+
590
+ # Internal method on the tracer pipeline.
591
+ # @api private
592
+ def ar_schema_notifications_enabled?
593
+ return false unless @configuration.respond_to?(:track_ar_schema_notifications?)
594
+
595
+ @configuration.track_ar_schema_notifications?
596
+ end
597
+
598
+ # Internal method on the tracer pipeline.
599
+ # @api private
600
+ def ar_schema_path_probes
601
+ %w[db/schema.rb db/structure.sql].each_with_object([]) do |rel, acc|
602
+ abs = File.expand_path(rel, @configuration.root)
603
+ acc << abs if File.file?(abs)
604
+ end
605
+ end
606
+
607
+ # Delegate to the legacy ::Coverage bootstrap if SimpleCov isn't
608
+ # already running. Matches RSpecTracer.setup_coverage behavior so
609
+ # v2 and legacy agree on when to call ::Coverage.start.
610
+ def ensure_coverage_started
611
+ return if defined?(SimpleCov) && SimpleCov.running
612
+ return if ::Coverage.respond_to?(:running?) && ::Coverage.running?
613
+
614
+ ::Coverage.start
615
+ rescue RuntimeError
616
+ # ::Coverage.start raises if already started on some Rubies
617
+ # without a running? predicate; safe to ignore.
618
+ nil
619
+ end
620
+
621
+ # Under parallel_tests, each worker writes to its own per-worker
622
+ # cache dir (`rspec_tracer_cache/parallel_tests_N/`) but warm-run
623
+ # reads come from the MERGED top-level cache (`rspec_tracer_cache/`,
624
+ # one level up). Last-process finalize at exit purges the per-worker
625
+ # dirs, so only the merged snapshot survives between runs. Match
626
+ # 1.x Cache#load_cache_for_run's `File.dirname(cache_path) if
627
+ # parallel_tests?` behavior so warm runs under parallel_tests
628
+ # actually skip examples.
629
+ def load_previous_snapshot
630
+ read_backend = build_read_backend
631
+ read_backend.load_graph(schema_version: RSpecTracer::Storage::Schema::CURRENT)
632
+ end
633
+
634
+ # Internal method on the tracer pipeline.
635
+ # @api private
636
+ def build_read_backend
637
+ return @storage_backend unless RSpecTracer.parallel_tests?
638
+
639
+ parent_cache_path = File.dirname(@configuration.cache_path)
640
+ build_storage_backend(parent_cache_path)
641
+ end
642
+
643
+ # On warm runs, skipped examples don't re-populate @all_examples,
644
+ # @all_files, or @graph - only newly-run examples do. Without
645
+ # seeding, the next save would drop the skipped examples'
646
+ # metadata + deps, and the following warm run would see them as
647
+ # "not previously seen" and force a cold re-run of the entire
648
+ # suite. Seed the three state buckets from the previous snapshot
649
+ # so newly-run examples overwrite and skipped examples carry
650
+ # forward.
651
+ #
652
+ # examples_coverage is NOT seeded here. A populated cache can
653
+ # carry a large examples_coverage map; eagerly materializing it
654
+ # at setup defeats LazySnapshot's whole point for the SQLite
655
+ # backend. The merge happens at build_snapshot time via
656
+ # merge_examples_coverage_with_previous instead.
657
+ def seed_state_from_previous(prev)
658
+ seed_all_examples_from_previous(prev)
659
+ seed_all_files_from_previous(prev)
660
+ seed_graph_from_previous(prev)
661
+ end
662
+
663
+ # Internal method on the tracer pipeline.
664
+ # @api private
665
+ def seed_all_examples_from_previous(prev)
666
+ return unless prev.all_examples.is_a?(Hash)
667
+
668
+ prev.all_examples.each do |id, meta|
669
+ @all_examples[id] = meta
670
+ end
671
+ end
672
+
673
+ # prev.all_files is file-name-keyed; the engine's @all_files is
674
+ # abs-path-keyed (path_to_file_name round-trips via the root prefix).
675
+ #
676
+ # Re-applies the current filter list at seed time so users who
677
+ # add a filter mid-development see the carried-over file entries
678
+ # drop on the next run (instead of waiting for a cold run). The
679
+ # filter contract should hold for both fresh attributions
680
+ # (attribute_to_example) and prior-snapshot carry-forward —
681
+ # otherwise add_filter has split semantics and a freshly-added
682
+ # filter does not exclude already-cached paths.
683
+ def seed_all_files_from_previous(prev)
684
+ return unless prev.all_files.is_a?(Hash)
685
+
686
+ prev.all_files.each_value do |meta|
687
+ next unless meta.is_a?(Hash)
688
+
689
+ file_path = symbol_or_string(meta, :file_path)
690
+ next if file_path.nil?
691
+
692
+ file_name = symbol_or_string(meta, :file_name) || path_to_file_name(file_path)
693
+ next if filtered_by_current_filters?(file_name)
694
+
695
+ @all_files[file_path] = {
696
+ file_name: file_name,
697
+ file_path: file_path,
698
+ digest: symbol_or_string(meta, :digest)
699
+ }
700
+ end
701
+ end
702
+
703
+ # prev.dependency keys on example_id, values are Set<file_name>.
704
+ # Register in @graph via absolute paths so the on-disk shape
705
+ # (file_name) converts at save time via dependency_by_name.
706
+ #
707
+ # Drops paths matching the current filter list so previously-
708
+ # cached deps that the new filter excludes do not leak through
709
+ # the carry-forward seed path. Same rationale as
710
+ # seed_all_files_from_previous: the filter contract is one
711
+ # contract, applied uniformly across fresh + carry-forward
712
+ # attributions.
713
+ def seed_graph_from_previous(prev)
714
+ return unless prev.dependency.is_a?(Hash)
715
+
716
+ prev.dependency.each do |example_id, file_names|
717
+ paths = Set.new
718
+ file_names.each do |name|
719
+ next if filtered_by_current_filters?(name)
720
+
721
+ paths << absolute_path(name)
722
+ end
723
+ @graph.register_example(example_id, paths)
724
+ end
725
+ end
726
+
727
+ # Helper: returns true if the file name matches any currently-
728
+ # configured filter. Mirrors the check site at
729
+ # `attribute_to_example` (engine.rb:770) so both fresh + carry-
730
+ # forward attributions converge on the same filter behavior.
731
+ def filtered_by_current_filters?(file_name)
732
+ @configuration.filters.any? { |f| f.match?(file_name: file_name) }
733
+ end
734
+
735
+ # Internal method on the tracer pipeline.
736
+ # @api private
737
+ def compute_filter_decisions
738
+ prev = @previous_snapshot
739
+ return if prev.nil?
740
+
741
+ seed_registry_from_previous(prev)
742
+ change_set = compute_change_set(prev)
743
+ whole_suite = whole_suite_changed?(prev)
744
+
745
+ result = RSpecTracer::Tracker::Filter.select(
746
+ graph: graph_from_previous(prev),
747
+ change_set: change_set,
748
+ registry: @registry,
749
+ whole_suite_invalidated: whole_suite,
750
+ all_example_ids: prev.all_examples.keys.to_set
751
+ )
752
+ @filtered_examples = result.transform_values { |reason| FILTER_REASON_STRINGS.fetch(reason) }
753
+ end
754
+
755
+ # Internal constant.
756
+ # @api private
757
+ SEED_STATUS_ORDER = %i[interrupted flaky failed pending].freeze
758
+ private_constant :SEED_STATUS_ORDER
759
+
760
+ # Internal method on the tracer pipeline.
761
+ # @api private
762
+ def seed_registry_from_previous(prev)
763
+ SEED_STATUS_ORDER.each do |status|
764
+ ids = prev.send(:"#{status}_examples")
765
+ ids.each { |id| seed_registry_entry(id, status, prev.all_examples[id] || {}) }
766
+ end
767
+ end
768
+
769
+ # Internal method on the tracer pipeline.
770
+ # @api private
771
+ def seed_registry_entry(id, status, metadata)
772
+ return if @registry.registered?(id)
773
+
774
+ @registry.register(id, metadata: metadata)
775
+ @registry.update_status(id, status)
776
+ end
777
+
778
+ # Rebuild a DependencyGraph from the previous Snapshot so Filter
779
+ # can intersect its cached dependency sets with the change_set.
780
+ def graph_from_previous(prev)
781
+ graph = RSpecTracer::Tracker::DependencyGraph.new
782
+ prev.dependency.each { |id, paths| graph.register_example(id, paths) }
783
+ graph
784
+ end
785
+
786
+ # Internal method on the tracer pipeline.
787
+ # @api private
788
+ def compute_change_set(prev)
789
+ changed = Set.new
790
+ prev.all_files.each_value do |file_meta|
791
+ file_name = symbol_or_string(file_meta, :file_name)
792
+ cached_digest = symbol_or_string(file_meta, :digest)
793
+ next if file_name.nil? || cached_digest.nil?
794
+
795
+ current_digest = current_file_digest(file_name)
796
+ changed << file_name if current_digest.nil? || current_digest != cached_digest
797
+ end
798
+
799
+ new_files = @new_file_detector.new_files(
800
+ known_paths: prev.all_files.keys.to_set.to_set { |k| absolute_path(k) }
801
+ )
802
+ new_files.each { |input| changed << path_to_file_name(input.path) }
803
+ changed
804
+ end
805
+
806
+ # Internal method on the tracer pipeline.
807
+ # @api private
808
+ def whole_suite_changed?(prev)
809
+ wsi_prev = prev.wsi_snapshot if prev.respond_to?(:wsi_snapshot)
810
+ @whole_suite_invalidators.invalidated?(wsi_prev) ||
811
+ @loaded_files_tracker.boot_set_invalidated?(prev.boot_set)
812
+ end
813
+
814
+ # Internal method on the tracer pipeline.
815
+ # @api private
816
+ def current_file_digest(file_name)
817
+ RSpecTracer::Tracker::FileDigest.compute(absolute_path(file_name))
818
+ end
819
+
820
+ # Internal method on the tracer pipeline.
821
+ # @api private
822
+ def record_coverage_delta(example_id, before, after)
823
+ entry = @examples_coverage[example_id] ||= {}
824
+
825
+ (before.keys | after.keys).each do |path|
826
+ b_lines = before[path]
827
+ a_lines = after[path]
828
+ next if b_lines == a_lines
829
+
830
+ file_entry = entry[path] ||= {}
831
+ accumulate_delta(file_entry, b_lines, a_lines)
832
+ end
833
+ end
834
+
835
+ # Internal method on the tracer pipeline.
836
+ # @api private
837
+ def accumulate_delta(file_entry, before_lines, after_lines)
838
+ length = (after_lines || before_lines || []).length
839
+ length.times do |i|
840
+ delta = line_delta(before_lines && before_lines[i], after_lines && after_lines[i])
841
+ file_entry[i] = delta if delta
842
+ end
843
+ end
844
+
845
+ # Returns the positive coverage delta for one line, or nil if the
846
+ # line isn't a delta worth recording (non-executable, identical,
847
+ # or a stale-going-backward entry).
848
+ def line_delta(before, after)
849
+ return nil if after.nil? || before == after
850
+
851
+ delta = after - (before || 0)
852
+ delta.positive? ? delta : nil
853
+ end
854
+
855
+ # Internal method on the tracer pipeline.
856
+ # @api private
857
+ def accumulate_line_coverage(accumulator, line_coverage)
858
+ line_coverage.each do |line_key, strength|
859
+ index = line_key.to_i
860
+ accumulator[index] += strength || 0
861
+ end
862
+ end
863
+
864
+ # Internal method on the tracer pipeline.
865
+ # @api private
866
+ def attribute_to_example(example_id, inputs)
867
+ paths = Set.new
868
+ inputs.each do |input|
869
+ next if @configuration.filters.any? { |f| f.match?(file_name: path_to_file_name(input.path)) }
870
+
871
+ paths << input.path
872
+ @all_files[input.path] = {
873
+ file_name: path_to_file_name(input.path),
874
+ file_path: input.path,
875
+ digest: input.digest
876
+ }
877
+ end
878
+ @graph.register_example(example_id, paths)
879
+ end
880
+
881
+ # Internal method on the tracer pipeline.
882
+ # @api private
883
+ def record_execution_result(example_id, result)
884
+ return unless @all_examples.key?(example_id)
885
+
886
+ @all_examples[example_id][:execution_result] = formatted_execution_result(result)
887
+ end
888
+
889
+ # Internal method on the tracer pipeline.
890
+ # @api private
891
+ def formatted_execution_result(result)
892
+ {
893
+ started_at: result.started_at.utc,
894
+ finished_at: result.finished_at.utc,
895
+ run_time: result.run_time,
896
+ status: result.status.to_s
897
+ }
898
+ end
899
+
900
+ # Internal method on the tracer pipeline.
901
+ # @api private
902
+ def build_snapshot
903
+ run_id = Digest::MD5.hexdigest(@all_examples.keys.sort.to_json)
904
+ @run_id = run_id
905
+
906
+ RSpecTracer::Storage::Snapshot.new(
907
+ schema_version: RSpecTracer::Storage::Schema::CURRENT,
908
+ run_id: run_id,
909
+ all_examples: @all_examples,
910
+ duplicate_examples: duplicates_for_snapshot,
911
+ interrupted_examples: @registry.ids_with_status(:interrupted),
912
+ flaky_examples: @registry.ids_with_status(:flaky),
913
+ failed_examples: @registry.ids_with_status(:failed),
914
+ pending_examples: @registry.ids_with_status(:pending),
915
+ skipped_examples: @registry.ids_with_status(:skipped),
916
+ all_files: all_files_by_name,
917
+ dependency: dependency_by_name,
918
+ reverse_dependency: reverse_dependency_by_name,
919
+ examples_coverage: merge_examples_coverage_with_previous,
920
+ boot_set: @loaded_files_tracker.boot_set_digest_snapshot,
921
+ wsi_snapshot: @whole_suite_invalidators.digest_snapshot,
922
+ env_snapshot: env_snapshot_for_persistence,
923
+ env_dependency: env_dependency_for_persistence,
924
+ cache_hit_reason: @filtered_examples.values.tally
925
+ )
926
+ end
927
+
928
+ # Merge the current run's per-example coverage with the previous
929
+ # run's. Re-run examples contribute their freshly-computed map
930
+ # (record_coverage_delta overwrote per-line strengths, so
931
+ # @examples_coverage[id] is the authoritative new coverage).
932
+ # Skipped examples don't appear in @examples_coverage, so the
933
+ # prev map carries them forward unchanged. Previously in
934
+ # seed_state_from_previous; moved here so large caches don't
935
+ # pay the full examples_coverage materialization cost at setup
936
+ # time. Preserves 1.x semantics: the saved map is the union of
937
+ # (prev minus this-run's keys) + this-run's entries.
938
+ def merge_examples_coverage_with_previous
939
+ merged = {}
940
+ if @previous_snapshot
941
+ prev = @previous_snapshot.examples_coverage
942
+ if prev.is_a?(Hash)
943
+ prev.each { |id, cov| merged[id] = cov unless @examples_coverage.key?(id) }
944
+ end
945
+ end
946
+ @examples_coverage.each { |id, cov| merged[id] = cov }
947
+ merged
948
+ end
949
+
950
+ # Snapshot only the env keys THIS run tracked - persisting keys
951
+ # that stopped being tracked (user removed `tracks: env: ...`
952
+ # between runs) would pin stale digests in the cache. Missing
953
+ # keys on the next load just trigger a one-time re-run for any
954
+ # example that reintroduces them, which is the correct behavior.
955
+ def env_snapshot_for_persistence
956
+ return {} if @env_snapshot.nil? || @tracked_env_names.empty?
957
+
958
+ @env_snapshot.digest_snapshot(@tracked_env_names)
959
+ end
960
+
961
+ # Project the per-example `@tracks_env` map (Set<env_name> per
962
+ # example_id) into a JSON-friendly Hash[id => sorted Array] for
963
+ # persistence. Reporters consume this to render the env-
964
+ # dependency view on the Examples Dependency report. Empty sets
965
+ # drop out so the on-disk map stays narrow. Sort keeps the output
966
+ # deterministic for downstream diffs and golden tests.
967
+ def env_dependency_for_persistence
968
+ result = {}
969
+ @tracks_env.each do |example_id, names|
970
+ next if names.nil? || names.empty?
971
+
972
+ result[example_id] = names.to_a.sort
973
+ end
974
+ result
975
+ end
976
+
977
+ # Resolve one glob against the project root into a Set of
978
+ # `:declared`-kind Inputs. Walks via Dir.glob
979
+ # (FNM_PATHNAME+FNM_EXTGLOB like DeclaredGlobs so user muscle
980
+ # memory works identically) and digests each hit with SHA256.
981
+ # Unreadable files are skipped silently - graceful degradation.
982
+ # Memoized per distinct glob string so N examples declaring the
983
+ # same glob pay the filesystem walk cost exactly once.
984
+ def resolved_glob_inputs(glob)
985
+ @resolved_glob_cache ||= {}
986
+ @resolved_glob_cache[glob] ||= walk_one_glob(glob)
987
+ end
988
+
989
+ # Internal method on the tracer pipeline.
990
+ # @api private
991
+ def walk_one_glob(glob)
992
+ inputs = Set.new
993
+ root = @configuration.root
994
+ Dir.glob(glob, File::FNM_PATHNAME | File::FNM_EXTGLOB, base: root).each do |rel|
995
+ abs = File.expand_path(rel, root)
996
+ next unless abs.start_with?("#{root}/") && File.file?(abs)
997
+
998
+ digest = tracks_file_digest(abs)
999
+ next if digest.nil?
1000
+
1001
+ inputs << RSpecTracer::Tracker::Input.for_file(
1002
+ path: abs, kind: :declared, digest: digest, root: root
1003
+ )
1004
+ end
1005
+ inputs
1006
+ end
1007
+
1008
+ # Internal method on the tracer pipeline.
1009
+ # @api private
1010
+ def tracks_file_digest(path)
1011
+ RSpecTracer::Tracker::FileDigest.compute(path)
1012
+ end
1013
+
1014
+ # Materialize the per-example tracks-file Input set. Returns an
1015
+ # empty Set when the example has no `tracks: files:` metadata,
1016
+ # keeping the downstream union cheap for the 99% case.
1017
+ def per_example_tracks_inputs(example_id)
1018
+ set = @tracks_files[example_id]
1019
+ set.nil? || set.empty? ? Set.new : set.dup
1020
+ end
1021
+
1022
+ # Internal method on the tracer pipeline.
1023
+ # @api private
1024
+ def duplicates_for_snapshot
1025
+ @duplicate_examples.select { |_, entries| entries.count > 1 }
1026
+ end
1027
+
1028
+ # Internal method on the tracer pipeline.
1029
+ # @api private
1030
+ def all_files_by_name
1031
+ @all_files.each_with_object({}) do |(_, meta), acc|
1032
+ acc[meta[:file_name]] = meta
1033
+ end
1034
+ end
1035
+
1036
+ # Internal method on the tracer pipeline.
1037
+ # @api private
1038
+ def dependency_by_name
1039
+ @graph.dependency_hash.transform_values do |paths|
1040
+ paths.to_set { |p| path_to_file_name(p) }
1041
+ end
1042
+ end
1043
+
1044
+ # Internal method on the tracer pipeline.
1045
+ # @api private
1046
+ def reverse_dependency_by_name
1047
+ @graph.reverse_dependency_hash.each_with_object({}) do |(path, ids), acc|
1048
+ acc[path_to_file_name(path)] = ids.to_set
1049
+ end
1050
+ end
1051
+
1052
+ # Internal method on the tracer pipeline.
1053
+ # @api private
1054
+ def absolute_path(file_name)
1055
+ File.expand_path(file_name.to_s.sub(%r{^/}, ''), @configuration.root)
1056
+ end
1057
+
1058
+ # Internal method on the tracer pipeline.
1059
+ # @api private
1060
+ def path_to_file_name(abs_path)
1061
+ root_prefix = "#{@configuration.root}/"
1062
+ return abs_path unless abs_path.start_with?(root_prefix)
1063
+
1064
+ "/#{abs_path[root_prefix.length..]}"
1065
+ end
1066
+
1067
+ # Internal method on the tracer pipeline.
1068
+ # @api private
1069
+ def symbol_or_string(hash, key)
1070
+ return hash[key] if hash.key?(key)
1071
+
1072
+ hash[key.to_s]
1073
+ end
1074
+ end
1075
+ # rubocop:enable Metrics/ClassLength
1076
+ end