rspec-tracer 2.0.0.pre.2 → 2.0.0.rc.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.
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'rspec_tracer/cli/snapshot_helpers'
6
+ require 'rspec_tracer/tracker/whole_suite_invalidators'
7
+
8
+ module RSpecTracer
9
+ # Internal CLI -- see {RSpecTracer} for the user-facing surface.
10
+ # @api private
11
+ module CLI
12
+ # `rspec-tracer 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
+ # {RSpecTracer::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 = 'rspec-tracer 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(RSpecTracer.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, .rspec-tracer) 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 rspec-tracer 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
+ # `RSpecTracer.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 `.rspec-tracer` config.
143
+ # @api private
144
+ def self.normalize_file_name(path)
145
+ root = RSpecTracer.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
@@ -18,7 +18,6 @@ module RSpecTracer
18
18
  def self.run(args, stdout: $stdout, stderr: $stderr)
19
19
  return print_help(stdout) if args.include?('-h') || args.include?('--help')
20
20
 
21
- require 'rspec_tracer/load_config'
22
21
  existing = existing_targets
23
22
  return nothing_to_remove(stdout) if existing.empty?
24
23
 
@@ -27,6 +26,10 @@ module RSpecTracer
27
26
 
28
27
  remove_each(stdout, existing)
29
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
30
33
  rescue StandardError => e
31
34
  stderr.puts "cache:clear: #{e.class}: #{e.message}"
32
35
  1
@@ -42,7 +45,7 @@ module RSpecTracer
42
45
 
43
46
  # @api private
44
47
  def self.skip_confirmation?(args)
45
- args.any? { |arg| SKIP_CONFIRMATION_FLAGS.include?(arg) }
48
+ args.intersect?(SKIP_CONFIRMATION_FLAGS)
46
49
  end
47
50
 
48
51
  # Internal helper for the tracer pipeline.
@@ -22,8 +22,6 @@ module RSpecTracer
22
22
  def self.run(args, stdout: $stdout, stderr: $stderr)
23
23
  return print_help(stdout) if args.include?('-h') || args.include?('--help')
24
24
 
25
- require 'rspec_tracer/load_config'
26
-
27
25
  cache_path = RSpecTracer.cache_path
28
26
  stdout.puts "cache_path: #{cache_path}"
29
27
  stdout.puts "size: #{format_bytes(directory_size(cache_path))}"
@@ -38,6 +36,10 @@ module RSpecTracer
38
36
  stdout.puts "last_run: #{run_id}"
39
37
  print_example_count(stdout, backend)
40
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
41
43
  rescue StandardError => e
42
44
  stderr.puts "cache:info: #{e.class}: #{e.message}"
43
45
  1
@@ -17,8 +17,6 @@ module RSpecTracer
17
17
  def self.run(args, stdout: $stdout, stderr: $stderr)
18
18
  return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
19
 
20
- require 'rspec_tracer/load_config'
21
-
22
20
  checks = [
23
21
  ruby_version_check,
24
22
  tracer_version_check,
@@ -31,11 +29,16 @@ module RSpecTracer
31
29
  rails_check,
32
30
  cache_schema_version_check,
33
31
  remote_cache_check,
34
- ar_schema_narrow_attribution_check
32
+ ar_schema_narrow_attribution_check,
33
+ ci_environment_check
35
34
  ]
36
35
  checks.each { |line| stdout.puts line }
37
36
  ok = checks.none? { |line| line.start_with?('FAIL') }
38
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
39
42
  rescue StandardError => e
40
43
  stderr.puts "doctor: #{e.class}: #{e.message}"
41
44
  1
@@ -253,6 +256,26 @@ module RSpecTracer
253
256
  end
254
257
  end
255
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
+
256
279
  # Internal helper for the tracer pipeline.
257
280
  # @api private
258
281
  def self.ar_schema_enabled?