pinspec 0.1.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 +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Pinspec
|
|
8
|
+
module Inputs
|
|
9
|
+
class SampleRunner
|
|
10
|
+
SCRIPT_PATH = "tmp/pinspec/sampler.rb"
|
|
11
|
+
|
|
12
|
+
Result = Data.define(:env, :counts, :rows, :stratified, :errors) do
|
|
13
|
+
def total_rows
|
|
14
|
+
counts.values.sum
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
total_rows.zero?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def rows_for(table)
|
|
22
|
+
seen = {}
|
|
23
|
+
|
|
24
|
+
(Array(rows[table]) + Array(stratified[table])).each do |row|
|
|
25
|
+
seen[row["id"] || row[:id] || row.hash] = row
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
seen.values
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def all_rows
|
|
32
|
+
counts.keys.each_with_object({}) { |table, out| out[table] = rows_for(table) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(app_root:, env: {}, rails_env: "development", timeout: 300)
|
|
37
|
+
@app_root = app_root
|
|
38
|
+
@env = env
|
|
39
|
+
@rails_env = rails_env
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fetch(requests)
|
|
44
|
+
write_script!(Sampler.script_for(requests))
|
|
45
|
+
|
|
46
|
+
stdout, stderr, status = Open3.capture3(environment, *command, chdir: @app_root)
|
|
47
|
+
|
|
48
|
+
unless status.success?
|
|
49
|
+
raise ProbeFailure,
|
|
50
|
+
"the sampler exited #{status.exitstatus} in #{@app_root}.\n" \
|
|
51
|
+
"#{stderr.to_s.lines.last(12).join}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
parse(stdout, stderr)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def write_script!(source)
|
|
60
|
+
path = File.join(@app_root, SCRIPT_PATH)
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
62
|
+
File.write(path, source)
|
|
63
|
+
path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def command
|
|
67
|
+
return ["rails", "runner", SCRIPT_PATH] unless File.file?(File.join(@app_root, "Gemfile"))
|
|
68
|
+
|
|
69
|
+
["bundle", "exec", "rails", "runner", SCRIPT_PATH]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def environment
|
|
73
|
+
Runner::Sandbox::SCRUBBED_ENV
|
|
74
|
+
.merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
|
|
75
|
+
.merge(@env)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse(stdout, stderr)
|
|
79
|
+
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
80
|
+
|
|
81
|
+
if json.nil?
|
|
82
|
+
raise ProbeFailure,
|
|
83
|
+
"the sampler produced no rows.\nstdout: #{stdout.to_s.lines.last(8).join}" \
|
|
84
|
+
"stderr: #{stderr.to_s.lines.last(8).join}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
parsed = JSON.parse(json)
|
|
88
|
+
|
|
89
|
+
Result.new(
|
|
90
|
+
env: parsed["env"], counts: parsed["counts"] || {}, rows: parsed["rows"] || {},
|
|
91
|
+
stratified: parsed["stratified"] || {}, errors: parsed["errors"] || []
|
|
92
|
+
)
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
raise ProbeFailure, "the sampler's output was not JSON (#{e.message})"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Inputs
|
|
7
|
+
class Sampler
|
|
8
|
+
SCRIPT_PATH = "tmp/pinspec/sampler.rb"
|
|
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
|
+
MAX_DISTINCT_STATUSES = 6
|
|
19
|
+
|
|
20
|
+
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
|
+
def script_for(requests)
|
|
46
|
+
new(requests).script
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(requests)
|
|
51
|
+
@requests = Array(requests)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def script
|
|
55
|
+
<<~RUBY
|
|
56
|
+
# frozen_string_literal: true
|
|
57
|
+
#
|
|
58
|
+
# Generated by pinspec #{VERSION}. Do not edit: regenerate it.
|
|
59
|
+
#
|
|
60
|
+
# READ-ONLY. This script issues SELECT statements and nothing else. It runs
|
|
61
|
+
# inside the target application via `bundle exec rails runner` so that the
|
|
62
|
+
# app's own adapter and database.yml decide what it connects to.
|
|
63
|
+
#
|
|
64
|
+
# Ruby 2.6 syntax floor: this runs in the app's Ruby, not pinspec's.
|
|
65
|
+
|
|
66
|
+
require "json"
|
|
67
|
+
|
|
68
|
+
unless defined?(ActiveRecord::Base)
|
|
69
|
+
abort("pinspec sampler: ActiveRecord is not loaded; is this a Rails application?")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
PINSPEC_REQUESTS = JSON.parse(<<'PINSPEC_REQUESTS_JSON')
|
|
73
|
+
#{JSON.pretty_generate(@requests.map { |request| stringify(request) })}
|
|
74
|
+
PINSPEC_REQUESTS_JSON
|
|
75
|
+
|
|
76
|
+
def pinspec_connection
|
|
77
|
+
ActiveRecord::Base.connection
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pinspec_count(table)
|
|
81
|
+
quoted = pinspec_connection.quote_table_name(table)
|
|
82
|
+
pinspec_connection.select_value("SELECT COUNT(*) FROM " + quoted).to_i
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
warn("pinspec sampler: cannot count " + table + ": " + e.class.to_s)
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Deterministic positions rather than random ones: the same database always
|
|
89
|
+
# yields the same corpus, so a plan stays content-addressable.
|
|
90
|
+
def pinspec_offsets(total, wanted)
|
|
91
|
+
return (0...total).to_a if total <= wanted
|
|
92
|
+
|
|
93
|
+
fractions = [0.0, 1.0, 0.25, 0.5, 0.75]
|
|
94
|
+
offsets = []
|
|
95
|
+
fractions.each do |fraction|
|
|
96
|
+
offset = (fraction * (total - 1)).round
|
|
97
|
+
offsets.push(offset) unless offsets.include?(offset)
|
|
98
|
+
break if offsets.length >= wanted
|
|
99
|
+
end
|
|
100
|
+
offsets
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def pinspec_rows_at(table, pk, offsets)
|
|
104
|
+
quoted = pinspec_connection.quote_table_name(table)
|
|
105
|
+
order = pinspec_connection.quote_column_name(pk)
|
|
106
|
+
rows = []
|
|
107
|
+
|
|
108
|
+
offsets.each do |offset|
|
|
109
|
+
sql = "SELECT * FROM " + quoted + " ORDER BY " + order +
|
|
110
|
+
" LIMIT 1 OFFSET " + offset.to_i.to_s
|
|
111
|
+
row = pinspec_connection.select_all(sql).to_a.first
|
|
112
|
+
rows.push(row) if row
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
rows
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One row per distinct status value: the single highest-coverage win for the
|
|
119
|
+
# ubiquitous `case status` service object.
|
|
120
|
+
def pinspec_stratified(table, column)
|
|
121
|
+
quoted = pinspec_connection.quote_table_name(table)
|
|
122
|
+
col = pinspec_connection.quote_column_name(column)
|
|
123
|
+
values = pinspec_connection.select_values(
|
|
124
|
+
"SELECT DISTINCT " + col + " FROM " + quoted +
|
|
125
|
+
" LIMIT #{MAX_DISTINCT_STATUSES}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
rows = []
|
|
129
|
+
values.each do |value|
|
|
130
|
+
next if value.nil?
|
|
131
|
+
|
|
132
|
+
sql = "SELECT * FROM " + quoted + " WHERE " + col + " = " +
|
|
133
|
+
pinspec_connection.quote(value) + " LIMIT 1"
|
|
134
|
+
row = pinspec_connection.select_all(sql).to_a.first
|
|
135
|
+
rows.push(row) if row
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
rows
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def pinspec_primary_key(table)
|
|
142
|
+
pinspec_connection.primary_key(table) || "id"
|
|
143
|
+
rescue StandardError
|
|
144
|
+
"id"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
result = {
|
|
148
|
+
"pinspec_sampler_version" => 1,
|
|
149
|
+
"env" => (defined?(Rails) ? Rails.env.to_s : ENV["RAILS_ENV"].to_s),
|
|
150
|
+
"counts" => {},
|
|
151
|
+
"rows" => {},
|
|
152
|
+
"stratified" => {},
|
|
153
|
+
"errors" => []
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
PINSPEC_REQUESTS.each do |request|
|
|
157
|
+
table = request["table"]
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
total = pinspec_count(table)
|
|
161
|
+
result["counts"][table] = total
|
|
162
|
+
next if total.zero?
|
|
163
|
+
|
|
164
|
+
pk = pinspec_primary_key(table)
|
|
165
|
+
wanted = (request["limit"] || 5).to_i
|
|
166
|
+
result["rows"][table] = pinspec_rows_at(table, pk, pinspec_offsets(total, wanted))
|
|
167
|
+
|
|
168
|
+
if request["status_column"]
|
|
169
|
+
result["stratified"][table] = pinspec_stratified(table, request["status_column"])
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
result["errors"].push(table + ": " + e.class.to_s + ": " + e.message.to_s)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
puts JSON.generate(result)
|
|
177
|
+
RUBY
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def stringify(request)
|
|
183
|
+
request.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Report
|
|
7
|
+
class Summary
|
|
8
|
+
OUTPUT = "tmp/pinspec/report.md"
|
|
9
|
+
|
|
10
|
+
def initialize(app_root:, profile:, target: nil, plan: nil, corpus: nil, stability: nil,
|
|
11
|
+
written: nil, outcomes: nil, scores: nil)
|
|
12
|
+
@app_root = app_root
|
|
13
|
+
@profile = profile
|
|
14
|
+
@target = target
|
|
15
|
+
@plan = plan
|
|
16
|
+
@corpus = corpus
|
|
17
|
+
@stability = stability
|
|
18
|
+
@written = written
|
|
19
|
+
@outcomes = outcomes
|
|
20
|
+
@scores = scores
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def path
|
|
24
|
+
File.join(@app_root, OUTPUT)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write!
|
|
28
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
29
|
+
File.write(path, render)
|
|
30
|
+
path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render
|
|
34
|
+
sections = [
|
|
35
|
+
heading,
|
|
36
|
+
verdict,
|
|
37
|
+
what_was_pinned,
|
|
38
|
+
what_was_refused,
|
|
39
|
+
isolation_note,
|
|
40
|
+
coverage_caveats,
|
|
41
|
+
redactions,
|
|
42
|
+
provenance,
|
|
43
|
+
hazards,
|
|
44
|
+
mutation_scores,
|
|
45
|
+
footer
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
sections.compact.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def heading
|
|
54
|
+
<<~MD
|
|
55
|
+
# pinspec characterization report
|
|
56
|
+
|
|
57
|
+
**Target:** `#{@target&.qualified_name || '(none)'}`
|
|
58
|
+
**Application:** `#{File.expand_path(@app_root)}`
|
|
59
|
+
**Rails:** #{@profile.rails_version || 'unknown'} | **Ruby:** #{@profile.ruby_version || 'unknown'}
|
|
60
|
+
**Plan:** `#{@plan&.plan_id}` | **Isolation:** #{@plan&.isolation}
|
|
61
|
+
**pinspec:** #{VERSION} (probe v#{PROBE_VERSION}, serializer v#{SERIALIZER_VERSION})
|
|
62
|
+
|
|
63
|
+
This report describes behaviour that exists TODAY. Nothing here is a
|
|
64
|
+
judgement that the behaviour is correct: pinspec pins bugs on purpose, so
|
|
65
|
+
that a refactor cannot change them silently.
|
|
66
|
+
MD
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def verdict
|
|
70
|
+
return not_verified if @outcomes.nil?
|
|
71
|
+
|
|
72
|
+
rows = @outcomes.map do |outcome|
|
|
73
|
+
state = outcome.green? ? "green" : "**#{outcome.status}** (#{outcome.diagnosis})"
|
|
74
|
+
|
|
75
|
+
"| #{outcome.config} | #{state} | #{outcome.examples || '-'} |"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
<<~MD
|
|
79
|
+
|
|
80
|
+
## Verification
|
|
81
|
+
|
|
82
|
+
The emitted spec was run in three environments, because one run on the
|
|
83
|
+
machine that captured it proves repeatability rather than portability.
|
|
84
|
+
|
|
85
|
+
| configuration | result | examples |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
#{rows.join("\n")}
|
|
88
|
+
|
|
89
|
+
- **isolated** - the file alone, as captured.
|
|
90
|
+
- **hostile** - a different timezone, locale and RSpec seed.
|
|
91
|
+
- **neighbored** - the file twice in one process, so accumulated state shows.
|
|
92
|
+
MD
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def not_verified
|
|
96
|
+
<<~MD
|
|
97
|
+
|
|
98
|
+
## Verification
|
|
99
|
+
|
|
100
|
+
**Not run in this pass.** This report was written by a command that scores
|
|
101
|
+
pins rather than one that verifies them, so nothing here attests that the
|
|
102
|
+
emitted spec runs green. Run `pinspec pin` for the three-configuration
|
|
103
|
+
matrix.
|
|
104
|
+
MD
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def what_was_pinned
|
|
108
|
+
return nil if @stability.nil?
|
|
109
|
+
|
|
110
|
+
lines = @stability.stable.map do |verdict|
|
|
111
|
+
observation = verdict.observation
|
|
112
|
+
outcome = observation["status"] == "raised" ? "raises #{observation.dig('error', 'class')}" : "returns a value"
|
|
113
|
+
jobs = observation["enqueued_jobs"].to_a.size
|
|
114
|
+
mail = observation["mail_deliveries"].to_a.size
|
|
115
|
+
effects = [jobs.positive? ? "#{jobs} job(s)" : nil, mail.positive? ? "#{mail} mail" : nil].compact
|
|
116
|
+
|
|
117
|
+
"- `#{verdict.case_id}` #{outcome}#{effects.empty? ? '' : ", #{effects.join(', ')}"}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
<<~MD
|
|
121
|
+
|
|
122
|
+
## What was pinned
|
|
123
|
+
|
|
124
|
+
#{@stability.stable.size} of #{@corpus&.size} input cases were stable across
|
|
125
|
+
#{@stability.runs} separate probe boots and are therefore pinned.
|
|
126
|
+
|
|
127
|
+
#{lines.empty? ? '_Nothing._' : lines.join("\n")}
|
|
128
|
+
|
|
129
|
+
#{@written ? "Pinned file: `#{@written.spec_path}`" : "This pass did not write the pin itself; run `pinspec pin` for that."}
|
|
130
|
+
MD
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def what_was_refused
|
|
134
|
+
return nil if @stability.nil? || @stability.unstable.empty?
|
|
135
|
+
|
|
136
|
+
lines = @stability.unstable.map do |verdict|
|
|
137
|
+
excerpt = verdict.diff.to_s.lines.first(4).map { |line| " #{line.chomp}" }.join("\n")
|
|
138
|
+
|
|
139
|
+
"- `#{verdict.case_id}` - **#{verdict.cause}**\n#{excerpt}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
<<~MD
|
|
143
|
+
|
|
144
|
+
## What was NOT pinned
|
|
145
|
+
|
|
146
|
+
These cases produced different results on two runs of the same code, so
|
|
147
|
+
pinning them would freeze an accident.
|
|
148
|
+
|
|
149
|
+
#{lines.join("\n")}
|
|
150
|
+
MD
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def isolation_note
|
|
154
|
+
return nil if @plan.nil?
|
|
155
|
+
|
|
156
|
+
if @plan.isolation == :truncation
|
|
157
|
+
<<~MD
|
|
158
|
+
|
|
159
|
+
## Isolation: truncation
|
|
160
|
+
|
|
161
|
+
This suite does not wrap examples in a transaction (#{@profile.isolation_source}),
|
|
162
|
+
so `after_commit` callbacks **do** fire - during the capture and in the
|
|
163
|
+
emitted spec alike. The capture therefore wrote to the test database and
|
|
164
|
+
truncated afterwards.
|
|
165
|
+
MD
|
|
166
|
+
else
|
|
167
|
+
note = @profile.after_commit_models.empty? ? "" : <<~EXTRA
|
|
168
|
+
|
|
169
|
+
#{@profile.after_commit_models.size} model(s) declare `after_commit`
|
|
170
|
+
(#{@profile.after_commit_models.map(&:model).uniq.join(', ')}). Under this
|
|
171
|
+
regime those callbacks never fire - not in the capture, and not in the
|
|
172
|
+
emitted spec. pinspec does not fake them, so this is a real and
|
|
173
|
+
documented divergence from production.
|
|
174
|
+
EXTRA
|
|
175
|
+
|
|
176
|
+
<<~MD
|
|
177
|
+
|
|
178
|
+
## Isolation: transaction
|
|
179
|
+
|
|
180
|
+
Every case ran inside a transaction that was rolled back (#{@profile.isolation_source}).
|
|
181
|
+
#{note}
|
|
182
|
+
MD
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def coverage_caveats
|
|
187
|
+
caveats = []
|
|
188
|
+
caveats << seq_caveat
|
|
189
|
+
caveats << truncated_caveat
|
|
190
|
+
caveats << quarantine_caveat
|
|
191
|
+
caveats << clock_caveat
|
|
192
|
+
caveats = caveats.compact
|
|
193
|
+
|
|
194
|
+
return nil if caveats.empty?
|
|
195
|
+
|
|
196
|
+
<<~MD
|
|
197
|
+
|
|
198
|
+
## Coverage caveats
|
|
199
|
+
|
|
200
|
+
Places where a pin asserts less than it appears to.
|
|
201
|
+
|
|
202
|
+
#{caveats.map { |caveat| "- #{caveat}" }.join("\n")}
|
|
203
|
+
MD
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def seq_caveat
|
|
207
|
+
count = rendered_observations.scan('"seq"').size
|
|
208
|
+
return nil if count.zero?
|
|
209
|
+
|
|
210
|
+
"#{count} value(s) are pinned as `{\"t\":\"seq\"}` - an autoincrement-shaped " \
|
|
211
|
+
"integer that could not be resolved to a record. The pin asserts an integer " \
|
|
212
|
+
"is present and nothing about which one, because sequence values are not " \
|
|
213
|
+
"reproducible."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def truncated_caveat
|
|
217
|
+
count = rendered_observations.scan('"truncated"').size
|
|
218
|
+
return nil if count.zero?
|
|
219
|
+
|
|
220
|
+
"#{count} value(s) were truncated at the serializer's depth limit, so anything " \
|
|
221
|
+
"deeper than that is unpinned."
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def quarantine_caveat
|
|
225
|
+
return nil if @stability.nil?
|
|
226
|
+
|
|
227
|
+
quarantined = @stability.unstable.select { |verdict| verdict.cause == :setup_error }
|
|
228
|
+
return nil if quarantined.empty?
|
|
229
|
+
|
|
230
|
+
"#{quarantined.size} case(s) could not have a world built for them and were " \
|
|
231
|
+
"dropped rather than pinned."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def clock_caveat
|
|
235
|
+
return nil unless @target&.clock_dependent?
|
|
236
|
+
|
|
237
|
+
sites = @target.clock_sites.map { |site| "`#{site.call}` (line #{site.line})" }.join(", ")
|
|
238
|
+
|
|
239
|
+
"The target reads the process clock at #{sites}. `Time.zone` does not govern " \
|
|
240
|
+
"those, so these pins hold only under `TZ=#{@plan&.env_fingerprint&.dig(:tz)}`. " \
|
|
241
|
+
"The emitted spec guards this rather than letting it pass for the wrong reason."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def redactions
|
|
245
|
+
clusters = import_steps
|
|
246
|
+
redacted = clusters.flat_map { |step| Array(step.payload[:redacted]) }.uniq
|
|
247
|
+
return nil if clusters.empty?
|
|
248
|
+
|
|
249
|
+
<<~MD
|
|
250
|
+
|
|
251
|
+
## Personal data
|
|
252
|
+
|
|
253
|
+
#{clusters.size} row(s) were imported from a real database. Rewritten
|
|
254
|
+
attributes: #{redacted.empty? ? 'none' : redacted.map { |name| "`#{name}`" }.join(', ')}.
|
|
255
|
+
|
|
256
|
+
Rewrites preserve **domain and length** - an email keeps its domain and its
|
|
257
|
+
character count - so a target that routes on a domain or validates a length
|
|
258
|
+
sees the same behaviour it always did. A redactor that changed behaviour
|
|
259
|
+
would be worse than none, because it would look correct.
|
|
260
|
+
|
|
261
|
+
Honest limit: read detection scans the target's own file. It cannot see a
|
|
262
|
+
transitive callee, so no warning is not proof of no read.
|
|
263
|
+
MD
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def provenance
|
|
267
|
+
steps = import_steps
|
|
268
|
+
return nil if steps.empty?
|
|
269
|
+
|
|
270
|
+
rows = steps.map { |step| "| `#{step.payload[:name]}` | #{step.payload[:model]} | `#{step.payload[:source]}` |" }
|
|
271
|
+
|
|
272
|
+
<<~MD
|
|
273
|
+
|
|
274
|
+
## Imported row provenance
|
|
275
|
+
|
|
276
|
+
| ref | model | source |
|
|
277
|
+
|---|---|---|
|
|
278
|
+
#{rows.join("\n")}
|
|
279
|
+
|
|
280
|
+
Sources are hashed. A spec file committed to a repository does not map its
|
|
281
|
+
fixtures back to production row ids.
|
|
282
|
+
MD
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def hazards
|
|
286
|
+
warnings = @profile.warnings
|
|
287
|
+
return nil if warnings.empty?
|
|
288
|
+
|
|
289
|
+
<<~MD
|
|
290
|
+
|
|
291
|
+
## Application hazards
|
|
292
|
+
|
|
293
|
+
Found while profiling the application, independently of this target.
|
|
294
|
+
|
|
295
|
+
#{warnings.map { |warning| "- #{warning}" }.join("\n\n")}
|
|
296
|
+
MD
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def mutation_scores
|
|
300
|
+
return nil if @scores.nil?
|
|
301
|
+
|
|
302
|
+
rows = @scores.scores.map do |score|
|
|
303
|
+
value = score.score.nil? ? "not scored" : "#{score.score}% (#{score.verdict})"
|
|
304
|
+
|
|
305
|
+
"| #{score.aspect} | #{value} | #{score.killed || '-'} | #{score.survived || '-'} |"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
gaps = @scores.surviving_all_aspects
|
|
309
|
+
|
|
310
|
+
<<~MD
|
|
311
|
+
|
|
312
|
+
## Mutation scores, by aspect
|
|
313
|
+
|
|
314
|
+
Each aspect is graded separately because they are blind to different
|
|
315
|
+
things: a return pin does not notice a deleted `perform_later`, and a job
|
|
316
|
+
pin does not notice the arithmetic.
|
|
317
|
+
|
|
318
|
+
| aspect | score | killed | survived |
|
|
319
|
+
|---|---|---|---|
|
|
320
|
+
#{rows.join("\n")}
|
|
321
|
+
|
|
322
|
+
#{gaps.empty? ? 'No mutant survived every aspect: together, these pins cover the target.' : "#{gaps.size} mutant(s) survived every aspect, which is the real gap:\n" + gaps.map { |m| "- `#{m['operator']}` at line #{m['line']} - `#{m['token']}`" }.join("\n")}
|
|
323
|
+
MD
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def footer
|
|
327
|
+
<<~MD
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
Generated by pinspec #{VERSION}. `pinspec pin` regenerates the pin and this
|
|
332
|
+
report; `pinspec validate` rewrites it with mutation scores. Neither file is
|
|
333
|
+
meant to be edited by hand.
|
|
334
|
+
MD
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def import_steps
|
|
338
|
+
@plan ? @plan.steps_of(:import_record) : []
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def rendered_observations
|
|
342
|
+
return "" if @stability.nil?
|
|
343
|
+
|
|
344
|
+
@rendered_observations ||= @stability.stable.map { |verdict| verdict.observation.to_s }.join
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|