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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracker/file_digest'
4
+ require_relative '../tracker/input'
5
+
6
+ module RSpecTracer
7
+ # Internal Rails — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module Rails
10
+ # ActiveSupport::Notifications observer for Rails-side inputs that
11
+ # Coverage and IOHooks can't see directly. Subscribes to:
12
+ #
13
+ # - render_template.action_view -> :template
14
+ # - render_partial.action_view -> :template
15
+ # - render_collection.action_view -> :template (Rails 7.1+)
16
+ # - sql.active_record -> :notification
17
+ # (on the first query per example, emits db/schema.rb +
18
+ # db/structure.sql as inputs)
19
+ #
20
+ # Lifecycle mirrors IOHooks: Engine.setup installs, example_started
21
+ # opens a thread-local bucket, subscribers append, example_finished
22
+ # harvests and clears the bucket, Engine.finalize unsubscribes.
23
+ #
24
+ # Payload access is defensively permissive - Rails minors differ on
25
+ # symbol vs string keys; missing payload or nil identifier is
26
+ # swallowed. Errors inside subscribers never propagate (CLAUDE.md
27
+ # "graceful degradation").
28
+ #
29
+ # Precedence: an observed template path already covered by a user
30
+ # declared glob (e.g. the Preset :views glob) skips notification
31
+ # emission via the injected `filter:` callable. Matches IOHooks'
32
+ # declared-glob-precedence rule from ARCHITECTURE.md.
33
+ #
34
+ # The sql.active_record subscriber is only attached when the caller
35
+ # passes a non-empty `ar_schema_paths:` list. Engine resolves the
36
+ # list from the `track_ar_schema_notifications` opt-in DSL; absent
37
+ # that, the AR subscriber is never installed and the sql.active_record
38
+ # event stream is ignored.
39
+ class Notifications
40
+ # Internal constant.
41
+ # @api private
42
+ BUCKET_KEY = :rspec_tracer_rails_bucket
43
+ # Internal constant.
44
+ # @api private
45
+ AR_FLAG_KEY = :rspec_tracer_rails_ar_emitted
46
+
47
+ class << self
48
+ # Internal attribute.
49
+ # @api private
50
+ attr_reader :root
51
+
52
+ # Internal method on the tracer pipeline.
53
+ # @api private
54
+ def install(root:, filter: ->(_path) { true }, ar_schema_paths: [])
55
+ @root = File.expand_path(root)
56
+ @root_prefix = "#{@root}/"
57
+ @filter = filter
58
+ @ar_schema_inputs = build_schema_inputs(ar_schema_paths)
59
+ @handles = []
60
+
61
+ subscribe_render_template
62
+ subscribe_render_partial
63
+ subscribe_render_collection if render_collection_supported?
64
+ subscribe_sql_active_record if ar_enabled?
65
+
66
+ self
67
+ end
68
+
69
+ # Internal method on the tracer pipeline.
70
+ # @api private
71
+ def uninstall
72
+ (@handles || []).each { |handle| safely_unsubscribe(handle) }
73
+ @handles = nil
74
+ @root = nil
75
+ @root_prefix = nil
76
+ @filter = nil
77
+ @ar_schema_inputs = nil
78
+ self
79
+ end
80
+
81
+ # Internal method on the tracer pipeline.
82
+ # @api private
83
+ def installed?
84
+ !@root_prefix.nil?
85
+ end
86
+
87
+ # rubocop:disable Naming/AccessorMethodName
88
+ def set_bucket(bucket)
89
+ Thread.current[BUCKET_KEY] = bucket
90
+ Thread.current[AR_FLAG_KEY] = false
91
+ end
92
+ # rubocop:enable Naming/AccessorMethodName
93
+
94
+ def clear_bucket
95
+ Thread.current[BUCKET_KEY] = nil
96
+ Thread.current[AR_FLAG_KEY] = nil
97
+ end
98
+
99
+ # Internal method on the tracer pipeline.
100
+ # @api private
101
+ def current_bucket
102
+ Thread.current[BUCKET_KEY]
103
+ end
104
+
105
+ # Pure-logic entry point for the render_*.action_view
106
+ # subscribers. Extracted so mutation smoke can exercise
107
+ # payload parsing without driving AS::Notifications.
108
+ def handle_render_event(payload)
109
+ return nil unless payload.is_a?(Hash)
110
+
111
+ identifier = payload[:identifier] || payload['identifier']
112
+ return nil if identifier.nil?
113
+
114
+ record_template(identifier)
115
+ end
116
+
117
+ # Pure-logic entry point for the sql.active_record subscriber.
118
+ # Payload contents are ignored - the event itself is the
119
+ # "this example touched AR" signal.
120
+ def handle_sql_event(_payload)
121
+ record_ar_schema
122
+ end
123
+
124
+ # Record a :template Input for the observed render identifier.
125
+ # Guarded by the same fast-reject ladder as IOHooks: install
126
+ # state, bucket presence, path-under-root, filter callable,
127
+ # identity dedup. The early-return ladder looks complex to
128
+ # rubocop's metric but is the simplest shape for a hot path.
129
+ # rubocop:disable Metrics/PerceivedComplexity
130
+ def record_template(path)
131
+ return nil if @root_prefix.nil?
132
+
133
+ bucket = Thread.current[BUCKET_KEY]
134
+ return nil if bucket.nil?
135
+ return nil unless path.is_a?(String) || path.respond_to?(:to_s)
136
+
137
+ path_str = path.to_s
138
+ return nil unless path_str.start_with?(@root_prefix)
139
+ return nil unless @filter.call(path_str)
140
+
141
+ identity = "template:#{path_str[@root_prefix.length..]}"
142
+ return nil if bucket.key?(identity)
143
+
144
+ digest = Tracker::FileDigest.compute(path_str)
145
+ return nil if digest.nil?
146
+
147
+ bucket[identity] = Tracker::Input.for_file(
148
+ path: path_str, kind: :template, digest: digest, root: @root
149
+ )
150
+ rescue StandardError
151
+ nil
152
+ end
153
+ # rubocop:enable Metrics/PerceivedComplexity
154
+
155
+ # Emit the pre-digested schema Inputs into the bucket on the
156
+ # first sql.active_record event per example. Subsequent events
157
+ # short-circuit via AR_FLAG_KEY - O(1) after the first query.
158
+ def record_ar_schema
159
+ return nil if @ar_schema_inputs.nil? || @ar_schema_inputs.empty?
160
+
161
+ bucket = Thread.current[BUCKET_KEY]
162
+ return nil if bucket.nil?
163
+ return nil if Thread.current[AR_FLAG_KEY]
164
+
165
+ Thread.current[AR_FLAG_KEY] = true
166
+ @ar_schema_inputs.each do |input|
167
+ bucket[input.identity] ||= input
168
+ end
169
+ nil
170
+ rescue StandardError
171
+ nil
172
+ end
173
+
174
+ private
175
+
176
+ # Internal method on the tracer pipeline.
177
+ # @api private
178
+ def ar_enabled?
179
+ !(@ar_schema_inputs.nil? || @ar_schema_inputs.empty?)
180
+ end
181
+
182
+ # Internal method on the tracer pipeline.
183
+ # @api private
184
+ def subscribe_render_template
185
+ @handles << ::ActiveSupport::Notifications.subscribe('render_template.action_view') do |*args|
186
+ handle_render_event(args.last)
187
+ end
188
+ end
189
+
190
+ # Internal method on the tracer pipeline.
191
+ # @api private
192
+ def subscribe_render_partial
193
+ @handles << ::ActiveSupport::Notifications.subscribe('render_partial.action_view') do |*args|
194
+ handle_render_event(args.last)
195
+ end
196
+ end
197
+
198
+ # Internal method on the tracer pipeline.
199
+ # @api private
200
+ def subscribe_render_collection
201
+ @handles << ::ActiveSupport::Notifications.subscribe('render_collection.action_view') do |*args|
202
+ handle_render_event(args.last)
203
+ end
204
+ end
205
+
206
+ # Internal method on the tracer pipeline.
207
+ # @api private
208
+ def subscribe_sql_active_record
209
+ @handles << ::ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
210
+ handle_sql_event(args.last)
211
+ end
212
+ end
213
+
214
+ # Rails 7.1 added render_collection.action_view. Subscribing to
215
+ # a non-existent event silently no-ops on modern AS, but the
216
+ # version check keeps `@handles` tight for uninstall accounting.
217
+ def render_collection_supported?
218
+ return false unless defined?(::Rails::VERSION::STRING)
219
+
220
+ Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new('7.1')
221
+ rescue StandardError
222
+ false
223
+ end
224
+
225
+ # Pre-digest the schema paths at install time. An unreadable or
226
+ # missing file is dropped on the floor - graceful degradation.
227
+ # Returns a frozen array so record_ar_schema can iterate without
228
+ # defensive dup.
229
+ def build_schema_inputs(paths)
230
+ Array(paths).each_with_object([]) do |path, acc|
231
+ input = try_build_schema_input(path)
232
+ acc << input if input
233
+ end.freeze
234
+ end
235
+
236
+ # Internal method on the tracer pipeline.
237
+ # @api private
238
+ def try_build_schema_input(path)
239
+ abs = File.expand_path(path.to_s, @root)
240
+ return nil unless abs.start_with?(@root_prefix)
241
+ return nil unless File.file?(abs)
242
+
243
+ digest = Tracker::FileDigest.compute(abs)
244
+ return nil if digest.nil?
245
+
246
+ Tracker::Input.for_file(
247
+ path: abs, kind: :notification, digest: digest, root: @root
248
+ )
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ # Internal method on the tracer pipeline.
254
+ # @api private
255
+ def safely_unsubscribe(handle)
256
+ ::ActiveSupport::Notifications.unsubscribe(handle)
257
+ rescue StandardError
258
+ nil
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative '../configuration'
6
+
7
+ module RSpecTracer
8
+ # Internal Rails — see {RSpecTracer} for the user-facing surface.
9
+ # @api private
10
+ #
11
+ # Internal Rails-integration subsystem (Railtie + ActiveSupport
12
+ # notifications + I18n hooks + the default-glob preset). Activated
13
+ # when the user's spec_helper requires `rspec_tracer/rails`.
14
+ module Rails
15
+ # Default Rails glob preset - the Coverage-invisible surface that
16
+ # rspec-tracer cannot auto-observe via its standard Ruby-execution
17
+ # tracking. Enabled by `track_rails_defaults` in .rspec-tracer.
18
+ #
19
+ # Covered categories (each toggleable via the `except:` kwarg):
20
+ #
21
+ # :views - app/views/**/* (ERB / Slim / Haml / JBuilder)
22
+ # :helpers - app/helpers/**/*.rb (tracked as declared so helper
23
+ # files re-invalidate even before Zeitwerk loads them)
24
+ # :locales - config/locales/**/*.yml (I18n data files)
25
+ # :config - config/*.yml + config/routes.rb (database.yml,
26
+ # cable.yml, storage.yml, routes - the YAML + routes
27
+ # dispatcher are all config surface)
28
+ # :schema - db/schema.rb + db/structure.sql
29
+ # :factories - spec/factories/**/*.rb + test/factories/**/*.rb
30
+ # :fixtures - spec/fixtures/**/*.{yml,yaml,json} + same under test/
31
+ #
32
+ # Intentionally omitted:
33
+ # - Gemfile.lock, .ruby-version, .rspec-tracer: already tracked as
34
+ # whole-suite invalidators (Tracker::WholeSuiteInvalidators).
35
+ # Listing them as :declared would cross input-kind categories.
36
+ # - config/environments/*.rb, config/app_constants.rb: loaded as
37
+ # Ruby at Rails boot, so LoadedFilesTracker.@boot_set captures
38
+ # them already as transitive-load inputs.
39
+ #
40
+ # Unknown `except:` keys raise Configuration::InvalidUsageError, same
41
+ # pattern as `storage_backend(:bogus)`.
42
+ class Preset
43
+ # Internal constant.
44
+ # @api private
45
+ DEFAULTS = {
46
+ views: %w[app/views/**/*].freeze,
47
+ helpers: %w[app/helpers/**/*.rb].freeze,
48
+ locales: %w[config/locales/**/*.yml].freeze,
49
+ config: %w[config/*.yml config/routes.rb].freeze,
50
+ schema: %w[db/schema.rb db/structure.sql].freeze,
51
+ factories: %w[spec/factories/**/*.rb test/factories/**/*.rb].freeze,
52
+ fixtures: %w[
53
+ spec/fixtures/**/*.{yml,yaml,json}
54
+ test/fixtures/**/*.{yml,yaml,json}
55
+ ].freeze
56
+ }.freeze
57
+
58
+ # Internal constant.
59
+ # @api private
60
+ ALLOWED_KEYS = DEFAULTS.keys.to_set.freeze
61
+
62
+ # Internal helper for the tracer pipeline.
63
+ # @api private
64
+ def self.globs(except: [])
65
+ excluded = normalize_excluded(except)
66
+ validate_excluded!(excluded)
67
+
68
+ DEFAULTS
69
+ .except(*excluded)
70
+ .values
71
+ .flatten
72
+ .uniq
73
+ .freeze
74
+ end
75
+
76
+ # Internal helper for the tracer pipeline.
77
+ # @api private
78
+ def self.normalize_excluded(except)
79
+ Array(except).compact
80
+ end
81
+
82
+ # Internal helper for the tracer pipeline.
83
+ # @api private
84
+ def self.validate_excluded!(excluded)
85
+ unknown = excluded.reject { |key| ALLOWED_KEYS.include?(key) }
86
+ return if unknown.empty?
87
+
88
+ raise RSpecTracer::Configuration::InvalidUsageError,
89
+ "unknown track_rails_defaults keys: #{unknown.inspect}; " \
90
+ "allowed: #{ALLOWED_KEYS.to_a.inspect}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Internal Rails — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ module Rails
7
+ # Rails lifecycle adapter. Only required by `lib/rspec_tracer/rails.rb`
8
+ # when `defined?(::Rails::Railtie)` is true, so loading this file in
9
+ # a non-Rails process is not expected and will raise NameError.
10
+ #
11
+ # Defines the Railtie class + registers one initializer that logs
12
+ # a single confirmation line. The actual ActiveSupport::Notifications
13
+ # subscribers for ActionView template renders, I18n lookups, and
14
+ # schema/factory/fixture tracking live in `notifications.rb` and
15
+ # `i18n_tracking.rb` and are wired by `Engine.setup`.
16
+ class Railtie < ::Rails::Railtie
17
+ initializer 'rspec_tracer.setup' do
18
+ RSpecTracer.logger.info 'rspec-tracer Rails integration loaded'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails integration entry point. Loaded explicitly by users who put
4
+ # `require 'rspec_tracer/rails'` in their spec_helper - the gem does not
5
+ # auto-load this file, so pure-Ruby suites pay zero cost.
6
+ #
7
+ # Behavior:
8
+ # - Always loads RSpecTracer::Rails::Preset so Configuration#track_rails_defaults
9
+ # works whether or not Rails itself is booted in the current process.
10
+ # - Loads RSpecTracer::Rails::Railtie iff `::Rails::Railtie` is defined,
11
+ # so requiring this file when Rails is absent is a clean no-op
12
+ # (AC1: "Rails absent: requiring rspec_tracer/rails noops cleanly").
13
+
14
+ require_relative 'rails/preset'
15
+ require_relative 'rails/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,140 @@
1
+ # Remote cache
2
+
3
+ Optional cross-machine cache layer. Downloads a prior run's dependency
4
+ graph so a fresh checkout (CI, new contributor) starts warm.
5
+
6
+ ## Responsibilities
7
+
8
+ - Download the cache for a given ref (commit SHA) into the local cache
9
+ directory, with schema-version validation.
10
+ - Upload the current local cache under the branch ref.
11
+ - Persist branch refs so history rewrites (`git commit --amend`,
12
+ `git pull -r`) don't invalidate the next run.
13
+ - Prune old entries per configured retention policy.
14
+
15
+ ## Public protocol
16
+
17
+ ```ruby
18
+ module RSpecTracer::RemoteCache::Backend
19
+ REQUIRED_METHODS = %i[
20
+ download upload branch_refs write_branch_refs prune! prune_all!
21
+ ].freeze
22
+
23
+ def self.conforms?(backend); end
24
+ end
25
+ ```
26
+
27
+ The shared-examples contract in `spec/contracts/remote_cache_backend.rb`
28
+ asserts the full behavioral contract on every implementation.
29
+
30
+ ## Shipped backends
31
+
32
+ All three share the two-tier layout (main + per-PR-branch), the same
33
+ schema_version validator, and the same retention knobs.
34
+
35
+ - **`S3Backend`** — primary target. Shells out to `aws` / `awslocal`
36
+ CLI. Cache is one `cache.tar.gz` per ref.
37
+ - **`LocalFsBackend`** — shared directory (NFS, dev cache, CI
38
+ workspace). Same `cache.tar.gz` archive format as S3 — a LocalFs
39
+ root can be rsync'd to/from S3 with no transform. Atomic uploads via
40
+ tmp-write + rename. No locking (unreliable over NFS; last-write-wins
41
+ on same SHA is correct because archive bytes are deterministic).
42
+ - **`RedisBackend`** — each ref stored as a Redis hash (field-per-file)
43
+ keyed under `<prefix>:main:<sha>` / `<prefix>:pr:<branch>:<sha>`.
44
+ `redis` gem is an OPTIONAL dependency — users add it to their own
45
+ Gemfile. A missing gem logs a clear warning and falls back to a cold
46
+ run instead of crashing the test suite.
47
+
48
+ ## Layout
49
+
50
+ ### S3 / LocalFS (archive-per-ref)
51
+
52
+ ```
53
+ <root>/main/<sha>/[<test_suite_id>/]cache.tar.gz
54
+ <root>/pr/<branch>/<sha>/[<test_suite_id>/]cache.tar.gz
55
+ <root>/pr/<branch>/branch_refs.json
56
+ ```
57
+
58
+ (`<root>` is `s3://<bucket>/<prefix>` for S3, a local/NFS path for
59
+ LocalFs.) Each `cache.tar.gz` packs `last_run.json` + the `<run_id>/`
60
+ directory (the 15-file local layout documented in
61
+ `USER_FACING_SURFACE.md` §6). Upload = 1 PUT; download = 1 GET. Local
62
+ disk layout is unchanged after unpack — external tooling that walks
63
+ `rspec_tracer_cache/` still sees the full 15-file breakdown.
64
+
65
+ ### Redis (hash-per-ref)
66
+
67
+ ```
68
+ <prefix>:main:<sha>[:<test_suite_id>] -> HASH
69
+ <prefix>:pr:<branch>:<sha>[:<test_suite_id>] -> HASH
70
+ <prefix>:pr:<branch>:branch_refs -> STRING (JSON)
71
+ ```
72
+
73
+ Hash fields: `_timestamp` (epoch), `last_run.json`, and one field per
74
+ file in the 15-file layout (`<run_id>/<file>.json`). Keeps Redis-native
75
+ inspection (`HGETALL`, `HKEYS`) usable without extracting an archive.
76
+
77
+ ## Tier routing
78
+
79
+ Tier is determined from `$GIT_BRANCH` vs `$GIT_DEFAULT_BRANCH`. PR
80
+ builds write to the PR tier; main builds write to the main tier. PR
81
+ downloads try their own tier first, then fall back to main tier for
82
+ the same ref (catches cherry-picks from main).
83
+
84
+ ## User-facing surface
85
+
86
+ The Rakefile (`lib/rspec_tracer/remote_cache/Rakefile`) defines three
87
+ tasks:
88
+
89
+ - `rspec_tracer:remote_cache:download`
90
+ - `rspec_tracer:remote_cache:upload`
91
+ - `rspec_tracer:remote_cache:prune_all` — maintenance task that walks
92
+ every PR branch and drops ones idle longer than
93
+ `cache_retention_pr_branch_ttl`. Intended for a nightly cron.
94
+
95
+ Users load the Rakefile from their own Rakefile:
96
+
97
+ ```ruby
98
+ spec = Gem::Specification.find_by_name('rspec-tracer')
99
+ load "#{spec.gem_dir}/lib/rspec_tracer/remote_cache/Rakefile"
100
+ ```
101
+
102
+ Env vars: `GIT_DEFAULT_BRANCH` (required), `GIT_BRANCH` (required for
103
+ download/upload; defaults to `GIT_DEFAULT_BRANCH` for prune_all), and
104
+ `TEST_SUITE_ID` (optional; scopes the cache key when set).
105
+
106
+ ## Configuration
107
+
108
+ Shortest path — one `remote_cache_uri` call:
109
+
110
+ ```ruby
111
+ RSpecTracer.configure do
112
+ remote_cache_uri 's3://my-bucket/rspec-tracer'
113
+ # or: remote_cache_uri 'file:///mnt/shared-cache'
114
+ # or: remote_cache_uri 'redis://redis.internal:6379/0'
115
+ cache_retention_count 100 # keep newest N on main tier
116
+ cache_retention_pr_branch_ttl '14 days' # idle-branch cutoff on pr tier
117
+ end
118
+ ```
119
+
120
+ Structured form for cases the URI cannot express:
121
+
122
+ ```ruby
123
+ RSpecTracer.configure do
124
+ remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'rspec-tracer'
125
+ # or:
126
+ remote_cache_backend :local_fs, root: '/mnt/shared-cache'
127
+ # or:
128
+ remote_cache_backend :redis, url: ENV.fetch('REDIS_URL'), prefix: 'rspec-tracer'
129
+ end
130
+ ```
131
+
132
+ ## Caveats
133
+
134
+ - **LocalFS over NFS**: cross-node consistency is eventual. A
135
+ download issued by node B immediately after an upload on node A may
136
+ miss; retries converge. Not a backend correctness bug.
137
+ - **Redis memory**: hash-per-ref without gzip compression uses ~2-5x
138
+ more bytes than the S3/LocalFS tar+gzip archive. Fine for realistic
139
+ cache sizes (a few hundred refs × ~100 KB raw); budget Redis memory
140
+ accordingly for very large fleets.
@@ -1,37 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # User-facing Rake task shim. The canonical loading pattern in a
4
+ # user's own Rakefile (preserved from 1.x, documented in README.md
5
+ # "Configuring CI"):
6
+ #
7
+ # spec = Gem::Specification.find_by_name('rspec-tracer')
8
+ # load "#{spec.gem_dir}/lib/rspec_tracer/remote_cache/Rakefile"
9
+ #
10
+ # This file must remain at the same path and continue to define the
11
+ # same two tasks. The `require 'rspec_tracer'` + `require
12
+ # 'rspec_tracer/remote_cache'` pair lazy-loads the backend tree only
13
+ # when the user actually invokes a cache task.
14
+
15
+ require 'rspec_tracer'
16
+ require 'rspec_tracer/remote_cache'
17
+
18
+ # rubocop:disable Metrics/BlockLength
3
19
  namespace :rspec_tracer do
