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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metadata'
4
+
5
+ module RSpecTracer
6
+ # Internal RSpec — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module RSpec
9
+ # Prepended onto `RSpec::Core::Runner` by
10
+ # `RSpecTracer::RSpec::Installation.install!`. Replaces the 1.x
11
+ # `RSpecTracer::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 `RSPEC_TRACER_DISABLE=1`
17
+ # or when the user forgot to call `RSpecTracer.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 - `RSpec tracer 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 RSpecTracer.engine
40
+
41
+ actual_count = ::RSpec.world.example_count
42
+ if _rspec_tracer_no_examples?(actual_count)
43
+ super
44
+ return
45
+ end
46
+
47
+ starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ filtered_examples_map, filtered_example_groups = _rspec_tracer_build_filter_decision
49
+
50
+ if _rspec_tracer_duplicates_detected?
51
+ RSpecTracer.duplicate_examples = RSpecTracer.fail_on_duplicates
52
+ filtered_examples_map, filtered_example_groups =
53
+ _rspec_tracer_drop_duplicate_examples(filtered_examples_map, filtered_example_groups)
54
+ end
55
+
56
+ ::RSpec.world.instance_variable_set(:@filtered_examples, filtered_examples_map)
57
+ ::RSpec.world.instance_variable_set(:@example_groups, filtered_example_groups)
58
+
59
+ current_count = ::RSpec.world.example_count
60
+ ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
62
+
63
+ RSpecTracer.logger.info <<-EXAMPLES.strip.gsub(/\s+/, ' ')
64
+ RSpec tracer is running #{current_count} examples (actual: #{actual_count},
65
+ skipped: #{actual_count - current_count}) (took #{elapsed})
66
+ EXAMPLES
67
+
68
+ RSpecTracer.running = true
69
+
70
+ super(filtered_example_groups)
71
+ end
72
+
73
+ private
74
+
75
+ # Internal method on the tracer pipeline.
76
+ # @api private
77
+ def _rspec_tracer_no_examples?(actual_count)
78
+ return false unless actual_count.zero?
79
+
80
+ RSpecTracer.running = true
81
+ RSpecTracer.no_examples = true
82
+ end
83
+
84
+ # Two-pass filter-decision walk over RSpec.world.filtered_examples.
85
+ #
86
+ # Pass 1 pre-walks every tracked example to compute its identity
87
+ # hash (Example.from) and register the `tracks:` metadata via
88
+ # `Engine#register_tracks`. This must complete for every
89
+ # example before ANY `run_example?` call because the env-
90
+ # invalidation pass (`Engine#apply_env_filter_decisions`) needs
91
+ # the full set of tracked env names to classify which examples
92
+ # re-run. Caching the tracer-example per example.object_id
93
+ # avoids a second MD5 in Pass 2.
94
+ #
95
+ # Between passes, `apply_env_filter_decisions` unions the
96
+ # env-changed decisions into @filtered_examples so Pass 2's
97
+ # `run_example?` sees them alongside the file-change decisions
98
+ # computed at Engine.setup time.
99
+ #
100
+ # Pass 2 makes the actual run/skip decisions and tags metadata.
101
+ # Ignored spec files (Configuration#ignore_spec_files) are
102
+ # handled in Pass 2 and skip both passes' engine surface -
103
+ # RSpec still runs them, the tracer never sees them.
104
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
105
+ def _rspec_tracer_build_filter_decision
106
+ to_run = Hash.new { |hash, group| hash[group] = [] }
107
+ groups = Set.new
108
+ engine = RSpecTracer.engine
109
+ tracer_cache = {}.compare_by_identity
110
+
111
+ _rspec_tracer_collect_tracks(engine, tracer_cache)
112
+ engine.apply_env_filter_decisions
113
+
114
+ ::RSpec.world.filtered_examples.each_pair do |example_group, examples|
115
+ examples.each do |example|
116
+ if RSpecTracer.ignore_spec_file?(example.metadata[:file_path])
117
+ to_run[example_group] << example
118
+ groups << example.example_group.parent_groups.last
119
+ next
120
+ end
121
+
122
+ tracer_example = tracer_cache.fetch(example)
123
+ example_id = tracer_example[:example_id]
124
+
125
+ if engine.run_example?(example_id)
126
+ run_reason = engine.run_example_reason(example_id)
127
+ tracer_example[:run_reason] = run_reason
128
+ example.metadata[:description] = "#{example.description} (#{run_reason})"
129
+
130
+ to_run[example_group] << example
131
+ groups << example.example_group.parent_groups.last
132
+
133
+ engine.register_example(tracer_example)
134
+ else
135
+ engine.on_example_skipped(example_id)
136
+ end
137
+ end
138
+ end
139
+
140
+ engine.deregister_duplicate_examples
141
+
142
+ [to_run, groups.to_a]
143
+ end
144
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
145
+
146
+ def _rspec_tracer_collect_tracks(engine, tracer_cache)
147
+ ::RSpec.world.filtered_examples.each_pair do |_example_group, examples|
148
+ examples.each do |example|
149
+ next if RSpecTracer.ignore_spec_file?(example.metadata[:file_path])
150
+
151
+ tracer_example = RSpecTracer::Example.from(example)
152
+ example_id = tracer_example[:example_id]
153
+ example.metadata[:rspec_tracer_example_id] = example_id
154
+ tracer_cache[example] = tracer_example
155
+
156
+ tracks = RSpecTracer::RSpec::Metadata.tracks_for(example)
157
+ next if tracks[:files].empty? && tracks[:env].empty?
158
+
159
+ engine.register_tracks(example_id, tracks)
160
+ end
161
+ end
162
+ end
163
+
164
+ # Logs the duplicate-identity diagnostic and returns whether any
165
+ # were found. The summary line keeps its 1.x wording (CI log
166
+ # parsers + specs match on it); the indented detail names each
167
+ # colliding example so the user can find and rename them.
168
+ # @api private
169
+ def _rspec_tracer_duplicates_detected?
170
+ duplicates = RSpecTracer.engine.duplicate_examples
171
+ return false if duplicates.empty?
172
+
173
+ total = duplicates.sum { |_, entries| entries.count }
174
+ RSpecTracer.logger.error(
175
+ "RSpec tracer detected #{total} duplicate example(s) across " \
176
+ "#{duplicates.size} identity hash(es). Examples that share one rspec-tracer " \
177
+ 'identity cannot be tracked separately and are dropped from this run - give ' \
178
+ "them distinct descriptions to fix:\n#{_rspec_tracer_duplicate_report(duplicates)}"
179
+ )
180
+ true
181
+ end
182
+
183
+ # The indented per-hash detail block for the duplicate
184
+ # diagnostic: the identity hash, then one labelled line per
185
+ # colliding example under it.
186
+ # @api private
187
+ def _rspec_tracer_duplicate_report(duplicates)
188
+ duplicates.map do |example_id, entries|
189
+ labelled = entries.map { |entry| " - #{_rspec_tracer_example_label(entry)}" }
190
+ " #{example_id}\n#{labelled.join("\n")}"
191
+ end.join("\n")
192
+ end
193
+
194
+ # `file:line description` for one colliding example, read off the
195
+ # `Example.from` payload (rerun location preferred, mirroring the
196
+ # reporter + `explain` columns).
197
+ # @api private
198
+ def _rspec_tracer_example_label(entry)
199
+ file = entry[:rerun_file_name] || entry[:file_name]
200
+ line = entry[:rerun_line_number] || entry[:line_number]
201
+
202
+ "#{file}:#{line} #{entry[:full_description] || entry[:description]}".rstrip
203
+ end
204
+
205
+ # Drops the colliding examples from the filtered run set. The
206
+ # rest of the suite still runs; `fail_on_duplicates` governs only
207
+ # the exit code (via `at_exit_behavior`), not whether anything
208
+ # runs. A group is kept only if it still has examples after the
209
+ # colliding ones are removed.
210
+ #
211
+ # The returned Hash uses the same `Hash.new { |h, k| h[k] = [] }`
212
+ # default-block shape as `_rspec_tracer_build_filter_decision`'s
213
+ # `to_run`. That contract matters because rspec-core's
214
+ # `RSpec::Core::World#example_count` walks `g.descendants` for
215
+ # every top-level group and reads `e.filtered_examples`
216
+ # (= `world.filtered_examples[e]`) on each descendant — including
217
+ # INTERMEDIATE groups (a `describe` that contains only nested
218
+ # `describe`s, no direct `it`s). A plain Hash returns `nil` for
219
+ # those keys, which then NPEs in
220
+ # `inject(0) { |a, e| a + e.filtered_examples.size }`. The
221
+ # default block returns `[]` (`.size == 0`), matching the
222
+ # pre-drop path's behavior.
223
+ # @api private
224
+ def _rspec_tracer_drop_duplicate_examples(examples_map, example_groups)
225
+ duplicate_ids = Set.new(RSpecTracer.engine.duplicate_examples.keys)
226
+
227
+ kept_map = Hash.new { |hash, group| hash[group] = [] }
228
+ examples_map.each do |group, examples|
229
+ survivors = examples.reject do |example|
230
+ duplicate_ids.include?(example.metadata[:rspec_tracer_example_id])
231
+ end
232
+ kept_map[group] = survivors unless survivors.empty?
233
+ end
234
+
235
+ [kept_map, example_groups.select { |group| kept_map.key?(group) }]
236
+ end
237
+ end
238
+ end
239
+ end
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTracer
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` per
9
+ # feedback_mutation_friendly_modules so mutant observes mutations
10
+ # through the singleton call path.
4
11
  module SourceFile
12
+ # Internal constant.
13
+ # @api private
5
14
  PROJECT_ROOT_REGEX = Regexp.new("^#{Regexp.escape(RSpecTracer.root)}").freeze
6
15
 
7
- module_function
8
-
9
- def from_path(file_path)
16
+ # Internal helper for the tracer pipeline.
17
+ # @api private
18
+ def self.from_path(file_path)
10
19
  return unless File.file?(file_path)
11
20
 
12
21
  {
@@ -16,21 +25,29 @@ module RSpecTracer
16
25
  }
17
26
  end
18
27
 
19
- def from_name(file_name)
28
+ # Internal helper for the tracer pipeline.
29
+ # @api private
30
+ def self.from_name(file_name)
20
31
  from_path(file_path(file_name))
21
32
  end
22
33
 
23
- def file_name(file_path)
34
+ # Internal helper for the tracer pipeline.
35
+ # @api private
36
+ def self.file_name(file_path)
24
37
  file_path.sub(PROJECT_ROOT_REGEX, '')
25
38
  end
26
39
 
27
- def file_path(file_name)
40
+ # Internal helper for the tracer pipeline.
41
+ # @api private
42
+ def self.file_path(file_name)
28
43
  return file_name if absolute_external_file?(file_name)
29
44
 
30
45
  File.expand_path(file_name.sub(%r{^/}, ''), RSpecTracer.root)
31
46
  end
32
47
 
33
- def absolute_external_file?(file_name)
48
+ # Internal helper for the tracer pipeline.
49
+ # @api private
50
+ def self.absolute_external_file?(file_name)
34
51
  file_name.start_with?('/') &&
35
52
  !file_name.start_with?(RSpecTracer.root) &&
36
53
  File.file?(file_name)
@@ -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 RSpecTracer::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/rspec_tracer/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 RSpecTracer
4
+ # Internal Storage — see {RSpecTracer} 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
+ # RSpecTracer.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 {RSpecTracer::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
+ # {RSpecTracer::Engine} (runtime) and the
71
+ # {RSpecTracer::CLI::CacheInfo} / {RSpecTracer::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/rspec-tracer 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 {RSpecTracer}
84
+ # top-level module).
85
+ # @return [Object] a backend instance satisfying {REQUIRED_METHODS}.
86
+ def self.build(cache_path:, configuration: RSpecTracer)
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
+ "rspec-tracer: 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