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,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'testsift/cli/snapshot_helpers'
6
+ require 'testsift/tracker/whole_suite_invalidators'
7
+
8
+ module TestSift
9
+ # Internal CLI -- see {TestSift} for the user-facing surface.
10
+ # @api private
11
+ module CLI
12
+ # `testsift blast-radius <file> [<file> ...]` -- show which
13
+ # examples a change to each given file would re-run on the next
14
+ # rspec invocation. Reads the persisted reverse-dependency map
15
+ # (file -> examples) plus the whole-suite invalidator watch list
16
+ # and the boot set, so whole-suite triggers (Gemfile.lock, boot
17
+ # files) report "re-runs all N examples" instead of under-reporting
18
+ # via a bare reverse-dependency lookup. Reports the file-change
19
+ # trigger only; examples that always re-run for status reasons
20
+ # (failed / flaky / pending / interrupted) are not included.
21
+ # Backend-agnostic: dispatches through
22
+ # {TestSift::Storage::Backend.build} (via
23
+ # {SnapshotHelpers.load_snapshot}) so `storage_backend :sqlite`
24
+ # resolves the latest run from the meta table instead of the
25
+ # JsonBackend-only `last_run.json` file.
26
+ module BlastRadius
27
+ # Internal constant.
28
+ # @api private
29
+ USAGE = 'testsift blast-radius [--list|--json] <file> [<file> ...]'
30
+
31
+ # Statuses whose blast radius is the entire suite (a change to
32
+ # the file invalidates every cached example, not just tracked
33
+ # dependents).
34
+ # @api private
35
+ WHOLE_SUITE_STATUSES = %w[whole_suite_invalidator boot_file].freeze
36
+
37
+ # @param args [Array<String>] sub-command args. `--list` / `--json`
38
+ # flags plus one or more file paths (relative to the project
39
+ # root or absolute).
40
+ # @param stdout [IO]
41
+ # @param stderr [IO]
42
+ # @return [Integer] exit status (0 = radius printed, including
43
+ # untracked / zero-dependent files; 1 = no files given, unknown
44
+ # option, cache missing, or schema mismatch).
45
+ def self.run(args, stdout: $stdout, stderr: $stderr)
46
+ return print_help(stdout) if SnapshotHelpers.help_requested?(args)
47
+
48
+ parsed = parse_args(args)
49
+ return unknown_option(parsed[:unknown], stderr) unless parsed[:unknown].nil?
50
+ return no_files(stderr) if parsed[:files].empty?
51
+
52
+ execute(parsed, stdout, stderr)
53
+ rescue Errno::EPIPE
54
+ # Downstream pipe (`... | head`, `... | jq`) closed early --
55
+ # routine in shell pipelines (the help text promotes them),
56
+ # not a failure. Exit 0 silently.
57
+ 0
58
+ rescue StandardError => e
59
+ stderr.puts "blast-radius: #{e.class}: #{e.message}"
60
+ 1
61
+ end
62
+
63
+ # Internal helper for the tracer pipeline.
64
+ # @api private
65
+ def self.execute(parsed, stdout, stderr)
66
+ loaded = SnapshotHelpers.load_snapshot(TestSift.cache_path, command: 'blast-radius', stderr: stderr)
67
+ return 1 if loaded.nil?
68
+
69
+ snapshot, = loaded
70
+ radii = parsed[:files].map { |file| radius_for(file, snapshot) }
71
+ if parsed[:json]
72
+ stdout.puts JSON.pretty_generate(json_payload(radii, snapshot))
73
+ else
74
+ print_report(stdout, radii, snapshot, list: parsed[:list])
75
+ end
76
+ 0
77
+ end
78
+
79
+ # Internal helper for the tracer pipeline.
80
+ # @api private
81
+ def self.parse_args(args)
82
+ parsed = { list: false, json: false, files: [], unknown: nil }
83
+ args.each do |arg|
84
+ case arg
85
+ when '--list' then parsed[:list] = true
86
+ when '--json' then parsed[:json] = true
87
+ when /\A-/ then parsed[:unknown] ||= arg
88
+ else parsed[:files] << arg
89
+ end
90
+ end
91
+ parsed
92
+ end
93
+
94
+ # Internal helper for the tracer pipeline.
95
+ # @api private
96
+ def self.unknown_option(arg, stderr)
97
+ stderr.puts "blast-radius: unknown option #{arg.inspect}"
98
+ stderr.puts " usage: #{USAGE}"
99
+ 1
100
+ end
101
+
102
+ # Internal helper for the tracer pipeline.
103
+ # @api private
104
+ def self.no_files(stderr)
105
+ stderr.puts 'blast-radius: no file paths given'
106
+ stderr.puts " usage: #{USAGE}"
107
+ 1
108
+ end
109
+
110
+ # Internal helper for the tracer pipeline.
111
+ # @api private
112
+ def self.print_help(stdout)
113
+ stdout.puts <<~HELP
114
+ Usage: #{USAGE}
115
+
116
+ Show which examples a change to each given file would re-run on
117
+ the next rspec invocation. Reports the file-change trigger only;
118
+ examples that always re-run for status reasons (failed / flaky /
119
+ pending / interrupted) are not included. Paths may be relative to
120
+ the project root or absolute. Whole-suite invalidators
121
+ (Gemfile.lock, .ruby-version, .testsift) and boot files
122
+ re-run every example. Backend-aware: works under
123
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
124
+ Requires a prior rspec run.
125
+
126
+ Multi-file input composes with git:
127
+
128
+ git diff --name-only main | xargs bundle exec testsift blast-radius
129
+
130
+ Options:
131
+ --list Enumerate affected examples (location + description).
132
+ --json Emit one machine-readable JSON document to stdout.
133
+ HELP
134
+ 0
135
+ end
136
+
137
+ # Convert a user-supplied path into the cache's file_name
138
+ # convention (project-relative with a leading `/`, absolute when
139
+ # outside the project root). Computed at command runtime against
140
+ # `TestSift.root` -- `SourceFile.file_name` is unsuitable here
141
+ # because its PROJECT_ROOT_REGEX freezes the root at gem-require
142
+ # time, before the CLI loads the project's `.testsift` config.
143
+ # @api private
144
+ def self.normalize_file_name(path)
145
+ root = TestSift.root
146
+ abs = File.expand_path(path, root)
147
+ return abs unless abs.start_with?("#{root}/")
148
+
149
+ "/#{abs[(root.length + 1)..]}"
150
+ end
151
+
152
+ # Classify one input file and collect its affected examples.
153
+ # Precedence: whole-suite invalidator watch file, then boot file
154
+ # (boot-set changes OR into whole-suite invalidation), then
155
+ # tracked reverse dependents, then untracked / no-dependents.
156
+ # Note the key conventions: `boot_set` keys are project-relative
157
+ # (no leading slash) while `reverse_dependency` / `all_files`
158
+ # keys carry a leading slash.
159
+ # @api private
160
+ def self.radius_for(path, snapshot)
161
+ file_name = normalize_file_name(path)
162
+ relative = file_name.delete_prefix('/')
163
+
164
+ if Tracker::WholeSuiteInvalidators::WATCH_FILES.include?(relative)
165
+ whole_suite_radius(path, file_name, 'whole_suite_invalidator', snapshot)
166
+ elsif (snapshot.boot_set || {}).key?(relative)
167
+ whole_suite_radius(path, file_name, 'boot_file', snapshot)
168
+ else
169
+ dependent_radius(path, file_name, snapshot)
170
+ end
171
+ end
172
+
173
+ # Internal helper for the tracer pipeline.
174
+ # @api private
175
+ def self.whole_suite_radius(path, file_name, status, snapshot)
176
+ ids = (snapshot.all_examples || {}).keys
177
+ { path: path, file_name: file_name, status: status,
178
+ examples: example_entries(ids, snapshot) }
179
+ end
180
+
181
+ # Internal helper for the tracer pipeline.
182
+ # @api private
183
+ def self.dependent_radius(path, file_name, snapshot)
184
+ ids = (snapshot.reverse_dependency || {})[file_name]
185
+ if ids.nil? || ids.empty?
186
+ status = (snapshot.all_files || {}).key?(file_name) ? 'no_dependents' : 'untracked'
187
+ { path: path, file_name: file_name, status: status, examples: [] }
188
+ else
189
+ { path: path, file_name: file_name, status: 'tracked',
190
+ examples: example_entries(ids, snapshot) }
191
+ end
192
+ end
193
+
194
+ # Internal helper for the tracer pipeline.
195
+ # @api private
196
+ def self.example_entries(ids, snapshot)
197
+ all = snapshot.all_examples || {}
198
+ ids.map { |id| example_entry(id.to_s, all[id]) }
199
+ .sort_by { |entry| [entry[:location], entry[:description]] }
200
+ end
201
+
202
+ # Enrich one example_id from the persisted all_examples meta.
203
+ # A dangling id (present in reverse_dependency but missing from
204
+ # all_examples -- stale or partially-written cache) degrades to
205
+ # `<unknown>` fields instead of raising.
206
+ # @api private
207
+ def self.example_entry(id, meta)
208
+ meta = {} unless meta.is_a?(::Hash)
209
+ file, line = SnapshotHelpers.example_location(meta)
210
+ {
211
+ example_id: id,
212
+ spec_file: file || '<unknown>',
213
+ location: file.nil? ? '<unknown>' : "#{file}:#{line}",
214
+ description: SnapshotHelpers.example_description(meta) || '<unknown>'
215
+ }
216
+ end
217
+
218
+ # Internal helper for the tracer pipeline.
219
+ # @api private
220
+ def self.print_report(stdout, radii, snapshot, list:)
221
+ radii.each do |radius|
222
+ stdout.puts summary_line(radius)
223
+ next unless list
224
+
225
+ radius[:examples].each do |entry|
226
+ stdout.puts " #{entry[:location]} #{entry[:description]}"
227
+ end
228
+ end
229
+ print_total(stdout, radii, snapshot) if radii.size > 1
230
+ end
231
+
232
+ # Internal helper for the tracer pipeline.
233
+ # The untracked wording sticks to what the tracer can actually
234
+ # observe: absence of the file from the cache. It must NOT claim
235
+ # the file "never loaded" -- files consumed outside the hooked
236
+ # surface (spec_helper.rb itself, which executes before coverage
237
+ # tracking starts; reads via unhooked APIs, C extensions, or
238
+ # other threads) load fine yet never appear in the cache. See
239
+ # the soundness model in ARCHITECTURE.md.
240
+ # @api private
241
+ def self.summary_line(radius)
242
+ file_name = radius[:file_name]
243
+ count = radius[:examples].size
244
+ case radius[:status]
245
+ when 'whole_suite_invalidator'
246
+ "#{file_name}: whole-suite invalidator -- re-runs all #{count} examples"
247
+ when 'boot_file'
248
+ "#{file_name}: boot file -- re-runs all #{count} examples"
249
+ when 'untracked'
250
+ "#{file_name}: not tracked in cache (no recorded dependents -- the tracer never " \
251
+ 'observed it as an input; see the soundness model in ARCHITECTURE.md)'
252
+ when 'no_dependents'
253
+ "#{file_name}: 0 examples (no tracked dependents)"
254
+ else
255
+ "#{file_name}: #{count} examples across #{spec_file_count(radius[:examples])} spec files"
256
+ end
257
+ end
258
+
259
+ # Internal helper for the tracer pipeline.
260
+ # @api private
261
+ def self.print_total(stdout, radii, snapshot)
262
+ if radii.any? { |radius| WHOLE_SUITE_STATUSES.include?(radius[:status]) }
263
+ total = (snapshot.all_examples || {}).size
264
+ stdout.puts "total: all #{total} examples (whole-suite invalidator)"
265
+ else
266
+ union = union_entries(radii)
267
+ stdout.puts "total: #{union.size} unique examples across #{spec_file_count(union)} spec files"
268
+ end
269
+ end
270
+
271
+ # Internal helper for the tracer pipeline.
272
+ # @api private
273
+ def self.union_entries(radii)
274
+ radii.flat_map { |radius| radius[:examples] }.uniq { |entry| entry[:example_id] }
275
+ end
276
+
277
+ # Internal helper for the tracer pipeline.
278
+ # @api private
279
+ def self.spec_file_count(entries)
280
+ entries.map { |entry| entry[:spec_file] }.uniq.size
281
+ end
282
+
283
+ # Internal helper for the tracer pipeline.
284
+ # @api private
285
+ def self.json_payload(radii, snapshot)
286
+ union = union_entries(radii)
287
+ {
288
+ 'files' => radii.map { |radius| file_json(radius) },
289
+ 'total' => {
290
+ 'example_count' => union.size,
291
+ 'spec_file_count' => spec_file_count(union),
292
+ 'whole_suite' => radii.any? { |radius| WHOLE_SUITE_STATUSES.include?(radius[:status]) },
293
+ 'all_examples_count' => (snapshot.all_examples || {}).size
294
+ }
295
+ }
296
+ end
297
+
298
+ # Internal helper for the tracer pipeline.
299
+ # @api private
300
+ def self.file_json(radius)
301
+ {
302
+ 'path' => radius[:path],
303
+ 'file_name' => radius[:file_name],
304
+ 'status' => radius[:status],
305
+ 'example_count' => radius[:examples].size,
306
+ 'spec_file_count' => spec_file_count(radius[:examples]),
307
+ 'examples' => radius[:examples].map do |entry|
308
+ {
309
+ 'example_id' => entry[:example_id],
310
+ 'location' => entry[:location],
311
+ 'description' => entry[:description]
312
+ }
313
+ end
314
+ }
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module TestSift
6
+ # Internal CLI — see {TestSift} for the user-facing surface.
7
+ # @api private
8
+ module CLI
9
+ # `testsift cache:clear` — remove cache, coverage, and report
10
+ # directories. Prompts for confirmation unless `--yes` is passed.
11
+ # The next rspec run is a cold run.
12
+ module CacheClear
13
+ # @param args [Array<String>] sub-command args (`-y` / `--yes`
14
+ # skips confirmation; `-h` / `--help` prints help).
15
+ # @param stdout [IO]
16
+ # @param stderr [IO]
17
+ # @return [Integer] exit status (0 = success / aborted, 1 = error).
18
+ def self.run(args, stdout: $stdout, stderr: $stderr)
19
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
20
+
21
+ existing = existing_targets
22
+ return nothing_to_remove(stdout) if existing.empty?
23
+
24
+ announce(stdout, existing)
25
+ return aborted(stdout) unless skip_confirmation?(args) || confirm?(stdout)
26
+
27
+ remove_each(stdout, existing)
28
+ 0
29
+ rescue Errno::EPIPE
30
+ # Downstream pipe (`... | head`) closed early -- routine in
31
+ # shell pipelines, not a failure. Exit 0 silently.
32
+ 0
33
+ rescue StandardError => e
34
+ stderr.puts "cache:clear: #{e.class}: #{e.message}"
35
+ 1
36
+ end
37
+
38
+ # Returns true when any of the documented skip-confirmation
39
+ # flags is present. `--yes` / `-y` is the canonical form;
40
+ # `--force` / `-f` is the Unix-conventional synonym accepted
41
+ # so users' muscle memory works. Either form skips the
42
+ # interactive `Proceed? [y/N]` prompt.
43
+ # @api private
44
+ SKIP_CONFIRMATION_FLAGS = %w[--yes -y --force -f].freeze
45
+
46
+ # @api private
47
+ def self.skip_confirmation?(args)
48
+ args.intersect?(SKIP_CONFIRMATION_FLAGS)
49
+ end
50
+
51
+ # Internal helper for the tracer pipeline.
52
+ # @api private
53
+ def self.existing_targets
54
+ [TestSift.cache_path, TestSift.coverage_path, TestSift.report_path]
55
+ .select { |path| File.directory?(path) }
56
+ end
57
+
58
+ # Internal helper for the tracer pipeline.
59
+ # @api private
60
+ def self.nothing_to_remove(stdout)
61
+ stdout.puts 'cache:clear: nothing to remove (cache directories do not exist)'
62
+ 0
63
+ end
64
+
65
+ # Internal helper for the tracer pipeline.
66
+ # @api private
67
+ def self.announce(stdout, existing)
68
+ stdout.puts 'cache:clear: will remove:'
69
+ existing.each { |path| stdout.puts " - #{path}" }
70
+ end
71
+
72
+ # Internal helper for the tracer pipeline.
73
+ # @api private
74
+ def self.confirm?(stdout)
75
+ stdout.print 'Proceed? [y/N] '
76
+ stdout.flush
77
+ response = $stdin.gets&.chomp&.downcase
78
+ %w[y yes].include?(response)
79
+ end
80
+
81
+ # Internal helper for the tracer pipeline.
82
+ # @api private
83
+ def self.aborted(stdout)
84
+ stdout.puts 'cache:clear: aborted'
85
+ 0
86
+ end
87
+
88
+ # Internal helper for the tracer pipeline.
89
+ # @api private
90
+ def self.remove_each(stdout, existing)
91
+ existing.each do |path|
92
+ FileUtils.rm_rf(path)
93
+ stdout.puts " removed #{path}"
94
+ end
95
+ end
96
+
97
+ # Internal helper for the tracer pipeline.
98
+ # @api private
99
+ def self.print_help(stdout)
100
+ stdout.puts <<~HELP
101
+ Usage: testsift cache:clear [--yes | --force]
102
+
103
+ Remove cache, coverage, and report directories. The next rspec
104
+ run will be a cold run (full re-execution + cache rebuild).
105
+
106
+ Options:
107
+ -y, --yes Skip the confirmation prompt.
108
+ -f, --force Synonym for --yes.
109
+ HELP
110
+ 0
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testsift/storage/backend'
4
+ require 'testsift/storage/json_backend'
5
+ require 'testsift/storage/schema'
6
+ require 'testsift/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
7
+
8
+ module TestSift
9
+ # Internal CLI — see {TestSift} for the user-facing surface.
10
+ # @api private
11
+ module CLI
12
+ # `testsift cache:info` — show cache size, last run, and
13
+ # invalidation stats. Backend-agnostic: dispatches through
14
+ # {TestSift::Storage::Backend.build} so `storage_backend
15
+ # :sqlite` reports the populated cache instead of the false
16
+ # "no cache yet" the JsonBackend-only path used to emit.
17
+ module CacheInfo
18
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
19
+ # @param stdout [IO]
20
+ # @param stderr [IO]
21
+ # @return [Integer] exit status (0 = success).
22
+ def self.run(args, stdout: $stdout, stderr: $stderr)
23
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
24
+
25
+ cache_path = TestSift.cache_path
26
+ stdout.puts "cache_path: #{cache_path}"
27
+ stdout.puts "size: #{format_bytes(directory_size(cache_path))}"
28
+
29
+ backend = Storage::Backend.build(cache_path: cache_path, configuration: TestSift)
30
+ run_id = backend.last_run_id
31
+ if run_id.nil? || run_id.to_s.empty?
32
+ stdout.puts 'last_run: no cache yet (run rspec first)'
33
+ return 0
34
+ end
35
+
36
+ stdout.puts "last_run: #{run_id}"
37
+ print_example_count(stdout, backend)
38
+ 0
39
+ rescue Errno::EPIPE
40
+ # Downstream pipe (`... | head`) closed early -- routine in
41
+ # shell pipelines, not a failure. Exit 0 silently.
42
+ 0
43
+ rescue StandardError => e
44
+ stderr.puts "cache:info: #{e.class}: #{e.message}"
45
+ 1
46
+ end
47
+
48
+ # Internal helper for the tracer pipeline.
49
+ # @api private
50
+ def self.print_help(stdout)
51
+ stdout.puts <<~HELP
52
+ Usage: testsift cache:info
53
+
54
+ Show the on-disk cache size, the last run id, and example counts
55
+ for the most recent run. Backend-aware: works under
56
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
57
+ Read-only; does not modify the cache.
58
+ HELP
59
+ 0
60
+ end
61
+
62
+ # Internal helper for the tracer pipeline.
63
+ # @api private
64
+ def self.print_example_count(stdout, backend)
65
+ snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
66
+ if snapshot.nil?
67
+ stdout.puts 'examples: <unknown> (schema mismatch; next rspec run will be cold)'
68
+ return
69
+ end
70
+
71
+ stdout.puts "examples: #{snapshot.all_examples.size} tracked"
72
+ end
73
+
74
+ # Internal helper for the tracer pipeline.
75
+ # @api private
76
+ def self.directory_size(path)
77
+ return 0 unless File.directory?(path)
78
+
79
+ total = 0
80
+ Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).each do |entry|
81
+ next unless File.file?(entry)
82
+
83
+ total += File.size(entry)
84
+ rescue SystemCallError
85
+ next
86
+ end
87
+ total
88
+ end
89
+
90
+ # Internal helper for the tracer pipeline.
91
+ # @api private
92
+ def self.format_bytes(bytes)
93
+ return '0 B' if bytes <= 0
94
+
95
+ units = %w[B KB MB GB]
96
+ scale = bytes
97
+ unit_index = 0
98
+ while scale >= 1024 && unit_index < units.length - 1
99
+ scale /= 1024.0
100
+ unit_index += 1
101
+ end
102
+ format('%<scale>.1f %<unit>s', scale: scale, unit: units[unit_index])
103
+ end
104
+ end
105
+ end
106
+ end