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