pinspec 0.2.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 +128 -0
- data/README.md +24 -3
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +266 -33
- data/lib/pinspec/cli.rb +143 -22
- data/lib/pinspec/config.rb +13 -2
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +0 -4
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +16 -3
- 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/sandbox.rb +19 -9
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +37 -2
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +2 -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
|
|
|
@@ -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
|
|
@@ -20,8 +20,6 @@ module Pinspec
|
|
|
20
20
|
"probe-#{pid}-#{object_id || rand(1 << 24)}.rb"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
DEFAULT_TIMEOUT = 600
|
|
24
|
-
|
|
25
23
|
FORCED_ENV = {
|
|
26
24
|
"DISABLE_SPRING" => "1",
|
|
27
25
|
"TZ" => "UTC",
|
|
@@ -36,11 +34,9 @@ module Pinspec
|
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
def initialize(app_root:, probe_source:,
|
|
37
|
+
def initialize(app_root:, probe_source:, env: {})
|
|
40
38
|
@app_root = app_root
|
|
41
39
|
@probe_source = probe_source
|
|
42
|
-
@timeout = timeout
|
|
43
|
-
@runner = runner
|
|
44
40
|
@env = env
|
|
45
41
|
end
|
|
46
42
|
|
|
@@ -80,24 +76,40 @@ module Pinspec
|
|
|
80
76
|
.merge(@env)
|
|
81
77
|
|
|
82
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
|
|
83
81
|
|
|
84
82
|
unless status.success?
|
|
85
83
|
raise ProbeFailure,
|
|
86
84
|
"the probe exited #{status.exitstatus} in #{@app_root}.\n" \
|
|
87
85
|
"#{runtime.note ? "#{runtime.note}\n\n" : ''}" \
|
|
88
|
-
"#{stderr
|
|
86
|
+
"#{excerpt(stderr)}"
|
|
89
87
|
end
|
|
90
88
|
|
|
91
89
|
parse(stdout, stderr, run)
|
|
92
90
|
end
|
|
93
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
|
+
|
|
94
106
|
def parse(stdout, stderr, run)
|
|
95
107
|
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
96
108
|
|
|
97
109
|
if json.nil?
|
|
98
110
|
raise ProbeFailure,
|
|
99
111
|
"the probe produced no observations.\n" \
|
|
100
|
-
"stdout: #{stdout
|
|
112
|
+
"stdout: #{excerpt(stdout)}\nstderr: #{excerpt(stderr)}"
|
|
101
113
|
end
|
|
102
114
|
|
|
103
115
|
parsed = JSON.parse(json)
|
|
@@ -114,8 +126,6 @@ module Pinspec
|
|
|
114
126
|
end
|
|
115
127
|
|
|
116
128
|
def runner_command
|
|
117
|
-
return @runner if @runner
|
|
118
|
-
|
|
119
129
|
relative = File.join(PROBE_DIR, File.basename(probe_path))
|
|
120
130
|
|
|
121
131
|
if File.file?(File.join(@app_root, "Gemfile"))
|
|
@@ -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)
|
data/lib/pinspec/types.rb
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pinspec
|
|
4
|
-
PARAM_KINDS = %i[req opt rest keyreq key keyrest].freeze
|
|
5
4
|
|
|
6
5
|
OPTIONAL_PARAM_KINDS = %i[opt key rest keyrest].freeze
|
|
7
6
|
POSITIONAL_PARAM_KINDS = %i[req opt rest].freeze
|
|
8
7
|
|
|
9
|
-
CONSTRUCTION_KINDS = %i[
|
|
10
|
-
new
|
|
11
|
-
class_method
|
|
12
|
-
interactor
|
|
13
|
-
dry_initializer
|
|
14
|
-
struct
|
|
15
|
-
model_instance
|
|
16
|
-
].freeze
|
|
17
8
|
|
|
18
9
|
Param = Data.define(:name, :kind, :default_source, :type_hint) do
|
|
19
10
|
def positional?
|
|
@@ -49,7 +40,8 @@ module Pinspec
|
|
|
49
40
|
:takes_block,
|
|
50
41
|
:source_range,
|
|
51
42
|
:referenced_constants,
|
|
52
|
-
:clock_sites
|
|
43
|
+
:clock_sites,
|
|
44
|
+
:construction_source
|
|
53
45
|
) do
|
|
54
46
|
def singleton?
|
|
55
47
|
construction_kind == :class_method
|
|
@@ -365,38 +357,6 @@ module Pinspec
|
|
|
365
357
|
def dsl_module
|
|
366
358
|
legacy_dsl ? "FactoryGirl" : "FactoryBot"
|
|
367
359
|
end
|
|
368
|
-
|
|
369
|
-
def traits_for(name)
|
|
370
|
-
ancestry(name).flat_map(&:traits)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def ancestry(name)
|
|
374
|
-
chain = []
|
|
375
|
-
seen = []
|
|
376
|
-
current = factory(name)
|
|
377
|
-
|
|
378
|
-
while current && !seen.include?(current.name)
|
|
379
|
-
seen << current.name
|
|
380
|
-
chain.unshift(current)
|
|
381
|
-
current = current.parent && factory(current.parent)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
chain
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def attributes_for(name, traits: [])
|
|
388
|
-
chain = ancestry(name)
|
|
389
|
-
merged = {}
|
|
390
|
-
|
|
391
|
-
chain.each { |f| f.attributes.each { |a| merged[a.name] = a } }
|
|
392
|
-
|
|
393
|
-
Array(traits).each do |trait_name|
|
|
394
|
-
found = chain.reverse.filter_map { |f| f.trait(trait_name) }.first
|
|
395
|
-
found&.attributes&.each { |a| merged[a.name] = a }
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
merged.values
|
|
399
|
-
end
|
|
400
360
|
end
|
|
401
361
|
|
|
402
362
|
Column = Data.define(
|
|
@@ -450,13 +410,7 @@ module Pinspec
|
|
|
450
410
|
end
|
|
451
411
|
|
|
452
412
|
SkippedStatement = Data.define(:kind, :table, :column, :references, :file, :line, :relevant) do
|
|
453
|
-
def relevant?
|
|
454
|
-
relevant == true
|
|
455
|
-
end
|
|
456
413
|
|
|
457
|
-
def tables_touched
|
|
458
|
-
([table] + Array(references)).compact.uniq
|
|
459
|
-
end
|
|
460
414
|
|
|
461
415
|
def to_s
|
|
462
416
|
subject = column ? "#{table}.#{column}" : table
|
|
@@ -486,12 +440,5 @@ module Pinspec
|
|
|
486
440
|
tables.flat_map { |t| t.columns.select(&:unknown_type?).map { |c| [t.name, c] } }
|
|
487
441
|
end
|
|
488
442
|
|
|
489
|
-
def annotate_relevance(planned_tables)
|
|
490
|
-
wanted = Array(planned_tables).map(&:to_s)
|
|
491
|
-
|
|
492
|
-
with(skipped_statements: skipped_statements.map do |statement|
|
|
493
|
-
statement.with(relevant: statement.tables_touched.any? { |t| wanted.include?(t) })
|
|
494
|
-
end)
|
|
495
|
-
end
|
|
496
443
|
end
|
|
497
444
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
3
5
|
require "json"
|
|
4
6
|
require "open3"
|
|
5
7
|
|
|
@@ -8,6 +10,8 @@ module Pinspec
|
|
|
8
10
|
class Verifier
|
|
9
11
|
CONFIGS = %i[isolated hostile neighbored].freeze
|
|
10
12
|
|
|
13
|
+
NEIGHBOUR_DIR = "tmp/pinspec"
|
|
14
|
+
|
|
11
15
|
HOSTILE_TZ_CANDIDATES = ["Etc/GMT+8", "Etc/GMT-6"].freeze
|
|
12
16
|
|
|
13
17
|
Outcome = Data.define(:config, :status, :diagnosis, :detail, :examples, :failures) do
|
|
@@ -52,6 +56,7 @@ module Pinspec
|
|
|
52
56
|
def run(config)
|
|
53
57
|
args, extra_env = arguments_for(config)
|
|
54
58
|
stdout, stderr, _status = Open3.capture3(environment.merge(extra_env), *args, chdir: @app_root)
|
|
59
|
+
FileUtils.rm_f(@neighbour) if @neighbour
|
|
55
60
|
|
|
56
61
|
summary = parse(stdout)
|
|
57
62
|
return failure(config, stdout, stderr, summary) if summary.nil? || summary["failure_count"].to_i.positive?
|
|
@@ -72,7 +77,7 @@ module Pinspec
|
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
def arguments_for(config)
|
|
75
|
-
relative =
|
|
80
|
+
relative = relative_spec_path
|
|
76
81
|
base = ["bundle", "exec", "rspec", "--format", "json"]
|
|
77
82
|
|
|
78
83
|
case config
|
|
@@ -81,10 +86,40 @@ module Pinspec
|
|
|
81
86
|
when :hostile
|
|
82
87
|
[base + ["--seed", "7", relative], { "TZ" => hostile_tz, "LANG" => "C", "LC_ALL" => "C" }]
|
|
83
88
|
when :neighbored
|
|
84
|
-
|
|
89
|
+
# A COPY alongside the original, because RSpec loads a given path once
|
|
90
|
+
# however many times it is named - so passing it twice re-ran nothing, and
|
|
91
|
+
# this configuration silently checked exactly what :isolated checks.
|
|
92
|
+
[base + [relative, neighbour_of(relative)], {}]
|
|
85
93
|
end
|
|
86
94
|
end
|
|
87
95
|
|
|
96
|
+
# Pathname does this properly: a string sub leaves an absolute path untouched
|
|
97
|
+
# when it does not start with the app root, which is exactly the case when
|
|
98
|
+
# someone runs `pinspec verify spec/foo_spec.rb` from inside their app.
|
|
99
|
+
def relative_spec_path
|
|
100
|
+
Pathname.new(File.expand_path(@spec_path))
|
|
101
|
+
.relative_path_from(Pathname.new(File.expand_path(@app_root)))
|
|
102
|
+
.to_s
|
|
103
|
+
rescue ArgumentError
|
|
104
|
+
@spec_path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Written into the app's tmp, with its relative requires rewritten to point back
|
|
108
|
+
# at the support files the original sits beside. Removed again after the run -
|
|
109
|
+
# pinspec does not leave files in somebody's repository.
|
|
110
|
+
def neighbour_of(relative)
|
|
111
|
+
source = File.expand_path(relative, @app_root)
|
|
112
|
+
target = File.join(@app_root, NEIGHBOUR_DIR, "neighbour_#{File.basename(relative)}")
|
|
113
|
+
support = File.dirname(source)
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
116
|
+
File.write(target, Analyzer::Source.read(source)
|
|
117
|
+
.gsub('require_relative "support/', %(require_relative "#{support}/support/)))
|
|
118
|
+
@neighbour = target
|
|
119
|
+
|
|
120
|
+
File.join(NEIGHBOUR_DIR, File.basename(target))
|
|
121
|
+
end
|
|
122
|
+
|
|
88
123
|
def hostile_tz
|
|
89
124
|
HOSTILE_TZ_CANDIDATES.find { |zone| zone != @captured_tz } || HOSTILE_TZ_CANDIDATES.first
|
|
90
125
|
end
|
data/lib/pinspec/version.rb
CHANGED
data/templates/factory_build.rb
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
module PinspecFactory
|
|
5
5
|
MAX_ATTEMPTS = 8
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
# factory_bot sequences are process-global and monotonic, so a pin whose world uses
|
|
8
|
+
# one is only reproducible while nothing else in the process built that factory
|
|
9
|
+
# first. Running the same pin twice in one process produced INV-1 then INV-2 and the
|
|
10
|
+
# second run failed against its own snapshot. Both hosts rewind before every case,
|
|
11
|
+
# so a case always sees the same sequence values.
|
|
12
|
+
def self.reset_sequences!
|
|
13
|
+
mod = defined?(FactoryBot) ? FactoryBot : (defined?(FactoryGirl) ? FactoryGirl : nil)
|
|
14
|
+
return unless mod.respond_to?(:rewind_sequences)
|
|
15
|
+
|
|
16
|
+
mod.rewind_sequences
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def self.default_module
|
|
@@ -31,7 +35,6 @@ module PinspecFactory
|
|
|
31
35
|
factory_module.create(name.to_sym, attrs)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
attempts[name.to_s] = attempt
|
|
35
38
|
return record
|
|
36
39
|
rescue StandardError => e
|
|
37
40
|
raise unless retryable?(e)
|
|
@@ -40,7 +43,6 @@ module PinspecFactory
|
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
attempts[name.to_s] = MAX_ATTEMPTS
|
|
44
46
|
raise last_error
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -48,7 +50,4 @@ module PinspecFactory
|
|
|
48
50
|
defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
def self.fragile
|
|
52
|
-
attempts.select { |_name, count| count > 1 }
|
|
53
|
-
end
|
|
54
53
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pinspec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rehan Munir
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- exe/pinspec
|
|
76
76
|
- lib/pinspec.rb
|
|
77
77
|
- lib/pinspec/analyzer/app_profile_reader.rb
|
|
78
|
+
- lib/pinspec/analyzer/discovery.rb
|
|
78
79
|
- lib/pinspec/analyzer/factory_registry.rb
|
|
79
80
|
- lib/pinspec/analyzer/inflector.rb
|
|
80
81
|
- lib/pinspec/analyzer/schema_reader.rb
|
|
@@ -83,7 +84,6 @@ files:
|
|
|
83
84
|
- lib/pinspec/batch.rb
|
|
84
85
|
- lib/pinspec/cli.rb
|
|
85
86
|
- lib/pinspec/config.rb
|
|
86
|
-
- lib/pinspec/emit/namer.rb
|
|
87
87
|
- lib/pinspec/emit/spec_writer.rb
|
|
88
88
|
- lib/pinspec/emit/stability_filter.rb
|
|
89
89
|
- lib/pinspec/errors.rb
|