pinspec 0.1.0 → 0.3.0

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.
data/lib/pinspec/cli.rb CHANGED
@@ -4,10 +4,25 @@ require "thor"
4
4
 
5
5
  module Pinspec
6
6
  class CLI < Thor
7
+ class_option :verbose, type: :boolean, default: false, aliases: "-V",
8
+ desc: "show the plan id, compared fields and per-case detail"
9
+
7
10
  def self.exit_on_failure?
8
11
  true
9
12
  end
10
13
 
14
+ ORDER = %w[pin verify init analyze validate report plan capture version].freeze
15
+
16
+ # Thor sorts the command list alphabetically after building it, which buries the
17
+ # one verb most people want between `init` and `plan`, and gives the two
18
+ # diagnostics equal billing with the product.
19
+ def self.sort_commands!(list)
20
+ list.sort_by! do |usage, _|
21
+ name = usage.to_s.split(/\s+/)[1].to_s
22
+ [ORDER.index(name) || ORDER.size, name]
23
+ end
24
+ end
25
+
11
26
  desc "version", "Print the pinspec version"
12
27
  def version
13
28
  puts "pinspec #{Pinspec::VERSION} " \
@@ -15,13 +30,78 @@ module Pinspec
15
30
  end
16
31
  map %w[--version -v] => :version
17
32
 
18
- desc "plan FILE#METHOD", "Resolve a target and print the SetupPlan that would build its world"
33
+ desc "verify SPEC_FILE", "Run any spec file in pinspec's environments, whoever wrote it"
34
+ method_option :app, type: :string, default: ".", desc: "target app root"
35
+ method_option :"verify-level", type: :string, default: "full", enum: %w[full isolated]
36
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
37
+ desc: "environment for the app's own runtime"
38
+ def verify(*args)
39
+ guarded do
40
+ spec_file = target_from(args)
41
+ path = File.expand_path(spec_file, options[:app])
42
+
43
+ unless File.file?(path)
44
+ raise TargetNotFound, "no spec file at #{path}"
45
+ end
46
+
47
+ outcomes = Verify::Verifier.new(
48
+ app_root: options[:app], spec_path: path, env: app_env,
49
+ level: setting("verify-level", "full").to_sym
50
+ ).verify
51
+
52
+ puts "verify #{spec_file}"
53
+ print_verification(outcomes)
54
+
55
+ raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
56
+ end
57
+ end
58
+
59
+ desc "init [APP_PATH]", "Write .pinspec.yml so later runs need no flags"
60
+ method_option :force, type: :boolean, default: false, desc: "overwrite an existing .pinspec.yml"
61
+ def init(app_path = ".")
62
+ guarded do
63
+ path = File.join(app_path, Config::FILENAME)
64
+
65
+ if File.file?(path) && !options[:force]
66
+ raise ConfigInvalid, "#{path} already exists. Pass --force to overwrite it."
67
+ end
68
+
69
+ runtime = Runner::Runtime.for(app_path)
70
+ profile = Analyzer::AppProfileReader.read(app_path)
71
+
72
+ # Deliberately NOT the detected environment. Writing the resolved PATH into a
73
+ # committed file bakes in one machine's layout and goes stale the moment the
74
+ # app changes Ruby - and detection re-runs on every invocation anyway. `env`
75
+ # is for what pinspec cannot work out: database credentials, feature flags.
76
+ File.write(path, Config.new(app_path, {
77
+ "cases" => Inputs::Corpus::DEFAULT_MAX_CASES,
78
+ "boots" => 2
79
+ }, path).to_yaml_document)
80
+
81
+ row "wrote", path
82
+ row "rails", profile.rails_version || "unknown"
83
+ row "ruby", runtime.ruby_version
84
+ row "runtime", runtime.detected? ? "#{runtime.manager}, detected on each run" : "this shell's Ruby"
85
+ puts
86
+
87
+ if runtime.note
88
+ puts runtime.note
89
+ puts
90
+ end
91
+
92
+ puts "Now: pinspec pin app/services/your_service.rb"
93
+ end
94
+ end
95
+
96
+ desc "plan TARGET", "Diagnostic: the world pinspec would build, without running anything"
19
97
  method_option :app, type: :string, default: ".", desc: "target app root"
