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,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metadata'
4
+
5
+ module TestSift
6
+ # Internal RSpec — see {TestSift} for the user-facing surface.
7
+ # @api private
8
+ module RSpec
9
+ # Prepended onto `RSpec::Core::Runner` by
10
+ # `TestSift::RSpec::Installation.install!`. Replaces the 1.x
11
+ # `TestSift::RSpecRunner` singleton-class prepend that the
12
+ # ObjectSpace loop in `setup_rspec?` used to install.
13
+ #
14
+ # Responsibilities, in `run_specs` order:
15
+ # 1. Early-return to `super` if the engine hasn't been set up
16
+ # (defensive; keeps the suite green under `TESTSIFT_DISABLE=1`
17
+ # or when the user forgot to call `TestSift.start`).
18
+ # 2. Partition `RSpec.world.filtered_examples` via the engine:
19
+ # tracked examples go through identity-hashing + filter decision;
20
+ # ignored examples (matched by `Configuration#ignore_spec_files`)
21
+ # pass through untouched - RSpec still runs them, but the tracer
22
+ # never sees them. Closes #41.
23
+ # 3. Detect duplicate example identities. Colliding examples are
24
+ # dropped from the run (per-example tracking can't attribute
25
+ # coverage to two examples sharing one identity hash); the
26
+ # rest of the suite still runs. `fail_on_duplicates=true` then
27
+ # surfaces a `::Kernel.exit(1)` in `at_exit_behavior`.
28
+ # 4. Overwrite `RSpec.world.@filtered_examples` +
29
+ # `@example_groups` with the filtered set, then log the run
30
+ # banner and delegate to `super`.
31
+ #
32
+ # The user-visible log line - `TestSift is running N examples
33
+ # (actual: N, skipped: N)` - is preserved byte-for-byte from 1.x so
34
+ # cucumber scenarios and CI log parsers keep working.
35
+ module RunnerHook
36
+ # Internal method on the tracer pipeline.
37
+ # @api private
38
+ def run_specs(example_groups)
39
+ return super unless TestSift.engine
40
+
41
+ actual_count = ::RSpec.world.example_count
42
+ return super if _testsift_no_examples?(actual_count)
43
+
44
+ starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ filtered_examples_map, filtered_example_groups = _testsift_build_filter_decision
46
+
47
+ if _testsift_duplicates_detected?
48
+ TestSift.duplicate_examples = TestSift.fail_on_duplicates
49
+ filtered_examples_map, filtered_example_groups =
50
+ _testsift_drop_duplicate_examples(filtered_examples_map, filtered_example_groups)
51
+ end
52
+
53
+ ::RSpec.world.instance_variable_set(:@filtered_examples, filtered_examples_map)
54
+ ::RSpec.world.instance_variable_set(:@example_groups, filtered_example_groups)
55
+
56
+ current_count = ::RSpec.world.example_count
57
+ ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ elapsed = TestSift::TimeFormatter.format_time(ending - starting)
59
+
60
+ TestSift.logger.info <<-EXAMPLES.strip.gsub(/\s+/, ' ')
61
+ TestSift is running #{current_count} examples (actual: #{actual_count},
62
+ skipped: #{actual_count - current_count}) (took #{elapsed})
63
+ EXAMPLES
64
+
65
+ TestSift.running = true
66
+
67
+ super(filtered_example_groups)
68
+ end
69
+
70
+ private
71
+
72
+ # Internal method on the tracer pipeline.
73
+ # @api private
74
+ def _testsift_no_examples?(actual_count)
75
+ return false unless actual_count.zero?
76
+
77
+ TestSift.running = true
78
+ TestSift.no_examples = true
79
+ end
80
+
81
+ # Two-pass filter-decision walk over RSpec.world.filtered_examples.
82
+ #
83
+ # Pass 1 pre-walks every tracked example to compute its identity
84
+ # hash (Example.from) and register the `tracks:` metadata via
85
+ # `Engine#register_tracks`. This must complete for every
86
+ # example before ANY `run_example?` call because the env-
87
+ # invalidation pass (`Engine#apply_env_filter_decisions`) needs
88
+ # the full set of tracked env names to classify which examples
89
+ # re-run. Caching the tracer-example per example.object_id
90
+ # avoids a second MD5 in Pass 2.
91
+ #
92
+ # Between passes, `apply_env_filter_decisions` unions the
93
+ # env-changed decisions into @filtered_examples so Pass 2's
94
+ # `run_example?` sees them alongside the file-change decisions
95
+ # computed at Engine.setup time.
96
+ #
97
+ # Pass 2 makes the actual run/skip decisions and tags metadata.
98
+ # Ignored spec files (Configuration#ignore_spec_files) are
99
+ # handled in Pass 2 and skip both passes' engine surface -
100
+ # RSpec still runs them, the tracer never sees them.
101
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
102
+ def _testsift_build_filter_decision
103
+ to_run = Hash.new { |hash, group| hash[group] = [] }
104
+ groups = Set.new
105
+ engine = TestSift.engine
106
+ tracer_cache = {}.compare_by_identity
107
+
108
+ _testsift_collect_tracks(engine, tracer_cache)
109
+ engine.apply_env_filter_decisions
110
+
111
+ ::RSpec.world.filtered_examples.each_pair do |example_group, examples|
112
+ examples.each do |example|
113
+ if TestSift.ignore_spec_file?(example.metadata[:file_path])
114
+ to_run[example_group] << example
115
+ groups << example.example_group.parent_groups.last
116
+ next
117
+ end
118
+
119
+ tracer_example = tracer_cache.fetch(example)
120
+ example_id = tracer_example[:example_id]
121
+
122
+ if engine.run_example?(example_id)
123
+ run_reason = engine.run_example_reason(example_id)
124
+ tracer_example[:run_reason] = run_reason
125
+ example.metadata[:description] = "#{example.description} (#{run_reason})"
126
+
127
+ to_run[example_group] << example
128
+ groups << example.example_group.parent_groups.last
129
+
130
+ else
131
+ engine.on_example_skipped(example_id)
132
+ end
133
+ end
134
+ end
135
+
136
+ engine.deregister_duplicate_examples
137
+
138
+ [to_run, groups.to_a]
139
+ end
140
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
141
+
142
+ def _testsift_collect_tracks(engine, tracer_cache)
143
+ ::RSpec.world.filtered_examples.each_pair do |_example_group, examples|
144
+ examples.each do |example|
145
+ next if TestSift.ignore_spec_file?(example.metadata[:file_path])
146
+
147
+ tracer_example = TestSift::Example.from(example)
148
+ example_id = tracer_example[:example_id]
149
+ example.metadata[:testsift_example_id] = example_id
150
+ tracer_cache[example] = tracer_example
151
+ engine.register_example(tracer_example)
152
+
153
+ tracks = TestSift::RSpec::Metadata.tracks_for(example)
154
+ next if tracks[:files].empty? && tracks[:env].empty?
155
+
156
+ engine.register_tracks(example_id, tracks)
157
+ end
158
+ end
159
+ end
160
+
161
+ # Logs the duplicate-identity diagnostic and returns whether any
162
+ # were found. The summary line keeps its 1.x wording (CI log
163
+ # parsers + specs match on it); the indented detail names each
164
+ # colliding example so the user can find and rename them.
165
+ # @api private
166
+ def _testsift_duplicates_detected?
167
+ duplicates = TestSift.engine.duplicate_examples
168
+ return false if duplicates.empty?
169
+
170
+ total = duplicates.sum { |_, entries| entries.count }
171
+ TestSift.logger.error(
172
+ "TestSift detected #{total} duplicate example(s) across " \
173
+ "#{duplicates.size} identity hash(es). Examples that share one testsift " \
174
+ 'identity cannot be tracked separately and are dropped from this run - give ' \
175
+ "them distinct descriptions to fix:\n#{_testsift_duplicate_report(duplicates)}"
176
+ )
177
+ true
178
+ end
179
+
180
+ # The indented per-hash detail block for the duplicate
181
+ # diagnostic: the identity hash, then one labelled line per
182
+ # colliding example under it.
183
+ # @api private
184
+ def _testsift_duplicate_report(duplicates)
185
+ duplicates.map do |example_id, entries|
186
+ labelled = entries.map { |entry| " - #{_testsift_example_label(entry)}" }
187
+ " #{example_id}\n#{labelled.join("\n")}"
188
+ end.join("\n")
189
+ end
190
+
191
+ # `file:line description` for one colliding example, read off the
192
+ # `Example.from` payload (rerun location preferred, mirroring the
193
+ # reporter + `explain` columns).
194
+ # @api private
195
+ def _testsift_example_label(entry)
196
+ file = entry[:rerun_file_name] || entry[:file_name]
197
+ line = entry[:rerun_line_number] || entry[:line_number]
198
+
199
+ "#{file}:#{line} #{entry[:full_description] || entry[:description]}".rstrip
200
+ end
201
+
202
+ # Drops the colliding examples from the filtered run set. The
203
+ # rest of the suite still runs; `fail_on_duplicates` governs only
204
+ # the exit code (via `at_exit_behavior`), not whether anything
205
+ # runs. A group is kept only if it still has examples after the
206
+ # colliding ones are removed.
207
+ #
208
+ # The returned Hash uses the same `Hash.new { |h, k| h[k] = [] }`
209
+ # default-block shape as `_testsift_build_filter_decision`'s
210
+ # `to_run`. That contract matters because rspec-core's
211
+ # `RSpec::Core::World#example_count` walks `g.descendants` for
212
+ # every top-level group and reads `e.filtered_examples`
213
+ # (= `world.filtered_examples[e]`) on each descendant — including
214
+ # INTERMEDIATE groups (a `describe` that contains only nested
215
+ # `describe`s, no direct `it`s). A plain Hash returns `nil` for
216
+ # those keys, which then NPEs in
217
+ # `inject(0) { |a, e| a + e.filtered_examples.size }`. The
218
+ # default block returns `[]` (`.size == 0`), matching the
219
+ # pre-drop path's behavior.
220
+ #
221
+ # `examples_map` is keyed by LEAF groups (the keys of
222
+ # `RSpec.world.filtered_examples`) while `example_groups` holds
223
+ # TOP-LEVEL groups (`example.example_group.parent_groups.last`,
224
+ # collected by `_testsift_build_filter_decision`). For any
225
+ # spec file with nested describes those two sets are disjoint,
226
+ # so the group filter maps each survivor back to its top-level
227
+ # parent — mirroring how the pre-drop path built the group list.
228
+ # Filtering `example_groups` by `kept_map.key?` instead used to
229
+ # drop every nested spec file in the suite the moment one
230
+ # duplicate pair existed anywhere (refinery soak: "running 68
231
+ # examples (actual: 399, skipped: 331)" from a single colliding
232
+ # pair in a 399-example suite).
233
+ # @api private
234
+ def _testsift_drop_duplicate_examples(examples_map, example_groups)
235
+ duplicate_ids = Set.new(TestSift.engine.duplicate_examples.keys)
236
+
237
+ kept_map = Hash.new { |hash, group| hash[group] = [] }
238
+ kept_top_level = Set.new
239
+ examples_map.each do |group, examples|
240
+ survivors = examples.reject do |example|
241
+ duplicate_ids.include?(example.metadata[:testsift_example_id])
242
+ end
243
+ next if survivors.empty?
244
+
245
+ kept_map[group] = survivors
246
+ survivors.each { |example| kept_top_level << example.example_group.parent_groups.last }
247
+ end
248
+
249
+ [kept_map, example_groups.select { |group| kept_top_level.include?(group) }]
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Path manipulation helpers used by Example.location_file_name.
5
+ # Post-coverage-stack retirement, the only caller is example.rb
6
+ # (the legacy CoverageReporter that previously used these is gone).
7
+ #
8
+ # All methods declared `def self.x` (not module_function) so mutant
9
+ # observes mutations through the singleton call path;
10
+ # module_function's anonymous singleton breaks that observability.
11
+ module SourceFile
12
+ # Internal constant.
13
+ # @api private
14
+ PROJECT_ROOT_REGEX = Regexp.new("^#{Regexp.escape(TestSift.root)}").freeze
15
+
16
+ # Internal helper for the tracer pipeline.
17
+ # @api private
18
+ def self.from_path(file_path)
19
+ return unless File.file?(file_path)
20
+
21
+ {
22
+ file_path: file_path,
23
+ file_name: file_name(file_path),
24
+ digest: Digest::MD5.hexdigest(File.binread(file_path))
25
+ }
26
+ end
27
+
28
+ # Internal helper for the tracer pipeline.
29
+ # @api private
30
+ def self.from_name(file_name)
31
+ from_path(file_path(file_name))
32
+ end
33
+
34
+ # Internal helper for the tracer pipeline.
35
+ # @api private
36
+ def self.file_name(file_path)
37
+ file_path.sub(PROJECT_ROOT_REGEX, '')
38
+ end
39
+
40
+ # Internal helper for the tracer pipeline.
41
+ # @api private
42
+ def self.file_path(file_name)
43
+ return file_name if absolute_external_file?(file_name)
44
+
45
+ File.expand_path(file_name.sub(%r{^/}, ''), TestSift.root)
46
+ end
47
+
48
+ # Internal helper for the tracer pipeline.
49
+ # @api private
50
+ def self.absolute_external_file?(file_name)
51
+ file_name.start_with?('/') &&
52
+ !file_name.start_with?(TestSift.root) &&
53
+ File.file?(file_name)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # Storage
2
+
3
+ Persistence layer for the dependency graph and per-example metadata.
4
+ Pluggable via a backend protocol.
5
+
6
+ ## Responsibilities
7
+
8
+ - Load and save the dependency graph atomically.
9
+ - Enforce `schema_version` — refuse to load a cache outside the supported
10
+ range. On mismatch: log, discard, fall back to cold boot. No
11
+ migrators.
12
+ - Never propagate storage errors to the caller — corrupted or missing
13
+ cache triggers a cold run, not a test-suite failure.
14
+
15
+ ## Public protocol
16
+
17
+ ```ruby
18
+ module TestSift::Storage::Backend
19
+ def load_graph(schema_version:); end
20
+ def save_graph(graph, schema_version:); end
21
+ def transactional_save(&block); end
22
+ end
23
+ ```
24
+
25
+ ## Planned backends
26
+
27
+ - `JsonBackend` — default, human-readable, diff-friendly.
28
+ - `SqliteBackend` — optional, for large suites where JSON load cost
29
+ dominates.
30
+
31
+ ## Status
32
+
33
+ Shipping in 2.0. The legacy 1.x persistence files
34
+ (`lib/testsift/cache.rb`, `report_writer.rb`, `coverage_writer.rb`)
35
+ were retired and replaced by this storage subsystem.
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal Storage — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ module Storage
7
+ # Protocol every storage backend must satisfy. JsonBackend is the
8
+ # default; SqliteBackend is an opt-in alternative on MRI. The
9
+ # `spec/contracts/storage_backend.rb` shared-examples group asserts
10
+ # the full contract on every implementation.
11
+ #
12
+ # Rationale for the method set (from ARCHITECTURE.md, Contracts
13
+ # between layers):
14
+ # - `load_graph(schema_version:)` returns `Snapshot` or `nil`.
15
+ # `nil` means "no cache, or schema mismatch." Never raises on
16
+ # corruption - the backend's job is to normalize malformed
17
+ # inputs into nil + a log line.
18
+ # - `save_graph(snapshot, schema_version:)` persists the graph.
19
+ # Either every file lands or none do (atomic via
20
+ # transactional_save).
21
+ # - `last_run_id` returns the identifier of the most recent
22
+ # successful save, or `nil` if no cache exists.
23
+ # - `transactional_save(&block)` runs the block with
24
+ # single-writer semantics and commits on clean exit; on any
25
+ # raise, the pre-block state is preserved.
26
+ # - `clear!` removes everything the backend owns.
27
+ #
28
+ # This module is intentionally documentation-only - it does not
29
+ # define stubs that raise NotImplementedError, because mutant
30
+ # would flag every `raise` as an alive mutation with no way to
31
+ # kill it. The shared-examples contract is the real gate.
32
+ #
33
+ # @example Registering a custom storage backend
34
+ # class MyBackend
35
+ # def load_graph(schema_version:); end
36
+ # def save_graph(snapshot, schema_version:); end
37
+ # def last_run_id; end
38
+ # def transactional_save(&block); yield; end
39
+ # def clear!; end
40
+ # end
41
+ #
42
+ # TestSift.configure do
43
+ # storage_backend MyBackend.new
44
+ # end
45
+ module Backend
46
+ # Internal constant.
47
+ # @api private
48
+ REQUIRED_METHODS = %i[
49
+ load_graph
50
+ save_graph
51
+ last_run_id
52
+ transactional_save
53
+ clear!
54
+ ].freeze
55
+
56
+ # Verifies a candidate object satisfies the backend protocol.
57
+ # Used by {TestSift::Configuration#storage_backend} to gate
58
+ # custom-backend registration at config-load time.
59
+ #
60
+ # @param backend [Object] candidate backend instance
61
+ # @return [Boolean] true if every {REQUIRED_METHODS} entry is
62
+ # responded to
63
+ def self.conforms?(backend)
64
+ REQUIRED_METHODS.all? { |m| backend.respond_to?(m) }
65
+ end
66
+
67
+ # Construct the configured storage backend instance. Single
68
+ # source of truth for the json/sqlite dispatch + sqlite-gem-
69
+ # missing graceful fallback to :json, used by both
70
+ # {TestSift::Engine} (runtime) and the
71
+ # {TestSift::CLI::CacheInfo} / {TestSift::CLI::Explain}
72
+ # sub-commands (post-run inspection). Pre-refactor, the
73
+ # dispatch lived only on Engine and the CLI sub-commands
74
+ # hardcoded the JsonBackend on-disk layout — so
75
+ # `bin/testsift cache:info` / `explain` reported "no
76
+ # last_run.json" even when `storage_backend :sqlite` had
77
+ # persisted a working cache.
78
+ #
79
+ # @param cache_path [String] root cache directory.
80
+ # @param configuration [Object] anything responding to the
81
+ # `storage_backend` / `storage_backend_opts` /
82
+ # `cache_retention_local_count` / `cache_size_warn_*` /
83
+ # `logger` accessors (defaults to the {TestSift}
84
+ # top-level module).
85
+ # @return [Object] a backend instance satisfying {REQUIRED_METHODS}.
86
+ def self.build(cache_path:, configuration: TestSift)
87
+ case configuration.storage_backend
88
+ when :sqlite
89
+ build_sqlite(cache_path: cache_path, configuration: configuration)
90
+ else
91
+ build_json(cache_path: cache_path, configuration: configuration)
92
+ end
93
+ end
94
+
95
+ # Internal helper for the tracer pipeline.
96
+ # JsonBackend / SqliteBackend constants are pre-required by
97
+ # every call site that loads {Backend} — Engine at gem-boot
98
+ # time, CLI sub-commands at their own require chain — so the
99
+ # method body stays focused on the construction shape mutant
100
+ # can actually verify. Returning a `require_relative` call to
101
+ # the method would be a structurally-unkillable mutation
102
+ # (Ruby caches requires; the test process always has both
103
+ # backends loaded).
104
+ # @api private
105
+ def self.build_json(cache_path:, configuration:)
106
+ JsonBackend.new(
107
+ cache_path: cache_path,
108
+ logger: configuration.logger,
109
+ retention_local_count: configuration.cache_retention_local_count,
110
+ warn_per_file_mb: configuration.cache_size_warn_per_file_mb,
111
+ warn_total_mb: configuration.cache_size_warn_total_mb,
112
+ serializer: configuration.storage_backend_opts[:serializer] || :json
113
+ )
114
+ end
115
+
116
+ # Internal helper for the tracer pipeline.
117
+ # See {build_json} for why the SqliteBackend require is at the
118
+ # call site rather than inside the method body.
119
+ # @api private
120
+ def self.build_sqlite(cache_path:, configuration:)
121
+ SqliteBackend.new(cache_path: cache_path, logger: configuration.logger)
122
+ rescue SqliteBackend::SqliteBackendError => e
123
+ configuration.logger.warn(
124
+ "testsift: sqlite backend unavailable (#{e.message}); falling back to :json"
125
+ )
126
+ build_json(cache_path: cache_path, configuration: configuration)
127
+ end
128
+ end
129
+ end
130
+ end