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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'time'
5
+ require 'uri'
6
+
7
+ require_relative 'git_ancestry'
8
+ require_relative 'local_fs_backend'
9
+ require_relative 'redis_backend'
10
+ require_relative 's3_backend'
11
+
12
+ module RSpecTracer
13
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
14
+ # @api private
15
+ module RemoteCache
16
+ # Orchestrator for the user-facing `rspec_tracer:remote_cache:*`
17
+ # Rake tasks. Composes `GitAncestry` + a `Backend` implementation,
18
+ # drives the download (candidate-ref walk + first-valid wins) and
19
+ # upload (branch_ref + branch_refs update + retention prune) flows.
20
+ #
21
+ # Called from `lib/rspec_tracer/remote_cache/Rakefile` which is
22
+ # loaded by the user's own Rakefile per USER_FACING_SURFACE.md section 5.
23
+ # The user-facing task surface is preserved from 1.x bit-for-bit:
24
+ # same task names, same env vars, same exit behavior.
25
+ #
26
+ # Graceful-degradation contract:
27
+ # - `download!` catches every StandardError, logs, returns false.
28
+ # A failed download is cold run; tests still proceed.
29
+ # - `upload!` catches every StandardError, logs, returns false.
30
+ # A failed upload is logged but doesn't propagate non-zero -
31
+ # the tests already passed; cache miss is recoverable next run.
32
+ #
33
+ class UserTasks
34
+ # Internal constant.
35
+ # @api private
36
+ BUILT_IN_BACKENDS = {
37
+ s3: S3Backend,
38
+ local_fs: LocalFsBackend,
39
+ redis: RedisBackend
40
+ }.freeze
41
+
42
+ # Module-level convenience for {#download!}. Equivalent to
43
+ # `UserTasks.new(configuration: ..., env: ...).download!`. Invoked
44
+ # by the bundled `rspec_tracer/remote_cache/Rakefile` shim that
45
+ # users `import` from their own `Rakefile`.
46
+ #
47
+ # @param configuration [Object] anything responding to the
48
+ # `cache_path` / `logger` / `remote_cache_*` config surface
49
+ # (defaults to the {RSpecTracer} top-level module).
50
+ # @param env [Hash] env hash to read `GIT_BRANCH` /
51
+ # `GIT_DEFAULT_BRANCH` from (defaults to `ENV`).
52
+ # @return [Boolean] true on a successful cache hit, false on cold
53
+ # run or graceful failure.
54
+ def self.download!(configuration: RSpecTracer, env: ENV)
55
+ new(configuration: configuration, env: env).download!
56
+ end
57
+
58
+ # Module-level convenience for {#upload!}. Equivalent to
59
+ # `UserTasks.new(configuration: ..., env: ...).upload!`. Invoked
60
+ # by the bundled `rspec_tracer/remote_cache/Rakefile` shim.
61
+ #
62
+ # @param configuration [Object] anything responding to the
63
+ # `cache_path` / `logger` / `remote_cache_*` config surface
64
+ # (defaults to the {RSpecTracer} top-level module).
65
+ # @param env [Hash] env hash to read `GIT_BRANCH` /
66
+ # `GIT_DEFAULT_BRANCH` from (defaults to `ENV`).
67
+ # @return [Boolean] true on a successful upload, false on
68
+ # graceful failure (logged but not raised).
69
+ def self.upload!(configuration: RSpecTracer, env: ENV)
70
+ new(configuration: configuration, env: env).upload!
71
+ end
72
+
73
+ # Internal helper for the tracer pipeline.
74
+ # @api private
75
+ def self.prune_all!(configuration: RSpecTracer, env: ENV)
76
+ new(configuration: configuration, env: env).prune_all!
77
+ end
78
+
79
+ # Internal helper for the tracer pipeline.
80
+ # @api private
81
+ def self.git_repo?
82
+ system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
83
+ end
84
+
85
+ # Internal method on the tracer pipeline.
86
+ # @api private
87
+ def initialize(configuration:, env:)
88
+ @config = configuration
89
+ @env = env
90
+ @logger = configuration.logger
91
+ end
92
+
93
+ # Pull the closest matching cache for the current branch + commit
94
+ # ancestry from the configured backend. Walks the candidate refs
95
+ # produced by {GitAncestry}, downloads + extracts the first ref
96
+ # whose archive validates, and returns true. Cold-run on miss.
97
+ #
98
+ # Errors are caught and logged; a failed download never propagates
99
+ # a non-zero exit into the test suite (graceful degradation).
100
+ #
101
+ # @example In a Rakefile
102
+ # require 'rspec_tracer/remote_cache/Rakefile'
103
+ # # provides `rake rspec_tracer:remote_cache:download` which
104
+ # # invokes RSpecTracer::RemoteCache::UserTasks.download!
105
+ #
106
+ # @return [Boolean] true on a successful download, false on cold
107
+ # run / graceful failure.
108
+ def download!
109
+ ancestry = build_ancestry
110
+ ancestry.merge_base_branch!
111
+ backend = build_backend(ancestry)
112
+
113
+ refs = candidate_refs(ancestry, backend)
114
+ if refs.empty?
115
+ @logger.warn 'rspec-tracer remote_cache: no cache candidates found; cold run'
116
+ return false
117
+ end
118
+
119
+ tree_sha = head_tree_sha
120
+ refs.each do |ref, origin|
121
+ @logger.debug "rspec-tracer remote_cache: trying ref #{ref}"
122
+ next unless backend.download(ref, tree_sha: tree_sha)
123
+
124
+ log_download_success(ref, origin, ancestry)
125
+ return true
126
+ end
127
+
128
+ @logger.warn 'rspec-tracer remote_cache: no suitable cache found; cold run'
129
+ false
130
+ rescue StandardError => e
131
+ @logger.warn "rspec-tracer remote_cache: download failed (#{e.class}: #{e.message}); cold run"
132
+ false
133
+ end
134
+
135
+ # Push the local cache directory for the current branch + commit
136
+ # to the configured backend, refresh the per-branch ref index, and
137
+ # apply retention pruning.
138
+ #
139
+ # Errors are caught and logged; a failed upload never propagates
140
+ # non-zero into the test suite (the tests already passed).
141
+ #
142
+ # @example In a Rakefile
143
+ # require 'rspec_tracer/remote_cache/Rakefile'
144
+ # # provides `rake rspec_tracer:remote_cache:upload` which
145
+ # # invokes RSpecTracer::RemoteCache::UserTasks.upload!
146
+ #
147
+ # @return [Boolean] true on success, false on graceful failure.
148
+ def upload!
149
+ ancestry = build_ancestry
150
+ ancestry.merge_base_branch!
151
+ backend = build_backend(ancestry)
152
+
153
+ backend.upload(ancestry.branch_ref, tree_sha: head_tree_sha)
154
+ @logger.info "rspec-tracer remote_cache: uploaded cache to #{ancestry.branch_ref}"
155
+ maybe_update_branch_refs(backend, ancestry)
156
+ maybe_prune(backend, ancestry)
157
+ maybe_warn_unbounded(backend)
158
+ true
159
+ rescue StandardError => e
160
+ @logger.warn "rspec-tracer remote_cache: upload failed (#{e.class}: #{e.message})"
161
+ false
162
+ end
163
+
164
+ # Cross-tier PR-branch prune. Walks every branch under the
165
+ # configured prefix and deletes whole branches idle longer than
166
+ # `cache_retention_pr_branch_ttl_seconds`. Designed as a periodic
167
+ # maintenance task (nightly cron) - dead PR branches whose tip is
168
+ # never re-uploaded otherwise accumulate forever because
169
+ # `maybe_prune` only scopes to the current upload's tier. Returns
170
+ # the total refs removed across all branches, or 0 on graceful
171
+ # failure.
172
+ def prune_all!
173
+ ttl = safe_config(:cache_retention_pr_branch_ttl_seconds)
174
+ if ttl.nil?
175
+ @logger.warn 'rspec-tracer remote_cache: prune_all requires cache_retention_pr_branch_ttl; skipping'
176
+ return 0
177
+ end
178
+
179
+ backend = build_backend(build_admin_ancestry)
180
+ removed = backend.prune_all!(pr_branch_ttl_seconds: ttl)
181
+ @logger.info "rspec-tracer remote_cache: prune_all removed #{removed} refs"
182
+ removed
183
+ rescue StandardError => e
184
+ @logger.warn "rspec-tracer remote_cache: prune_all failed (#{e.class}: #{e.message})"
185
+ 0
186
+ end
187
+
188
+ private
189
+
190
+ # Internal method on the tracer pipeline.
191
+ # @api private
192
+ def build_ancestry
193
+ GitAncestry.new(
194
+ default_branch: require_env('GIT_DEFAULT_BRANCH'),
195
+ branch: require_env('GIT_BRANCH')
196
+ )
197
+ end
198
+
199
+ # Ancestry for cross-tier admin tasks (prune_all). Only
200
+ # GIT_DEFAULT_BRANCH is required; GIT_BRANCH defaults to it so
201
+ # the backend constructs in main-tier mode (prune_all walks every
202
+ # pr/ branch regardless of the backend's own tier, so the branch
203
+ # value does not affect behavior). Running prune_all from a
204
+ # cron/workflow that is not tied to a specific PR should work
205
+ # with just GIT_DEFAULT_BRANCH set.
206
+ def build_admin_ancestry
207
+ default = require_env('GIT_DEFAULT_BRANCH')
208
+ branch = @env['GIT_BRANCH']
209
+ branch = default if branch.nil? || branch.to_s.empty?
210
+ GitAncestry.new(default_branch: default, branch: branch)
211
+ end
212
+
213
+ # Internal method on the tracer pipeline.
214
+ # @api private
215
+ def require_env(name)
216
+ value = @env[name]
217
+ raise "#{name} environment variable is not set" if value.nil? || value.to_s.empty?
218
+
219
+ value
220
+ end
221
+
222
+ # Internal method on the tracer pipeline.
223
+ # @api private
224
+ def build_backend(ancestry)
225
+ entry = remote_cache_backend_entry
226
+ raise 'no remote_cache_backend configured' if entry.nil?
227
+
228
+ name_or_class, user_opts = entry
229
+ klass = resolve_backend_class(name_or_class)
230
+ klass.new(**merge_runtime_opts(user_opts, ancestry))
231
+ end
232
+
233
+ # Internal method on the tracer pipeline.
234
+ # @api private
235
+ def remote_cache_backend_entry
236
+ explicit = safe_config(:remote_cache_backend_entry)
237
+ return explicit if explicit
238
+
239
+ derive_from_legacy_dsl
240
+ end
241
+
242
+ # Probe the legacy `reports_s3_path` DSL when no explicit
243
+ # `remote_cache_backend` is configured. Gated on
244
+ # {Configuration#reports_s3_path_set?} so the deprecation
245
+ # warning fires only when the user actually set the DSL or its
246
+ # env var — never on the probe path when neither is configured.
247
+ # @api private
248
+ def derive_from_legacy_dsl
249
+ return nil unless safe_config(:reports_s3_path_set?)
250
+
251
+ s3_uri = safe_config(:reports_s3_path)
252
+ return nil if s3_uri.nil? || s3_uri.to_s.empty?
253
+
254
+ uri = URI.parse(s3_uri)
255
+ return nil unless uri.scheme == 's3' && uri.host && !uri.host.empty?
256
+
257
+ prefix = uri.path.to_s.sub(%r{^/}, '')
258
+ [
259
+ :s3,
260
+ {
261
+ bucket: uri.host,
262
+ prefix: prefix,
263
+ local: safe_config(:use_local_aws) == true
264
+ }
265
+ ]
266
+ end
267
+
268
+ # Internal method on the tracer pipeline.
269
+ # @api private
270
+ def resolve_backend_class(name_or_class)
271
+ case name_or_class
272
+ when Symbol
273
+ BUILT_IN_BACKENDS.fetch(name_or_class) do
274
+ raise "unknown remote_cache_backend: #{name_or_class.inspect}"
275
+ end
276
+ when Class
277
+ name_or_class
278
+ else
279
+ raise "invalid remote_cache_backend: #{name_or_class.class}"
280
+ end
281
+ end
282
+
283
+ # Internal method on the tracer pipeline.
284
+ # @api private
285
+ def merge_runtime_opts(user_opts, ancestry)
286
+ runtime = {
287
+ branch: ancestry.branch_name,
288
+ default_branch: ancestry.default_branch_name,
289
+ cache_path: @config.cache_path,
290
+ test_suite_id: @env['TEST_SUITE_ID'],
291
+ logger: @logger
292
+ }
293
+ # User opts win for bucket/prefix/local; runtime opts are injected
294
+ # fresh (caller never sets these via DSL).
295
+ runtime.merge(user_opts)
296
+ end
297
+
298
+ # Returns a timestamp-sorted (newest first) list of
299
+ # `[ref, origin]` tuples where origin is `:branch` (PR-tier
300
+ # branch_refs upload) or `:ancestry` (commit-ancestry fallback,
301
+ # which on PR builds means a cross-branch hit on the
302
+ # default-branch tier). Same merge order as before — on PR
303
+ # builds branch_refs come first, ancestry refs second; on
304
+ # collision ancestry wins, so the origin reflects the winning
305
+ # source. The info-log shape in {#download!} reads the origin
306
+ # tag to qualify cross-branch fallback hits in the INFO line.
307
+ # @api private
308
+ def candidate_refs(ancestry, backend)
309
+ refs = {}
310
+ origins = {}
311
+ if ancestry.pr_build?
312
+ backend.branch_refs(ancestry.branch_name).each do |sha, ts|
313
+ refs[sha] = ts
314
+ origins[sha] = :branch
315
+ end
316
+ end
317
+ ancestry.ancestry_refs.each do |sha, ts|
318
+ refs[sha] = ts
319
+ origins[sha] = :ancestry
320
+ end
321
+ refs.sort_by { |_, ts| -ts }.map { |sha, _| [sha, origins[sha]] }
322
+ end
323
+
324
+ # Emit an INFO log line on a successful download. Distinguishes
325
+ # PR-tier branch_refs hits ("restored cache from <ref>") from
326
+ # ancestry-fallback hits on PR builds, which get the explicit
327
+ # "(cross-branch fallback)" qualifier so the user can tell at
328
+ # a glance whether the PR-tier cache existed or whether the
329
+ # restore traversed the default branch.
330
+ # @api private
331
+ def log_download_success(ref, origin, ancestry)
332
+ qualifier = origin == :ancestry && ancestry.pr_build? ? ' (cross-branch fallback)' : ''
333
+ @logger.info "rspec-tracer remote_cache: restored cache from #{ref}#{qualifier}"
334
+ end
335
+
336
+ # Internal method on the tracer pipeline.
337
+ # @api private
338
+ def maybe_update_branch_refs(backend, ancestry)
339
+ return unless ancestry.pr_build?
340
+
341
+ existing = backend.branch_refs(ancestry.branch_name)
342
+ updated = existing.merge(ancestry.branch_ref => Time.now.to_i)
343
+ filtered = filter_branch_refs(updated, ancestry)
344
+ backend.write_branch_refs(ancestry.branch_name, filtered)
345
+ end
346
+
347
+ # Bound branch_refs to 25 most-recent, filtered to refs newer
348
+ # than the oldest ancestry commit when ancestry is non-empty.
349
+ # Matches 1.x `Repo#filter_branch_refs`.
350
+ def filter_branch_refs(refs, ancestry)
351
+ ancestry_refs = ancestry.ancestry_refs
352
+ bounded =
353
+ if ancestry_refs.empty?
354
+ refs.sort_by { |_, ts| -ts }.first(GitAncestry::ANCESTRY_DEPTH)
355
+ else
356
+ oldest_ts = ancestry_refs.values.min
357
+ refs
358
+ .select { |_, ts| ts >= oldest_ts }
359
+ .sort_by { |_, ts| -ts }
360
+ .first(GitAncestry::ANCESTRY_DEPTH)
361
+ end
362
+ bounded.to_h
363
+ end
364
+
365
+ # Retention knob routing per approved scope:
366
+ # - `cache_retention_count` / `cache_retention_duration`
367
+ # apply to the main tier only. Main accumulates linearly;
368
+ # these cap it.
369
+ # - `cache_retention_pr_branch_ttl` applies to PR tier only.
370
+ # PR branches die after merge; TTL prunes the whole branch
371
+ # prefix when it's been idle.
372
+ def maybe_prune(backend, ancestry)
373
+ return unless backend.respond_to?(:prune!)
374
+
375
+ opts = retention_opts_for(ancestry)
376
+ return if opts.values.all?(&:nil?)
377
+
378
+ removed = backend.prune!(**opts)
379
+ @logger.debug "rspec-tracer remote_cache: pruned #{removed} refs" if removed.positive?
380
+ end
381
+
382
+ # Internal method on the tracer pipeline.
383
+ # @api private
384
+ def retention_opts_for(ancestry)
385
+ if ancestry.pr_build?
386
+ { count: nil, duration_seconds: nil,
387
+ pr_branch_ttl_seconds: safe_config(:cache_retention_pr_branch_ttl_seconds) }
388
+ else
389
+ { count: safe_config(:cache_retention_count),
390
+ duration_seconds: safe_config(:cache_retention_duration_seconds),
391
+ pr_branch_ttl_seconds: nil }
392
+ end
393
+ end
394
+
395
+ # Internal method on the tracer pipeline.
396
+ # @api private
397
+ def maybe_warn_unbounded(backend)
398
+ return unless backend.respond_to?(:unbounded_warning)
399
+ # Only meaningful on the main tier - PR tier gets branch-TTL
400
+ # retention and is bounded by branch lifecycle.
401
+ return if safe_config(:cache_retention_count)
402
+ return if safe_config(:cache_retention_duration_seconds)
403
+
404
+ warning = backend.unbounded_warning
405
+ @logger.warn(warning) if warning
406
+ end
407
+
408
+ # Internal method on the tracer pipeline.
409
+ # @api private
410
+ def safe_config(method)
411
+ @config.public_send(method)
412
+ rescue NoMethodError
413
+ nil
414
+ end
415
+
416
+ # Resolve HEAD's tree SHA via `git rev-parse HEAD^{tree}` for
417
+ # forwarding to the backend's tree-SHA secondary index. Same
418
+ # shell-invocation shape as `GitAncestry`'s `git rev-parse` calls,
419
+ # but graceful nil instead of raising: tree_sha is best-effort.
420
+ # When git is unavailable, the rev-parse fails, or HEAD doesn't
421
+ # exist (shallow clone, fresh repo), nil signals "skip the tree-
422
+ # SHA index" to the backend - S3Backend treats nil as no-op and
423
+ # falls through to the standard commit-SHA lookup; LocalFs / Redis
424
+ # accept the kwarg as a no-op.
425
+ def head_tree_sha
426
+ output = `git rev-parse HEAD^{tree} 2>/dev/null`.chomp
427
+ return nil unless $CHILD_STATUS&.success?
428
+ return nil if output.empty?
429
+
430
+ output
431
+ rescue StandardError
432
+ nil
433
+ end
434
+ end
435
+ end
436
+ end
@@ -1,71 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RSpecTracer
4
- module RemoteCache
5
- class Validator
6
- class ValidationError < StandardError; end
7
-
8
- CACHE_FILES_PER_TEST_SUITE = 11
9
-
10
- def initialize
11
- @test_suite_id = ENV.fetch('TEST_SUITE_ID', nil)
12
- @test_suites = ENV.fetch('TEST_SUITES', nil)
13
- @use_test_suite_id_cache = ENV.fetch('USE_TEST_SUITE_ID_CACHE', nil) == 'true'
14
-
15
- if @test_suite_id.nil? ^ @test_suites.nil?
16
- raise(
17
- ValidationError,
18
- 'Both the environment variables TEST_SUITE_ID and TEST_SUITES are not set'
19
- )
20
- end
21
-
22
- setup
23
- end
24
-
25
- def valid?(ref, cache_files)
26
- if @use_test_suite_id_cache
27
- test_suite_id_specific_validation?(ref, cache_files)
28
- else
29
- general_validation?(ref, cache_files)
30
- end
31
- end
32
-
33
- private
3
+ require 'json'
34
4
 