20
98
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES,
21
99
  desc: "max input cases per method"
100
+ method_option :method, type: :string, banner: "NAME",
101
+ desc: "the method to pin, when discovery would guess or refuse"
22
102
  def plan(target)
23
103
  guarded do
24
- file, method = Analyzer::TargetParser.split_target(target)
104
+ file, method = resolve_target(target)
25
105
  target_profile = Analyzer::TargetParser.parse(file, method)
26
106
 
27
107
  print_profile(target_profile)
@@ -33,7 +113,7 @@ module Pinspec
33
113
  target: target_profile,
34
114
  plan: setup_plan,
35
115
  schema: app_profile.schema,
36
- max_cases: options[:cases]
116
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES)
37
117
  )
38
118
 
39
119
  print_plan(setup_plan)
@@ -45,6 +125,7 @@ module Pinspec
45
125
  desc "analyze [APP_PATH]", "App profile: schema, factories, auth, tenancy, hazards"
46
126
  def analyze(app_path = ".")
47
127
  guarded do
128
+ Config.load(app_path)
48
129
  profile = Analyzer::AppProfileReader.read(app_path)
49
130
 
50
131
  print_app(profile)
@@ -56,32 +137,35 @@ module Pinspec
56
137
  end
57
138
  end
58
139
 
59
- desc "capture FILE#METHOD", "Run the probe, write observations.json"
140
+ desc "capture TARGET", "Diagnostic: run the probe only, and write observations.json"
60
141
  method_option :app, type: :string, default: ".", desc: "target app root"
61
142
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
62
143
  method_option :boots, type: :numeric, default: 2,
63
144
  desc: "probe boots; 2 is the default because one process shares warm caches"
64
145
  method_option :"compare-sql", type: :boolean, default: false,
65
146
  desc: "include SQL fingerprints in the stability decision"
66
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
147
+ method_option :method, type: :string, banner: "NAME",
148
+ desc: "the method to pin, when discovery would guess or refuse"
149
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
67
150
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
68
151
  method_option :sample, type: :boolean, default: false,
69
152
  desc: "read real rows through a generated read-only script in the app"
70
153
  method_option :"no-redact", type: :boolean, default: false,
71
154
  desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
72
- def capture(target)
155
+ def capture(*args)
73
156
  guarded do
74
- file, method = Analyzer::TargetParser.split_target(target)
157
+ target = target_from(args)
158
+ file, method = resolve_target(target)
75
159
 
76
160
  result = Runner::Capture.new(
77
161
  app_root: options[:app],
78
162
  target: file,
79
163
  method: method,
80
- max_cases: options[:cases],
81
- boots: options[:boots],
82
- compare_sql: options[:"compare-sql"],
164
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES),
165
+ boots: setting('boots', 2),
166
+ compare_sql: setting('compare-sql', false),
83
167
  sandbox_env: app_env,
84
- sample: options[:sample],
168
+ sample: setting('sample', false),
85
169
  redact: !options[:"no-redact"]
86
170
  ).run
87
171
 
@@ -91,7 +175,7 @@ module Pinspec
91
175
  end
92
176
  end
93
177
 
94
- desc "pin FILE#METHOD", "Plan + capture + emit + verify"
178
+ desc "pin TARGET", "Capture, emit and verify. TARGET is FILE, FILE#METHOD or a directory"
95
179
  method_option :app, type: :string, default: ".", desc: "target app root"
96
180
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
97
181
  method_option :boots, type: :numeric, default: 2
@@ -99,27 +183,59 @@ module Pinspec
99
183
  method_option :"skip-verify", type: :boolean, default: false
100
184
  method_option :force, type: :boolean, default: false,
101
185
  desc: "overwrite a pin file that has been hand-edited"
102
- method_option :snapshot, type: :string, default: "inline", enum: %w[inline insta approvals],
103
- desc: "snapshot backend"
104
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
186
+ method_option :method, type: :string, banner: "NAME",
187
+ desc: "the method to pin, when discovery would guess or refuse"
188
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
105
189
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
106
190
  method_option :sample, type: :boolean, default: false,
