testsift 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE +22 -0
  4. data/README.md +572 -0
  5. data/bin/testsift +15 -0
  6. data/lib/testsift/cache/Rakefile +43 -0
  7. data/lib/testsift/cli/blast_radius.rb +318 -0
  8. data/lib/testsift/cli/cache_clear.rb +114 -0
  9. data/lib/testsift/cli/cache_info.rb +106 -0
  10. data/lib/testsift/cli/doctor.rb +307 -0
  11. data/lib/testsift/cli/explain.rb +273 -0
  12. data/lib/testsift/cli/report_open.rb +90 -0
  13. data/lib/testsift/cli/snapshot_helpers.rb +119 -0
  14. data/lib/testsift/cli.rb +219 -0
  15. data/lib/testsift/configuration.rb +1467 -0
  16. data/lib/testsift/defaults.rb +11 -0
  17. data/lib/testsift/engine/snapshot_builder.rb +117 -0
  18. data/lib/testsift/engine.rb +896 -0
  19. data/lib/testsift/example.rb +188 -0
  20. data/lib/testsift/filter.rb +103 -0
  21. data/lib/testsift/line_stub.rb +62 -0
  22. data/lib/testsift/load_config.rb +15 -0
  23. data/lib/testsift/load_default_config.rb +46 -0
  24. data/lib/testsift/load_global_config.rb +33 -0
  25. data/lib/testsift/load_local_config.rb +17 -0
  26. data/lib/testsift/logger.rb +59 -0
  27. data/lib/testsift/rails/README.md +78 -0
  28. data/lib/testsift/rails/i18n_tracking.rb +137 -0
  29. data/lib/testsift/rails/notifications.rb +263 -0
  30. data/lib/testsift/rails/preset.rb +94 -0
  31. data/lib/testsift/rails/railtie.rb +22 -0
  32. data/lib/testsift/rails.rb +15 -0
  33. data/lib/testsift/remote_cache/README.md +140 -0
  34. data/lib/testsift/remote_cache/Rakefile +61 -0
  35. data/lib/testsift/remote_cache/archive.rb +137 -0
  36. data/lib/testsift/remote_cache/backend.rb +73 -0
  37. data/lib/testsift/remote_cache/git_ancestry.rb +241 -0
  38. data/lib/testsift/remote_cache/local_fs_backend.rb +439 -0
  39. data/lib/testsift/remote_cache/redis_backend.rb +554 -0
  40. data/lib/testsift/remote_cache/s3_backend.rb +712 -0
  41. data/lib/testsift/remote_cache/user_tasks.rb +436 -0
  42. data/lib/testsift/remote_cache/validator.rb +50 -0
  43. data/lib/testsift/remote_cache.rb +22 -0
  44. data/lib/testsift/reporters/README.md +103 -0
  45. data/lib/testsift/reporters/base.rb +87 -0
  46. data/lib/testsift/reporters/coverage_json_reporter.rb +338 -0
  47. data/lib/testsift/reporters/html/.gitignore +19 -0
  48. data/lib/testsift/reporters/html/.prettierignore +4 -0
  49. data/lib/testsift/reporters/html/.prettierrc.json +9 -0
  50. data/lib/testsift/reporters/html/README.md +80 -0
  51. data/lib/testsift/reporters/html/dist/assets/index.css +2 -0
  52. data/lib/testsift/reporters/html/dist/assets/index.js +1 -0
  53. data/lib/testsift/reporters/html/dist/index.html +24 -0
  54. data/lib/testsift/reporters/html/eslint.config.js +62 -0
  55. data/lib/testsift/reporters/html/package-lock.json +4910 -0
  56. data/lib/testsift/reporters/html/package.json +29 -0
  57. data/lib/testsift/reporters/html/src/app.jsx +130 -0
  58. data/lib/testsift/reporters/html/src/components/AllExamples.jsx +86 -0
  59. data/lib/testsift/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  60. data/lib/testsift/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  61. data/lib/testsift/reporters/html/src/components/FilesDependency.jsx +72 -0
  62. data/lib/testsift/reporters/html/src/components/FlakyExamples.jsx +42 -0
  63. data/lib/testsift/reporters/html/src/components/ReportTable.jsx +131 -0
  64. data/lib/testsift/reporters/html/src/components/SearchBar.jsx +19 -0
  65. data/lib/testsift/reporters/html/src/index.html +23 -0
  66. data/lib/testsift/reporters/html/src/main.jsx +37 -0
  67. data/lib/testsift/reporters/html/src/styles.css +434 -0
  68. data/lib/testsift/reporters/html/vite.config.js +42 -0
  69. data/lib/testsift/reporters/html_reporter.rb +266 -0
  70. data/lib/testsift/reporters/json_reporter.rb +88 -0
  71. data/lib/testsift/reporters/payload_builder.rb +241 -0
  72. data/lib/testsift/reporters/registry.rb +120 -0
  73. data/lib/testsift/reporters/terminal_reporter.rb +264 -0
  74. data/lib/testsift/rspec/README.md +73 -0
  75. data/lib/testsift/rspec/installation.rb +97 -0
  76. data/lib/testsift/rspec/metadata.rb +96 -0
  77. data/lib/testsift/rspec/parallel_tests.rb +459 -0
  78. data/lib/testsift/rspec/rails_setup.rb +42 -0
  79. data/lib/testsift/rspec/reporter_hook.rb +90 -0
  80. data/lib/testsift/rspec/runner_hook.rb +253 -0
  81. data/lib/testsift/source_file.rb +56 -0
  82. data/lib/testsift/storage/README.md +35 -0
  83. data/lib/testsift/storage/backend.rb +130 -0
  84. data/lib/testsift/storage/json_backend.rb +884 -0
  85. data/lib/testsift/storage/lazy_snapshot.rb +65 -0
  86. data/lib/testsift/storage/schema.rb +50 -0
  87. data/lib/testsift/storage/serializer/json.rb +41 -0
  88. data/lib/testsift/storage/serializer/msgpack.rb +167 -0
  89. data/lib/testsift/storage/snapshot.rb +141 -0
  90. data/lib/testsift/storage/sqlite_backend.rb +709 -0
  91. data/lib/testsift/time_formatter.rb +74 -0
  92. data/lib/testsift/tracker/README.md +36 -0
  93. data/lib/testsift/tracker/coverage_adapter.rb +180 -0
  94. data/lib/testsift/tracker/declared_globs.rb +100 -0
  95. data/lib/testsift/tracker/dependency_graph.rb +134 -0
  96. data/lib/testsift/tracker/env_matcher.rb +127 -0
  97. data/lib/testsift/tracker/env_snapshot.rb +77 -0
  98. data/lib/testsift/tracker/example_registry.rb +153 -0
  99. data/lib/testsift/tracker/file_digest.rb +61 -0
  100. data/lib/testsift/tracker/filter.rb +127 -0
  101. data/lib/testsift/tracker/input.rb +99 -0
  102. data/lib/testsift/tracker/io_hooks/file.rb +55 -0
  103. data/lib/testsift/tracker/io_hooks/io.rb +24 -0
  104. data/lib/testsift/tracker/io_hooks/json.rb +23 -0
  105. data/lib/testsift/tracker/io_hooks/kernel.rb +26 -0
  106. data/lib/testsift/tracker/io_hooks/yaml.rb +38 -0
  107. data/lib/testsift/tracker/io_hooks.rb +195 -0
  108. data/lib/testsift/tracker/loaded_files_tracker.rb +290 -0
  109. data/lib/testsift/tracker/new_file_detector.rb +62 -0
  110. data/lib/testsift/tracker/whole_suite_invalidators.rb +96 -0
  111. data/lib/testsift/version.rb +7 -0
  112. data/lib/testsift.rb +335 -0
  113. metadata +193 -0