4
20
  namespace :remote_cache do
5
21
  desc 'Download cache'
6
22
  task :download do
7
- require 'rspec_tracer'
8
-
9
- unless system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
23
+ unless RSpecTracer::RemoteCache::UserTasks.git_repo?
10
24
  RSpecTracer.logger.error 'Not a git repository'
11
-
12
25
  exit
13
26
  end
14
27
 
15
- RSpecTracer::RemoteCache::Cache.new.download
28
+ RSpecTracer::RemoteCache::UserTasks.download!(
29
+ configuration: RSpecTracer,
30
+ env: ENV
31
+ )
16
32
  end
17
33
 
18
34
  desc 'Upload cache'
19
35
  task :upload do
20
- require 'rspec_tracer'
21
-
22
- unless system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
36
+ unless RSpecTracer::RemoteCache::UserTasks.git_repo?
23
37
  RSpecTracer.logger.error 'Not a git repository'
24
-
25
38
  exit
26
39
  end
27
40
 
28
41
  unless ENV['CI'] == 'true' || RSpecTracer.upload_non_ci_reports
29
42
  RSpecTracer.logger.warn 'Uploading reports from a non CI environment is disabled'
30
-
31
43
  exit
32
44
  end
33
45
 
34
- RSpecTracer::RemoteCache::Cache.new.upload
46
+ RSpecTracer::RemoteCache::UserTasks.upload!(
47
+ configuration: RSpecTracer,
48
+ env: ENV
49
+ )
50
+ end
51
+
52
+ desc 'Prune dead PR branches across the whole remote cache (maintenance task)'
53
+ task :prune_all do
54
+ RSpecTracer::RemoteCache::UserTasks.prune_all!(
55
+ configuration: RSpecTracer,
56
+ env: ENV
57
+ )
35
58
  end
36
59
  end
37
60
  end
61
+ # rubocop:enable Metrics/BlockLength