107
191
  desc: "read real rows through a generated read-only script in the app"
108
192
  method_option :"no-redact", type: :boolean, default: false,
109
193
  desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
110
- def pin(target)
194
+ method_option :snapshot, type: :string, hide: true
195
+ def pin(*args)
111
196
  guarded do
112
- refuse_unbuilt_backend!
197
+ target = target_from(args)
198
+ warn_about_retired_flags!
113
199
  warn_about_redaction!
114
- file, method = Analyzer::TargetParser.split_target(target)
200
+
201
+ return pin_directory(target) if File.directory?(target)
202
+
203
+ pin_one(target)
204
+ end
205
+ end
206
+
207
+ no_commands do
208
+ def pin_directory(path)
209
+ files = Batch.targets_in(path)
210
+ raise TargetNotFound, "no .rb files under #{path}" if files.empty?
211
+
212
+ # Counted once over the directory: the method name this application uses for
213
+ # its entry points. chatwoot says `perform`, mastodon says `call`.
214
+ @convention = Analyzer::Discovery.convention_for(files)
215
+
216
+ puts "pinning #{files.size} file(s) under #{path}" \
217
+ "#{@convention ? " (this app's convention: ##{@convention})" : ''}"
218
+ puts
219
+
220
+ report = Batch.new(files) { |file| pin_one(file, quiet: true) }.run
221
+
222
+ print_batch(report, path)
223
+
224
+ raise NothingStableToPin,
225
+ "nothing under #{path} could be pinned. Each refusal above says why." unless report.anything_pinned?
226
+ end
227
+
228
+ def pin_one(target, quiet: false)
229
+ file, method = resolve_target(target)
115
230
 
116
231
  capture = Runner::Capture.new(
117
232
  app_root: options[:app], target: file, method: method,
118
- max_cases: options[:cases], boots: options[:boots], sandbox_env: app_env,
119
- sample: options[:sample], redact: !options[:"no-redact"]
233
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES),
234
+ boots: setting('boots', 2), sandbox_env: app_env,
235
+ sample: setting('sample', false), redact: !options[:"no-redact"] && setting("redact", true)
120
236
  ).run
121
237
 
122
- print_capture(capture)
238
+ print_capture(capture) unless quiet
123
239
  raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
124
240
 
125
241
  written = Emit::SpecWriter.new(
@@ -129,19 +245,27 @@ module Pinspec
129
245
  force: options[:force]
130
246
  ).write!
131
247
 
132
- puts
133
- print_written(written)
248
+ unless quiet
249
+ puts
250
+ print_written(written)
251
+ end
134
252
 
135
- return if options[:"skip-verify"]
253
+ if options[:"skip-verify"]
254
+ return Batch::Outcome.new(file: file, target: capture.target.qualified_name, status: :pinned,
255
+ detail: "not verified", pinned: written.pinned_cases.size,
256
+ spec_path: written.spec_path)
257
+ end
136
258
 
137
259
  outcomes = Verify::Verifier.new(
138
260
  app_root: options[:app], spec_path: written.spec_path,
139
- level: options[:"verify-level"].to_sym, env: app_env,
261
+ level: setting("verify-level", "full").to_sym, env: app_env,
140
262
  captured_tz: capture.plan.env_fingerprint[:tz]
141
263
  ).verify
142
264
 
143
- puts
144
- print_verification(outcomes)
265
+ unless quiet
266
+ puts
267
+ print_verification(outcomes)
268
+ end
145
269
 
146
270
  report_path = Report::Summary.new(
147
271
  app_root: options[:app], profile: Analyzer::AppProfileReader.read(options[:app]),
@@ -149,27 +273,36 @@ module Pinspec
149
273
  stability: capture.stability, written: written, outcomes: outcomes
150
274
  ).write!
151
275
 
152
- puts
153
- row "report", report_path
276
+ unless quiet
277
+ puts
278
+ row "report", report_path
279
+ end
154
280
 
155
281
  raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
282
+
283
+ Batch::Outcome.new(file: file, target: capture.target.qualified_name, status: :pinned,
284
+ detail: outcomes.map(&:config).join(", "), pinned: written.pinned_cases.size,
285
+ spec_path: written.spec_path)
156
286
  end
