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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative 'file_digest'
6
+ require_relative 'input'
7
+ # Sub-module hooks loaded eagerly: $LOADED_FEATURES makes require
8
+ # idempotent so placement doesn't change observable behavior, and
9
+ # Engine always calls IOHooks.install at boot - the previous
10
+ # install-time require_relative deferred a load that always fires
11
+ # anyway, paying the same total cost ~5 ms later at the cost of
12
+ # the standard requires-at-top Ruby convention.
13
+ require_relative 'io_hooks/file'
14
+ require_relative 'io_hooks/io'
15
+ require_relative 'io_hooks/yaml'
16
+ require_relative 'io_hooks/json'
17
+ require_relative 'io_hooks/kernel'
18
+
19
+ module TestSift
20
+ module Tracker
21
+ # Observer #2 in the 2.0 tracker pipeline (CoverageAdapter is #1).
22
+ # Intercepts Ruby's file-reading primitives via Module#prepend and
23
+ # emits Tracker::Input values for files touched by the currently-
24
+ # active example.
25
+ #
26
+ # Lifecycle:
27
+ # 1. IOHooks.install(root:, filter:, extensions:) - called once
28
+ # at Tracker.setup time. Prepends hook modules onto
29
+ # File/IO/YAML/JSON/Kernel singleton classes.
30
+ # 2. Example execution runs inside IOHooks.with_bucket(bucket)
31
+ # {...}. Hooks push Inputs into the thread-local bucket;
32
+ # outside a with_bucket call every hook fast-rejects.
33
+ # 3. IOHooks.uninstall clears state - prepended modules stay in
34
+ # the ancestry (Ruby has no public API to remove a prepend),
35
+ # but every hook fast-rejects on the nil @root_prefix guard,
36
+ # so post-uninstall they're functionally no-ops.
37
+ #
38
+ # Hot-path rejection order (cheapest first):
39
+ # 1. @root_prefix present (install state)
40
+ # 2. thread-local bucket present (inside an example)
41
+ # 3. path is String / to_s-able
42
+ # 4. path.start_with?(@root_prefix)
43
+ # 5. allow-predicate (extensions + filter, or .rb for Kernel)
44
+ # 6. bucket.key?(identity) (dedup before SHA256)
45
+ #
46
+ # Digest is SHA256 hex (same as CoverageAdapter; schema_version
47
+ # bump to change). Computed only *after* dedup, so a file read N
48
+ # times in one example pays the SHA256 cost exactly once.
49
+ module IOHooks
50
+ # Non-Ruby extensions the hook is interested in. .rb is covered
51
+ # by CoverageAdapter, so it's excluded from the default :data
52
+ # allow-set. Kernel#load uses a separate predicate (.rb only).
53
+ DEFAULT_EXTENSIONS = %w[
54
+ .yml .yaml .json .erb .haml .slim .builder .jbuilder .ru .rake
55
+ ].to_set.freeze
56
+
57
+ BUCKET_KEY = :testsift_io_bucket
58
+ # Re-entry guard: Digest::SHA256.file internally opens the file,
59
+ # which re-fires the File.open hook. Without this flag, the
60
+ # first hooked read blows the stack via infinite recursion.
61
+ REENTRY_KEY = :testsift_io_in_hook
62
+
63
+ class << self
64
+ attr_reader :root
65
+
66
+ def install(root:, filter: ->(_path) { true }, extensions: DEFAULT_EXTENSIONS)
67
+ @root = File.expand_path(root)
68
+ @root_prefix = "#{@root}/"
69
+ @extensions = extensions
70
+ @filter = filter
71
+
72
+ ::File.singleton_class.prepend(FileReads)
73
+ ::IO.singleton_class.prepend(IOReads)
74
+ ::YAML.singleton_class.prepend(YAMLReads) if defined?(::YAML)
75
+ ::JSON.singleton_class.prepend(JSONReads) if defined?(::JSON)
76
+ # Two prepends: singleton_class catches `Kernel.load('x')`,
77
+ # the module itself catches implicit `load 'x'` in method
78
+ # bodies (via Object's ancestor chain). `module_function`
79
+ # on Kernel creates two separate method objects, so both
80
+ # dispatch paths must be instrumented independently.
81
+ ::Kernel.singleton_class.prepend(KernelReads)
82
+ ::Kernel.prepend(KernelReads)
83
+
84
+ self
85
+ end
86
+
87
+ def uninstall
88
+ @root = nil
89
+ @root_prefix = nil
90
+ @extensions = nil
91
+ @filter = nil
92
+ self
93
+ end
94
+
95
+ def installed?
96
+ !@root_prefix.nil?
97
+ end
98
+
99
+ def current_bucket
100
+ Thread.current[BUCKET_KEY]
101
+ end
102
+
103
+ def with_bucket(bucket)
104
+ prev = Thread.current[BUCKET_KEY]
105
+ Thread.current[BUCKET_KEY] = bucket
106
+ begin
107
+ yield
108
+ ensure
109
+ Thread.current[BUCKET_KEY] = prev
110
+ end
111
+ end
112
+
113
+ # Non-block lifecycle for integration with RSpec hooks (which
114
+ # can't wrap the example body in a Ruby block). The Tracker
115
+ # coordinator calls set_bucket at example_started time and
116
+ # clear_bucket at example_finished time. Unlike with_bucket,
117
+ # these do not save/restore a prior bucket - the coordinator
118
+ # owns the Thread.current slot for the span of an example.
119
+ # rubocop:disable Naming/AccessorMethodName
120
+ def set_bucket(bucket)
121
+ Thread.current[BUCKET_KEY] = bucket
122
+ end
123
+ # rubocop:enable Naming/AccessorMethodName
124
+
125
+ def clear_bucket
126
+ Thread.current[BUCKET_KEY] = nil
127
+ end
128
+
129
+ # Record a :data input (File/IO/YAML/JSON hooks). The
130
+ # allow-predicate is the coordinator's default extension +
131
+ # filter combo.
132
+ def record(path)
133
+ _record(path, :data) do |p|
134
+ @extensions.include?(File.extname(p)) && @filter.call(p)
135
+ end
136
+ end
137
+
138
+ # Record a :ruby input (Kernel#load hook). Belt-and-suspenders
139
+ # for dynamically-constructed load paths that might bypass the
140
+ # Coverage module's require-graph observation. The example
141
+ # registry dedupes overlap with CoverageAdapter.
142
+ def record_ruby_load(path)
143
+ _record(path, :ruby) { |p| p.end_with?('.rb') }
144
+ end
145
+
146
+ private
147
+
148
+ # All hook entrypoints funnel through here. Fast-path order is
149
+ # tuned for the common case (hook fires, there's no bucket):
150
+ # do the cheapest checks first and bail before touching any
151
+ # allocation. Only on the slow path do we set the re-entry
152
+ # guard - `Digest::SHA256.file` internally opens the file and
153
+ # would otherwise blow the stack.
154
+ #
155
+ # Any error is swallowed - hooks never propagate failure into
156
+ # the user's test suite (CLAUDE.md "graceful degradation").
157
+ #
158
+ # The early-return ladder looks complex to RuboCop's metric
159
+ # but is actually the simplest shape for a hot path: each
160
+ # guard rejects in ~1-2 machine ops.
161
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
162
+ def _record(path, kind)
163
+ return if @root_prefix.nil?
164
+
165
+ bucket = Thread.current[BUCKET_KEY]
166
+ return if bucket.nil?
167
+ return unless path.is_a?(String) || path.respond_to?(:to_s)
168
+
169
+ path_str = path.to_s
170
+ return unless path_str.start_with?(@root_prefix)
171
+ return unless yield(path_str)
172
+
173
+ identity = "#{kind}:#{path_str[@root_prefix.length..]}"
174
+ return if bucket.key?(identity)
175
+ return if Thread.current[REENTRY_KEY]
176
+
177
+ Thread.current[REENTRY_KEY] = true
178
+ begin
179
+ digest = FileDigest.compute(path_str)
180
+ if digest
181
+ bucket[identity] = Input.for_file(
182
+ path: path_str, kind: kind, digest: digest, root: @root
183
+ )
184
+ end
185
+ ensure
186
+ Thread.current[REENTRY_KEY] = false
187
+ end
188
+ rescue StandardError
189
+ nil
190
+ end
191
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'set'
5
+
6
+ require_relative 'file_digest'
7
+ require_relative 'input'
8
+
9
+ module TestSift
10
+ # Internal Tracker — see {TestSift} for the user-facing surface.
11
+ # @api private
12
+ module Tracker
13
+ # Observer #5 in the 2.0 tracker pipeline. Closes the constants-
14
+ # lookup blind spot in 1.x's coverage-diff approach.
15
+ #
16
+ # The bug: when file A defines constants at load time and example E2
17
+ # references them without triggering a re-require, E2's coverage
18
+ # diff is empty for A. If A changes, the filter incorrectly skips
19
+ # E2 on the next run.
20
+ #
21
+ # The fix: track every project file Ruby has ever loaded during the
22
+ # process, and attribute them as transitive dependencies of every
23
+ # example that runs afterward.
24
+ #
25
+ # - `@boot_set`: frozen Set<String> captured at Tracker.setup.
26
+ # Files loaded before any example runs (spec_helper requires,
27
+ # gem boot code, constant autoloads). Changes to these are
28
+ # whole-suite invalidators - any modification re-runs every
29
+ # example.
30
+ # - `@loaded_set`: append-only Set<String>. Grows as examples run
31
+ # and Coverage observes new files. Before each example, the
32
+ # filter treats the entire @loaded_set as input to that example;
33
+ # after each example, any newly-loaded path is attributed
34
+ # specifically to the just-completed example *and* added to
35
+ # @loaded_set for the benefit of subsequent examples.
36
+ #
37
+ # Rationale - why not smarter?
38
+ # ----------------------------
39
+ # The cheaper alternatives all fail correctness or cost:
40
+ # - TracePoint(:class, :c_return) fires on every C method call;
41
+ # orders-of-magnitude overhead.
42
+ # - Ruby-AST scans for constant references are unreliable under
43
+ # metaprogramming (const_get, send, autoload blocks).
44
+ # - Constant-table introspection doesn't tell us which *example*
45
+ # used which constant.
46
+ # - Stack-trace sampling is probabilistic - inappropriate for
47
+ # cache correctness.
48
+ # The "loaded set" approach is the cheapest correct solution. Cost:
49
+ # the test cache is slightly less selective (a lib/constants.rb
50
+ # change re-runs every example that ran after it loaded rather than
51
+ # just some subset). Correctness is the win.
52
+ #
53
+ # Input kind reuse
54
+ # ----------------
55
+ # Emits Input values with `kind: :ruby` - every file in the
56
+ # loaded-set is a Ruby source file (`::Coverage` tracks Ruby only),
57
+ # and the dependency graph keys on path (ignoring kind) so a
58
+ # separate `:transitive_load` kind would buy nothing observable.
59
+ # Overlap with CoverageAdapter's `:ruby` emissions dedupes naturally
60
+ # at graph registration.
61
+ #
62
+ # Digest cache
63
+ # ------------
64
+ # Each path is digested at most once per run (first time it appears
65
+ # in either the boot set or a stop_example diff). The cache backs
66
+ # both `loaded_set_inputs` and `boot_set_digest_snapshot`, so
67
+ # boot-set invalidation comparison is free after the initial capture.
68
+ #
69
+ # Enablement flag
70
+ # ---------------
71
+ # `enabled:` (default true) threads through from
72
+ # Configuration#transitive_load_tracking. When false, every method
73
+ # degrades to a no-op that returns empty collections. This gives
74
+ # teams an opt-out for pathological suites where the transitive
75
+ # over-approximation is too aggressive.
76
+ class LoadedFilesTracker
77
+ # Internal constant.
78
+ # @api private
79
+ DEFAULT_PEEK = -> { ::Coverage.peek_result.keys }
80
+
81
+ # boot_set is exposed as an attr_reader rather than a hand-
82
+ # rolled method so RuboCop's Style/TrivialAccessors stays quiet;
83
+ # nil is a valid "not yet captured" state callers rely on.
84
+ attr_reader :root, :boot_set
85
+
86
+ # Internal method on the tracer pipeline.
87
+ # @api private
88
+ def initialize(root:, peek: DEFAULT_PEEK, enabled: true)
89
+ @root = File.expand_path(root)
90
+ @root_prefix = "#{@root}/"
91
+ @peek = peek
92
+ @enabled = enabled
93
+ @boot_set = nil
94
+ @loaded_set = Set.new
95
+ @input_cache = {}
96
+ # Steady-state fast-path cache for stop_example: ::Coverage's
97
+ # tracked-file set grows monotonically; if peek_result.length
98
+ # is unchanged since the last stop_example call, no new project
99
+ # files can have appeared. Skip the per-path filter loop
100
+ # entirely. Initialized to nil so the first call always falls
101
+ # through to full work + populates the cache.
102
+ @last_peek_length = nil
103
+ end
104
+
105
+ # Internal method on the tracer pipeline.
106
+ # @api private
107
+ def enabled?
108
+ @enabled
109
+ end
110
+
111
+ # Capture the boot set once. Idempotent: subsequent calls return
112
+ # the frozen Set captured on the first call. When disabled,
113
+ # returns an empty frozen Set without touching ::Coverage.
114
+ #
115
+ # Paths whose digest fails (unreadable files) are dropped on the
116
+ # floor - they stay absent from @boot_set, @loaded_set, and
117
+ # @input_cache, preserving the "every tracked path has an Input"
118
+ # invariant. Downstream filtering accepts the slight under-count
119
+ # (a truly-unreadable boot file was never going to be a useful
120
+ # invalidation signal anyway).
121
+ def capture_boot_set!
122
+ return @boot_set unless @boot_set.nil?
123
+
124
+ if @enabled
125
+ successful_paths = build_inputs(filtered_peek_paths).each_with_object(Set.new) do |input, acc|
126
+ acc << input.path
127
+ end
128
+ # Construct @loaded_set and @boot_set from distinct Set
129
+ # instances so freezing one can't poison the other -
130
+ # stop_example must be able to mutate @loaded_set forever
131
+ # while @boot_set stays frozen for invalidator comparison.
132
+ @loaded_set = successful_paths
133
+ @boot_set = Set.new(successful_paths).freeze
134
+ else
135
+ @loaded_set = Set.new
136
+ @boot_set = Set.new.freeze
137
+ end
138
+ @boot_set
139
+ end
140
+
141
+ # Hash[relative_path => sha256_hex] for every file in the boot
142
+ # set. The engine compares this against the previous run's
143
+ # stored `Snapshot.boot_set` - any inequality is a whole-suite
144
+ # invalidator.
145
+ #
146
+ # Invariant (enforced by capture_boot_set!): every path in
147
+ # `@boot_set` has a matching `@input_cache` entry, so the fetch
148
+ # never raises. Disabled trackers produce an empty `@boot_set`,
149
+ # so the enumeration naturally returns {} without a guard.
150
+ def boot_set_digest_snapshot
151
+ return {} if @boot_set.nil?
152
+
153
+ @boot_set.to_h { |path| [relative_path(path), @input_cache.fetch(path).digest] }
154
+ end
155
+
156
+ # Compare the current boot set's digest snapshot against a
157
+ # previously-stored one. `nil` previous_snapshot (first run, no
158
+ # cache) is treated as "not invalidated by this signal" - first
159
+ # run is already a cold run for unrelated reasons.
160
+ #
161
+ # Disabled tracker never invalidates - the engine ORs this with
162
+ # WholeSuiteInvalidators.invalidated?, so returning false keeps
163
+ # the tracker silent when the feature is off.
164
+ def boot_set_invalidated?(previous_snapshot)
165
+ return false unless @enabled
166
+ return false if previous_snapshot.nil?
167
+
168
+ boot_set_digest_snapshot != previous_snapshot
169
+ end
170
+
171
+ # Set<Input> covering the full @loaded_set. Callers merge this
172
+ # into an example's Input bucket at start_example time - every
173
+ # file loaded up to this point is a transitive dependency.
174
+ # Returns a fresh Set per call so mutation stays local.
175
+ #
176
+ # Disabled trackers never populate @input_cache (capture_boot_set!
177
+ # skips the build_inputs pass), so no explicit enabled guard is
178
+ # needed - the enumeration naturally yields Set.new.
179
+ def loaded_set_inputs
180
+ @input_cache.values.to_set
181
+ end
182
+
183
+ # Diff-and-grow. Called after an example finishes with the paths
184
+ # of the same ::Coverage snapshot the engine took at that
185
+ # boundary, so the tracker never requests a snapshot of its own
186
+ # per example. Finds paths the tracker hadn't seen yet, digests
187
+ # them, adds them to @loaded_set + @input_cache, and returns the
188
+ # new-paths-only Input set so the caller can attribute them to
189
+ # the just-completed example.
190
+ #
191
+ # Paths whose digest fails are dropped from both @loaded_set and
192
+ # the returned set - the next stop_example will retry them
193
+ # (useful if the failure was transient) and keeps the
194
+ # "@loaded_set => @input_cache has an entry" invariant.
195
+ #
196
+ # Steady state (no new files loaded this example) is one length
197
+ # comparison with no digest work.
198
+ def stop_example(_example_id, coverage_paths)
199
+ return Set.new unless @enabled
200
+
201
+ new_paths = new_filtered_paths(coverage_paths)
202
+ return Set.new if new_paths.empty?
203
+
204
+ new_inputs = build_inputs(new_paths)
205
+ @loaded_set.merge(new_inputs.map(&:path))
206
+ new_inputs
207
+ end
208
+
209
+ # Defensive copy for external callers / property specs. Callers
210
+ # that want read-only size should use `loaded_set_size` instead -
211
+ # avoids the dup allocation.
212
+ def loaded_set
213
+ @loaded_set.dup
214
+ end
215
+
216
+ # Internal method on the tracer pipeline.
217
+ # @api private
218
+ def loaded_set_size
219
+ @loaded_set.size
220
+ end
221
+
222
+ private
223
+
224
+ # Full filtered peek - returns a Set of every under-root String
225
+ # path in the peek result. Used by capture_boot_set! (no prior
226
+ # `@loaded_set` to diff against).
227
+ def filtered_peek_paths
228
+ @peek.call.each_with_object(Set.new) do |path, acc|
229
+ acc << path if path.is_a?(String) && path.start_with?(@root_prefix)
230
+ end
231
+ rescue StandardError
232
+ Set.new
233
+ end
234
+
235
+ # Diff-filtered paths - returns only paths not yet in @loaded_set.
236
+ # One hash lookup per path (vs. the two a Set.new + Set - Set
237
+ # pipeline would pay).
238
+ #
239
+ # Steady-state fast-path: ::Coverage's tracked-file set grows
240
+ # monotonically across the run, so when the snapshot's path count
241
+ # matches the count from the previous call no new project paths
242
+ # can have appeared. Returns [] without iterating.
243
+ def new_filtered_paths(paths)
244
+ return [] if @last_peek_length == paths.length
245
+
246
+ @last_peek_length = paths.length
247
+ paths.each_with_object([]) do |path, acc|
248
+ next unless path.is_a?(String)
249
+ next unless path.start_with?(@root_prefix)
250
+ next if @loaded_set.include?(path)
251
+
252
+ acc << path
253
+ end
254
+ end
255
+
256
+ # Builds Input objects for paths not yet cached; returns the Set
257
+ # of Inputs produced for `paths` (may exclude entries whose
258
+ # digest failed). Side effect: populates @input_cache.
259
+ #
260
+ # Callers are responsible for passing only paths absent from
261
+ # `@input_cache` (capture_boot_set! on empty cache, stop_example
262
+ # on `filtered_peek_keys - @loaded_set` where `@loaded_set`
263
+ # mirrors `@input_cache`'s keys modulo digest failures).
264
+ def build_inputs(paths)
265
+ paths.each_with_object(Set.new) do |path, acc|
266
+ digest = file_digest(path)
267
+ next if digest.nil?
268
+
269
+ input = Input.for_file(path: path, kind: :ruby, digest: digest, root: @root)
270
+ @input_cache[path] = input
271
+ acc << input
272
+ end
273
+ end
274
+
275
+ # Internal method on the tracer pipeline.
276
+ # @api private
277
+ def file_digest(path)
278
+ FileDigest.compute(path)
279
+ end
280
+
281
+ # Internal method on the tracer pipeline.
282
+ # @api private
283
+ def relative_path(abs_path)
284
+ return abs_path unless abs_path.start_with?(@root_prefix)
285
+
286
+ abs_path[@root_prefix.length..]
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative 'declared_globs'
6
+
7
+ module TestSift
8
+ # Internal Tracker — see {TestSift} for the user-facing surface.
9
+ # @api private
10
+ module Tracker
11
+ # Observer #5 (composition of DeclaredGlobs + cache diff). Fixes
12
+ # a 1.x gap: fetch_changed_files only iterated the previous run's
13
+ # cache, so newly-added source files were never discovered and
14
+ # never triggered re-runs.
15
+ #
16
+ # The fix: at boot, walk the union of user-declared globs and a
17
+ # pure-Ruby default set (lib/**/*.rb). Every match on disk that is
18
+ # not in the loaded cache's known_paths emits an Input, which the
19
+ # filter engine treats as "added" and re-runs any example whose
20
+ # dependency graph could plausibly include it.
21
+ #
22
+ # Kind is :declared for every emission. The pure-Ruby default is
23
+ # logically a pre-declared glob on the user's behalf - the
24
+ # attribution semantics are identical to an explicit
25
+ # `track_files 'lib/**/*.rb'`.
26
+ #
27
+ # The Rails preset (app/**/*.rb) flows through
28
+ # Configuration#track_rails_defaults; the default list here stays
29
+ # framework-agnostic.
30
+ class NewFileDetector
31
+ # Internal constant.
32
+ # @api private
33
+ DEFAULT_GLOBS = %w[lib/**/*.rb].freeze
34
+
35
+ # Internal attribute.
36
+ # @api private
37
+ attr_reader :root
38
+
39
+ # Input contract: declared_globs and default_globs are Arrays of
40
+ # String glob patterns. Configuration#declared_globs already
41
+ # normalizes user input (flatten / compact / to_s / uniq / freeze),
42
+ # and DeclaredGlobs#initialize re-applies the same coercion
43
+ # downstream (`Array(globs).flatten.compact.map(&:to_s).uniq.freeze`),
44
+ # so this constructor stays narrow on purpose - any defensive
45
+ # coercion here would be triple-applied dead code.
46
+ def initialize(root:, declared_globs: [], default_globs: DEFAULT_GLOBS)
47
+ @root = File.expand_path(root)
48
+ @walker = DeclaredGlobs.new(root: @root, globs: declared_globs + default_globs)
49
+ end
50
+
51
+ # Set<Input> for every on-disk match not present in the supplied
52
+ # known_paths Set. Called once per suite boot; the walker's
53
+ # underlying digest work is memoized on the DeclaredGlobs
54
+ # instance so repeated calls within a single suite don't re-hash.
55
+ # Engine#compute_change_set passes a Set directly; Set#include?
56
+ # is O(1) so no upstream `to_set` is needed.
57
+ def new_files(known_paths:)
58
+ @walker.walk.reject { |input| known_paths.include?(input.path) }.to_set
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ require_relative 'file_digest'
6
+ require_relative '../version'
7
+
8
+ module TestSift
9
+ # Internal Tracker — see {TestSift} for the user-facing surface.
10
+ # @api private
11
+ module Tracker
12
+ # Observer #4 in the 2.0 tracker pipeline. Emits the binary
13
+ # "blow it all up" signal that runs before any per-example
14
+ # filtering - when any watched file changes, the filter engine
15
+ # treats every example as affected.
16
+ #
17
+ # Watch list is deliberately hard-coded (not config-overridable):
18
+ # these are the files whose semantics are universal across any
19
+ # testsift user.
20
+ #
21
+ # - Gemfile.lock : dependency changes ripple through every spec
22
+ # - .ruby-version : Ruby version changes can shift any behavior
23
+ # - .testsift : tracer config changes (filters, declared
24
+ # globs) affect what the cache considers fresh
25
+ #
26
+ # Plus a synthetic entry for the testsift gem identity itself -
27
+ # a gem upgrade that changes invalidation semantics has to
28
+ # invalidate the cache, which lockfile tracking alone doesn't catch
29
+ # (the gem path is version-stamped but Gemfile.lock only sees the
30
+ # constraint, not the resolved install).
31
+ #
32
+ # Graceful degradation (CLAUDE.md): absent watch files are skipped
33
+ # silently. Key-presence asymmetry is the invalidation signal
34
+ # (snapshot A has key, snapshot B does not => invalidated).
35
+ class WholeSuiteInvalidators
36
+ # Internal constant.
37
+ # @api private
38
+ WATCH_FILES = %w[Gemfile.lock .ruby-version .testsift].freeze
39
+ # Internal constant.
40
+ # @api private
41
+ GEM_IDENTITY_KEY = 'testsift-gem'
42
+
43
+ # Internal attribute.
44
+ # @api private
45
+ attr_reader :root, :gem_version
46
+
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def initialize(root:, gem_version: TestSift::VERSION)
50
+ @root = File.expand_path(root)
51
+ @gem_version = gem_version
52
+ end
53
+
54
+ # Fresh snapshot on every call - callers typically take one at
55
+ # boot (stored as the "current" snapshot) and compare against a
56
+ # previously-loaded snapshot (returned by the storage backend).
57
+ # Unlike DeclaredGlobs.walk, this is not memoized: the caller
58
+ # chooses when to sample.
59
+ def digest_snapshot
60
+ snapshot = {}
61
+ WATCH_FILES.each do |rel|
62
+ digest = file_digest(File.join(@root, rel))
63
+ snapshot[rel] = digest unless digest.nil?
64
+ end
65
+ snapshot[GEM_IDENTITY_KEY] = gem_identity_digest
66
+ snapshot
67
+ end
68
+
69
+ # nil previous_snapshot = first-run case (no cache); treat as
70
+ # invalidated so the filter engine runs every example. Any
71
+ # subsequent run with a stored snapshot compares value-equality
72
+ # across the whole Hash, which captures added/removed/changed
73
+ # watch files in one check.
74
+ def invalidated?(previous_snapshot)
75
+ return true if previous_snapshot.nil?
76
+
77
+ digest_snapshot != previous_snapshot
78
+ end
79
+
80
+ private
81
+
82
+ # SystemCallError on missing / non-file paths is treated as
83
+ # "not present" via the central FileDigest cache, which returns
84
+ # nil. Mirrors every other tracker's digest path.
85
+ def file_digest(path)
86
+ FileDigest.compute(path)
87
+ end
88
+
89
+ # Internal method on the tracer pipeline.
90
+ # @api private
91
+ def gem_identity_digest
92
+ Digest::SHA256.hexdigest("testsift-#{@gem_version}")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # The gem version. release-please bumps it in the release pull request;
5
+ # the release workflow refuses to publish a tag that does not match it.
6
+ VERSION = '0.0.1'
7
+ end