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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +206 -0
- data/README.md +80 -18
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +272 -34
- data/lib/pinspec/batch.rb +80 -0
- data/lib/pinspec/cli.rb +347 -59
- data/lib/pinspec/config.rb +92 -0
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +2 -2
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +19 -6
- data/lib/pinspec/inputs/sampler.rb +0 -32
- data/lib/pinspec/report/summary.rb +30 -0
- data/lib/pinspec/runner/capture.rb +3 -1
- data/lib/pinspec/runner/probe_generator.rb +4 -1
- data/lib/pinspec/runner/runtime.rb +131 -0
- data/lib/pinspec/runner/sandbox.rb +45 -27
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +40 -5
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +5 -2
- data/lib/pinspec/emit/namer.rb +0 -103
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 "
|
|
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 =
|
|
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:
|
|
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
|
|
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 :
|
|
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(
|
|
155
|
+
def capture(*args)
|
|
73
156
|
guarded do
|
|
74
|
-
|
|
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:
|
|
81
|
-
boots:
|
|
82
|
-
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:
|
|
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
|
|
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 :
|
|
103
|
-
|
|
104
|
-
method_option :"app-env", type: :
|
|
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
|
-
|
|
194
|
+
method_option :snapshot, type: :string, hide: true
|
|
195
|
+
def pin(*args)
|
|
111
196
|
guarded do
|
|
112
|
-
|
|
197
|
+
target = target_from(args)
|
|
198
|
+
warn_about_retired_flags!
|
|
113
199
|
warn_about_redaction!
|
|
114
|
-
|
|
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:
|
|
119
|
-
|
|
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
|
-
|
|
133
|
-
|
|
248
|
+
unless quiet
|
|
249
|
+
puts
|
|
250
|
+
print_written(written)
|
|
251
|
+
end
|
|
134
252
|
|
|
135
|
-
|
|
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:
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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 :
|
|
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(
|
|
298
|
+
def validate(*args)
|
|
167
299
|
guarded do
|
|
168
|
-
|
|
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:
|
|
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:
|
|
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"])
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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 "
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|