157
287
  end
158
288
 
159
- desc "validate FILE#METHOD", "Mutation-score a pin, one aspect at a time"
289
+ desc "validate TARGET", "Mutation-score a pin, one aspect at a time"
160
290
  method_option :app, type: :string, default: ".", desc: "target app root"
161
291
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
162
292
  method_option :"test-command", type: :string,
163
293
  desc: "run the app's suite in its own runtime (for apps on Ruby < 3.4)"
164
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
294
+ method_option :method, type: :string, banner: "NAME",
295
+ desc: "the method to pin, when discovery would guess or refuse"
296
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
165
297
  desc: "environment for the app's own runtime"
166
- def validate(target)
298
+ def validate(*args)
167
299
  guarded do
168
- file, method = Analyzer::TargetParser.split_target(target)
300
+ target = target_from(args)
301
+ file, method = resolve_target(target)
169
302
 
170
303
  capture = Runner::Capture.new(
171
304
  app_root: options[:app], target: file, method: method,
172
- max_cases: options[:cases], sandbox_env: app_env
305
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES), sandbox_env: app_env
173
306
  ).run
174
307
 
175
308
  raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
@@ -180,7 +313,7 @@ module Pinspec
180
313
  app_root: options[:app], target: capture.target, plan: capture.plan,
181
314
  corpus: capture.corpus, stability: capture.stability,
182
315
  fk_map: profile.schema.fk_map,
183
- env: app_env, test_command: options[:"test-command"]
316
+ env: app_env, test_command: setting("test-command", nil)
184
317
  ).run
185
318
 
186
319
  print_scores(report)
@@ -214,21 +347,149 @@ module Pinspec
214
347
 
215
348
  private
216
349
 