@@ -0,0 +1,896 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'set'
7
+
8
+ require_relative 'engine/snapshot_builder'
9
+ require_relative 'tracker/coverage_adapter'
10
+ require_relative 'tracker/declared_globs'
11
+ require_relative 'tracker/dependency_graph'
12
+ require_relative 'tracker/env_matcher'
13
+ require_relative 'tracker/env_snapshot'
14
+ require_relative 'tracker/example_registry'
15
+ require_relative 'tracker/file_digest'
16
+ require_relative 'tracker/filter'
17
+ require_relative 'tracker/input'
18
+ require_relative 'tracker/io_hooks'
19
+ require_relative 'tracker/loaded_files_tracker'
20
+ require_relative 'tracker/new_file_detector'
21
+ require_relative 'tracker/whole_suite_invalidators'
22
+ require_relative 'storage/backend'
23
+ require_relative 'storage/json_backend'
24
+ require_relative 'storage/schema'
25
+ require_relative 'storage/snapshot'
26
+ require_relative 'storage/sqlite_backend'
27
+
28
+ module TestSift
29
+ # rubocop:disable Metrics/ClassLength
30
+ class Engine
31
+ include SnapshotBuilder
32
+
33
+ # Internal constant.
34
+ # @api private
35
+ EXAMPLE_RUN_REASON = {
36
+ explicit_run: 'Explicit run',
37
+ no_cache: 'No cache',
38
+ interrupted: 'Interrupted previously',
39
+ flaky_example: 'Flaky example',
40
+ failed_example: 'Failed previously',
41
+ pending_example: 'Pending previously',
42
+ files_changed: 'Files changed',
43
+ whole_suite_invalidator: 'Whole-suite invalidator changed',
44
+ env_changed: 'Environment changed'
45
+ }.freeze
46
+
47
+ # Map from Filter#select reasons to the legacy-shaped strings
48
+ # users see in test output ("foo (Files changed)"). Keeps the
49
+ # user surface unchanged under v2.
50
+ FILTER_REASON_STRINGS = {
51
+ whole_suite_invalidator: EXAMPLE_RUN_REASON[:whole_suite_invalidator],
52
+ interrupted: EXAMPLE_RUN_REASON[:interrupted],
53
+ flaky_example: EXAMPLE_RUN_REASON[:flaky_example],
54
+ failed_example: EXAMPLE_RUN_REASON[:failed_example],
55
+ pending_example: EXAMPLE_RUN_REASON[:pending_example],
56
+ no_cache: EXAMPLE_RUN_REASON[:no_cache],
57
+ files_changed: EXAMPLE_RUN_REASON[:files_changed],
58
+ env_changed: EXAMPLE_RUN_REASON[:env_changed]
59
+ }.freeze
60
+
61
+ # Internal attribute.
62
+ # @api private
63
+ attr_reader :registry, :graph, :loaded_files_tracker, :coverage_adapter,
64
+ :declared_globs, :whole_suite_invalidators, :new_file_detector,
65
+ :env_snapshot, :storage_backend, :all_examples, :duplicate_examples,
66
+ :examples_coverage, :all_files
67
+
68
+ # Internal method on the tracer pipeline.
69
+ # @api private
70
+ def initialize(configuration:, read_cache_path: nil)
71
+ @configuration = configuration
72
+ @read_cache_path = read_cache_path
73
+ @filtered_examples = {}
74
+ @all_examples = {}
75
+ @duplicate_examples = {}
76
+ @examples_coverage = {}
77
+ @all_files = {}
78
+ @file_names = {}
79
+ @absolute_paths = {}
80
+ @filter_decisions = {}
81
+ @tracks_files = Hash.new { |h, id| h[id] = Set.new } # id => Set<abs_path>
82
+ @tracks_env = Hash.new { |h, id| h[id] = Set.new } # id => Set<env_name>
83
+ @tracked_env_names = Set.new
84
+ @config_tracked_env_names = Set.new # config-level subset (post-expansion)
85
+ @previous_snapshot = nil
86
+ @run_id = nil
87
+ @before_peek = nil
88
+ end
89
+
90
+ # Internal method on the tracer pipeline.
91
+ # @api private
92
+ def setup
93
+ @configuration.freeze_declared_globs!
94
+
95
+ build_observers
96
+ install_io_hooks
97
+ install_rails_observers if @configuration.rails?
98
+ ensure_coverage_started
99
+
100
+ @loaded_files_tracker.capture_boot_set!
101
+ @declared_globs.walk
102
+
103
+ @previous_snapshot = load_previous_snapshot
104
+ seed_state_from_previous(@previous_snapshot) if @previous_snapshot
105
+ register_config_tracked_env_names
106
+ compute_filter_decisions
107
+ self
108
+ end
109
+
110
+ # --- filter-phase surface (mirrors legacy Runner) --------------
111
+
112
+ def run_example?(example_id)
113
+ return true if @configuration.run_all_examples
114
+
115
+ previously_seen = @previous_snapshot&.all_examples&.key?(example_id)
116
+ !previously_seen || @filtered_examples.key?(example_id)
117
+ end
118
+
119
+ # Internal method on the tracer pipeline.
120
+ # @api private
121
+ def run_example_reason(example_id)
122
+ return EXAMPLE_RUN_REASON[:explicit_run] if @configuration.run_all_examples
123
+
124
+ @filtered_examples[example_id] || EXAMPLE_RUN_REASON[:no_cache]
125
+ end
126
+
127
+ def register_example(example)
128
+ example_id = example[:example_id]
129
+ @registry.register(example_id, metadata: example, identity_hash: example_id)
130
+ example.replace(@all_examples.fetch(example_id, {}).merge(example))
131
+ @all_examples[example_id] = example
132
+ @duplicate_examples[example_id] ||= []
133
+ @duplicate_examples[example_id] << example
134
+ self
135
+ end
136
+
137
+ # Per-example tracking DSL hook. Called from RunnerHook with the
138
+ # normalized `{files: Set<String>, env: Set<String>}` that
139
+ # `RSpec::Metadata.tracks_for(example)` produced. Resolves the
140
+ # file globs against the project root once per distinct glob
141
+ # string (memoized) and unions the matching Inputs into this
142
+ # example's dependency set. Env names are accumulated into
143
+ # `@tracked_env_names` so the finalize snapshot covers every key
144
+ # the run cared about.
145
+ #
146
+ # Per-example env entries may carry wildcard patterns (`tracks:
147
+ # { env: 'RAILS_*' }`). `EnvMatcher.expand` is the single funnel
148
+ # - literals pass through, wildcards expand against the live ENV,
149
+ # and unsupported syntax raises ArgumentError at this point
150
+ # (RunnerHook Pass 1, before any example body runs).
151
+ # rubocop:disable Metrics/PerceivedComplexity
152
+ def register_tracks(example_id, tracks)
153
+ files = tracks[:files] || tracks['files'] || Set.new
154
+ envs = tracks[:env] || tracks['env'] || Set.new
155
+
156
+ files.each { |glob| @tracks_files[example_id].merge(resolved_glob_inputs(glob)) } unless files.empty?
157
+ return self if envs.empty?
158
+
159
+ expanded = TestSift::Tracker::EnvMatcher.expand(envs.map(&:to_s))
160
+ @tracks_env[example_id].merge(expanded)
161
+ @tracked_env_names.merge(expanded)
162
+ self
163
+ end
164
+ # rubocop:enable Metrics/PerceivedComplexity
165
+
166
+ # Called from RunnerHook AFTER the filter-decision pre-walk has
167
+ # populated `@tracks_env` / `@tracked_env_names` for every
168
+ # example. Compares each declared env key against the previous
169
+ # snapshot's `env_snapshot` via Tracker::EnvSnapshot; marks any
170
+ # example whose tracked-env set intersects the invalidated set
171
+ # as re-runnable. Strictly additive vs other filter reasons - if
172
+ # the example was already in `@filtered_examples` for a stronger
173
+ # reason (files_changed / whole_suite_invalidator /
174
+ # failed_example / ...), env_changed does NOT overwrite.
175
+ #
176
+ # Config-level path: when an invalidated key intersects
177
+ # `@config_tracked_env_names` (the post-expansion config-level
178
+ # set), every previously-seen example re-runs - mirrors the
179
+ # `track_files` "declared globs attach to every example"
180
+ # semantics. New examples (not in @previous_snapshot.all_examples)
181
+ # already run via the no_cache path; no special-casing needed.
182
+ def apply_env_filter_decisions
183
+ return self if @previous_snapshot.nil?
184
+ return self if @tracked_env_names.empty?
185
+
186
+ invalidated = @env_snapshot.invalidated_keys(
187
+ @previous_snapshot.env_snapshot, @tracked_env_names
188
+ )
189
+ return self if invalidated.empty?
190
+
191
+ reason = FILTER_REASON_STRINGS.fetch(:env_changed)
192
+ mark_all_prev_examples(reason) if invalidated.intersect?(@config_tracked_env_names)
193
+ mark_per_example_intersections(invalidated, reason)
194
+ self
195
+ end
196
+
197
+ # Internal method on the tracer pipeline.
198
+ # @api private
199
+ def deregister_duplicate_examples
200
+ @duplicate_examples.select! { |_, entries| entries.count > 1 }
201
+ return if @duplicate_examples.empty?
202
+
203
+ @all_examples.reject! { |id, _| @duplicate_examples.key?(id) }
204
+ self
205
+ end
206
+
207
+ # --- per-example surface --------------------------------------
208
+
209
+ # Internal method on the tracer pipeline.
210
+ # @api private
211
+ def example_started
212
+ @before_peek = @coverage_adapter.peek
213
+ @current_bucket = {}
214
+ @current_rails_bucket = {}
215
+ TestSift::Tracker::IOHooks.set_bucket(@current_bucket)
216
+ set_rails_bucket(@current_rails_bucket)
217
+ self
218
+ end
219
+
220
+ # Internal method on the tracer pipeline.
221
+ # @api private
222
+ def example_finished(example_id)
223
+ after_peek, coverage_paths = @coverage_adapter.peek_with_paths
224
+ record_coverage_delta(example_id, @before_peek, after_peek)
225
+ io_inputs = @current_bucket.values
226
+ rails_inputs = @current_rails_bucket ? @current_rails_bucket.values : []
227
+ TestSift::Tracker::IOHooks.clear_bucket
228
+ clear_rails_bucket
229
+
230
+ transitive_inputs = @loaded_files_tracker.loaded_set_inputs |
231
+ @loaded_files_tracker.stop_example(example_id, coverage_paths)
232
+ coverage_inputs = @coverage_adapter.compute_diff(@before_peek, after_peek)
233
+ declared_inputs = @declared_globs.walk
234
+ tracks_inputs = per_example_tracks_inputs(example_id)
235
+ attribute_to_example(
236
+ example_id,
237
+ coverage_inputs | transitive_inputs | io_inputs.to_set |
238
+ rails_inputs.to_set | declared_inputs | tracks_inputs
239
+ )
240
+
241
+ @before_peek = nil
242
+ @current_bucket = nil
243
+ @current_rails_bucket = nil
244
+ self
245
+ end
246
+
247
+ # Internal method on the tracer pipeline.
248
+ # @api private
249
+ def on_example_skipped(example_id)
250
+ @registry.register(example_id) unless @registry.registered?(example_id)
251
+ @registry.update_status(example_id, :skipped)
252
+ self
253
+ end
254
+
255
+ # Internal method on the tracer pipeline.
256
+ # @api private
257
+ def on_example_passed(example_id, result)
258
+ return if @duplicate_examples[example_id]&.count.to_i > 1
259
+
260
+ status = flaky_history?(example_id) ? :flaky : :passed
261
+ @registry.update_status(example_id, status)
262
+ record_execution_result(example_id, result)
263
+ self
264
+ end
265
+
266
+ # Internal method on the tracer pipeline.
267
+ # @api private
268
+ def on_example_failed(example_id, result)
269
+ return if @duplicate_examples[example_id]&.count.to_i > 1
270
+
271
+ status = previously_flaky?(example_id) ? :flaky : :failed
272
+ @registry.update_status(example_id, status)
273
+ record_execution_result(example_id, result)
274
+ self
275
+ end
276
+
277
+ # Internal method on the tracer pipeline.
278
+ # @api private
279
+ def on_example_pending(example_id, result)
280
+ return if @duplicate_examples[example_id]&.count.to_i > 1
281
+
282
+ @registry.update_status(example_id, :pending)
283
+ record_execution_result(example_id, result)
284
+ self
285
+ end
286
+
287
+ # --- finalize ------------------------------------------------
288
+
289
+ def finalize
290
+ @registry.all_example_ids.each do |id|
291
+ next if @registry.status_of(id)
292
+
293
+ @registry.update_status(id, :interrupted)
294
+ end
295
+
296
+ snapshot = build_snapshot
297
+ @storage_backend.save_graph(snapshot, schema_version: TestSift::Storage::Schema::CURRENT)
298
+ uninstall_rails_observers
299
+ snapshot
300
+ end
301
+
302
+ # For every previously-skipped example id, accumulate per-line
303
+ # coverage strengths from the previous run's per-example coverage
304
+ # map into the missed_coverage return value. Deleted files /
305
+ # missing entries are skipped silently. Consumed by
306
+ # Reporters::CoverageJsonReporter at finalize time so coverage.json
307
+ # carries forward the contribution of skipped examples.
308
+ #
309
+ # Returns Hash[file_path => Hash[line_number => cumulative_strength]].
310
+ def merge_skipped_coverage(skipped_ids, previous_examples_coverage = nil)
311
+ source = previous_examples_coverage || @previous_snapshot&.examples_coverage || {}
312
+ missed = Hash.new { |h, f| h[f] = Hash.new(0) }
313
+
314
+ skipped_ids.each do |example_id|
315
+ example_coverage = source[example_id]
316
+ next if example_coverage.nil?
317
+
318
+ example_coverage.each do |file_path, line_coverage|
319
+ accumulate_line_coverage(missed[file_path], line_coverage)
320
+ end
321
+ end
322
+
323
+ missed
324
+ end
325
+
326
+ # --- accessors used by specs ---------------------------------
327
+
328
+ def filtered_example_ids
329
+ @filtered_examples.keys
330
+ end
331
+
332
+ # Internal method on the tracer pipeline.
333
+ # @api private
334
+ def previous_snapshot_loaded?
335
+ !@previous_snapshot.nil?
336
+ end
337
+
338
+ private
339
+
340
+ # Read the config-level `track_env(*names)` accumulator,
341
+ # expand any wildcard patterns against the live ENV via
342
+ # EnvMatcher.expand (which raises ArgumentError on unsupported
343
+ # syntax - intentionally surfaces config errors at run start),
344
+ # and seed both `@config_tracked_env_names` (for the global
345
+ # mark-every-example branch in apply_env_filter_decisions) and
346
+ # `@tracked_env_names` (so the finalize snapshot includes every
347
+ # config-level key alongside per-example keys).
348
+ def register_config_tracked_env_names
349
+ patterns = @configuration.tracked_env_names
350
+ return if patterns.nil? || patterns.empty?
351
+
352
+ expanded = TestSift::Tracker::EnvMatcher.expand(patterns)
353
+ @config_tracked_env_names.merge(expanded)
354
+ @tracked_env_names.merge(expanded)
355
+ end
356
+
357
+ # Mark every previously-seen example for re-run. Called
358
+ # when a config-level env key flips between runs.
359
+ def mark_all_prev_examples(reason)
360
+ @previous_snapshot.all_examples.each_key do |example_id|
361
+ next if @filtered_examples.key?(example_id)
362
+
363
+ @filtered_examples[example_id] = reason
364
+ end
365
+ end
366
+
367
+ # Per-example env-changed mark. Walks @tracks_env,
368
+ # marks examples whose declared env set intersects invalidated.
369
+ # Additive vs other filter reasons (won't overwrite).
370
+ def mark_per_example_intersections(invalidated, reason)
371
+ @tracks_env.each do |example_id, envs|
372
+ next unless envs.intersect?(invalidated)
373
+ next if @filtered_examples.key?(example_id)
374
+
375
+ @filtered_examples[example_id] = reason
376
+ end
377
+ end
378
+
379
+ # Internal method on the tracer pipeline.
380
+ # @api private
381
+ def build_observers
382
+ @registry = TestSift::Tracker::ExampleRegistry.new
383
+ @graph = TestSift::Tracker::DependencyGraph.new
384
+ @coverage_adapter = TestSift::Tracker::CoverageAdapter.new(
385
+ root: @configuration.root, filters: @configuration.filters
386
+ )
387
+ @declared_globs = TestSift::Tracker::DeclaredGlobs.new(
388
+ root: @configuration.root, globs: @configuration.declared_globs
389
+ )
390
+ @whole_suite_invalidators = TestSift::Tracker::WholeSuiteInvalidators.new(
391
+ root: @configuration.root
392
+ )
393
+ @env_snapshot = TestSift::Tracker::EnvSnapshot.new
394
+ @new_file_detector = TestSift::Tracker::NewFileDetector.new(
395
+ root: @configuration.root, declared_globs: @configuration.declared_globs
396
+ )
397
+ @loaded_files_tracker = TestSift::Tracker::LoadedFilesTracker.new(
398
+ root: @configuration.root, enabled: @configuration.transitive_load_tracking
399
+ )
400
+ @storage_backend = build_storage_backend(@configuration.cache_path)
401
+ end
402
+
403
+ # Resolve the configured storage backend to a concrete instance.
404
+ # Delegates to {TestSift::Storage::Backend.build}, the single
405
+ # factory shared with the CLI sub-commands so `cache:info` /
406
+ # `explain` compose correctly with `storage_backend :sqlite`.
407
+ def build_storage_backend(cache_path)
408
+ TestSift::Storage::Backend.build(
409
+ cache_path: cache_path, configuration: @configuration
410
+ )
411
+ end
412
+
413
+ # Internal method on the tracer pipeline.
414
+ # @api private
415
+ def install_io_hooks
416
+ declared = @declared_globs
417
+ TestSift::Tracker::IOHooks.install(
418
+ root: @configuration.root,
419
+ filter: ->(path) { !declared.covers?(path) }
420
+ )
421
+ end
422
+
423
+ # Subscriber-install body. Idempotent via `@rails_observers_installed`
424
+ # so a re-call (e.g. eager path then late-bind firing on a slow Rails
425
+ # load) is a no-op. Shared between the eager and late-bind paths.
426
+ def install_rails_observers
427
+ return if rails_observers_installed?
428
+
429
+ require_relative 'rails/notifications'
430
+ require_relative 'rails/i18n_tracking'
431
+
432
+ declared = @declared_globs
433
+ filter = ->(path) { !declared.covers?(path) }
434
+ ar_paths = ar_schema_notifications_enabled? ? ar_schema_path_probes : []
435
+
436
+ TestSift::Rails::Notifications.install(
437
+ root: @configuration.root, filter: filter, ar_schema_paths: ar_paths
438
+ )
439
+ TestSift::Rails::I18nTracking.install(
440
+ root: @configuration.root, filter: filter
441
+ )
442
+
443
+ @rails_observers_installed = true
444
+ rescue StandardError => e
445
+ @configuration.logger.warn(
446
+ "testsift: Rails observer install failed (#{e.class}: #{e.message})"
447
+ )
448
+ @rails_observers_installed = false
449
+ end
450
+
451
+ # Internal method on the tracer pipeline.
452
+ # @api private
453
+ def uninstall_rails_observers
454
+ return unless rails_observers_installed?
455
+
456
+ TestSift::Rails::Notifications.uninstall
457
+ TestSift::Rails::I18nTracking.uninstall
458
+ @rails_observers_installed = false
459
+ rescue StandardError
460
+ @rails_observers_installed = false
461
+ end
462
+
463
+ # Internal method on the tracer pipeline.
464
+ # @api private
465
+ def rails_observers_installed?
466
+ defined?(@rails_observers_installed) && @rails_observers_installed == true
467
+ end
468
+
469
+ public :install_rails_observers, :rails_observers_installed?
470
+
471
+ # rubocop:disable Naming/AccessorMethodName
472
+ def set_rails_bucket(bucket)
473
+ return unless rails_observers_installed?
474
+
475
+ TestSift::Rails::Notifications.set_bucket(bucket)
476
+ end
477
+ # rubocop:enable Naming/AccessorMethodName
478
+
479
+ def clear_rails_bucket
480
+ return unless rails_observers_installed?
481
+
482
+ TestSift::Rails::Notifications.clear_bucket
483
+ end
484
+
485
+ # Internal method on the tracer pipeline.
486
+ # @api private
487
+ def ar_schema_notifications_enabled?
488
+ return false unless @configuration.respond_to?(:track_ar_schema_notifications?)
489
+
490
+ @configuration.track_ar_schema_notifications?
491
+ end
492
+
493
+ # Internal method on the tracer pipeline.
494
+ # @api private
495
+ def ar_schema_path_probes
496
+ %w[db/schema.rb db/structure.sql].each_with_object([]) do |rel, acc|
497
+ abs = File.expand_path(rel, @configuration.root)
498
+ acc << abs if File.file?(abs)
499
+ end
500
+ end
501
+
502
+ # Delegate to the legacy ::Coverage bootstrap if SimpleCov isn't
503
+ # already running. Matches TestSift.setup_coverage behavior so
504
+ # the two entry points agree on when to call ::Coverage.start
505
+ # AND on which Ruby Coverage modes are enabled.
506
+ def ensure_coverage_started
507
+ return if defined?(SimpleCov) && SimpleCov.running
508
+ return if ::Coverage.respond_to?(:running?) && ::Coverage.running?
509
+
510
+ ::Coverage.start(**configuration_coverage_modes)
511
+ rescue RuntimeError
512
+ # ::Coverage.start raises if already started on some Rubies
513
+ # without a running? predicate; safe to ignore.
514
+ nil
515
+ end
516
+
517
+ # Reads `coverage_modes` off the configuration when available
518
+ # (extended with `TestSift::Configuration`). Defaults to an
519
+ # empty Hash for stubbed-configuration unit tests so the splat
520
+ # `Coverage.start(**{})` keeps lines-only legacy behavior.
521
+ def configuration_coverage_modes
522
+ return {} unless @configuration.respond_to?(:coverage_modes_for_start)
523
+
524
+ @configuration.coverage_modes_for_start
525
+ end
526
+
527
+ # Under parallel_tests, each worker writes to its own per-worker
528
+ # cache dir (`testsift_cache/parallel_tests_N/`) but warm-run
529
+ # reads come from the MERGED top-level cache (`testsift_cache/`,
530
+ # one level up). Last-process finalize at exit purges the per-worker
531
+ # dirs, so only the merged snapshot survives between runs. Match
532
+ # 1.x Cache#load_cache_for_run's `File.dirname(cache_path) if
533
+ # parallel_tests?` behavior so warm runs under parallel_tests
534
+ # actually skip examples.
535
+ def load_previous_snapshot
536
+ read_backend = build_read_backend
537
+ read_backend.load_graph(schema_version: TestSift::Storage::Schema::CURRENT)
538
+ end
539
+
540
+ # Internal method on the tracer pipeline.
541
+ # @api private
542
+ def build_read_backend
543
+ return @storage_backend unless @read_cache_path
544
+
545
+ build_storage_backend(@read_cache_path)
546
+ end
547
+
548
+ # On warm runs, skipped examples don't re-populate @all_examples,
549
+ # @all_files, or @graph - only newly-run examples do. Without
550
+ # seeding, the next save would drop the skipped examples'
551
+ # metadata + deps, and the following warm run would see them as
552
+ # "not previously seen" and force a cold re-run of the entire
553
+ # suite. Seed the three state buckets from the previous snapshot
554
+ # so newly-run examples overwrite and skipped examples carry
555
+ # forward.
556
+ #
557
+ # examples_coverage is NOT seeded here. A populated cache can
558
+ # carry a large examples_coverage map; eagerly materializing it
559
+ # at setup defeats LazySnapshot's whole point for the SQLite
560
+ # backend. The merge happens at build_snapshot time via
561
+ # merge_examples_coverage_with_previous instead.
562
+ def seed_state_from_previous(prev)
563
+ seed_all_examples_from_previous(prev)
564
+ seed_all_files_from_previous(prev)
565
+ seed_graph_from_previous(prev)
566
+ end
567
+
568
+ # Internal method on the tracer pipeline.
569
+ # @api private
570
+ def seed_all_examples_from_previous(prev)
571
+ return unless prev.all_examples.is_a?(Hash)
572
+
573
+ prev.all_examples.each do |id, meta|
574
+ @all_examples[id] = meta
575
+ end
576
+ end
577
+
578
+ # prev.all_files is file-name-keyed; the engine's @all_files is
579
+ # abs-path-keyed (path_to_file_name round-trips via the root prefix).
580
+ #
581
+ # Re-applies the current filter list at seed time so users who
582
+ # add a filter mid-development see the carried-over file entries
583
+ # drop on the next run (instead of waiting for a cold run). The
584
+ # filter contract should hold for both fresh attributions
585
+ # (attribute_to_example) and prior-snapshot carry-forward —
586
+ # otherwise add_filter has split semantics and a freshly-added
587
+ # filter does not exclude already-cached paths.
588
+ def seed_all_files_from_previous(prev)
589
+ return unless prev.all_files.is_a?(Hash)
590
+
591
+ prev.all_files.each_value do |meta|
592
+ next unless meta.is_a?(Hash)
593
+
594
+ file_path = symbol_or_string(meta, :file_path)
595
+ next if file_path.nil?
596
+
597
+ file_name = symbol_or_string(meta, :file_name) || path_to_file_name(file_path)
598
+ next if filtered_by_current_filters?(file_name)
599
+
600
+ @all_files[file_path] = {
601
+ file_name: file_name,
602
+ file_path: file_path,
603
+ digest: symbol_or_string(meta, :digest)
604
+ }
605
+ end
606
+ end
607
+
608
+ # prev.dependency keys on example_id, values are Set<file_name>.
609
+ # Register in @graph via absolute paths so the on-disk shape
610
+ # (file_name) converts at save time via dependency_by_name.
611
+ #
612
+ # Drops paths matching the current filter list so previously-
613
+ # cached deps that the new filter excludes do not leak through
614
+ # the carry-forward seed path. Same rationale as
615
+ # seed_all_files_from_previous: the filter contract is one
616
+ # contract, applied uniformly across fresh + carry-forward
617
+ # attributions.
618
+ def seed_graph_from_previous(prev)
619
+ return unless prev.dependency.is_a?(Hash)
620
+
621
+ prev.dependency.each do |example_id, file_names|
622
+ paths = []
623
+ file_names.each do |name|
624
+ next if filtered_by_current_filters?(name)
625
+
626
+ paths << absolute_path(name)
627
+ end
628
+ @graph.register_example(example_id, paths)
629
+ end
630
+ end
631
+
632
+ # Helper: returns true if the file name matches any currently-
633
+ # configured filter. Mirrors the check site at
634
+ # `attribute_to_example` (engine.rb:770) so both fresh + carry-
635
+ # forward attributions converge on the same filter behavior.
636
+ def filtered_by_current_filters?(file_name)
637
+ @filter_decisions.fetch(file_name) do
638
+ @filter_decisions[file_name] = @configuration.filters.any? { |f| f.match?(file_name: file_name) }
639
+ end
640
+ end
641
+
642
+ # Internal method on the tracer pipeline.
643
+ # @api private
644
+ def compute_filter_decisions
645
+ prev = @previous_snapshot
646
+ return if prev.nil?
647
+
648
+ seed_registry_from_previous(prev)
649
+ change_set = compute_change_set(prev)
650
+ whole_suite = whole_suite_changed?(prev)
651
+
652
+ result = TestSift::Tracker::Filter.select(
653
+ graph: graph_from_previous(prev),
654
+ change_set: change_set,
655
+ registry: @registry,
656
+ whole_suite_invalidated: whole_suite,
657
+ all_example_ids: prev.all_examples.keys.to_set
658
+ )
659
+ @filtered_examples = result.transform_values { |reason| FILTER_REASON_STRINGS.fetch(reason) }
660
+ end
661
+
662
+ # Internal constant.
663
+ # @api private
664
+ SEED_STATUS_ORDER = %i[interrupted flaky failed pending].freeze
665
+ private_constant :SEED_STATUS_ORDER
666
+
667
+ # Internal method on the tracer pipeline.
668
+ # @api private
669
+ def seed_registry_from_previous(prev)
670
+ SEED_STATUS_ORDER.each do |status|
671
+ ids = prev.send(:"#{status}_examples")
672
+ ids.each { |id| seed_registry_entry(id, status, prev.all_examples[id] || {}) }
673
+ end
674
+ end
675
+
676
+ # Internal method on the tracer pipeline.
677
+ # @api private
678
+ def seed_registry_entry(id, status, metadata)
679
+ return if @registry.registered?(id)
680
+
681
+ @registry.register(id, metadata: metadata)
682
+ @registry.update_status(id, status)
683
+ end
684
+
685
+ # Rebuild a DependencyGraph from the previous Snapshot so Filter
686
+ # can intersect its cached dependency sets with the change_set.
687
+ def graph_from_previous(prev)
688
+ graph = TestSift::Tracker::DependencyGraph.new
689
+ prev.dependency.each { |id, paths| graph.register_example(id, paths) }
690
+ graph
691
+ end
692
+
693
+ # Internal method on the tracer pipeline.
694
+ # @api private
695
+ def compute_change_set(prev)
696
+ changed = Set.new
697
+ prev.all_files.each_value do |file_meta|
698
+ file_name = symbol_or_string(file_meta, :file_name)
699
+ cached_digest = symbol_or_string(file_meta, :digest)
700
+ next if file_name.nil? || cached_digest.nil?
701
+
702
+ current_digest = current_file_digest(file_name)
703
+ changed << file_name if current_digest.nil? || current_digest != cached_digest
704
+ end
705
+
706
+ new_files = @new_file_detector.new_files(
707
+ known_paths: prev.all_files.keys.to_set.to_set { |k| absolute_path(k) }
708
+ )
709
+ new_files.each { |input| changed << path_to_file_name(input.path) }
710
+ changed
711
+ end
712
+
713
+ # Internal method on the tracer pipeline.
714
+ # @api private
715
+ def whole_suite_changed?(prev)
716
+ wsi_prev = prev.wsi_snapshot if prev.respond_to?(:wsi_snapshot)
717
+ @whole_suite_invalidators.invalidated?(wsi_prev) ||
718
+ @loaded_files_tracker.boot_set_invalidated?(prev.boot_set)
719
+ end
720
+
721
+ # Internal method on the tracer pipeline.
722
+ # @api private
723
+ def current_file_digest(file_name)
724
+ TestSift::Tracker::FileDigest.compute(absolute_path(file_name))
725
+ end
726
+
727
+ # Internal method on the tracer pipeline.
728
+ # @api private
729
+ def record_coverage_delta(example_id, before, after)
730
+ entry = @examples_coverage[example_id] ||= {}
731
+
732
+ (before.keys | after.keys).each do |path|
733
+ b_lines = before[path]
734
+ a_lines = after[path]
735
+ next if b_lines == a_lines
736
+
737
+ file_entry = entry[path] ||= {}
738
+ accumulate_delta(file_entry, b_lines, a_lines)
739
+ end
740
+ end
741
+
742
+ # Internal method on the tracer pipeline.
743
+ # @api private
744
+ def accumulate_delta(file_entry, before_lines, after_lines)
745
+ length = (after_lines || before_lines || []).length
746
+ length.times do |i|
747
+ delta = line_delta(before_lines && before_lines[i], after_lines && after_lines[i])
748
+ file_entry[i] = delta if delta
749
+ end
750
+ end
751
+
752
+ # Returns the positive coverage delta for one line, or nil if the
753
+ # line isn't a delta worth recording (non-executable, identical,
754
+ # or a stale-going-backward entry).
755
+ def line_delta(before, after)
756
+ return nil if after.nil? || before == after
757
+
758
+ delta = after - (before || 0)
759
+ delta.positive? ? delta : nil
760
+ end
761
+
762
+ # Internal method on the tracer pipeline.
763
+ # @api private
764
+ def accumulate_line_coverage(accumulator, line_coverage)
765
+ line_coverage.each do |line_key, strength|
766
+ index = line_key.to_i
767
+ accumulator[index] += strength || 0
768
+ end
769
+ end
770
+
771
+ # Internal method on the tracer pipeline.
772
+ # @api private
773
+ def attribute_to_example(example_id, inputs)
774
+ paths = []
775
+ inputs.each do |input|
776
+ file_name = attributed_file_name(input.path)
777
+ next if file_name.nil?
778
+
779
+ paths << input.path
780
+ entry = @all_files[input.path]
781
+ next if entry && entry[:digest] == input.digest && entry[:file_name] == file_name
782
+
783
+ @all_files[input.path] = { file_name: file_name, file_path: input.path, digest: input.digest }
784
+ end
785
+ @graph.register_example(example_id, paths)
786
+ end
787
+
788
+ # Internal method on the tracer pipeline.
789
+ # @api private
790
+ def attributed_file_name(path)
791
+ file_name = path_to_file_name(path)
792
+ file_name unless filtered_by_current_filters?(file_name)
793
+ end
794
+
795
+ # Internal method on the tracer pipeline.
796
+ # @api private
797
+ def record_execution_result(example_id, result)
798
+ return unless @all_examples.key?(example_id)
799
+
800
+ @all_examples[example_id][:execution_result] = result.dup
801
+ end
802
+
803
+ # Ports the 1.x flaky-detection contract: an example transitions
804
+ # into `:flaky` when it (a) failed on the previous run and passes
805
+ # on this run (the canonical "intermittent" signal) OR (b) was
806
+ # already flaky on the previous run, regardless of this run's
807
+ # outcome (sticky once detected). Mirrors 1.x's
808
+ # `ReportGenerator#generate_flaky_examples_report` which iterated
809
+ # `prev_failed | prev_flaky` and registered the example as flaky
810
+ # unless the prev_failed branch failed again this run.
811
+ def flaky_history?(id)
812
+ previously_failed?(id) || previously_flaky?(id)
813
+ end
814
+
815
+ # Internal method on the tracer pipeline.
816
+ # @api private
817
+ def previously_failed?(id)
818
+ @previous_snapshot&.failed_examples&.include?(id)
819
+ end
820
+
821
+ # Internal method on the tracer pipeline.
822
+ # @api private
823
+ def previously_flaky?(id)
824
+ @previous_snapshot&.flaky_examples&.include?(id)
825
+ end
826
+
827
+ # Resolve one glob against the project root into a Set of
828
+ # `:declared`-kind Inputs. Walks via Dir.glob
829
+ # (FNM_PATHNAME+FNM_EXTGLOB like DeclaredGlobs so user muscle
830
+ # memory works identically) and digests each hit with SHA256.
831
+ # Unreadable files are skipped silently - graceful degradation.
832
+ # Memoized per distinct glob string so N examples declaring the
833
+ # same glob pay the filesystem walk cost exactly once.
834
+ def resolved_glob_inputs(glob)
835
+ @resolved_glob_cache ||= {}
836
+ @resolved_glob_cache[glob] ||= walk_one_glob(glob)
837
+ end
838
+
839
+ # Internal method on the tracer pipeline.
840
+ # @api private
841
+ def walk_one_glob(glob)
842
+ inputs = Set.new
843
+ root = @configuration.root
844
+ Dir.glob(glob, File::FNM_PATHNAME | File::FNM_EXTGLOB, base: root).each do |rel|
845
+ abs = File.expand_path(rel, root)
846
+ next unless abs.start_with?("#{root}/") && File.file?(abs)
847
+
848
+ digest = tracks_file_digest(abs)
849
+ next if digest.nil?
850
+
851
+ inputs << TestSift::Tracker::Input.for_file(
852
+ path: abs, kind: :declared, digest: digest, root: root
853
+ )
854
+ end
855
+ inputs
856
+ end
857
+
858
+ # Internal method on the tracer pipeline.
859
+ # @api private
860
+ def tracks_file_digest(path)
861
+ TestSift::Tracker::FileDigest.compute(path)
862
+ end
863
+
864
+ # Materialize the per-example tracks-file Input set. Returns an
865
+ # empty Set when the example has no `tracks: files:` metadata,
866
+ # keeping the downstream union cheap for the 99% case.
867
+ def per_example_tracks_inputs(example_id)
868
+ set = @tracks_files[example_id]
869
+ set.nil? || set.empty? ? Set.new : set.dup
870
+ end
871
+
872
+ # Internal method on the tracer pipeline.
873
+ # @api private
874
+ def absolute_path(file_name)
875
+ @absolute_paths[file_name] ||= -File.expand_path(file_name.to_s.sub(%r{^/}, ''), @configuration.root)
876
+ end
877
+
878
+ # Internal method on the tracer pipeline.
879
+ # @api private
880
+ def path_to_file_name(abs_path)
881
+ @file_names[abs_path] ||= begin
882
+ root_prefix = "#{@configuration.root}/"
883
+ -(abs_path.start_with?(root_prefix) ? "/#{abs_path[root_prefix.length..]}" : abs_path)
884
+ end
885
+ end
886
+
887
+ # Internal method on the tracer pipeline.
888
+ # @api private
889
+ def symbol_or_string(hash, key)
890
+ return hash[key] if hash.key?(key)
891
+
892
+ hash[key.to_s]
893
+ end
894
+ end
895
+ # rubocop:enable Metrics/ClassLength
896
+ end