35
- def setup
36
- if @test_suites.nil?
37
- @last_run_files_count = 1
38
- @last_run_files_regex = '/%<ref>s/last_run.json$'
39
- @cached_files_count = CACHE_FILES_PER_TEST_SUITE
40
- @cached_files_regex = '/%<ref>s/[0-9a-f]{32}/.+.json$'
41
- else
42
- @test_suites = @test_suites.to_i
43
- @test_suites_regex = (1..@test_suites).to_a.join('|')
5
+ require_relative '../storage/schema'
44
6
 
45
- @last_run_files_count = @test_suites
46
- @last_run_files_regex = "/%<ref>s/(#{@test_suites_regex})/last_run.json$"
47
- @cached_files_count = CACHE_FILES_PER_TEST_SUITE * @test_suites
48
- @cached_files_regex = "/%<ref>s/(#{@test_suites_regex})/[0-9a-f]{32}/.+.json$"
49
- end
50
- end
51
-
52
- def general_validation?(ref, cache_files)
53
- last_run_regex = Regexp.new(format(@last_run_files_regex, ref: ref))
54
-
55
- return false if cache_files.count { |file| file.match?(last_run_regex) } != @last_run_files_count
56
-
57
- cache_regex = Regexp.new(format(@cached_files_regex, ref: ref))
58
-
59
- cache_files.count { |file| file.match?(cache_regex) } == @cached_files_count
7
+ module RSpecTracer
8
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
9
+ # @api private
10
+ module RemoteCache
11
+ # Cache validator. Replaces 1.x's CACHE_FILES_PER_TEST_SUITE=11
12
+ # file-count check, which broke under any FILENAMES change (v2
13
+ # grew from 11 to 15 files across Phase 3-6, so the old validator
14
+ # refused every v2 cache).
15
+ #
16
+ # New signal: `schema_version` in `last_run.json`. The storage
17
+ # backend writes `Storage::Schema::CURRENT` on every save; this
18
+ # validator accepts only `Storage::Schema::SUPPORTED` values. Same
19
+ # policy as `Storage::JsonBackend#load_graph`: mismatch means "cold
20
+ # run," no migrators, one free cold run on upgrade.
21
+ #
22
+ # Atomicity note: `last_run.json` is written last via tmp+rename
23
+ # (see `Storage::JsonBackend#write_last_run_atomic`). If
24
+ # `last_run.json` exists, every other file in the run was present
25
+ # at write time. So the file-count sanity check 1.x did was already
26
+ # redundant with the atomicity guarantee; we drop it cleanly.
27
+ module Validator
28
+ # True when the given parsed last_run manifest is acceptable to
29
+ # this tracer version. Missing / unparseable / wrong-shape inputs
30
+ # all return false without raising.
31
+ def self.valid?(manifest)
32
+ return false unless manifest.is_a?(Hash)
33
+
34
+ RSpecTracer::Storage::Schema.supported?(manifest['schema_version'])
60
35
  end