350
+ # In 0.1.0 --app-env was a Thor array option, so `--app-env A=1 B=2 C=3` was the
351
+ # documented way to pass several. Making it repeatable fixed the option
352
+ # swallowing the target, but it would also have silently reinterpreted every
353
+ # existing invocation - the extra pairs would land here as positionals. So they
354
+ # are still understood, and the target is whichever positional is not a
355
+ # KEY=VALUE pair, wherever it sits on the line.
356
+ LEGACY_ENV_PAIR = /\A[A-Za-z_][A-Za-z0-9_]*=/
357
+
358
+ def target_from(args)
359
+ targets, pairs = args.partition { |arg| !arg.match?(LEGACY_ENV_PAIR) }
360
+ @legacy_env = pairs
361
+
362
+ if targets.empty?
363
+ raise TargetNotFound, no_target_message
364
+ end
365
+
366
+ if targets.size > 1
367
+ raise AmbiguousTarget,
368
+ "more than one target given (#{targets.join(', ')}). pinspec pins one " \
369
+ "file, one FILE#METHOD, or one directory per run."
370
+ end
371
+
372
+ warn_about_legacy_env unless pairs.empty?
373
+ targets.first
374
+ end
375
+
376
+ def warn_about_legacy_env
377
+ warn "pinspec: `--app-env A=1 B=2` is the 0.1.0 form and still works. " \
378
+ "Prefer `--app-env A=1 --app-env B=2`, or record it once with `pinspec init`."
379
+ end
380
+
381
+ # `pinspec pin app/services/foo.rb` used to assume #call. Across five public Rails
382
+ # codebases only 14% of service files had a resolvable one - chatwoot's are named
383
+ # `perform` - so the method is discovered instead, using the convention the
384
+ # application itself follows.
385
+ def resolve_target(target)
386
+ file, method = Analyzer::TargetParser.split_target(target)
387
+
388
+ unless File.file?(file) || File.directory?(file)
389
+ raise TargetNotFound,
390
+ "no file at #{file}. Pass a path to a Ruby file, a FILE#METHOD, or a " \
391
+ "directory - paths are relative to where you are, not to --app."
392
+ end
393
+
394
+ return [file, method] if method
395
+ return [file, options[:method]] if options[:method]
396
+
397
+ choice = Analyzer::Discovery.new(file).choose(convention: @convention)
398
+
399
+ if choice.ambiguous?
400
+ raise AmbiguousTarget, ambiguous_message(file, choice)
401
+ end
402
+
403
+ @chosen = choice
404
+ [file, existing_pin_method(file, choice) || choice.descriptor]
405
+ end
406
+
407
+ # Discovery follows the application's convention, which can differ from what an
408
+ # older pinspec chose - it always assumed #call. Re-pinning a class under a new
409
+ # method would leave the previous pin sitting in the suite, unmaintained and still
410
+ # running. So an existing pin for this class decides.
411
+ def existing_pin_method(file, choice)
412
+ dir = File.join(options[:app], Emit::SpecWriter::SPEC_DIR)
413
+ return nil unless File.directory?(dir)
414
+
415
+ profile = Analyzer::TargetParser.parse(file, choice.descriptor)
416
+ stem = Emit::SpecWriter.class_stem(profile.class_name)
417
+
418
+ # A pin's filename cannot carry `?` or `!`, so compare on the same stripped
419
+ # form the filename uses - otherwise a pinned `#valid?` reads back as `valid`,
420
+ # which is not a method the class has.
421
+ stripped = ->(name) { name.to_s.gsub(/[^a-z0-9_]/i, "") }
422
+ pinned = Dir.glob(File.join(dir, "#{stem}_*_spec.rb"))
423
+ .map { |path| File.basename(path).delete_prefix("#{stem}_").delete_suffix("_spec.rb") }
424
+ .reject { |name| name == stripped.call(choice.method_name) }
425
+
426
+ return nil unless pinned.size == 1
427
+
428
+ # Resolve the stem back to the method the class really defines, so `?` and `!`
429
+ # survive the round trip.
430
+ surface = Analyzer::Discovery.new(file).surface
431
+ real = (surface[:instance] + surface[:singleton]).find { |name| stripped.call(name) == pinned.first }
432
+ return nil if real.nil?
433
+
434
+ warn "pinspec: keeping ##{real}, which this class is already pinned on. " \
435
+ "Discovery would have chosen ##{choice.method_name}; pass " \
436
+ "#{File.basename(file)}##{choice.method_name} to switch."
437
+ real
438
+ rescue StandardError
439
+ nil
440
+ end
441
+
442
+ # --skip-verify means nothing was verified, so the summary must not say it was.
443
+ def no_target_message
444
+ return "no spec file given: pinspec verify spec/models/order_spec.rb" if current_command_chain.first == :verify
445
+
446
+ "no target given. Pass a file, a FILE#METHOD, or a directory: " \
447
+ "pinspec pin app/services/invoice_calculator.rb"
448
+ end
449
+
450
+ def verified_word
451
+ options[:"skip-verify"] ? "not verified" : "verified"
452
+ end
453
+
454
+ def ambiguous_message(file, choice)
455
+ if choice.reason == :no_public_methods
456
+ "#{File.basename(file)} defines no public method to pin. Name one explicitly " \
457
+ "if it is private on purpose: #{File.basename(file)}#the_method"
458
+ else
459
+ "#{File.basename(file)} defines several public methods and none is a " \
460
+ "conventional entry point (#{choice.candidates.join(', ')}). Name the one " \
461
+ "you mean: #{File.basename(file)}##{choice.candidates.first}"
462
+ end
463
+ end
464
+
465
+ def config
466
+ @config ||= Config.load(options[:app] || ".")
467
+ end
468
+
469
+ # File first, then anything typed on the command line, so a flag always wins.
217
470
  def app_env
218
- Array(options[:"app-env"]).each_with_object({}) do |pair, out|
471
+ pairs = Array(@legacy_env) + Array(options[:"app-env"])
472
+
473
+ typed = pairs.each_with_object({}) do |pair, out|
219
474
  key, value = pair.split("=", 2)
220
475
  out[key] = value.to_s
221
476
  end
477
+
478
+ config.env.merge(typed)
479
+ end
480
+
481
+ def setting(key, default)
482
+ config.value(key, options[key.to_sym], default)
222
483
  end
223
484
 
