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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal TimeFormatter — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ #
7
+ # Internal helper for human-friendly elapsed-time formatting in
8
+ # tracer log lines and the terminal reporter (e.g. "1 minute 23 seconds").
9
+ module TimeFormatter
10
+ # Internal constant.
11
+ # @api private
12
+ DEFAULT_PRECISION = 2
13
+ # Internal constant.
14
+ # @api private
15
+ SECONDS_PRECISION = 5
16
+
17
+ # Internal constant.
18
+ # @api private
19
+ UNITS = {
20
+ second: 60,
21
+ minute: 60,
22
+ hour: 24,
23
+ day: Float::INFINITY
24
+ }.freeze
25
+
26
+ # Internal helper for the tracer pipeline.
27
+ # @api private
28
+ def self.format_time(seconds)
29
+ return pluralize(format_duration(seconds), 'second') if seconds < 60
30
+
31
+ formatted_duration = UNITS.each_pair.with_object([]) do |(unit, count), duration|
32
+ next unless seconds.positive?
33
+
34
+ seconds, remainder = seconds.divmod(count)
35
+
36
+ next if remainder.zero?
37
+
38
+ duration << pluralize(format_duration(remainder), unit)
39
+ end
40
+
41
+ formatted_duration.reverse.join(' ')
42
+ end
43
+
44
+ class << self
45
+ private
46
+
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def format_duration(duration)
50
+ return 0 if duration.negative?
51
+
52
+ precision = duration < 1 ? SECONDS_PRECISION : DEFAULT_PRECISION
53
+
54
+ strip_trailing_zeroes(format("%<duration>0.#{precision}f", duration: duration))
55
+ end
56
+
57
+ # Internal method on the tracer pipeline.
58
+ # @api private
59
+ def strip_trailing_zeroes(formatted_duration)
60
+ formatted_duration.sub(/(?:(\..*[^0])0+|\.0+)$/, '\1')
61
+ end
62
+
63
+ # Internal method on the tracer pipeline.
64
+ # @api private
65
+ def pluralize(duration, unit)
66
+ if (duration.to_f - 1).abs < Float::EPSILON
67
+ "#{duration} #{unit}"
68
+ else
69
+ "#{duration} #{unit}s"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,36 @@
1
+ # Tracker
2
+
3
+ Core engine for input identification. A test is a pure function of its
4
+ inputs; this layer identifies every input and hashes it.
5
+
6
+ ## Responsibilities
7
+
8
+ - Observe Ruby-executed source via `Coverage`.
9
+ - Intercept file I/O (`File`, `IO`, `YAML`, `JSON`, `Kernel`).
10
+ - Subscribe to framework notifications (`ActiveSupport::Notifications`).
11
+ - Evaluate user-declared globs.
12
+ - Track whole-suite invalidators (`Gemfile.lock`, `.ruby-version`, config,
13
+ gem version).
14
+ - Build the dependency graph and expose `affected_examples` to the
15
+ filter.
16
+
17
+ ## Public protocol
18
+
19
+ ```ruby
20
+ module TestSift::Tracker
21
+ def setup(configuration:); end
22
+ def start_example(example_id); end
23
+ def stop_example(example_id); end
24
+ def affected_examples(all_example_ids); end
25
+ def finalize; end
26
+ end
27
+ ```
28
+
29
+ Each input type has exactly one observation mechanism. Declared globs
30
+ take precedence over auto-interception for overlapping files.
31
+
32
+ ## Status
33
+
34
+ Placeholder for 2.0. Legacy 1.x tracking logic lives in
35
+ `lib/testsift/` top-level files (`runner.rb`, `rspec_reporter.rb`,
36
+ `ruby_coverage.rb`, `cache.rb`) and migrates here during Phase 3.
@@ -0,0 +1,180 @@
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
+ # Wraps Ruby's built-in ::Coverage module. The first observer in
14
+ # the 2.0 tracker pipeline -- ingests the per-file line-coverage
15
+ # bitmap that MRI/JRuby maintain natively, normalizes the two
16
+ # possible shapes (array vs SimpleCov-style hash) and emits
17
+ # Tracker::Input values for the files touched between two peeks.
18
+ #
19
+ # All state lives on the instance; no reads from TestSift.*.
20
+ # Pass root, filters, and mode at construction time.
21
+ #
22
+ # Content digest is SHA256 hex (see Input's file-level comment).
23
+ # Changing the algorithm is a storage schema_version bump.
24
+ class CoverageAdapter
25
+ # ::Coverage.peek_result returns one of two shapes:
26
+ # :array -- { path => [hit_counts | nil, ...] } (default)
27
+ # :hash -- { path => { lines: [...], branches: {...} } } (SimpleCov
28
+ # with branch
29
+ # coverage)
30
+ # :auto detects on the first peek by sniffing a value's type.
31
+ MODES = %i[auto array hash].freeze
32
+
33
+ # Internal attribute.
34
+ # @api private
35
+ attr_reader :root, :filters, :mode
36
+
37
+ # Internal method on the tracer pipeline.
38
+ # @api private
39
+ def initialize(root:, filters: [], mode: :auto)
40
+ raise ArgumentError, "invalid mode: #{mode.inspect}, allowed: #{MODES}" \
41
+ unless MODES.include?(mode)
42
+
43
+ @root = File.expand_path(root)
44
+ @root_prefix = "#{@root}/"
45
+ @filters = filters
46
+ @filter_decisions = {}
47
+ @mode = mode
48
+ end
49
+
50
+ # Snapshot of the current coverage state: { absolute_path =>
51
+ # Array<Integer|nil> } for files under project root that survive
52
+ # the user filter. Hash-mode input is reduced to its :lines
53
+ # component -- 2.0 ignores branch coverage (same as 1.x; noted in
54
+ # the upgrade docs).
55
+ def peek
56
+ normalize(coverage_result) { |path| filtered?(path) }
57
+ end
58
+
59
+ # One ::Coverage snapshot read two ways: `[#peek's filtered line
60
+ # map, every path Coverage tracks]`. The example-finish boundary
61
+ # hands the paths to LoadedFilesTracker so loaded-file tracking
62
+ # consumes this snapshot instead of producing another one.
63
+ def peek_with_paths
64
+ raw = coverage_result
65
+ [normalize(raw) { |path| filtered?(path) }, raw.keys]
66
+ end
67
+
68
+ # Same shape as #peek but only filters by `@root_prefix` -- skips
69
+ # the user `filters` filter. The coverage.json emitter
70
+ # (`Reporters::CoverageJsonReporter`) calls this at finalize to
71
+ # capture cumulative coverage matching legacy semantics: 1.x's
72
+ # CoverageReporter#peek_coverage applied no `filters` filter at
73
+ # peek time and let `coverage_filters` do the final exclusion.
74
+ # Routing the emitter's finalize peek through this method keeps
75
+ # the lib/-wide `::Coverage.peek_result` call-site count at 3
76
+ # (one per file: this adapter, rspec/installation, and
77
+ # tracker/loaded_files_tracker) instead of adding a fourth.
78
+ def peek_unfiltered
79
+ normalize(coverage_result) { false }
80
+ end
81
+
82
+ # Same root-prefix scoping as #peek_unfiltered, but preserves
83
+ # Coverage's full per-file shape (Hash `{ lines: [...], branches:
84
+ # {...} }` in hash mode; Array<Integer|nil> in array mode) instead
85
+ # of reducing hash entries to their `:lines` component.
86
+ #
87
+ # Lone caller is the SimpleCov interop shim
88
+ # (`Reporters::CoverageJsonReporter::SimpleCovInterop`): when
89
+ # SimpleCov has `enable_coverage :branch`, Coverage runs in hash
90
+ # mode and the prepended `Coverage.result` MUST hand back the
91
+ # branches sub-hash so SimpleCov's branch-coverage report path
92
+ # has data to render. The legacy `peek_unfiltered` strips
93
+ # branches because the user-facing `coverage.json` shape is
94
+ # documented as `Array<Integer|nil>` per file (1.x compatibility);
95
+ # do not change `peek_unfiltered` to do otherwise without bumping
96
+ # the storage schema.
97
+ def peek_unfiltered_full
98
+ coverage_result.each_with_object({}) do |(path, stats), acc|
99
+ next unless path.start_with?(@root_prefix)
100
+
101
+ acc[path] = stats
102
+ end
103
+ end
104
+
105
+ # Pure function: returns Set<Input> for files whose line arrays
106
+ # changed between `before` and `after`. Handles nil line entries
107
+ # (unexecutable lines) correctly -- nil<->nil is not a delta, any
108
+ # other transition is.
109
+ def compute_diff(before, after)
110
+ changed = Set.new
111
+ (before.keys | after.keys).each do |path|
112
+ next unless delta?(before[path], after[path])
113
+
114
+ changed << Input.for_file(
115
+ path: path,
116
+ kind: :ruby,
117
+ digest: file_digest(path),
118
+ root: @root
119
+ )
120
+ end
121
+ changed
122
+ end
123
+
124
+ private
125
+
126
+ # Single owner of this adapter's `::Coverage.peek_result` call
127
+ # site. Every public peek produces exactly one snapshot here.
128
+ def coverage_result
129
+ raw = ::Coverage.peek_result
130
+ @mode = detect_mode(raw) if @mode == :auto
131
+ raw
132
+ end
133
+
134
+ # Skip block decides exclusion; everything that survives gets
135
+ # normalized through the array/hash mode handling.
136
+ def normalize(raw)
137
+ raw.each_with_object({}) do |(path, stats), acc|
138
+ next unless path.start_with?(@root_prefix)
139
+ next if yield(path)
140
+
141
+ acc[path] = @mode == :hash ? stats[:lines] : stats
142
+ end
143
+ end
144
+
145
+ # Internal method on the tracer pipeline.
146
+ # @api private
147
+ def detect_mode(raw)
148
+ return :array if raw.empty?
149
+
150
+ raw.each_value.first.is_a?(Hash) ? :hash : :array
151
+ end
152
+
153
+ # Internal method on the tracer pipeline.
154
+ # @api private
155
+ def filtered?(path)
156
+ return false if @filters.empty?
157
+
158
+ @filter_decisions.fetch(path) do
159
+ # Legacy filter convention: file_name is root-stripped with
160
+ # leading slash. Keep it so existing .testsift filter
161
+ # strings/regexes work under the new adapter unchanged.
162
+ file_name = path.sub(@root, '')
163
+ @filter_decisions[path] = @filters.any? { |f| f.match?(file_name: file_name) }
164
+ end
165
+ end
166
+
167
+ # Internal method on the tracer pipeline.
168
+ # @api private
169
+ def delta?(before, after)
170
+ before.nil? || after.nil? || before != after
171
+ end
172
+
173
+ # Internal method on the tracer pipeline.
174
+ # @api private
175
+ def file_digest(path)
176
+ FileDigest.compute(path)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative 'file_digest'
6
+ require_relative 'input'
7
+
8
+ module TestSift
9
+ # Internal Tracker — see {TestSift} for the user-facing surface.
10
+ # @api private
11
+ module Tracker
12
+ # Observer #3 in the 2.0 tracker pipeline (CoverageAdapter = #1,
13
+ # IOHooks = #2). Walks user-declared glob patterns at boot, digests
14
+ # each matching file, and emits :declared Inputs. Declared globs
15
+ # cover inputs that cannot be auto-observed - files no app code
16
+ # reads (Gemfile.lock, .testsift), or files that might not be
17
+ # rendered during a given run (a newly-added template).
18
+ #
19
+ # Exposes #covers? so IOHooks can skip paths the declared-globs
20
+ # walk already observed. ARCHITECTURE rule (Input taxonomy): "if
21
+ # the user declares a glob that covers the same files we auto-
22
+ # intercept, the declared glob takes precedence."
23
+ #
24
+ # Graceful degradation (CLAUDE.md): an unreadable declared file is
25
+ # skipped silently - the tracer never propagates failure into the
26
+ # user's test suite. Identity-based Set dedup collapses overlap
27
+ # when two globs match the same file.
28
+ class DeclaredGlobs
29
+ # FNM_PATHNAME keeps `*` from eating `/` so globs walk directory
30
+ # boundaries predictably. FNM_EXTGLOB enables `{a,b}` alternation
31
+ # so `coverage_track_files '{app,lib}/**/*.rb'` (a real pattern
32
+ # in the sample projects) matches the same files `Dir.glob` would.
33
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
34
+
35
+ # Internal attribute.
36
+ # @api private
37
+ attr_reader :root, :globs
38
+
39
+ # Internal method on the tracer pipeline.
40
+ # @api private
41
+ def initialize(root:, globs: [])
42
+ @root = File.expand_path(root)
43
+ @root_prefix = "#{@root}/"
44
+ @globs = Array(globs).flatten.compact.map(&:to_s).uniq.freeze
45
+ end
46
+
47
+ # Memoized across calls - the architecture constraint is "glob
48
+ # walk at boot is a single pass; result cached for the life of
49
+ # the tracker instance."
50
+ def walk
51
+ @walk ||= compute_walk
52
+ end
53
+
54
+ # O(N_globs) per call; N is typically 1-10. Absolute paths only -
55
+ # the callers (IOHooks precedence, NewFileDetector diff) always
56
+ # hold absolute paths. Paths outside root never match.
57
+ def covers?(path)
58
+ return false if @globs.empty?
59
+ return false unless path.start_with?(@root_prefix)
60
+
61
+ rel = path[@root_prefix.length..]
62
+ @globs.any? { |glob| File.fnmatch?(glob, rel, FNMATCH_FLAGS) }
63
+ end
64
+
65
+ # Per-example attribution. Declared inputs attach to every
66
+ # example in the suite (per-example narrowing is available via
67
+ # the per-example `tracks:` DSL). Each example gets its own Set
68
+ # copy so downstream mutation of one example's input set does
69
+ # not leak into siblings.
70
+ def attribute_to(example_ids)
71
+ inputs = walk
72
+ example_ids.to_h { |id| [id, Set.new(inputs)] }
73
+ end
74
+
75
+ private
76
+
77
+ # Internal method on the tracer pipeline.
78
+ # @api private
79
+ def compute_walk
80
+ @globs.each_with_object(Set.new) do |glob, acc|
81
+ Dir.glob(glob, base: @root).each do |rel|
82
+ abs = File.expand_path(rel, @root)
83
+ next unless abs.start_with?(@root_prefix) && File.file?(abs)
84
+
85
+ digest = file_digest(abs)
86
+ next if digest.nil?
87
+
88
+ acc << Input.for_file(path: abs, kind: :declared, digest: digest, root: @root)
89
+ end
90
+ end
91
+ end
92
+
93
+ # Internal method on the tracer pipeline.
94
+ # @api private
95
+ def file_digest(path)
96
+ FileDigest.compute(path)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module TestSift
6
+ # Internal Tracker — see {TestSift} for the user-facing surface.
7
+ # @api private
8
+ module Tracker
9
+ # Directed bipartite graph over (example_id, file path). The
10
+ # forward map answers "which files does this example depend on?";
11
+ # the memoized inverse answers "which examples depend on this
12
+ # file?" in O(1) per lookup.
13
+ #
14
+ # The graph is append-only during a run. `register_example` is
15
+ # called once per example at example-finished time with the Input
16
+ # set that CoverageAdapter + IOHooks + DeclaredGlobs collected.
17
+ # The inverse is invalidated on every register_example and lazily
18
+ # rebuilt on the next `examples_depending_on` call.
19
+ #
20
+ # Keyed by file path, not Input identity
21
+ # -------------------------------------
22
+ # An earlier design framed the inverse as "input_identity -> Set<example_id>",
23
+ # which embeds the Input#kind (e.g. "ruby:lib/foo.rb" vs
24
+ # "declared:lib/foo.rb"). In practice the change-set that drives
25
+ # filtering comes from diffing Snapshot.all_files digests, which
26
+ # is keyed by path only - the kind that observed the file is not
27
+ # recoverable from a digest mismatch. Keying the graph by path
28
+ # eliminates that recovery burden and matches 1.x's
29
+ # dependency.json shape exactly (Hash[example_id => Set<path>]),
30
+ # keeping the Snapshot byte-compatible. Kind-aware filtering, if
31
+ # ever needed, can layer on top without changing the graph shape.
32
+ #
33
+ # API accepts Set<Input>, Set<String>, or any mix - anything that
34
+ # responds to :path is treated as an Input; otherwise `.to_s`.
35
+ # This keeps observer call sites terse (pass the Input set
36
+ # directly) without forcing Snapshot-load sites to reconstruct
37
+ # Input objects from paths.
38
+ class DependencyGraph
39
+ # Internal method on the tracer pipeline.
40
+ # @api private
41
+ def initialize
42
+ @forward = {}
43
+ @inverse_index = nil
44
+ end
45
+
46
+ # Internal method on the tracer pipeline.
47
+ # @api private
48
+ def register_example(example_id, inputs)
49
+ @forward[example_id] = coerce_paths(inputs)
50
+ @inverse_index = nil
51
+ self
52
+ end
53
+
54
+ # Internal method on the tracer pipeline.
55
+ # @api private
56
+ def paths_for(example_id)
57
+ @forward[example_id] || Set.new
58
+ end
59
+
60
+ # Internal method on the tracer pipeline.
61
+ # @api private
62
+ def example_ids
63
+ @forward.keys
64
+ end
65
+
66
+ # Internal method on the tracer pipeline.
67
+ # @api private
68
+ def empty?
69
+ @forward.empty?
70
+ end
71
+
72
+ # O(|change_set| x avg-examples-per-file). `change_set` can be
73
+ # a Set<Input>, Set<String>, or any mix - coerced the same way
74
+ # `register_example` coerces its inputs arg.
75
+ def examples_depending_on(change_set)
76
+ return Set.new if @forward.empty?
77
+
78
+ paths = coerce_paths(change_set)
79
+ return Set.new if paths.empty?
80
+
81
+ affected = Set.new
82
+ paths.each do |path|
83
+ ids = inverse_index[path]
84
+ affected.merge(ids) if ids
85
+ end
86
+ affected
87
+ end
88
+
89
+ # Snapshot projection. `dependency_hash` feeds
90
+ # Snapshot.dependency; `reverse_dependency_hash` feeds
91
+ # Snapshot.reverse_dependency. Both return fresh Sets per value
92
+ # so downstream mutation can't leak into the graph's state.
93
+ def dependency_hash
94
+ @forward.transform_values(&:dup)
95
+ end
96
+
97
+ # Internal method on the tracer pipeline.
98
+ # @api private
99
+ def reverse_dependency_hash
100
+ inverse_index.transform_values(&:dup)
101
+ end
102
+
103
+ private
104
+
105
+ # Internal method on the tracer pipeline.
106
+ # @api private
107
+ def coerce_paths(collection)
108
+ return Set.new if collection.nil?
109
+
110
+ collection.each_with_object(Set.new) do |entry, acc|
111
+ acc << (entry.respond_to?(:path) ? entry.path : entry.to_s)
112
+ end
113
+ end
114
+
115
+ # Internal method on the tracer pipeline.
116
+ # @api private
117
+ def inverse_index
118
+ @inverse_index ||= build_inverse_index
119
+ end
120
+
121
+ # Internal method on the tracer pipeline.
122
+ # @api private
123
+ def build_inverse_index
124
+ map = {}
125
+ @forward.each do |example_id, paths|
126
+ paths.each do |path|
127
+ (map[path] ||= Set.new) << example_id
128
+ end
129
+ end
130
+ map
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal Tracker — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ module Tracker
7
+ # Wildcard env matching helper.
8
+ #
9
+ # Lives outside Configuration so configure's alias loop does not
10
+ # leak its private helpers as public _name DSL surface (the loop
11
+ # wraps every instance method, private ones included). Pure
12
+ # utility module; def self.x style keeps methods observable to
13
+ # mutant (module_function's anonymous singleton is not).
14
+ # ASCII-only source, since mutant's parser rejects non-US-ASCII.
15
+ #
16
+ # Patterns accepted:
17
+ # - Literal env name "AUTH_TOKEN"
18
+ # - Single trailing wildcard "RAILS_*"
19
+ # - Single leading wildcard "*_TOKEN"
20
+ # - Bare wildcard (matches all) "*"
21
+ #
22
+ # Patterns rejected (raise ArgumentError):
23
+ # - Multi-segment / embedded * "RAILS_*_ENV"
24
+ # - Multiple * "RAILS_*_*"
25
+ # - Character classes "RAILS_[A-Z]*"
26
+ # - Negation / glob escape "RAILS_!ENV", "RAILS_\X"
27
+ # - Question mark "RAILS_?ENV"
28
+ # - Empty / nil
29
+ module EnvMatcher
30
+ # Internal constant.
31
+ # @api private
32
+ WILDCARD = '*'
33
+ # Internal constant.
34
+ # @api private
35
+ DISALLOWED_CHARS = %w[? [ ] ! \\].freeze
36
+
37
+ # Boolean: does the pattern contain at least one wildcard?
38
+ def self.wildcard?(pattern)
39
+ pattern.to_s.include?(WILDCARD)
40
+ end
41
+
42
+ # Expand a list of patterns against env keys. Literals pass
43
+ # through; wildcards are anchored + grepped against env.keys.
44
+ # Returns a deduped Array<String> in input order.
45
+ #
46
+ # `env` is injectable for testability; defaults to ::ENV. Reads
47
+ # only env.keys, never values.
48
+ def self.expand(patterns, env: ::ENV)
49
+ result = []
50
+ patterns.each do |pattern|
51
+ str = pattern.to_s
52
+ validate!(str)
53
+ if wildcard?(str)
54
+ re = glob_to_regex(str)
55
+ env.each_key { |k| result << k if re.match?(k) }
56
+ else
57
+ result << str
58
+ end
59
+ end
60
+ result.uniq
61
+ end
62
+
63
+ # Boolean: does `name` match `pattern`? Single-pattern variant
64
+ # of expand for ad-hoc checks (specs, future call sites). Named
65
+ # `match_glob?` (predicate suffix) per Naming/PredicateMethod.
66
+ def self.match_glob?(pattern, name)
67
+ str = pattern.to_s
68
+ validate!(str)
69
+ return str == name.to_s unless wildcard?(str)
70
+
71
+ glob_to_regex(str).match?(name.to_s)
72
+ end
73
+
74
+ # Raise ArgumentError on unsupported pattern syntax. Called
75
+ # before any regex build so users see a clear message at
76
+ # config-load time (Engine#setup) or at RunnerHook Pass 1
77
+ # (per-example metadata) rather than a regex parse crash.
78
+ # rubocop:disable Metrics/PerceivedComplexity
79
+ def self.validate!(pattern)
80
+ if pattern.nil? || pattern.empty?
81
+ raise ArgumentError,
82
+ "track_env pattern must be a non-empty String (got #{pattern.inspect})"
83
+ end
84
+
85
+ DISALLOWED_CHARS.each do |c|
86
+ next unless pattern.include?(c)
87
+
88
+ raise ArgumentError,
89
+ "track_env pattern #{pattern.inspect} contains unsupported character " \
90
+ "#{c.inspect} (allowed: literals, single trailing/leading *)"
91
+ end
92
+
93
+ return if pattern == WILDCARD
94
+
95
+ stars = pattern.count(WILDCARD)
96
+ return if stars.zero?
97
+
98
+ if stars > 1
99
+ raise ArgumentError,
100
+ "track_env pattern #{pattern.inspect} contains multiple wildcards " \
101
+ '(only one * is allowed, at the start or end)'
102
+ end
103
+
104
+ return if pattern.start_with?(WILDCARD) || pattern.end_with?(WILDCARD)
105
+
106
+ raise ArgumentError,
107
+ "track_env pattern #{pattern.inspect} has an embedded wildcard " \
108
+ '(only single trailing/leading * is supported, e.g. PREFIX_* or *_SUFFIX)'
109
+ end
110
+ # rubocop:enable Metrics/PerceivedComplexity
111
+
112
+ # Build an anchored regex from a wildcard pattern. `*` becomes
113
+ # `[^=]*` (env names contain no `=` by Posix; defensive). Anchors
114
+ # are mandatory or "RAILS_*" would match "MY_RAILS_THING".
115
+ # Private singleton helper - exposed only via match_glob / expand.
116
+ class << self
117
+ private
118
+
119
+ # Internal method on the tracer pipeline.
120
+ # @api private
121
+ def glob_to_regex(pattern)
122
+ /\A#{Regexp.escape(pattern).gsub('\\*', '[^=]*')}\z/
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end