61
36
 
62
- def test_suite_id_specific_validation?(ref, cache_files)
63
- last_run_regex = Regexp.new("/#{ref}/#{@test_suite_id}/last_run.json$")
64
- cache_regex = Regexp.new("/#{ref}/#{@test_suite_id}/[0-9a-f]{32}/.+.json$")
65
-
66
- return false unless cache_files.any? { |file| file.match?(last_run_regex) }
67
-
68
- cache_files.any? { |file| file.match?(cache_regex) }
37
+ # Read + parse + validate a last_run.json file on disk. Returns
38
+ # true iff the file exists, parses as JSON, has a Hash root, and
39
+ # carries a supported schema_version. Any I/O or parse failure
40
+ # (missing file -> Errno::ENOENT, unreadable -> Errno::EACCES,
41
+ # malformed JSON -> JSON::ParserError) is caught by the rescue
42
+ # below and degraded to false.
43
+ def self.valid_file?(path)
44
+ valid?(JSON.parse(File.read(path, encoding: 'UTF-8')))
45
+ rescue StandardError
46
+ false
69
47
  end
70
48
  end
71
49
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for the `rspec-tracer/remote_cache` namespace. Loaded
4
+ # lazily by the user-facing Rakefile shim at
5
+ # `lib/rspec_tracer/remote_cache/Rakefile`, not by the main
6
+ # `rspec_tracer` gem load. Test-suite runs that never invoke the
7
+ # remote-cache Rake tasks pay zero cost for this subtree.
8
+
9
+ require_relative 'remote_cache/backend'
10
+ require_relative 'remote_cache/validator'
11
+ require_relative 'remote_cache/git_ancestry'
12
+ require_relative 'remote_cache/local_fs_backend'
13
+ require_relative 'remote_cache/redis_backend'
14
+ require_relative 'remote_cache/s3_backend'
15
+ require_relative 'remote_cache/user_tasks'
16
+
17
+ module RSpecTracer
18
+ # Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
19
+ # @api private
20
+ module RemoteCache
21
+ end
22
+ end
@@ -0,0 +1,103 @@
1
+ # Reporters
2
+
3
+ Output formatters for tracer results. Each reporter is pluggable via
4
+ `config.add_reporter`; the tracer engine functions with zero reporters
5
+ attached. Reporters sit above the Tracker + Storage layers, consume
6
+ the finalized `Storage::Snapshot`, and emit to `report_dir`.
7
+
8
+ ## Files
9
+
10
+ | File | Role |
11
+ |-------------------------|------------------------------------------------------------------------|
12
+ | `base.rb` | Abstract `Reporters::Base` with `initialize(snapshot:, report_dir:, run_metadata:, logger:, **opts)`, `generate`, `no_op?`. |
13
+ | `payload_builder.rb` | `Reporters::PayloadBuilder` — shared schema-v1 payload builder consumed by both JSON and HTML. |
14
+ | `json_reporter.rb` | `Reporters::JsonReporter` — writes `report_dir/report.json` with schema version 1; 5 report types. |
15
+ | `terminal_reporter.rb` | `Reporters::TerminalReporter` — concise stdout summary (≤ 5 lines); respects `NO_COLOR`. |
16
+ | `html_reporter.rb` | `Reporters::HtmlReporter` — writes `report_dir/index.html` (Preact bundle + server-rendered fallback tables). |
17
+ | `registry.rb` | `Reporters::Registry` — resolves configured reporters, rescues per-reporter, warns + continues on failure. |
18
+ | `html/` | Committed frontend toolchain (Preact + Vite). See [html/README.md](html/README.md). |
19
+
20
+ ## Configuration
21
+
22
+ ```ruby
23
+ # .rspec-tracer
24
+ add_reporter :terminal
25
+ add_reporter :json
26
+ add_reporter MyCustomReporter, color: false
27
+ ```
28
+
29
+ Symbols resolve via `Registry::BUILT_INS` (`:terminal`, `:json`,
30
+ `:html`). Class values pass through — must duck-type
31
+ `Reporters::Base`. If no `add_reporter` calls are made, `Registry`
32
+ defaults to `[:terminal, :json, :html]`.
33
+
34
+ ## JSON schema (version 1)
35
+
36
+ `<report_dir>/report.json` envelope:
37
+
38
+ ```json
39
+ {
40
+ "schema_version": 1,
41
+ "run_id": "<hex>",
42
+ "generated_at": "<ISO-8601>",
43
+ "summary": { "total_examples": N, "passed_examples": N, "..." : "..." },
44
+ "reports": {
45
+ "all_examples": [...],
46
+ "duplicate_examples": [...],
47
+ "flaky_examples": [...],
48
+ "examples_dependency": [...],
49
+ "files_dependency": [...]
50
+ }
51
+ }
52
+ ```
53
+
54
+ Additive fields on existing objects are non-breaking. Removing or
55
+ renaming a top-level key bumps `SCHEMA_VERSION`. Full field list
56
+ lives in `json_reporter.rb`'s documentation comment.
57
+
58
+ ## Graceful degradation
59
+
60
+ Every reporter runs inside an isolated rescue in `Registry#emit_all`.
61
+ A raising reporter logs a warning via `configuration.logger.warn` and
62
+ emission continues with the next reporter. This matches the Storage
63
+ backend contract — a tracer failure never propagates a non-zero exit
64
+ into the user's test suite.
65
+
66
+ ## Extension
67
+
68
+ Subclass `Reporters::Base`:
69
+
70
+ ```ruby
71
+ class MySlackReporter < RSpecTracer::Reporters::Base
72
+ def generate
73
+ return if no_op?
74
+
75
+ post_to_slack(snapshot, report_dir, run_metadata)
76
+ end
77
+ end
78
+ ```
79
+
80
+ Register via `config.add_reporter MySlackReporter, webhook_url: ENV['SLACK_URL']`.
81
+
82
+ ## HTML reporter
83
+
84
+ `HtmlReporter` emits `<report_dir>/index.html` plus a sibling
85
+ `assets/` directory containing the pre-built bundle (Preact + CSS).
86
+ The frontend source + build tooling live in
87
+ [`html/`](html/README.md); `dist/` is committed so users never run
88
+ `npm`. Rebuild maintainer-side via `task reporters:html:build`.
89
+
90
+ The reporter renders two layers:
91
+
92
+ 1. A `<script id="report-data" type="application/json">` payload
93
+ built by `PayloadBuilder` (same payload JSON reporter writes,
94
+ minus the pretty-print).
95
+ 2. Server-rendered fallback `<table>` elements for every report
96
+ type. If JavaScript is disabled or the bundle fails to load,
97
+ these stay in the DOM and remain readable; when Preact hydrates,
98
+ the bundle removes the fallback and renders the interactive
99
+ view.
100
+
101
+ This two-layer approach is load-bearing for the "works without
102
+ JavaScript" AC — the reporter output is a usable read even in
103
+ degraded environments.