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,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal CLI — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ module CLI
7
+ # `testsift doctor` — diagnose config + environment.
8
+ # Reports Ruby + testsift versions, project root resolution,
9
+ # cache / coverage / report directory state, and SimpleCov / Rails
10
+ # presence. Exits 0 on healthy diagnosis, 1 if any check fails.
11
+ module Doctor
12
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
13
+ # @param stdout [IO]
14
+ # @param stderr [IO]
15
+ # @return [Integer] exit status (0 = healthy, 1 = any check
16
+ # FAILed; warnings keep status 0).
17
+ def self.run(args, stdout: $stdout, stderr: $stderr)
18
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
+
20
+ checks = [
21
+ ruby_version_check,
22
+ tracer_version_check,
23
+ project_root_check,
24
+ cache_path_check,
25
+ coverage_path_check,
26
+ report_path_check,
27
+ git_check,
28
+ simplecov_check,
29
+ rails_check,
30
+ cache_schema_version_check,
31
+ remote_cache_check,
32
+ ar_schema_narrow_attribution_check,
33
+ ci_environment_check
34
+ ]
35
+ checks.each { |line| stdout.puts line }
36
+ ok = checks.none? { |line| line.start_with?('FAIL') }
37
+ ok ? 0 : 1
38
+ rescue Errno::EPIPE
39
+ # Downstream pipe (`... | head`) closed early -- routine in
40
+ # shell pipelines, not a failure. Exit 0 silently.
41
+ 0
42
+ rescue StandardError => e
43
+ stderr.puts "doctor: #{e.class}: #{e.message}"
44
+ 1
45
+ end
46
+
47
+ # Internal helper for the tracer pipeline.
48
+ # @api private
49
+ def self.print_help(stdout)
50
+ stdout.puts <<~HELP
51
+ Usage: testsift doctor
52
+
53
+ Diagnose testsift config and environment. Prints a checklist
54
+ of versions, paths, and integrations; exits 0 if all checks pass.
55
+ HELP
56
+ 0
57
+ end
58
+
59
+ # Internal helper for the tracer pipeline.
60
+ # @api private
61
+ def self.ruby_version_check
62
+ "OK ruby: #{RUBY_DESCRIPTION}"
63
+ end
64
+
65
+ # Internal helper for the tracer pipeline.
66
+ # @api private
67
+ def self.tracer_version_check
68
+ "OK testsift: #{TestSift::VERSION}"
69
+ end
70
+
71
+ # Internal helper for the tracer pipeline.
72
+ # @api private
73
+ def self.project_root_check
74
+ "OK root: #{TestSift.root}"
75
+ end
76
+
77
+ # Internal helper for the tracer pipeline.
78
+ # @api private
79
+ def self.cache_path_check
80
+ path_check('cache_path:', TestSift.cache_path)
81
+ end
82
+
83
+ # Internal helper for the tracer pipeline.
84
+ # @api private
85
+ def self.coverage_path_check
86
+ path_check('coverage_path:', TestSift.coverage_path)
87
+ end
88
+
89
+ # Internal helper for the tracer pipeline.
90
+ # @api private
91
+ def self.report_path_check
92
+ path_check('report_path:', TestSift.report_path)
93
+ end
94
+
95
+ # Internal helper for the tracer pipeline.
96
+ # @api private
97
+ def self.path_check(label, path)
98
+ return "FAIL #{label} <missing>" if path.nil? || path.empty?
99
+ return "FAIL #{label} #{path} (does not exist)" unless File.directory?(path)
100
+ return "FAIL #{label} #{path} (not writable)" unless File.writable?(path)
101
+
102
+ "OK #{label} #{path}"
103
+ end
104
+
105
+ # Internal helper for the tracer pipeline.
106
+ # @api private
107
+ def self.git_check
108
+ if system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
109
+ 'OK git: HEAD reachable (remote_cache will work)'
110
+ else
111
+ 'WARN git: not in a git repo (remote_cache will degrade gracefully)'
112
+ end
113
+ end
114
+
115
+ # `bundle exec testsift doctor` runs in its own process via
116
+ # the gem's `bin/testsift` binstub, NOT inside the user's
117
+ # rspec boot — so app code never loads here and a bare
118
+ # `defined?(::SimpleCov)` check would falsely report "not
119
+ # loaded" on projects that DO have SimpleCov in their
120
+ # Gemfile. Probe `Gem.loaded_specs` first to surface the
121
+ # "installed but not loaded in doctor's process" case
122
+ # separately from "actually not installed."
123
+ def self.simplecov_check
124
+ return 'OK SimpleCov: loaded (interop active)' if defined?(::SimpleCov)
125
+
126
+ spec = Gem.loaded_specs['simplecov']
127
+ return "INFO SimpleCov: installed (v#{spec.version}; not loaded in doctor's process)" if spec
128
+
129
+ 'INFO SimpleCov: not installed (this is fine; SimpleCov is optional)'
130
+ end
131
+
132
+ # See {.simplecov_check} for the doctor-runs-in-its-own-
133
+ # process rationale. Same three-state probe shape: loaded in
134
+ # this process / installed but not loaded / not installed.
135
+ def self.rails_check
136
+ return "OK Rails: #{::Rails::VERSION::STRING}" if defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
137
+
138
+ spec = Gem.loaded_specs['rails']
139
+ return "INFO Rails: installed (v#{spec.version}; not loaded in doctor's process)" if spec
140
+
141
+ 'INFO Rails: not installed (this is fine for non-Rails projects)'
142
+ end
143
+
144
+ # Surface a 1.x->2.0 cache mismatch BEFORE the user runs
145
+ # rspec and watches everything re-run mysteriously. Reads the
146
+ # cached `last_run.json` (if any) and compares its
147
+ # `schema_version` against the gem's `Schema::CURRENT`. Three
148
+ # outcomes: no cache yet (INFO), match (OK), or mismatch (WARN
149
+ # with the cold-run note). Never FAIL - schema mismatches are
150
+ # the documented cold-run path, not a hard error.
151
+ def self.cache_schema_version_check
152
+ require 'testsift/storage/schema'
153
+ require 'json'
154
+
155
+ cache_path = TestSift.cache_path
156
+ last_run_path = File.join(cache_path.to_s, 'last_run.json')
157
+ unless File.file?(last_run_path)
158
+ return 'INFO schema: no cache yet (next rspec run is cold; expected on first install)'
159
+ end
160
+
161
+ manifest = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))
162
+ stored = manifest['schema_version']
163
+ current = TestSift::Storage::Schema::CURRENT
164
+ if stored == current
165
+ "OK schema: v#{current} (matches gem)"
166
+ else
167
+ "WARN schema: stored v#{stored.inspect} != gem v#{current} " \
168
+ '(next rspec run is a cold run; expected on 1.x->2.0 upgrade)'
169
+ end
170
+ rescue StandardError => e
171
+ "WARN schema: could not read cache manifest: #{e.class}: #{e.message}"
172
+ end
173
+
174
+ # When remote_cache is configured, verify the backend is
175
+ # reachable from doctor's vantage point so the user catches
176
+ # misconfig (typo'd S3 path / unreachable Redis URL / unwritable
177
+ # local-fs dir) BEFORE the next CI run fails. Best-effort: never
178
+ # FAIL the gate, just surface a WARN/INFO line.
179
+ REMOTE_CACHE_PROBES = {
180
+ s3: ->(opts) { remote_cache_s3_check(opts) },
181
+ local_fs: ->(opts) { remote_cache_local_fs_check(opts) },
182
+ redis: ->(opts) { remote_cache_redis_check(opts) }
183
+ }.freeze
184
+
185
+ # Internal helper for the tracer pipeline.
186
+ # @api private
187
+ def self.remote_cache_check
188
+ entry = remote_cache_entry
189
+ return 'INFO remote_cache: not configured (skip)' unless entry
190
+
191
+ backend, opts = entry
192
+ probe = REMOTE_CACHE_PROBES[backend]
193
+ return "INFO remote_cache: custom backend #{backend.inspect} (skipping reachability probe)" if probe.nil?
194
+
195
+ instance_exec(opts, &probe)
196
+ end
197
+
198
+ # Internal helper for the tracer pipeline.
199
+ # @api private
200
+ def self.remote_cache_entry
201
+ return nil unless TestSift.respond_to?(:remote_cache_backend_entry)
202
+
203
+ TestSift.remote_cache_backend_entry
204
+ end
205
+
206
+ # Internal helper for the tracer pipeline.
207
+ # @api private
208
+ def self.remote_cache_s3_check(opts)
209
+ bucket = opts[:bucket] || opts['bucket']
210
+ return 'WARN remote_cache: :s3 configured without :bucket' if bucket.nil? || bucket.empty?
211
+
212
+ "OK remote_cache: :s3 bucket=#{bucket} " \
213
+ '(reachability not probed locally; verified end-to-end on next CI run)'
214
+ end
215
+
216
+ # Internal helper for the tracer pipeline.
217
+ # @api private
218
+ def self.remote_cache_local_fs_check(opts)
219
+ path = opts[:path] || opts['path']
220
+ return 'WARN remote_cache: :local_fs configured without :path' if path.nil? || path.empty?
221
+ return "WARN remote_cache: :local_fs path #{path} does not exist" unless File.directory?(path)
222
+ return "WARN remote_cache: :local_fs path #{path} not writable" unless File.writable?(path)
223
+
224
+ "OK remote_cache: :local_fs path=#{path}"
225
+ end
226
+
227
+ # Internal helper for the tracer pipeline.
228
+ # @api private
229
+ def self.remote_cache_redis_check(opts)
230
+ url = opts[:url] || opts['url'] || ENV.fetch('TESTSIFT_REMOTE_CACHE_URI', nil)
231
+ return 'WARN remote_cache: :redis configured without :url' if url.nil? || url.empty?
232
+
233
+ "OK remote_cache: :redis url=#{url} " \
234
+ '(reachability not probed locally; verified end-to-end on next CI run)'
235
+ end
236
+
237
+ # Surface the narrow-attribution precondition at diagnostic time.
238
+ # When `track_ar_schema_notifications` is enabled AND Rails is
239
+ # loaded AND the rspec-rails default `use_transactional_fixtures
240
+ # = true` is in effect, per-example BEGIN/COMMIT fires
241
+ # `sql.active_record` inside the testsift bucket and
242
+ # attribution silently widens. Same shape as the boot-time warn
243
+ # in TestSift.start, surfaced here so users running
244
+ # `testsift doctor` see the issue without having to boot a
245
+ # full rspec run first.
246
+ def self.ar_schema_narrow_attribution_check
247
+ return 'INFO AR schema: track_ar_schema_notifications not enabled' unless ar_schema_enabled?
248
+ return 'INFO AR schema: Rails not loaded' unless rails_loaded?
249
+
250
+ if transactional_fixtures_default?
251
+ 'WARN AR schema: track_ar_schema_notifications + use_transactional_fixtures=true ' \
252
+ 'silently widens to whole-suite-on-schema-change. See README ' \
253
+ 'section "Narrow AR schema attribution".'
254
+ else
255
+ 'OK AR schema: narrow attribution preconditions look good'
256
+ end
257
+ end
258
+
259
+ # Environment variables that signal a CI environment, one per
260
+ # major provider plus the near-universal `CI` convention.
261
+ CI_ENV_VARS = %w[CI GITHUB_ACTIONS GITLAB_CI CIRCLECI BUILDKITE TF_BUILD].freeze
262
+
263
+ # Surface the cache-persistence recipes exactly when the user is
264
+ # on CI, where a non-persisted cache silently degrades every run
265
+ # to a cold run. Detection requires a var that is set AND
266
+ # non-empty: `ENV['CI'] == ""` is truthy in Ruby (a shell with
267
+ # `CI=` exported would otherwise read as detected). INFO both
268
+ # ways - being in or out of CI is not a health state, so this
269
+ # check never affects the exit status.
270
+ def self.ci_environment_check
271
+ var = CI_ENV_VARS.find { |v| !ENV[v].to_s.empty? }
272
+ if var
273
+ "INFO ci: detected via ENV[#{var}] -- cache persistence recipes: docs/CI_RECIPES.md"
274
+ else
275
+ 'INFO ci: not detected (local run)'
276
+ end
277
+ end
278
+
279
+ # Internal helper for the tracer pipeline.
280
+ # @api private
281
+ def self.ar_schema_enabled?
282
+ return false unless TestSift.respond_to?(:track_ar_schema_notifications?)
283
+
284
+ TestSift.track_ar_schema_notifications?
285
+ end
286
+
287
+ # Internal helper for the tracer pipeline.
288
+ # @api private
289
+ def self.rails_loaded?
290
+ defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
291
+ end
292
+
293
+ # Internal helper for the tracer pipeline.
294
+ # @api private
295
+ def self.transactional_fixtures_default?
296
+ return false unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
297
+
298
+ cfg = ::RSpec.configuration
299
+ return false unless cfg.respond_to?(:use_transactional_fixtures)
300
+
301
+ cfg.use_transactional_fixtures != false
302
+ rescue StandardError
303
+ false
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testsift/cli/snapshot_helpers'
4
+
5
+ module TestSift
6
+ # Internal CLI -- see {TestSift} for the user-facing surface.
7
+ # @api private
8
+ module CLI
9
+ # `testsift explain <example>` -- show why a given example is
10
+ # scheduled to run or skip on the next rspec invocation. Backend-
11
+ # agnostic: dispatches through {TestSift::Storage::Backend.build}
12
+ # (via {SnapshotHelpers.load_snapshot}) so `storage_backend :sqlite`
13
+ # resolves the latest run from the meta table instead of the
14
+ # JsonBackend-only `last_run.json` file.
15
+ module Explain
16
+ # @param args [Array<String>] sub-command args. First positional
17
+ # arg is the example_id (or substring) to explain. `--not-run`
18
+ # flips the output to the skip-side view: why the example was
19
+ # NOT run last time, and what would make it run next time.
20
+ # @param stdout [IO]
21
+ # @param stderr [IO]
22
+ # @return [Integer] exit status (0 = explanation printed,
23
+ # 1 = example not found / cache missing).
24
+ def self.run(args, stdout: $stdout, stderr: $stderr)
25
+ args = args.dup
26
+ not_run = !args.delete('--not-run').nil?
27
+ return print_help(stdout) if SnapshotHelpers.help_requested?(args)
28
+
29
+ loaded = SnapshotHelpers.load_snapshot(TestSift.cache_path, command: 'explain', stderr: stderr)
30
+ return 1 if loaded.nil?
31
+
32
+ snapshot, backend = loaded
33
+ match = find_example(snapshot.all_examples, args.first)
34
+ return no_match(args.first, snapshot.all_examples, stderr) if match.nil?
35
+
36
+ if not_run
37
+ print_not_run_explanation(stdout, match, snapshot, backend)
38
+ else
39
+ print_explanation(stdout, match, snapshot)
40
+ end
41
+ 0
42
+ rescue Errno::EPIPE
43
+ # Downstream pipe (`... | head`) closed early -- routine in
44
+ # shell pipelines, not a failure. Exit 0 silently.
45
+ 0
46
+ rescue StandardError => e
47
+ stderr.puts "explain: #{e.class}: #{e.message}"
48
+ 1
49
+ end
50
+
51
+ # Internal helper for the tracer pipeline.
52
+ # @api private
53
+ def self.no_match(query, all_examples, stderr)
54
+ stderr.puts "explain: no example matching #{query.inspect}"
55
+ stderr.puts " cache has #{all_examples.size} examples; pass an example_id or substring of description"
56
+ 1
57
+ end
58
+
59
+ # Internal helper for the tracer pipeline.
60
+ # @api private
61
+ def self.print_help(stdout)
62
+ stdout.puts <<~HELP
63
+ Usage: testsift explain [--not-run] <example_id_or_substring>
64
+
65
+ Show why an example is scheduled to run or skip. Matches against
66
+ example_id exactly first, then falls back to a substring match
67
+ on the example's full_description. Backend-aware: works under
68
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
69
+ Requires a prior rspec run.
70
+
71
+ --not-run flips to the skip-side view: whether the example was
72
+ skipped on the last run (and why no run trigger fired), its last
73
+ recorded status, and what would make it run on the next rspec
74
+ invocation. The cache keeps only the most recent snapshot, so
75
+ "last status" reflects the last run, not a run history.
76
+ HELP
77
+ 0
78
+ end
79
+
80
+ # Internal helper for the tracer pipeline.
81
+ # @api private
82
+ def self.find_example(all_examples, query)
83
+ return all_examples[query] if all_examples.key?(query)
84
+
85
+ all_examples.find do |id, meta|
86
+ meta = {} unless meta.is_a?(::Hash)
87
+ desc = SnapshotHelpers.example_description(meta) || ''
88
+ id.include?(query) || desc.include?(query)
89
+ end&.last
90
+ end
91
+
92
+ # Internal helper for the tracer pipeline.
93
+ # @api private
94
+ def self.print_explanation(stdout, meta, snapshot)
95
+ meta = {} unless meta.is_a?(::Hash)
96
+ id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id')
97
+ format_lines(meta, skipped: skipped?(id, snapshot)).each { |line| stdout.puts line }
98
+ print_dependency_summary(stdout, meta, snapshot)
99
+ end
100
+
101
+ # Internal helper for the tracer pipeline.
102
+ # @api private
103
+ def self.format_lines(meta, skipped: false)
104
+ id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id') || '<unknown>'
105
+ file, line = SnapshotHelpers.example_location(meta)
106
+ status = last_status(meta)
107
+ [
108
+ "id: #{id}",
109
+ "description: #{SnapshotHelpers.example_description(meta)}",
110
+ "location: #{file}:#{line}",
111
+ "last status: #{status}",
112
+ "run reason: #{run_reason_field(meta, skipped)}"
113
+ ]
114
+ end
115
+
116
+ # The run-side `run reason:` value. For an example the filter
117
+ # SKIPPED on the last run, the persisted run_reason is the
118
+ # carry-forward reason seeded from an earlier snapshot -- it says
119
+ # why the example ran back then, not why it is in its current
120
+ # state. Printing it bare would misreport a skipped example as
121
+ # having a current run trigger, so flag it and point at the
122
+ # `--not-run` skip-side view instead.
123
+ # @api private
124
+ def self.run_reason_field(meta, skipped)
125
+ reason = SnapshotHelpers.fetch_meta(meta, 'run_reason') || '<not recorded>'
126
+ return reason unless skipped
127
+
128
+ "#{reason} (carried forward from an earlier run; " \
129
+ 'this example was SKIPPED last run -- use --not-run for the skip-side view)'
130
+ end
131
+
132
+ # Internal helper for the tracer pipeline.
133
+ # @api private
134
+ def self.last_status(meta)
135
+ SnapshotHelpers.dig_meta(meta, 'execution_result', 'status') ||
136
+ SnapshotHelpers.fetch_meta(meta, 'status') || 'unknown'
137
+ end
138
+
139
+ # The `--not-run` view. Deliberately does NOT print the
140
+ # `run reason:` field from all_examples meta: for a skipped
141
+ # example that field is the carry-forward PRIOR-run reason
142
+ # (seeded from the previous snapshot), so echoing it here would
143
+ # misreport why the example did not run. Everything below is
144
+ # derived from the per-run membership sets instead
145
+ # (skipped_examples / filtered_examples / the status id-sets).
146
+ def self.print_not_run_explanation(stdout, meta, snapshot, backend)
147
+ meta = {} unless meta.is_a?(::Hash)
148
+ id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id') || '<unknown>'
149
+ filter_persisted = SnapshotHelpers.filter_decisions_persisted?(backend)
150
+ print_not_run_header(stdout, meta, id)
151
+ stdout.puts last_run_line(id, snapshot, filter_persisted: filter_persisted)
152
+ not_run_detail_lines(id, snapshot).each { |detail| stdout.puts detail }
153
+ stdout.puts "last status: #{last_status(meta)} (most recent snapshot; this cache keeps only the last run)"
154
+ stdout.puts "next run: #{next_run_line(id, snapshot)}"
155
+ print_dependency_summary(stdout, meta, snapshot)
156
+ end
157
+
158
+ # Internal helper for the tracer pipeline.
159
+ # @api private
160
+ def self.print_not_run_header(stdout, meta, id)
161
+ file, line = SnapshotHelpers.example_location(meta)
162
+ stdout.puts "id: #{id}"
163
+ stdout.puts "description: #{SnapshotHelpers.example_description(meta)}"
164
+ stdout.puts "location: #{file}:#{line}"
165
+ end
166
+
167
+ # Detail lines printed under `last run:`: the itemized skip
168
+ # derivation for a skipped id, a run-side hint for an id that
169
+ # actually ran, nothing for the no-decision fallbacks.
170
+ # @return [Array<String>]
171
+ def self.not_run_detail_lines(id, snapshot)
172
+ return skip_reason_lines(id, snapshot) if skipped?(id, snapshot)
173
+
174
+ return [" it was not skipped; run 'testsift explain #{id}' for the run-side view"] if ran_reason(id, snapshot)
175
+
176
+ []
177
+ end
178
+
179
+ # One `last run:` line for the `--not-run` view, derived from
180
+ # per-run membership: skipped_examples (the filter skipped it),
181
+ # filtered_examples (it ran, with the recorded reason), or
182
+ # neither. The neither case splits on `filter_persisted`: a
183
+ # backend that persists filter decisions (json) with an empty
184
+ # decision set means the last run recorded no decision for this
185
+ # id (a cold run persists empty sets by design, and an id absent
186
+ # from the last run has no entry either) -- only a
187
+ # non-persisting backend (sqlite) earns the storage-backend
188
+ # wording.
189
+ # @return [String]
190
+ def self.last_run_line(id, snapshot, filter_persisted:)
191
+ return 'last run: skipped (cache hit)' if skipped?(id, snapshot)
192
+
193
+ reason = ran_reason(id, snapshot)
194
+ return "last run: ran -- #{reason}" if reason
195
+ return 'last run: <not recorded by this storage backend>' unless filter_persisted
196
+
197
+ 'last run: no filter decision recorded (cold run, or this example was not part of it)'
198
+ end
199
+
200
+ # Itemized derivation of WHY the filter skipped the example.
201
+ # Sound because a skip logically implies every trigger in the
202
+ # engine's precedence chain declined: no whole-suite invalidator
203
+ # fired, no boot file changed (the engine ORs the boot-set check
204
+ # into whole-suite invalidation), the example carried no
205
+ # always-re-run status, none of its tracked dependency files
206
+ # changed, and its tracked environment snapshot was unchanged.
207
+ # @return [Array<String>]
208
+ def self.skip_reason_lines(id, snapshot)
209
+ deps = Array((snapshot.dependency || {})[id])
210
+ [
211
+ 'skip reason: no run trigger fired last run:',
212
+ ' - whole-suite invalidators (Gemfile.lock, .ruby-version, .testsift, gem version): unchanged',
213
+ ' - boot set: no boot file changed',
214
+ ' - prior status: not failed / flaky / pending / interrupted',
215
+ " - dependency files: #{deps.size} tracked, none changed",
216
+ ' - environment snapshot: unchanged for this example'
217
+ ]
218
+ end
219
+
220
+ # Prediction line for the NEXT run, from the persisted status
221
+ # sets (the inputs to the next run's always-re-run triggers).
222
+ # @return [String]
223
+ def self.next_run_line(id, snapshot)
224
+ reason = always_rerun_reason(id, snapshot)
225
+ return "will re-run regardless (#{reason} last run)" if reason
226
+
227
+ 'runs only if a dependency, whole-suite invalidator, boot file, or tracked env var changes'
228
+ end
229
+
230
+ # First always-re-run status the id carries, in the filter's
231
+ # precedence order (interrupted > flaky > failed > pending), or
232
+ # nil when the example has no status-based re-run trigger.
233
+ # @return [String, nil]
234
+ def self.always_rerun_reason(id, snapshot)
235
+ return 'interrupted' if in_id_set?(snapshot.interrupted_examples, id)
236
+ return 'flaky' if in_id_set?(snapshot.flaky_examples, id)
237
+ return 'failed' if in_id_set?(snapshot.failed_examples, id)
238
+ return 'pending' if in_id_set?(snapshot.pending_examples, id)
239
+
240
+ nil
241
+ end
242
+
243
+ # Internal helper for the tracer pipeline.
244
+ # @api private
245
+ def self.skipped?(id, snapshot)
246
+ in_id_set?(snapshot.skipped_examples, id)
247
+ end
248
+
249
+ # Internal helper for the tracer pipeline.
250
+ # @api private
251
+ def self.ran_reason(id, snapshot)
252
+ SnapshotHelpers.fetch_meta(snapshot.filtered_examples || {}, id)
253
+ end
254
+
255
+ # Internal helper for the tracer pipeline.
256
+ # @api private
257
+ def self.in_id_set?(id_set, id)
258
+ (id_set || []).include?(id)
259
+ end
260
+
261
+ # Internal helper for the tracer pipeline.
262
+ # @api private
263
+ def self.print_dependency_summary(stdout, meta, snapshot)
264
+ id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id')
265
+ deps = snapshot.dependency || {}
266
+ files = Array(deps[id])
267
+ stdout.puts "dependencies: #{files.size} files tracked"
268
+ files.first(10).each { |f| stdout.puts " - #{f}" }
269
+ stdout.puts " ... (#{files.size - 10} more)" if files.size > 10
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal CLI — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ module CLI
7
+ # `testsift report:open` — open the HTML report in the default
8
+ # browser. Resolves `report_path/index.html` and dispatches via
9
+ # `open` (macOS) / `xdg-open` (Linux). Falls back to printing the
10
+ # path when no opener is available.
11
+ module ReportOpen
12
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
13
+ # @param stdout [IO]
14
+ # @param stderr [IO]
15
+ # @return [Integer] exit status (0 = opened or path printed,
16
+ # 1 = report missing).
17
+ def self.run(args, stdout: $stdout, stderr: $stderr)
18
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
+
20
+ report_path = TestSift.report_path
21
+ index_path = File.join(report_path, 'index.html')
22
+
23
+ unless File.file?(index_path)
24
+ stderr.puts "report:open: no report at #{index_path}"
25
+ stderr.puts ' run rspec first to generate the HTML report'
26
+ return 1
27
+ end
28
+
29
+ opener = detect_opener
30
+ if opener.nil?
31
+ stdout.puts "report:open: report at #{index_path}"
32
+ stdout.puts ' no opener detected (open / xdg-open); open the path manually'
33
+ return 0
34
+ end
35
+
36
+ launch(opener, index_path, stdout, stderr)
37
+ rescue Errno::EPIPE
38
+ # Downstream pipe (`... | head`) closed early -- routine in
39
+ # shell pipelines, not a failure. Exit 0 silently.
40
+ 0
41
+ rescue StandardError => e
42
+ stderr.puts "report:open: #{e.class}: #{e.message}"
43
+ 1
44
+ end
45
+
46
+ # Internal helper for the tracer pipeline.
47
+ # @api private
48
+ def self.launch(opener, index_path, stdout, stderr)
49
+ if system(opener, index_path, out: File::NULL, err: File::NULL)
50
+ stdout.puts "report:open: opened #{index_path} via #{opener}"
51
+ 0
52
+ else
53
+ stderr.puts "report:open: failed to launch #{opener} #{index_path}"
54
+ 1
55
+ end
56
+ end
57
+
58
+ # Internal helper for the tracer pipeline.
59
+ # @api private
60
+ def self.print_help(stdout)
61
+ stdout.puts <<~HELP
62
+ Usage: testsift report:open
63
+
64
+ Open the HTML report (`report_path/index.html`) in the default
65
+ browser. Uses `open` on macOS and `xdg-open` on Linux. Prints
66
+ the path and exits 0 when no opener is available.
67
+ HELP
68
+ 0
69
+ end
70
+
71
+ # Returns the opener binary name on PATH, or nil. Checked in
72
+ # priority order: macOS `open`, then Linux `xdg-open`. Windows
73
+ # is unsupported per `COMPATIBILITY_MATRIX.md`'s explicit drop;
74
+ # users on Windows see the print-the-path fallback.
75
+ def self.detect_opener
76
+ %w[open xdg-open].find { |bin| which(bin) }
77
+ end
78
+
79
+ # Internal helper for the tracer pipeline.
80
+ # @api private
81
+ def self.which(binary)
82
+ found = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
83
+ path = File.join(dir, binary)
84
+ File.file?(path) && File.executable?(path)
85
+ end
86
+ found ? binary : nil
87
+ end
88
+ end
89
+ end
90
+ end