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
|
@@ -18,7 +18,7 @@ module Pinspec
|
|
|
18
18
|
Result = Data.define(:spec_path, :support_paths, :pinned_cases, :aspects)
|
|
19
19
|
|
|
20
20
|
def initialize(app_root:, target:, plan:, corpus:, stability:, fk_map:, force: false,
|
|
21
|
-
only_aspect: nil, spec_dir: nil
|
|
21
|
+
only_aspect: nil, spec_dir: nil)
|
|
22
22
|
@app_root = app_root
|
|
23
23
|
@target = target
|
|
24
24
|
@plan = plan
|
|
@@ -28,7 +28,18 @@ module Pinspec
|
|
|
28
28
|
@force = force
|
|
29
29
|
@only_aspect = only_aspect
|
|
30
30
|
@spec_dir = spec_dir || SPEC_DIR
|
|
31
|
-
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The class half of a pin's filename, so a caller can find the pin for a class
|
|
34
|
+
# without knowing which method it froze.
|
|
35
|
+
def self.class_stem(class_name)
|
|
36
|
+
class_name.to_s
|
|
37
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
38
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
39
|
+
.downcase
|
|
40
|
+
.tr("/", "_")
|
|
41
|
+
.tr(":", "_")
|
|
42
|
+
.squeeze("_")
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def spec_path
|
|
@@ -101,7 +112,7 @@ module Pinspec
|
|
|
101
112
|
end
|
|
102
113
|
|
|
103
114
|
def file_stem
|
|
104
|
-
|
|
115
|
+
self.class.class_stem(@target.class_name) + "_" + @target.method_name.to_s.gsub(/[^a-z0-9_]/i, "")
|
|
105
116
|
end
|
|
106
117
|
|
|
107
118
|
def render
|
|
@@ -206,8 +217,13 @@ module Pinspec
|
|
|
206
217
|
fingerprint = @plan.env_fingerprint
|
|
207
218
|
|
|
208
219
|
<<~RUBY
|
|
220
|
+
# travel_to and travel_back live here. rspec-rails includes them only when a
|
|
221
|
+
# suite opts in, and a pin must not depend on that.
|
|
222
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
223
|
+
|
|
209
224
|
before do
|
|
210
225
|
#{clock_guard(fingerprint)} pinspec_clear_sinks
|
|
226
|
+
PinspecFactory.reset_sequences!
|
|
211
227
|
|
|
212
228
|
travel_to Time.parse(#{PINSPEC_EPOCH.inspect})
|
|
213
229
|
srand(#{PINSPEC_SEED})
|
|
@@ -254,11 +270,16 @@ module Pinspec
|
|
|
254
270
|
end
|
|
255
271
|
|
|
256
272
|
def create_call(payload)
|
|
273
|
+
attrs = (payload[:attrs] || {}).merge(assoc_arguments(payload))
|
|
274
|
+
|
|
257
275
|
if payload[:factory]
|
|
258
|
-
|
|
276
|
+
# The probe passes these to the factory too. Dropping them here built a
|
|
277
|
+
# different world in the emitted spec than the one that was captured.
|
|
278
|
+
return "PinspecFactory.create(:#{payload[:factory]})" if attrs.empty?
|
|
279
|
+
|
|
280
|
+
return "PinspecFactory.create(:#{payload[:factory]}, #{render_attrs(attrs)})"
|
|
259
281
|
end
|
|
260
282
|
|
|
261
|
-
attrs = (payload[:attrs] || {}).merge(assoc_arguments(payload))
|
|
262
283
|
"#{payload[:model]}.create!(#{render_attrs(attrs)})"
|
|
263
284
|
end
|
|
264
285
|
|
|
@@ -321,9 +342,6 @@ module Pinspec
|
|
|
321
342
|
end
|
|
322
343
|
|
|
323
344
|
def description_for(input_case, observation)
|
|
324
|
-
named = descriptions[input_case.id]
|
|
325
|
-
return named if named
|
|
326
|
-
|
|
327
345
|
outcome =
|
|
328
346
|
case observation["status"]
|
|
329
347
|
when "raised" then "raises #{observation.dig('error', 'class')}"
|
|
@@ -333,20 +351,6 @@ module Pinspec
|
|
|
333
351
|
"#{@target.method_name} #{outcome} (#{input_case.id}, #{input_case.origin})"
|
|
334
352
|
end
|
|
335
353
|
|
|
336
|
-
def descriptions
|
|
337
|
-
return @descriptions if defined?(@descriptions)
|
|
338
|
-
|
|
339
|
-
@descriptions =
|
|
340
|
-
if @namer&.enabled?
|
|
341
|
-
pairs = pinned.filter_map do |verdict|
|
|
342
|
-
input_case = @corpus.cases.find { |c| c.id == verdict.case_id }
|
|
343
|
-
[input_case, verdict.observation] if input_case
|
|
344
|
-
end
|
|
345
|
-
@namer.describe(pairs)
|
|
346
|
-
else
|
|
347
|
-
{}
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
354
|
|
|
351
355
|
def value_shape(value)
|
|
352
356
|
case value && value["t"]
|
data/lib/pinspec/errors.rb
CHANGED
|
@@ -19,11 +19,10 @@ module Pinspec
|
|
|
19
19
|
DECIMAL_HINTS = %w[BigDecimal Decimal].freeze
|
|
20
20
|
|
|
21
21
|
class << self
|
|
22
|
-
def values_for(param, column: nil
|
|
22
|
+
def values_for(param, column: nil)
|
|
23
23
|
declared = default_value(param)
|
|
24
24
|
edges = edges_for(param, column)
|
|
25
|
-
|
|
26
|
-
ordered = default_first ? [declared, *edges] : [*edges, declared]
|
|
25
|
+
ordered = [declared, *edges]
|
|
27
26
|
|
|
28
27
|
present(ordered).map { |value| encode(value, param) }.uniq
|
|
29
28
|
end
|
|
@@ -48,7 +48,7 @@ module Pinspec
|
|
|
48
48
|
unless status.success?
|
|
49
49
|
raise ProbeFailure,
|
|
50
50
|
"the sampler exited #{status.exitstatus} in #{@app_root}.\n" \
|
|
51
|
-
"#{stderr
|
|
51
|
+
"#{excerpt(stderr)}"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
parse(stdout, stderr)
|
|
@@ -63,6 +63,20 @@ module Pinspec
|
|
|
63
63
|
path
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# A boot failure says WHAT went wrong on its first line and where on the rest.
|
|
67
|
+
# Showing only the last N lines shows the bottom of a backtrace - gem_prelude,
|
|
68
|
+
# rubygems.rb - which is identical for every failure and identifies none of them.
|
|
69
|
+
# Diagnosing a real application's failure took three commands and a read of
|
|
70
|
+
# pinspec's own internals because of that.
|
|
71
|
+
def excerpt(text, head: 6, tail: 6)
|
|
72
|
+
lines = text.to_s.lines
|
|
73
|
+
return lines.join if lines.size <= head + tail
|
|
74
|
+
|
|
75
|
+
lines.first(head).join +
|
|
76
|
+
" ... #{lines.size - head - tail} more line(s) ...\n" +
|
|
77
|
+
lines.last(tail).join
|
|
78
|
+
end
|
|
79
|
+
|
|
66
80
|
def command
|
|
67
81
|
return ["rails", "runner", SCRIPT_PATH] unless File.file?(File.join(@app_root, "Gemfile"))
|
|
68
82
|
|
|
@@ -70,9 +84,9 @@ module Pinspec
|
|
|
70
84
|
end
|
|
71
85
|
|
|
72
86
|
def environment
|
|
73
|
-
Runner::
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
Runner::Runtime.for(@app_root).env
|
|
88
|
+
.merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
|
|
89
|
+
.merge(@env)
|
|
76
90
|
end
|
|
77
91
|
|
|
78
92
|
def parse(stdout, stderr)
|
|
@@ -80,8 +94,7 @@ module Pinspec
|
|
|
80
94
|
|
|
81
95
|
if json.nil?
|
|
82
96
|
raise ProbeFailure,
|
|
83
|
-
"the sampler produced no rows.\nstdout: #{stdout
|
|
84
|
-
"stderr: #{stderr.to_s.lines.last(8).join}"
|
|
97
|
+
"the sampler produced no rows.\nstdout: #{excerpt(stdout)}\nstderr: #{excerpt(stderr)}"
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
parsed = JSON.parse(json)
|
|
@@ -7,41 +7,9 @@ module Pinspec
|
|
|
7
7
|
class Sampler
|
|
8
8
|
SCRIPT_PATH = "tmp/pinspec/sampler.rb"
|
|
9
9
|
|
|
10
|
-
ENV_PREFERENCE = %w[development test].freeze
|
|
11
|
-
|
|
12
|
-
PRODUCTION_HINTS = [
|
|
13
|
-
/(?<![a-z])prod(uction)?(?![a-z])/i,
|
|
14
|
-
/(?<![a-z])live(?![a-z])/i,
|
|
15
|
-
/(?<![a-z])master(?![a-z])/i
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
10
|
MAX_DISTINCT_STATUSES = 6
|
|
19
11
|
|
|
20
12
|
class << self
|
|
21
|
-
def choose_env(available:, counts: {}, override: nil)
|
|
22
|
-
return override if override
|
|
23
|
-
|
|
24
|
-
populated = ENV_PREFERENCE.find do |env|
|
|
25
|
-
available.include?(env) && counts.fetch(env, 0).positive?
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
populated || ENV_PREFERENCE.find { |env| available.include?(env) } || available.first
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def production_like?(name)
|
|
32
|
-
PRODUCTION_HINTS.any? { |hint| name.to_s.match?(hint) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def guard_production!(name, confirmed: false)
|
|
36
|
-
return unless production_like?(name)
|
|
37
|
-
return if confirmed
|
|
38
|
-
|
|
39
|
-
raise EnvironmentRefused,
|
|
40
|
-
"#{name.inspect} looks like a production database. pinspec only ever " \
|
|
41
|
-
"SELECTs, but it will not read production without being told to: " \
|
|
42
|
-
"re-run with --sample-db pointed somewhere else, or confirm explicitly."
|
|
43
|
-
end
|
|
44
|
-
|
|
45
13
|
def script_for(requests)
|
|
46
14
|
new(requests).script
|
|
47
15
|
end
|
|
@@ -185,6 +185,7 @@ module Pinspec
|
|
|
185
185
|
|
|
186
186
|
def coverage_caveats
|
|
187
187
|
caveats = []
|
|
188
|
+
caveats << vacuous_caveat
|
|
188
189
|
caveats << seq_caveat
|
|
189
190
|
caveats << truncated_caveat
|
|
190
191
|
caveats << quarantine_caveat
|
|
@@ -203,6 +204,35 @@ module Pinspec
|
|
|
203
204
|
MD
|
|
204
205
|
end
|
|
205
206
|
|
|
207
|
+
# The honest half of pin strength, and the free half. A case that returned an
|
|
208
|
+
# empty collection, wrote no rows, enqueued nothing and sent nothing did run -
|
|
209
|
+
# but almost any change to the target would still satisfy it. Measured on real
|
|
210
|
+
# code, these are exactly the pins that score weak or worthless.
|
|
211
|
+
def vacuous_caveat
|
|
212
|
+
return nil if @stability.nil?
|
|
213
|
+
|
|
214
|
+
count = @stability.stable.count { |verdict| vacuous?(verdict.observation) }
|
|
215
|
+
return nil if count.zero?
|
|
216
|
+
|
|
217
|
+
"#{count} pinned case(s) observed nothing happening: an empty or absent return " \
|
|
218
|
+
"value, no rows written, no jobs, no mail. The pin holds, but almost any " \
|
|
219
|
+
"change to the target would still satisfy it. Give the target a world with " \
|
|
220
|
+
"something in it - `--sample` reads real rows - before relying on these."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
EMPTY_RETURNS = [[], {}, nil, false, ""].freeze
|
|
224
|
+
|
|
225
|
+
def vacuous?(observation)
|
|
226
|
+
return false unless observation["status"] == "returned"
|
|
227
|
+
return false unless observation["enqueued_jobs"].to_a.empty?
|
|
228
|
+
return false unless observation["mail_deliveries"].to_a.empty?
|
|
229
|
+
return false unless observation["db_delta"].to_h.values.all? { |n| n.to_i.zero? }
|
|
230
|
+
|
|
231
|
+
EMPTY_RETURNS.include?(Tags.decode(observation["return_value"]))
|
|
232
|
+
rescue StandardError
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
206
236
|
def seq_caveat
|
|
207
237
|
count = rendered_observations.scan('"seq"').size
|
|
208
238
|
return nil if count.zero?
|
|
@@ -17,7 +17,9 @@ module Pinspec
|
|
|
17
17
|
@target_file = target
|
|
18
18
|
@method = method
|
|
19
19
|
@max_cases = max_cases
|
|
20
|
-
|
|
20
|
+
# Two is the floor, not the default. One boot has nothing to compare against,
|
|
21
|
+
# so every case would be called stable by virtue of never being checked.
|
|
22
|
+
@boots = [boots.to_i, 2].max
|
|
21
23
|
@compare_sql = compare_sql
|
|
22
24
|
@sandbox_env = sandbox_env
|
|
23
25
|
@output_dir = output_dir || File.join(app_root, Sandbox::PROBE_DIR)
|
|
@@ -574,6 +574,7 @@ module Pinspec
|
|
|
574
574
|
begin
|
|
575
575
|
pinspec_with_isolation do
|
|
576
576
|
begin
|
|
577
|
+
PinspecFactory.reset_sequences!
|
|
577
578
|
pinspec_run_steps(records, refs)
|
|
578
579
|
rescue StandardError => setup_error
|
|
579
580
|
observation["status"] = "setup_error"
|
|
@@ -638,7 +639,9 @@ module Pinspec
|
|
|
638
639
|
# Between cases: anything memoized in a cache or a CurrentAttributes slot
|
|
639
640
|
# would otherwise leak into the next case.
|
|
640
641
|
Rails.cache.clear if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
|
641
|
-
|
|
642
|
+
if defined?(ActiveSupport::CurrentAttributes) && ActiveSupport::CurrentAttributes.respond_to?(:reset_all)
|
|
643
|
+
ActiveSupport::CurrentAttributes.reset_all
|
|
644
|
+
end
|
|
642
645
|
pinspec_clear_sinks
|
|
643
646
|
PinspecClock.travel_back
|
|
644
647
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
module Runner
|
|
5
|
+
class Runtime
|
|
6
|
+
MANAGERS = %i[rvm rbenv asdf mise chruby].freeze
|
|
7
|
+
|
|
8
|
+
Result = Data.define(:env, :ruby_version, :manager, :note) do
|
|
9
|
+
def detected?
|
|
10
|
+
!manager.nil?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def same_ruby?
|
|
14
|
+
manager.nil? && note.nil?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
BUNDLER_VARS = %w[
|
|
19
|
+
BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLE_APP_CONFIG
|
|
20
|
+
BUNDLER_VERSION BUNDLER_SETUP RUBYOPT RUBYLIB
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.for(app_root, home: Dir.home, current: RUBY_VERSION)
|
|
24
|
+
new(app_root, home: home, current: current).resolve
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(app_root, home: Dir.home, current: RUBY_VERSION)
|
|
28
|
+
@app_root = app_root
|
|
29
|
+
@home = home
|
|
30
|
+
@current = current
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve
|
|
34
|
+
wanted = declared_version
|
|
35
|
+
return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if wanted.nil?
|
|
36
|
+
return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if satisfied_by_current?(wanted)
|
|
37
|
+
|
|
38
|
+
found = MANAGERS.filter_map { |manager| send(:"#{manager}_env", wanted)&.then { |e| [manager, e] } }.first
|
|
39
|
+
|
|
40
|
+
if found.nil?
|
|
41
|
+
return Result.new(
|
|
42
|
+
env: scrub, ruby_version: wanted, manager: nil,
|
|
43
|
+
note: "#{@app_root} declares Ruby #{wanted} and this is #{@current}, but no " \
|
|
44
|
+
"installation of #{wanted} was found under rvm, rbenv, asdf, mise or " \
|
|
45
|
+
"chruby. Pass the app's runtime explicitly:\n" \
|
|
46
|
+
" --app-env PATH=/path/to/ruby-#{wanted}/bin:$PATH"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
manager, env = found
|
|
51
|
+
Result.new(env: scrub.merge(env), ruby_version: wanted, manager: manager, note: nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def declared_version
|
|
55
|
+
from_file(".ruby-version") || from_tool_versions
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Only bundler's variables are removed. GEM_HOME and GEM_PATH are left alone:
|
|
61
|
+
# dropping them is what forced every invocation to hand the app's gemset back
|
|
62
|
+
# by hand, and they are only wrong when the app runs on a different Ruby - in
|
|
63
|
+
# which case the detected runtime overwrites them anyway.
|
|
64
|
+
def scrub
|
|
65
|
+
BUNDLER_VARS.to_h { |name| [name, nil] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def from_file(name)
|
|
69
|
+
path = File.join(@app_root, name)
|
|
70
|
+
return nil unless File.file?(path)
|
|
71
|
+
|
|
72
|
+
Analyzer::Source.read(path).strip[/\d+\.\d+(\.\d+)?/]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def from_tool_versions
|
|
76
|
+
path = File.join(@app_root, ".tool-versions")
|
|
77
|
+
return nil unless File.file?(path)
|
|
78
|
+
|
|
79
|
+
Analyzer::Source.read(path)[/^\s*ruby\s+(\S+)/, 1]&.[](/\d+\.\d+(\.\d+)?/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A patch-level difference is not worth relocating the runtime for: 3.3.0 and
|
|
83
|
+
# 3.3.6 run the same code. A minor difference is.
|
|
84
|
+
def satisfied_by_current?(wanted)
|
|
85
|
+
@current == wanted || series(@current) == series(wanted)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def series(version)
|
|
89
|
+
version.to_s.split(".").first(2).join(".")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def rvm_env(wanted)
|
|
93
|
+
root = File.join(@home, ".rvm")
|
|
94
|
+
ruby = File.join(root, "rubies", "ruby-#{wanted}", "bin")
|
|
95
|
+
return nil unless File.directory?(ruby)
|
|
96
|
+
|
|
97
|
+
gems = File.join(root, "gems", "ruby-#{wanted}")
|
|
98
|
+
env = { "PATH" => prepend(ruby) }
|
|
99
|
+
if File.directory?(gems)
|
|
100
|
+
env["GEM_HOME"] = gems
|
|
101
|
+
env["GEM_PATH"] = "#{gems}:#{gems}@global"
|
|
102
|
+
end
|
|
103
|
+
env
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def rbenv_env(wanted)
|
|
107
|
+
bin = File.join(@home, ".rbenv", "versions", wanted, "bin")
|
|
108
|
+
File.directory?(bin) ? { "PATH" => prepend(bin), "RBENV_VERSION" => wanted } : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def asdf_env(wanted)
|
|
112
|
+
bin = File.join(@home, ".asdf", "installs", "ruby", wanted, "bin")
|
|
113
|
+
File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def mise_env(wanted)
|
|
117
|
+
bin = File.join(@home, ".local", "share", "mise", "installs", "ruby", wanted, "bin")
|
|
118
|
+
File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def chruby_env(wanted)
|
|
122
|
+
bin = File.join(@home, ".rubies", "ruby-#{wanted}", "bin")
|
|
123
|
+
File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def prepend(bin)
|
|
127
|
+
[bin, ENV.fetch("PATH", "")].reject(&:empty?).join(File::PATH_SEPARATOR)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -8,9 +8,17 @@ module Pinspec
|
|
|
8
8
|
module Runner
|
|
9
9
|
class Sandbox
|
|
10
10
|
PROBE_DIR = "tmp/pinspec"
|
|
11
|
-
PROBE_FILE = "probe.rb"
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
# One probe file per run. A fixed path means two pinspec runs against the same
|
|
13
|
+
# application overwrite each other's probe between boots, and the result is not
|
|
14
|
+
# an error: the stability filter compares one target's observations against
|
|
15
|
+
# another's and reports the target as unstable, or crashes looking up a case id
|
|
16
|
+
# that belongs to a different corpus. Silent and wrong, which is worse than a
|
|
17
|
+
# lock. `pinspec pin app/services` is sequential, but a developer with two
|
|
18
|
+
# terminals or a CI that fans out is not.
|
|
19
|
+
def self.probe_filename(pid: Process.pid, object_id: nil)
|
|
20
|
+
"probe-#{pid}-#{object_id || rand(1 << 24)}.rb"
|
|
21
|
+
end
|
|
14
22
|
|
|
15
23
|
FORCED_ENV = {
|
|
16
24
|
"DISABLE_SPRING" => "1",
|
|
@@ -18,18 +26,7 @@ module Pinspec
|
|
|
18
26
|
"RAILS_ENV" => "test"
|
|
19
27
|
}.freeze
|
|
20
28
|
|
|
21
|
-
SCRUBBED_ENV = {
|
|
22
|
-
"BUNDLE_GEMFILE" => nil,
|
|
23
|
-
"BUNDLE_PATH" => nil,
|
|
24
|
-
"BUNDLE_BIN_PATH" => nil,
|
|
25
|
-
"BUNDLE_APP_CONFIG" => nil,
|
|
26
|
-
"BUNDLER_VERSION" => nil,
|
|
27
|
-
"BUNDLER_SETUP" => nil,
|
|
28
|
-
"RUBYOPT" => nil,
|
|
29
|
-
"RUBYLIB" => nil,
|
|
30
|
-
"GEM_HOME" => nil,
|
|
31
|
-
"GEM_PATH" => nil
|
|
32
|
-
}.freeze
|
|
29
|
+
SCRUBBED_ENV = Runtime::BUNDLER_VARS.to_h { |name| [name, nil] }.freeze
|
|
33
30
|
|
|
34
31
|
Result = Data.define(:run, :observations, :env, :plan_id, :stderr) do
|
|
35
32
|
def observation(case_id)
|
|
@@ -37,16 +34,14 @@ module Pinspec
|
|
|
37
34
|
end
|
|
38
35
|
end
|
|
39
36
|
|
|
40
|
-
def initialize(app_root:, probe_source:,
|
|
37
|
+
def initialize(app_root:, probe_source:, env: {})
|
|
41
38
|
@app_root = app_root
|
|
42
39
|
@probe_source = probe_source
|
|
43
|
-
@timeout = timeout
|
|
44
|
-
@runner = runner
|
|
45
40
|
@env = env
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
def probe_path
|
|
49
|
-
File.join(@app_root, PROBE_DIR,
|
|
44
|
+
@probe_path ||= File.join(@app_root, PROBE_DIR, self.class.probe_filename(object_id: object_id))
|
|
50
45
|
end
|
|
51
46
|
|
|
52
47
|
def write_probe!
|
|
@@ -58,38 +53,63 @@ module Pinspec
|
|
|
58
53
|
def capture(boots: 2)
|
|
59
54
|
write_probe!
|
|
60
55
|
|
|
61
|
-
(1..boots).map
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
results = (1..boots).map { |run| execute(run: run, seed: 40 + run) }
|
|
57
|
+
|
|
58
|
+
# Removed only on success. A probe left behind after a failure is the
|
|
59
|
+
# evidence; one left behind after every run just fills tmp/ with files a
|
|
60
|
+
# reader cannot tell apart.
|
|
61
|
+
FileUtils.rm_f(probe_path)
|
|
62
|
+
results
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def runtime
|
|
66
|
+
@runtime ||= Runtime.for(@app_root)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
private
|
|
67
70
|
|
|
68
71
|
def execute(run:, seed:)
|
|
69
72
|
command = runner_command
|
|
70
|
-
env =
|
|
73
|
+
env = runtime.env
|
|
71
74
|
.merge(FORCED_ENV)
|
|
72
75
|
.merge("PINSPEC_SHUFFLE_SEED" => seed.to_s)
|
|
73
76
|
.merge(@env)
|
|
74
77
|
|
|
75
78
|
stdout, stderr, status = Open3.capture3(env, *command, chdir: @app_root)
|
|
79
|
+
stdout = stdout.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
80
|
+
stderr = stderr.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
76
81
|
|
|
77
82
|
unless status.success?
|
|
78
83
|
raise ProbeFailure,
|
|
79
84
|
"the probe exited #{status.exitstatus} in #{@app_root}.\n" \
|
|
80
|
-
"#{
|
|
85
|
+
"#{runtime.note ? "#{runtime.note}\n\n" : ''}" \
|
|
86
|
+
"#{excerpt(stderr)}"
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
parse(stdout, stderr, run)
|
|
84
90
|
end
|
|
85
91
|
|
|
92
|
+
# A boot failure says WHAT went wrong on its first line and where on the rest.
|
|
93
|
+
# Showing only the last N lines shows the bottom of a backtrace - gem_prelude,
|
|
94
|
+
# rubygems.rb - which is identical for every failure and identifies none of them.
|
|
95
|
+
# Diagnosing a real application's failure took three commands and a read of
|
|
96
|
+
# pinspec's own internals because of that.
|
|
97
|
+
def excerpt(text, head: 6, tail: 6)
|
|
98
|
+
lines = text.to_s.lines
|
|
99
|
+
return lines.join if lines.size <= head + tail
|
|
100
|
+
|
|
101
|
+
lines.first(head).join +
|
|
102
|
+
" ... #{lines.size - head - tail} more line(s) ...\n" +
|
|
103
|
+
lines.last(tail).join
|
|
104
|
+
end
|
|
105
|
+
|
|
86
106
|
def parse(stdout, stderr, run)
|
|
87
107
|
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
88
108
|
|
|
89
109
|
if json.nil?
|
|
90
110
|
raise ProbeFailure,
|
|
91
111
|
"the probe produced no observations.\n" \
|
|
92
|
-
"stdout: #{stdout
|
|
112
|
+
"stdout: #{excerpt(stdout)}\nstderr: #{excerpt(stderr)}"
|
|
93
113
|
end
|
|
94
114
|
|
|
95
115
|
parsed = JSON.parse(json)
|
|
@@ -106,9 +126,7 @@ module Pinspec
|
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def runner_command
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
relative = File.join(PROBE_DIR, PROBE_FILE)
|
|
129
|
+
relative = File.join(PROBE_DIR, File.basename(probe_path))
|
|
112
130
|
|
|
113
131
|
if File.file?(File.join(@app_root, "Gemfile"))
|
|
114
132
|
["bundle", "exec", "rails", "runner", relative]
|
|
@@ -5,8 +5,6 @@ require "digest"
|
|
|
5
5
|
module Pinspec
|
|
6
6
|
module Setup
|
|
7
7
|
class ContextBuilder
|
|
8
|
-
MAX_GENERATIONS = 3
|
|
9
|
-
|
|
10
8
|
class << self
|
|
11
9
|
def build(target:, profile:, imports: [], generation: 1, tz: nil)
|
|
12
10
|
new(target: target, profile: profile, imports: imports, generation: generation, tz: tz).build
|
|
@@ -33,8 +31,11 @@ module Pinspec
|
|
|
33
31
|
refuse_attachment_targets!
|
|
34
32
|
|
|
35
33
|
environment_steps
|
|
36
|
-
|
|
34
|
+
# Imports first: create_record_for skips a table whose ref already exists, so
|
|
35
|
+
# building factory rows first meant every binding pointed at the factory
|
|
36
|
+
# record and the sampled row - the whole point of --sample - went unused.
|
|
37
37
|
import_steps
|
|
38
|
+
record_steps
|
|
38
39
|
context_steps
|
|
39
40
|
subject_step
|
|
40
41
|
|
|
@@ -146,11 +147,13 @@ module Pinspec
|
|
|
146
147
|
@resolver.table_for_type_hint(param.type_hint)
|
|
147
148
|
end
|
|
148
149
|
|
|
150
|
+
# Only the hints that actually occur. Measured over 841 real service files, these
|
|
151
|
+
# ten are the only ones ever produced; the other thirty-one fired zero times and
|
|
152
|
+
# none of them has a Boundary value, so a hint that did somehow reach the list
|
|
153
|
+
# would be exempted from the refusal and then passed as nil - manufacturing the
|
|
154
|
+
# exact fiction the refusal exists to prevent.
|
|
149
155
|
NON_MODEL_HINTS = %w[
|
|
150
|
-
String Symbol Integer Float
|
|
151
|
-
FalseClass Hash Array Range Time Date DateTime Proc Class Module Object
|
|
152
|
-
NilClass Params Param Options Option Attributes Attribute Id Ids Name
|
|
153
|
-
Amount Quantity Count Total Price Rate Percentage Status State Kind Type
|
|
156
|
+
String Symbol Integer Float Boolean Hash Array Time Date Object
|
|
154
157
|
].freeze
|
|
155
158
|
|
|
156
159
|
def refuse_unresolvable_model_param!(param)
|