224
- def refuse_unbuilt_backend!
225
- backend = options[:snapshot]
226
- return if backend.nil? || backend == "inline"
485
+ # `--snapshot` selected a backend whose only implementation refused two of its
486
+ # three values. It is accepted and ignored so that upgrading does not break a
487
+ # script that passed it.
488
+ def warn_about_retired_flags!
489
+ return if options[:snapshot].nil?
227
490
 
228
- raise VerifyFailed,
229
- "the #{backend} snapshot backend is not built yet; only `inline` is. " \
230
- "Inline snapshots keep the pinned value in the spec file, where a reviewer " \
231
- "can read it - which is why it is the default. Re-run without --snapshot."
491
+ warn "pinspec: --snapshot was removed and is ignored. A pin has always been " \
492
+ "inline literals, which is what that flag selected by default."
232
493
  end
233
494
 
234
495
  def warn_about_redaction!
@@ -264,7 +525,7 @@ module Pinspec
264
525
  return if graph.skipped_statements.empty?
265
526
 
266
527
  puts
267
- puts " hazards (relevance is decided once a plan exists, in M-05):"
528
+ puts " hazards - statements pinspec could not read; relevant only if a plan needs these tables:"
268
529
  graph.skipped_statements.each { |statement| puts " #{statement}" }
269
530
  end
270
531
 
@@ -300,17 +561,18 @@ module Pinspec
300
561
  stability = result.stability
301
562
 
302
563
  puts "capture #{result.target.qualified_name}"
303
- row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
304
- row "runs", "#{stability.runs} boots"
305
- row "cases", result.corpus.size
306
- row "stable", "#{stability.stable.size} of #{result.corpus.size}"
307
- row "compared", stability.compared_fields.join(", ")
308
- row "observations", result.output_path
309
-
310
- unless stability.stable.empty?
311
- puts
312
- puts " stable, and therefore pinnable:"
313
- stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
564
+ row "stable", "#{stability.stable.size} of #{result.corpus.size} cases, over #{stability.runs} boots"
565
+
566
+ if verbose?
567
+ row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
568
+ row "compared", stability.compared_fields.join(", ")
569
+ row "observations", result.output_path
570
+
571
+ unless stability.stable.empty?
572
+ puts
573
+ puts " stable, and therefore pinnable:"
574
+ stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
575
+ end
314
576
  end
315
577
 
316
578
  return if stability.unstable.empty?
@@ -346,10 +608,11 @@ module Pinspec
346
608
 
347
609
  def print_written(written)
348
610
  puts "emitted #{written.spec_path}"
349
- written.support_paths.each { |path| row "support", path }
350
- row "pinned", written.pinned_cases.join(", ")
351
- row "aspects", written.aspects.reject { |_, count| count.zero? }
352
- .map { |aspect, count| "#{count} #{aspect}" }.join(", ")
611
+ row "pinned", "#{written.pinned_cases.size} case(s): " +
612
+ written.aspects.reject { |_, count| count.zero? }
613
+ .map { |aspect, count| "#{count} #{aspect}" }.join(", ")
614
+
615
+ written.support_paths.each { |path| row "support", path } if verbose?
353
616
  end
354
617
 
355
618
  def print_verification(outcomes)
@@ -578,8 +841,33 @@ module Pinspec
578
841
  values.empty? ? "(none)" : values.join(", ")
579
842
  end
580
843
 
844
+ def verbose?
845
+ options[:verbose]
846
+ end
847
+
581
848
  def row(label, value)
582
849
  puts format(" %-14s %s", label, value)
583
850
  end
851
+
852
+ def print_batch(report, path)
853
+ width = report.outcomes.map { |o| File.basename(o.file).length }.max.to_i
854
+
855
+ report.outcomes.each do |outcome|
856
+ name = File.basename(outcome.file).ljust(width)
857
+
858
+ puts case outcome.status
859
+ when :pinned then format(" pinned %s %-10s %d case(s), #{verified_word}",
860
+ name, "##{outcome.target.to_s[/#(.+)\z/, 1]}", outcome.pinned)
861
+ when :refused then format(" skipped %s %s", name, outcome.detail)
862
+ else format(" FAILED %s %s", name, outcome.detail)
863
+ end
864
+ end
865
+
866
+ puts
867
+ row "pinned", "#{report.pinned.size} of #{report.outcomes.size}"
868
+ row "skipped", report.refused.size if report.refused.any?
869
+ row "failed", report.failed.size if report.failed.any?
870
+ row "specs", File.join(options[:app], Emit::SpecWriter::SPEC_DIR) if report.anything_pinned?
871
+ end
584
872
  end
