testsift 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE +22 -0
  4. data/README.md +572 -0
  5. data/bin/testsift +15 -0
  6. data/lib/testsift/cache/Rakefile +43 -0
  7. data/lib/testsift/cli/blast_radius.rb +318 -0
  8. data/lib/testsift/cli/cache_clear.rb +114 -0
  9. data/lib/testsift/cli/cache_info.rb +106 -0
  10. data/lib/testsift/cli/doctor.rb +307 -0
  11. data/lib/testsift/cli/explain.rb +273 -0
  12. data/lib/testsift/cli/report_open.rb +90 -0
  13. data/lib/testsift/cli/snapshot_helpers.rb +119 -0
  14. data/lib/testsift/cli.rb +219 -0
  15. data/lib/testsift/configuration.rb +1467 -0
  16. data/lib/testsift/defaults.rb +11 -0
  17. data/lib/testsift/engine/snapshot_builder.rb +117 -0
  18. data/lib/testsift/engine.rb +896 -0
  19. data/lib/testsift/example.rb +188 -0
  20. data/lib/testsift/filter.rb +103 -0
  21. data/lib/testsift/line_stub.rb +62 -0
  22. data/lib/testsift/load_config.rb +15 -0
  23. data/lib/testsift/load_default_config.rb +46 -0
  24. data/lib/testsift/load_global_config.rb +33 -0
  25. data/lib/testsift/load_local_config.rb +17 -0
  26. data/lib/testsift/logger.rb +59 -0
  27. data/lib/testsift/rails/README.md +78 -0
  28. data/lib/testsift/rails/i18n_tracking.rb +137 -0
  29. data/lib/testsift/rails/notifications.rb +263 -0
  30. data/lib/testsift/rails/preset.rb +94 -0
  31. data/lib/testsift/rails/railtie.rb +22 -0
  32. data/lib/testsift/rails.rb +15 -0
  33. data/lib/testsift/remote_cache/README.md +140 -0
  34. data/lib/testsift/remote_cache/Rakefile +61 -0
  35. data/lib/testsift/remote_cache/archive.rb +137 -0
  36. data/lib/testsift/remote_cache/backend.rb +73 -0
  37. data/lib/testsift/remote_cache/git_ancestry.rb +241 -0
  38. data/lib/testsift/remote_cache/local_fs_backend.rb +439 -0
  39. data/lib/testsift/remote_cache/redis_backend.rb +554 -0
  40. data/lib/testsift/remote_cache/s3_backend.rb +712 -0
  41. data/lib/testsift/remote_cache/user_tasks.rb +436 -0
  42. data/lib/testsift/remote_cache/validator.rb +50 -0
  43. data/lib/testsift/remote_cache.rb +22 -0
  44. data/lib/testsift/reporters/README.md +103 -0
  45. data/lib/testsift/reporters/base.rb +87 -0
  46. data/lib/testsift/reporters/coverage_json_reporter.rb +338 -0
  47. data/lib/testsift/reporters/html/.gitignore +19 -0
  48. data/lib/testsift/reporters/html/.prettierignore +4 -0
  49. data/lib/testsift/reporters/html/.prettierrc.json +9 -0
  50. data/lib/testsift/reporters/html/README.md +80 -0
  51. data/lib/testsift/reporters/html/dist/assets/index.css +2 -0
  52. data/lib/testsift/reporters/html/dist/assets/index.js +1 -0
  53. data/lib/testsift/reporters/html/dist/index.html +24 -0
  54. data/lib/testsift/reporters/html/eslint.config.js +62 -0
  55. data/lib/testsift/reporters/html/package-lock.json +4910 -0
  56. data/lib/testsift/reporters/html/package.json +29 -0
  57. data/lib/testsift/reporters/html/src/app.jsx +130 -0
  58. data/lib/testsift/reporters/html/src/components/AllExamples.jsx +86 -0
  59. data/lib/testsift/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  60. data/lib/testsift/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  61. data/lib/testsift/reporters/html/src/components/FilesDependency.jsx +72 -0
  62. data/lib/testsift/reporters/html/src/components/FlakyExamples.jsx +42 -0
  63. data/lib/testsift/reporters/html/src/components/ReportTable.jsx +131 -0
  64. data/lib/testsift/reporters/html/src/components/SearchBar.jsx +19 -0
  65. data/lib/testsift/reporters/html/src/index.html +23 -0
  66. data/lib/testsift/reporters/html/src/main.jsx +37 -0
  67. data/lib/testsift/reporters/html/src/styles.css +434 -0
  68. data/lib/testsift/reporters/html/vite.config.js +42 -0
  69. data/lib/testsift/reporters/html_reporter.rb +266 -0
  70. data/lib/testsift/reporters/json_reporter.rb +88 -0
  71. data/lib/testsift/reporters/payload_builder.rb +241 -0
  72. data/lib/testsift/reporters/registry.rb +120 -0
  73. data/lib/testsift/reporters/terminal_reporter.rb +264 -0
  74. data/lib/testsift/rspec/README.md +73 -0
  75. data/lib/testsift/rspec/installation.rb +97 -0
  76. data/lib/testsift/rspec/metadata.rb +96 -0
  77. data/lib/testsift/rspec/parallel_tests.rb +459 -0
  78. data/lib/testsift/rspec/rails_setup.rb +42 -0
  79. data/lib/testsift/rspec/reporter_hook.rb +90 -0
  80. data/lib/testsift/rspec/runner_hook.rb +253 -0
  81. data/lib/testsift/source_file.rb +56 -0
  82. data/lib/testsift/storage/README.md +35 -0
  83. data/lib/testsift/storage/backend.rb +130 -0
  84. data/lib/testsift/storage/json_backend.rb +884 -0
  85. data/lib/testsift/storage/lazy_snapshot.rb +65 -0
  86. data/lib/testsift/storage/schema.rb +50 -0
  87. data/lib/testsift/storage/serializer/json.rb +41 -0
  88. data/lib/testsift/storage/serializer/msgpack.rb +167 -0
  89. data/lib/testsift/storage/snapshot.rb +141 -0
  90. data/lib/testsift/storage/sqlite_backend.rb +709 -0
  91. data/lib/testsift/time_formatter.rb +74 -0
  92. data/lib/testsift/tracker/README.md +36 -0
  93. data/lib/testsift/tracker/coverage_adapter.rb +180 -0
  94. data/lib/testsift/tracker/declared_globs.rb +100 -0
  95. data/lib/testsift/tracker/dependency_graph.rb +134 -0
  96. data/lib/testsift/tracker/env_matcher.rb +127 -0
  97. data/lib/testsift/tracker/env_snapshot.rb +77 -0
  98. data/lib/testsift/tracker/example_registry.rb +153 -0
  99. data/lib/testsift/tracker/file_digest.rb +61 -0
  100. data/lib/testsift/tracker/filter.rb +127 -0
  101. data/lib/testsift/tracker/input.rb +99 -0
  102. data/lib/testsift/tracker/io_hooks/file.rb +55 -0
  103. data/lib/testsift/tracker/io_hooks/io.rb +24 -0
  104. data/lib/testsift/tracker/io_hooks/json.rb +23 -0
  105. data/lib/testsift/tracker/io_hooks/kernel.rb +26 -0
  106. data/lib/testsift/tracker/io_hooks/yaml.rb +38 -0
  107. data/lib/testsift/tracker/io_hooks.rb +195 -0
  108. data/lib/testsift/tracker/loaded_files_tracker.rb +290 -0
  109. data/lib/testsift/tracker/new_file_detector.rb +62 -0
  110. data/lib/testsift/tracker/whole_suite_invalidators.rb +96 -0
  111. data/lib/testsift/version.rb +7 -0
  112. data/lib/testsift.rb +335 -0
  113. metadata +193 -0
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracker/file_digest'
4
+ require_relative '../tracker/input'
5
+ require_relative 'notifications'
6
+
7
+ module TestSift
8
+ # Internal Rails — see {TestSift} for the user-facing surface.
9
+ # @api private
10
+ module Rails
11
+ # I18n backend observer. Covers custom backends (Redis-backed,
12
+ # DB-backed, Chain) that bypass YAML.load_file and would otherwise
13
+ # miss the IOHooks YAML hook.
14
+ #
15
+ # Mechanism: Module#prepend onto ::I18n::Backend::Base - every
16
+ # backend subclass's load_translations resolves through the hook,
17
+ # even when the subclass overrides and super-calls Base. Backends
18
+ # that never super-call Base#load_translations fall through the
19
+ # hook, but the common backends (Simple, Chain, Cascade) all do.
20
+ #
21
+ # Shares Notifications' thread-local bucket so Engine.setup opens
22
+ # and clears one bucket per example that covers both observer
23
+ # families. Engine harvests `bucket.values` at example_finished.
24
+ #
25
+ # Graceful degradation:
26
+ # - install no-ops if ::I18n::Backend::Base is absent (tracer
27
+ # boot survives even in weird I18n-free app graphs).
28
+ # - Every record call swallows StandardError (CLAUDE.md) - a
29
+ # digest failure or bucket-shape surprise never propagates into
30
+ # the user's test run.
31
+ class I18nTracking
32
+ class << self
33
+ # Internal attribute.
34
+ # @api private
35
+ attr_reader :root
36
+
37
+ # Internal method on the tracer pipeline.
38
+ # @api private
39
+ def install(root:, filter: ->(_path) { true })
40
+ @root = File.expand_path(root)
41
+ @root_prefix = "#{@root}/"
42
+ @filter = filter
43
+ @prepended = prepend_backend_hook
44
+
45
+ self
46
+ end
47
+
48
+ # Prepended modules cannot be removed from the ancestry chain
49
+ # (Ruby has no public API), mirroring IOHooks.uninstall. Every
50
+ # hook entry point fast-rejects on @root_prefix nil once
51
+ # install state clears, so post-uninstall the hook is a no-op.
52
+ def uninstall
53
+ @root = nil
54
+ @root_prefix = nil
55
+ @filter = nil
56
+ @prepended = false
57
+ self
58
+ end
59
+
60
+ # Internal method on the tracer pipeline.
61
+ # @api private
62
+ def installed?
63
+ !@root_prefix.nil?
64
+ end
65
+
66
+ # Called from LoadTranslationsHook for every filename passed
67
+ # to I18n::Backend::Base#load_translations. Array form keeps
68
+ # the hook a single call site.
69
+ def record_translations(filenames)
70
+ return nil if @root_prefix.nil?
71
+
72
+ Array(filenames).each { |path| record_translation(path) }
73
+ nil
74
+ end
75
+
76
+ # Pure-logic entry point. Same fast-reject ladder as
77
+ # Notifications#record_template. Emits :notification kind
78
+ # so the I18n source is distinguishable from template events
79
+ # in downstream reporters. The ladder is longer than rubocop's
80
+ # perceived-complexity threshold by design - each guard is a
81
+ # cheap fast-reject.
82
+ # rubocop:disable Metrics/PerceivedComplexity
83
+ def record_translation(path)
84
+ return nil if @root_prefix.nil?
85
+
86
+ bucket = Notifications.current_bucket
87
+ return nil if bucket.nil?
88
+ return nil unless path.is_a?(String) || path.respond_to?(:to_s)
89
+
90
+ path_str = path.to_s
91
+ return nil unless path_str.start_with?(@root_prefix)
92
+ return nil unless @filter.call(path_str)
93
+
94
+ identity = "notification:#{path_str[@root_prefix.length..]}"
95
+ return nil if bucket.key?(identity)
96
+
97
+ digest = Tracker::FileDigest.compute(path_str)
98
+ return nil if digest.nil?
99
+
100
+ bucket[identity] = Tracker::Input.for_file(
101
+ path: path_str, kind: :notification, digest: digest, root: @root
102
+ )
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ # rubocop:enable Metrics/PerceivedComplexity
107
+
108
+ private
109
+
110
+ # Internal method on the tracer pipeline.
111
+ # @api private
112
+ def prepend_backend_hook
113
+ return false unless defined?(::I18n::Backend::Base)
114
+
115
+ ::I18n::Backend::Base.prepend(LoadTranslationsHook)
116
+ true
117
+ rescue StandardError
118
+ false
119
+ end
120
+ end
121
+
122
+ # Prepended onto I18n::Backend::Base. Every subclass's
123
+ # load_translations ultimately resolves through here via super.
124
+ # Intercepts the filename list, delegates recording to the
125
+ # singleton, and forwards to the real implementation.
126
+ # @api private
127
+ module LoadTranslationsHook
128
+ # Internal method on the tracer pipeline.
129
+ # @api private
130
+ def load_translations(*filenames)
131
+ TestSift::Rails::I18nTracking.record_translations(filenames)
132
+ super
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracker/file_digest'
4
+ require_relative '../tracker/input'
5
+
6
+ module TestSift
7
+ # Internal Rails — see {TestSift} 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 = :testsift_rails_bucket
43
+ # Internal constant.
44
+ # @api private
45
+ AR_FLAG_KEY = :testsift_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 TestSift
8
+ # Internal Rails — see {TestSift} 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 `testsift/rails`.
14
+ module Rails
15
+ # Default Rails glob preset - the Coverage-invisible surface that
16
+ # testsift cannot auto-observe via its standard Ruby-execution
17
+ # tracking. Enabled by `track_rails_defaults` in .testsift.
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, .testsift: 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 TestSift::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 TestSift
4
+ # Internal Rails — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ module Rails
7
+ # Rails lifecycle adapter. Only required by `lib/testsift/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 'testsift.setup' do
18
+ TestSift.logger.info 'testsift 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 'testsift/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 TestSift::Rails::Preset so Configuration#track_rails_defaults
9
+ # works whether or not Rails itself is booted in the current process.
10
+ # - Loads TestSift::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 testsift/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 TestSift::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
+ `testsift_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/testsift/remote_cache/Rakefile`) defines three
87
+ tasks:
88
+
89
+ - `testsift:remote_cache:download`
90
+ - `testsift:remote_cache:upload`
91
+ - `testsift: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('testsift')
99
+ load "#{spec.gem_dir}/lib/testsift/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
+ TestSift.configure do
112
+ remote_cache_uri 's3://my-bucket/testsift'
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
+ TestSift.configure do
124
+ remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'testsift'
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: 'testsift'
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.