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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +204 -151
- data/lib/rspec_tracer/cli/blast_radius.rb +318 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +5 -2
- data/lib/rspec_tracer/cli/cache_info.rb +4 -2
- data/lib/rspec_tracer/cli/doctor.rb +26 -3
- data/lib/rspec_tracer/cli/explain.rb +180 -63
- data/lib/rspec_tracer/cli/report_open.rb +13 -5
- data/lib/rspec_tracer/cli/snapshot_helpers.rb +119 -0
- data/lib/rspec_tracer/cli.rb +116 -13
- data/lib/rspec_tracer/configuration.rb +2 -3
- data/lib/rspec_tracer/defaults.rb +7 -1
- data/lib/rspec_tracer/line_stub.rb +3 -2
- data/lib/rspec_tracer/logger.rb +27 -8
- data/lib/rspec_tracer/reporters/html/package-lock.json +213 -200
- data/lib/rspec_tracer/reporters/html/package.json +1 -1
- data/lib/rspec_tracer/rspec/metadata.rb +1 -1
- data/lib/rspec_tracer/rspec/runner_hook.rb +19 -2
- data/lib/rspec_tracer/source_file.rb +3 -3
- data/lib/rspec_tracer/tracker/env_matcher.rb +5 -5
- data/lib/rspec_tracer/version.rb +1 -1
- metadata +11 -9
|
@@ -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.
|
|
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?
|