rspec-tracer 1.2.2 → 2.0.0.pre.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -45
  3. data/README.md +439 -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 +98 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +103 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +275 -0
  9. data/lib/rspec_tracer/cli/explain.rb +148 -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 +1100 -3
  13. data/lib/rspec_tracer/engine.rb +1076 -0
  14. data/lib/rspec_tracer/example.rb +21 -6
  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 +397 -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 +178 -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 +68 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +866 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +43 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +90 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +127 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +686 -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 +232 -381
  104. metadata +93 -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,397 @@
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|
121
+ @logger.debug "rspec-tracer remote_cache: trying ref #{ref}"
122
+ return true if backend.download(ref, tree_sha: tree_sha)
123
+ end
124
+
125
+ @logger.warn 'rspec-tracer remote_cache: no suitable cache found; cold run'
126
+ false
127
+ rescue StandardError => e
128
+ @logger.warn "rspec-tracer remote_cache: download failed (#{e.class}: #{e.message}); cold run"
129
+ false
130
+ end
131
+
132
+ # Push the local cache directory for the current branch + commit
133
+ # to the configured backend, refresh the per-branch ref index, and
134
+ # apply retention pruning.
135
+ #
136
+ # Errors are caught and logged; a failed upload never propagates
137
+ # non-zero into the test suite (the tests already passed).
138
+ #
139
+ # @example In a Rakefile
140
+ # require 'rspec_tracer/remote_cache/Rakefile'
141
+ # # provides `rake rspec_tracer:remote_cache:upload` which
142
+ # # invokes RSpecTracer::RemoteCache::UserTasks.upload!
143
+ #
144
+ # @return [Boolean] true on success, false on graceful failure.
145
+ def upload!
146
+ ancestry = build_ancestry
147
+ ancestry.merge_base_branch!
148
+ backend = build_backend(ancestry)
149
+
150
+ backend.upload(ancestry.branch_ref, tree_sha: head_tree_sha)
151
+ maybe_update_branch_refs(backend, ancestry)
152
+ maybe_prune(backend, ancestry)
153
+ maybe_warn_unbounded(backend)
154
+ true
155
+ rescue StandardError => e
156
+ @logger.warn "rspec-tracer remote_cache: upload failed (#{e.class}: #{e.message})"
157
+ false
158
+ end
159
+
160
+ # Cross-tier PR-branch prune. Walks every branch under the
161
+ # configured prefix and deletes whole branches idle longer than
162
+ # `cache_retention_pr_branch_ttl_seconds`. Designed as a periodic
163
+ # maintenance task (nightly cron) - dead PR branches whose tip is
164
+ # never re-uploaded otherwise accumulate forever because
165
+ # `maybe_prune` only scopes to the current upload's tier. Returns
166
+ # the total refs removed across all branches, or 0 on graceful
167
+ # failure.
168
+ def prune_all!
169
+ ttl = safe_config(:cache_retention_pr_branch_ttl_seconds)
170
+ if ttl.nil?
171
+ @logger.warn 'rspec-tracer remote_cache: prune_all requires cache_retention_pr_branch_ttl; skipping'
172
+ return 0
173
+ end
174
+
175
+ backend = build_backend(build_admin_ancestry)
176
+ removed = backend.prune_all!(pr_branch_ttl_seconds: ttl)
177
+ @logger.debug "rspec-tracer remote_cache: prune_all removed #{removed} refs"
178
+ removed
179
+ rescue StandardError => e
180
+ @logger.warn "rspec-tracer remote_cache: prune_all failed (#{e.class}: #{e.message})"
181
+ 0
182
+ end
183
+
184
+ private
185
+
186
+ # Internal method on the tracer pipeline.
187
+ # @api private
188
+ def build_ancestry
189
+ GitAncestry.new(
190
+ default_branch: require_env('GIT_DEFAULT_BRANCH'),
191
+ branch: require_env('GIT_BRANCH')
192
+ )
193
+ end
194
+
195
+ # Ancestry for cross-tier admin tasks (prune_all). Only
196
+ # GIT_DEFAULT_BRANCH is required; GIT_BRANCH defaults to it so
197
+ # the backend constructs in main-tier mode (prune_all walks every
198
+ # pr/ branch regardless of the backend's own tier, so the branch
199
+ # value does not affect behavior). Running prune_all from a
200
+ # cron/workflow that is not tied to a specific PR should work
201
+ # with just GIT_DEFAULT_BRANCH set.
202
+ def build_admin_ancestry
203
+ default = require_env('GIT_DEFAULT_BRANCH')
204
+ branch = @env['GIT_BRANCH']
205
+ branch = default if branch.nil? || branch.to_s.empty?
206
+ GitAncestry.new(default_branch: default, branch: branch)
207
+ end
208
+
209
+ # Internal method on the tracer pipeline.
210
+ # @api private
211
+ def require_env(name)
212
+ value = @env[name]
213
+ raise "#{name} environment variable is not set" if value.nil? || value.to_s.empty?
214
+
215
+ value
216
+ end
217
+
218
+ # Internal method on the tracer pipeline.
219
+ # @api private
220
+ def build_backend(ancestry)
221
+ entry = remote_cache_backend_entry
222
+ raise 'no remote_cache_backend configured' if entry.nil?
223
+
224
+ name_or_class, user_opts = entry
225
+ klass = resolve_backend_class(name_or_class)
226
+ klass.new(**merge_runtime_opts(user_opts, ancestry))
227
+ end
228
+
229
+ # Internal method on the tracer pipeline.
230
+ # @api private
231
+ def remote_cache_backend_entry
232
+ explicit = safe_config(:remote_cache_backend_entry)
233
+ return explicit if explicit
234
+
235
+ derive_from_legacy_dsl
236
+ end
237
+
238
+ # Internal method on the tracer pipeline.
239
+ # @api private
240
+ def derive_from_legacy_dsl
241
+ s3_uri = safe_config(:reports_s3_path)
242
+ return nil if s3_uri.nil? || s3_uri.to_s.empty?
243
+
244
+ uri = URI.parse(s3_uri)
245
+ return nil unless uri.scheme == 's3' && uri.host && !uri.host.empty?
246
+
247
+ prefix = uri.path.to_s.sub(%r{^/}, '')
248
+ [
249
+ :s3,
250
+ {
251
+ bucket: uri.host,
252
+ prefix: prefix,
253
+ local: safe_config(:use_local_aws) == true
254
+ }
255
+ ]
256
+ end
257
+
258
+ # Internal method on the tracer pipeline.
259
+ # @api private
260
+ def resolve_backend_class(name_or_class)
261
+ case name_or_class
262
+ when Symbol
263
+ BUILT_IN_BACKENDS.fetch(name_or_class) do
264
+ raise "unknown remote_cache_backend: #{name_or_class.inspect}"
265
+ end
266
+ when Class
267
+ name_or_class
268
+ else
269
+ raise "invalid remote_cache_backend: #{name_or_class.class}"
270
+ end
271
+ end
272
+
273
+ # Internal method on the tracer pipeline.
274
+ # @api private
275
+ def merge_runtime_opts(user_opts, ancestry)
276
+ runtime = {
277
+ branch: ancestry.branch_name,
278
+ default_branch: ancestry.default_branch_name,
279
+ cache_path: @config.cache_path,
280
+ test_suite_id: @env['TEST_SUITE_ID'],
281
+ logger: @logger
282
+ }
283
+ # User opts win for bucket/prefix/local; runtime opts are injected
284
+ # fresh (caller never sets these via DSL).
285
+ runtime.merge(user_opts)
286
+ end
287
+
288
+ # Internal method on the tracer pipeline.
289
+ # @api private
290
+ def candidate_refs(ancestry, backend)
291
+ refs = {}
292
+ refs.merge!(backend.branch_refs(ancestry.branch_name)) if ancestry.pr_build?
293
+ refs.merge!(ancestry.ancestry_refs)
294
+ refs.sort_by { |_, ts| -ts }.map(&:first)
295
+ end
296
+
297
+ # Internal method on the tracer pipeline.
298
+ # @api private
299
+ def maybe_update_branch_refs(backend, ancestry)
300
+ return unless ancestry.pr_build?
301
+
302
+ existing = backend.branch_refs(ancestry.branch_name)
303
+ updated = existing.merge(ancestry.branch_ref => Time.now.to_i)
304
+ filtered = filter_branch_refs(updated, ancestry)
305
+ backend.write_branch_refs(ancestry.branch_name, filtered)
306
+ end
307
+
308
+ # Bound branch_refs to 25 most-recent, filtered to refs newer
309
+ # than the oldest ancestry commit when ancestry is non-empty.
310
+ # Matches 1.x `Repo#filter_branch_refs`.
311
+ def filter_branch_refs(refs, ancestry)
312
+ ancestry_refs = ancestry.ancestry_refs
313
+ bounded =
314
+ if ancestry_refs.empty?
315
+ refs.sort_by { |_, ts| -ts }.first(GitAncestry::ANCESTRY_DEPTH)
316
+ else
317
+ oldest_ts = ancestry_refs.values.min
318
+ refs
319
+ .select { |_, ts| ts >= oldest_ts }
320
+ .sort_by { |_, ts| -ts }
321
+ .first(GitAncestry::ANCESTRY_DEPTH)
322
+ end
323
+ bounded.to_h
324
+ end
325
+
326
+ # Retention knob routing per approved scope:
327
+ # - `cache_retention_count` / `cache_retention_duration`
328
+ # apply to the main tier only. Main accumulates linearly;
329
+ # these cap it.
330
+ # - `cache_retention_pr_branch_ttl` applies to PR tier only.
331
+ # PR branches die after merge; TTL prunes the whole branch
332
+ # prefix when it's been idle.
333
+ def maybe_prune(backend, ancestry)
334
+ return unless backend.respond_to?(:prune!)
335
+
336
+ opts = retention_opts_for(ancestry)
337
+ return if opts.values.all?(&:nil?)
338
+
339
+ removed = backend.prune!(**opts)
340
+ @logger.debug "rspec-tracer remote_cache: pruned #{removed} refs" if removed.positive?
341
+ end
342
+
343
+ # Internal method on the tracer pipeline.
344
+ # @api private
345
+ def retention_opts_for(ancestry)
346
+ if ancestry.pr_build?
347
+ { count: nil, duration_seconds: nil,
348
+ pr_branch_ttl_seconds: safe_config(:cache_retention_pr_branch_ttl_seconds) }
349
+ else
350
+ { count: safe_config(:cache_retention_count),
351
+ duration_seconds: safe_config(:cache_retention_duration_seconds),
352
+ pr_branch_ttl_seconds: nil }
353
+ end
354
+ end
355
+
356
+ # Internal method on the tracer pipeline.
357
+ # @api private
358
+ def maybe_warn_unbounded(backend)
359
+ return unless backend.respond_to?(:unbounded_warning)
360
+ # Only meaningful on the main tier - PR tier gets branch-TTL
361
+ # retention and is bounded by branch lifecycle.
362
+ return if safe_config(:cache_retention_count)
363
+ return if safe_config(:cache_retention_duration_seconds)
364
+
365
+ warning = backend.unbounded_warning
366
+ @logger.warn(warning) if warning
367
+ end
368
+
369
+ # Internal method on the tracer pipeline.
370
+ # @api private
371
+ def safe_config(method)
372
+ @config.public_send(method)
373
+ rescue NoMethodError
374
+ nil
375
+ end
376
+
377
+ # Resolve HEAD's tree SHA via `git rev-parse HEAD^{tree}` for
378
+ # forwarding to the backend's tree-SHA secondary index. Same
379
+ # shell-invocation shape as `GitAncestry`'s `git rev-parse` calls,
380
+ # but graceful nil instead of raising: tree_sha is best-effort.
381
+ # When git is unavailable, the rev-parse fails, or HEAD doesn't
382
+ # exist (shallow clone, fresh repo), nil signals "skip the tree-
383
+ # SHA index" to the backend - S3Backend treats nil as no-op and
384
+ # falls through to the standard commit-SHA lookup; LocalFs / Redis
385
+ # accept the kwarg as a no-op.
386
+ def head_tree_sha
387
+ output = `git rev-parse HEAD^{tree} 2>/dev/null`.chomp
388
+ return nil unless $CHILD_STATUS&.success?
389
+ return nil if output.empty?
390
+
391
+ output
392
+ rescue StandardError
393
+ nil
394
+ end
395
+ end
396
+ end
397
+ 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.