585
873
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pinspec
6
+ class Config
7
+ FILENAME = ".pinspec.yml"
8
+
9
+ KEYS = %w[cases boots sample redact compare-sql verify-level test-command env].freeze
10
+
11
+ # Keys that used to be valid. A config written for an older pinspec is ignored
12
+ # with a note rather than failing the run - an upgrade should not stop a build.
13
+ RETIRED_KEYS = %w[snapshot].freeze
14
+
15
+ EMPTY = { "env" => {} }.freeze
16
+
17
+ def self.load(app_root)
18
+ path = File.join(app_root.to_s, FILENAME)
19
+ return new(app_root, EMPTY, nil) unless File.file?(path)
20
+
21
+ parsed = YAML.safe_load(Analyzer::Source.read(path), permitted_classes: [], aliases: false) || {}
22
+ raise ConfigInvalid, "#{path} must contain a mapping, got #{parsed.class}" unless parsed.is_a?(Hash)
23
+
24
+ retired = parsed.keys.map(&:to_s) & RETIRED_KEYS
25
+ unless retired.empty?
26
+ warn "pinspec: #{path} sets #{retired.map(&:inspect).join(', ')}, which " \
27
+ "#{retired.size == 1 ? 'was' : 'were'} removed. Ignoring #{retired.size == 1 ? 'it' : 'them'}; " \
28
+ "delete the line to silence this."
29
+ end
30
+
31
+ unknown = parsed.keys.map(&:to_s) - KEYS - RETIRED_KEYS
32
+ unless unknown.empty?
33
+ raise ConfigInvalid,
34
+ "#{path} has unknown #{unknown.size == 1 ? 'key' : 'keys'} " \
35
+ "#{unknown.map(&:inspect).join(', ')}. Known keys: #{KEYS.join(', ')}."
36
+ end
37
+
38
+ new(app_root, parsed, path)
39
+ rescue Psych::SyntaxError => e
40
+ raise ConfigInvalid, "#{path} is not valid YAML: #{e.message}"
41
+ end
42
+
43
+ attr_reader :path
44
+
45
+ def initialize(app_root, data, path)
46
+ @app_root = app_root
47
+ @data = data || {}
48
+ @path = path
49
+ end
50
+
51
+ def exist?
52
+ !@path.nil?
53
+ end
54
+
55
+ def env
56
+ raw = @data["env"] || {}
57
+ raise ConfigInvalid, "#{@path}: `env` must be a mapping of KEY: VALUE" unless raw.is_a?(Hash)
58
+
59
+ raw.transform_keys(&:to_s).transform_values(&:to_s)
60
+ end
61
+
62
+ def [](key)
63
+ @data[key.to_s]
64
+ end
65
+
66
+ # CLI flag beats file beats default. Thor cannot tell a flag that was typed from
67
+ # one that defaulted, so the caller passes the default separately and an option
68
+ # equal to it is treated as untyped.
69
+ def value(key, given, default)
70
+ return given unless given == default || given.nil?
71
+
72
+ fetched = @data[key.to_s]
73
+ fetched.nil? ? default : fetched
74
+ end
75
+
76
+ def to_yaml_document
77
+ <<~YAML
78
+ # pinspec settings. Every key here is a CLI flag you would otherwise repeat;
79
+ # a flag on the command line still wins.
80
+ #
81
+ # The app's Ruby is detected on each run from .ruby-version or .tool-versions,
82
+ # so it is deliberately not recorded here. Use `env` only for what pinspec
83
+ # cannot work out for itself:
84
+ #
85
+ # env:
86
+ # DATABASE_USERNAME: myapp
87
+ #
88
+ #{YAML.dump(@data).sub(/\A---\n/, "").chomp}
89
+ YAML
90
+ end
91
+ end
92
+ end