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,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pinspec
|
|
7
|
+
module Emit
|
|
8
|
+
class SpecWriter
|
|
9
|
+
SPEC_DIR = "spec/characterization"
|
|
10
|
+
SUPPORT_DIR = "spec/characterization/support"
|
|
11
|
+
|
|
12
|
+
SERIALIZER_TEMPLATE = File.expand_path("../../../templates/serializer.rb", __dir__)
|
|
13
|
+
SUPPORT_TEMPLATE = File.expand_path("../../../templates/spec_support.rb", __dir__)
|
|
14
|
+
FACTORY_TEMPLATE = File.expand_path("../../../templates/factory_build.rb", __dir__)
|
|
15
|
+
|
|
16
|
+
PROVENANCE = "pinspec:generated"
|
|
17
|
+
|
|
18
|
+
Result = Data.define(:spec_path, :support_paths, :pinned_cases, :aspects)
|
|
19
|
+
|
|
20
|
+
def initialize(app_root:, target:, plan:, corpus:, stability:, fk_map:, force: false,
|
|
21
|
+
only_aspect: nil, spec_dir: nil, namer: nil)
|
|
22
|
+
@app_root = app_root
|
|
23
|
+
@target = target
|
|
24
|
+
@plan = plan
|
|
25
|
+
@corpus = corpus
|
|
26
|
+
@stability = stability
|
|
27
|
+
@fk_map = fk_map
|
|
28
|
+
@force = force
|
|
29
|
+
@only_aspect = only_aspect
|
|
30
|
+
@spec_dir = spec_dir || SPEC_DIR
|
|
31
|
+
@namer = namer
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def spec_path
|
|
35
|
+
suffix = @only_aspect ? "_#{@only_aspect}" : ""
|
|
36
|
+
|
|
37
|
+
File.join(@app_root, @spec_dir, "#{file_stem}#{suffix}_spec.rb")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write!
|
|
41
|
+
guard_existing!
|
|
42
|
+
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(spec_path))
|
|
44
|
+
FileUtils.mkdir_p(File.join(@app_root, SUPPORT_DIR))
|
|
45
|
+
|
|
46
|
+
support = write_support!
|
|
47
|
+
File.write(spec_path, render)
|
|
48
|
+
|
|
49
|
+
Result.new(
|
|
50
|
+
spec_path: spec_path, support_paths: support,
|
|
51
|
+
pinned_cases: pinned.map { |verdict| verdict.case_id }, aspects: aspect_counts
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def guard_existing!
|
|
56
|
+
return unless File.file?(spec_path)
|
|
57
|
+
|
|
58
|
+
existing = Analyzer::Source.read(spec_path)
|
|
59
|
+
return if @force
|
|
60
|
+
|
|
61
|
+
unless existing.include?(PROVENANCE)
|
|
62
|
+
raise VerifyFailed,
|
|
63
|
+
"#{spec_path} already exists and was not written by pinspec. " \
|
|
64
|
+
"Nothing pinspec did not write is ever overwritten; move it aside " \
|
|
65
|
+
"or pass --force."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def pinned
|
|
72
|
+
@stability.stable
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def aspect_counts
|
|
76
|
+
counts = { return: 0, error: 0, jobs: 0, mail: 0 }
|
|
77
|
+
|
|
78
|
+
pinned.each do |verdict|
|
|
79
|
+
observation = verdict.observation
|
|
80
|
+
counts[:error] += 1 if observation["status"] == "raised"
|
|
81
|
+
counts[:return] += 1 if observation["status"] == "returned"
|
|
82
|
+
counts[:jobs] += 1 unless observation["enqueued_jobs"].to_a.empty?
|
|
83
|
+
counts[:mail] += 1 unless observation["mail_deliveries"].to_a.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
counts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_support!
|
|
90
|
+
paths = {
|
|
91
|
+
"pinspec_serializer.rb" => File.read(SERIALIZER_TEMPLATE),
|
|
92
|
+
"pinspec_support.rb" => File.read(SUPPORT_TEMPLATE),
|
|
93
|
+
"pinspec_factory.rb" => File.read(FACTORY_TEMPLATE)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
paths.map do |name, source|
|
|
97
|
+
path = File.join(@app_root, SUPPORT_DIR, name)
|
|
98
|
+
File.write(path, source)
|
|
99
|
+
path
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def file_stem
|
|
104
|
+
underscore(@target.class_name).tr("/", "_") + "_" + @target.method_name.to_s.gsub(/[^a-z0-9_]/i, "")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render
|
|
108
|
+
[
|
|
109
|
+
header,
|
|
110
|
+
"require \"#{suite_helper}\"",
|
|
111
|
+
"require_relative \"#{support_prefix}support/pinspec_serializer\"",
|
|
112
|
+
"require_relative \"#{support_prefix}support/pinspec_support\"",
|
|
113
|
+
"require_relative \"#{support_prefix}support/pinspec_factory\"",
|
|
114
|
+
"\nRSpec.describe #{@target.class_name}, :pinspec do",
|
|
115
|
+
indent(isolation_hook, 2),
|
|
116
|
+
indent(environment_hook, 2),
|
|
117
|
+
indent(records, 2),
|
|
118
|
+
indent(helpers, 2),
|
|
119
|
+
indent(cases, 2),
|
|
120
|
+
"end"
|
|
121
|
+
].reject(&:empty?).join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def suite_helper
|
|
125
|
+
return @suite_helper if defined?(@suite_helper)
|
|
126
|
+
|
|
127
|
+
@suite_helper =
|
|
128
|
+
%w[rails_helper spec_helper test_helper].find do |name|
|
|
129
|
+
File.file?(File.join(@app_root, "spec", "#{name}.rb")) ||
|
|
130
|
+
File.file?(File.join(@app_root, "test", "#{name}.rb"))
|
|
131
|
+
end || "rails_helper"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def support_prefix
|
|
135
|
+
@spec_dir == SPEC_DIR ? "" : "#{File.expand_path(File.join(@app_root, SUPPORT_DIR))}/".sub(%r{/support/$}, "/")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def header
|
|
139
|
+
lines = [
|
|
140
|
+
"# frozen_string_literal: true",
|
|
141
|
+
"#",
|
|
142
|
+
"# #{PROVENANCE} - characterization pin. Do not hand-edit: re-run pinspec.",
|
|
143
|
+
"#",
|
|
144
|
+
"# plan_id: #{@plan.plan_id}",
|
|
145
|
+
"# serializer: #{SERIALIZER_VERSION}",
|
|
146
|
+
"# isolation: #{@plan.isolation}",
|
|
147
|
+
"# captured: #{@stability.runs} boots, #{pinned.size} of #{@corpus.size} cases stable",
|
|
148
|
+
"#",
|
|
149
|
+
"# This file freezes what the code does TODAY. A failure here means the",
|
|
150
|
+
"# behaviour changed - not that the behaviour is correct. Bugs get pinned on",
|
|
151
|
+
"# purpose."
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
lines.concat(clock_warning) if @target.clock_dependent?
|
|
155
|
+
lines.concat(unstable_notes) unless @stability.unstable.empty?
|
|
156
|
+
lines << ""
|
|
157
|
+
lines.join("\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def clock_warning
|
|
161
|
+
sites = @target.clock_sites.map { |site| "#{site.call} (line #{site.line})" }.join(", ")
|
|
162
|
+
|
|
163
|
+
[
|
|
164
|
+
"#",
|
|
165
|
+
"# WARNING: the target reads the PROCESS clock at #{sites}.",
|
|
166
|
+
"# Time.zone does not govern those, so these pins are only valid under the",
|
|
167
|
+
"# capture's timezone (TZ=#{@plan.env_fingerprint[:tz]}). The guard below fails",
|
|
168
|
+
"# loudly rather than letting them pass for the wrong reason."
|
|
169
|
+
]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def unstable_notes
|
|
173
|
+
lines = ["#", "# Not pinned, because the two capture runs disagreed:"]
|
|
174
|
+
|
|
175
|
+
@stability.unstable.each do |verdict|
|
|
176
|
+
lines << "# #{verdict.case_id}: #{verdict.cause}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
lines
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def isolation_hook
|
|
183
|
+
return truncation_note if @plan.isolation == :truncation
|
|
184
|
+
|
|
185
|
+
<<~RUBY
|
|
186
|
+
# isolation: transaction. Forced regardless of this suite's own strategy,
|
|
187
|
+
# because the capture ran this way and after_commit callbacks depend on it.
|
|
188
|
+
around do |example|
|
|
189
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
190
|
+
example.run
|
|
191
|
+
raise ActiveRecord::Rollback
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
RUBY
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def truncation_note
|
|
198
|
+
<<~RUBY
|
|
199
|
+
# isolation: truncation. This suite does not wrap examples in a transaction,
|
|
200
|
+
# so after_commit callbacks DO fire here - as they did during the capture.
|
|
201
|
+
# These examples mutate the test database.
|
|
202
|
+
RUBY
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def environment_hook
|
|
206
|
+
fingerprint = @plan.env_fingerprint
|
|
207
|
+
|
|
208
|
+
<<~RUBY
|
|
209
|
+
before do
|
|
210
|
+
#{clock_guard(fingerprint)} pinspec_clear_sinks
|
|
211
|
+
|
|
212
|
+
travel_to Time.parse(#{PINSPEC_EPOCH.inspect})
|
|
213
|
+
srand(#{PINSPEC_SEED})
|
|
214
|
+
I18n.locale = #{fingerprint[:locale].inspect}
|
|
215
|
+
Time.zone = #{fingerprint[:zone].inspect}
|
|
216
|
+
#{flag_lines}end
|
|
217
|
+
|
|
218
|
+
after { travel_back }
|
|
219
|
+
RUBY
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def clock_guard(fingerprint)
|
|
223
|
+
return "" unless @target.clock_dependent?
|
|
224
|
+
|
|
225
|
+
" pinspec_guard_env!(#{fingerprint[:tz].to_s.inspect})\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def flag_lines
|
|
229
|
+
flags = @plan.steps_of(:set_flag)
|
|
230
|
+
return "" if flags.empty?
|
|
231
|
+
|
|
232
|
+
flags.map do |step|
|
|
233
|
+
state = step.payload[:enabled] ? "enable" : "disable"
|
|
234
|
+
" Flipper.#{state}(#{step.payload[:flag].inspect})\n"
|
|
235
|
+
end.join
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def records
|
|
239
|
+
steps = @plan.record_steps
|
|
240
|
+
return "" if steps.empty?
|
|
241
|
+
|
|
242
|
+
steps.map { |step| record_line(step) }.join("\n") + "\n"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def record_line(step)
|
|
246
|
+
payload = step.payload
|
|
247
|
+
|
|
248
|
+
if step.kind == :import_record
|
|
249
|
+
return "# imported from #{payload[:source]}\n" \
|
|
250
|
+
"let!(:#{payload[:name]}) { #{import_call(payload)} }"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
"let!(:#{payload[:name]}) { #{create_call(payload)} }"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def create_call(payload)
|
|
257
|
+
if payload[:factory]
|
|
258
|
+
return "PinspecFactory.create(:#{payload[:factory]})"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
attrs = (payload[:attrs] || {}).merge(assoc_arguments(payload))
|
|
262
|
+
"#{payload[:model]}.create!(#{render_attrs(attrs)})"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def import_call(payload)
|
|
266
|
+
attrs = (payload[:attrs] || {}).transform_values { |tagged| Tags.decode(tagged) }
|
|
267
|
+
|
|
268
|
+
"#{payload[:model]}.create!(#{render_attrs(attrs)})"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def assoc_arguments(payload)
|
|
272
|
+
(payload[:assoc_refs] || {}).each_with_object({}) do |(column, ref), out|
|
|
273
|
+
out[column] = RawRuby.new("#{ref}.id")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def render_attrs(attrs)
|
|
278
|
+
attrs.map { |name, value| "#{name}: #{render_value(value)}" }.join(", ")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def render_value(value)
|
|
282
|
+
return value.source if value.is_a?(RawRuby)
|
|
283
|
+
|
|
284
|
+
canonical_literal(value)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
class RawRuby
|
|
288
|
+
attr_reader :source
|
|
289
|
+
|
|
290
|
+
def initialize(source)
|
|
291
|
+
@source = source
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def helpers
|
|
296
|
+
<<~RUBY
|
|
297
|
+
let(:pinspec_records) { { #{ref_pairs} } }
|
|
298
|
+
let(:pinspec_fk_map) { #{canonical_literal(@fk_map)} }
|
|
299
|
+
let(:pinspec_refs_for) { ->(result) { pinspec_refs(pinspec_records, result) } }
|
|
300
|
+
RUBY
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def ref_pairs
|
|
304
|
+
@plan.record_steps.map { |step| "#{step.payload[:name]}: #{step.payload[:name]}" }.join(", ")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def cases
|
|
308
|
+
pinned.map { |verdict| render_case(verdict) }.join("\n")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def render_case(verdict)
|
|
312
|
+
input_case = @corpus.cases.find { |c| c.id == verdict.case_id }
|
|
313
|
+
observation = verdict.observation
|
|
314
|
+
|
|
315
|
+
<<~RUBY
|
|
316
|
+
describe "#{description_for(input_case, observation)}" do
|
|
317
|
+
#{indent(subject_line(input_case), 2).strip}
|
|
318
|
+
|
|
319
|
+
#{indent(expectations(observation), 2)}end
|
|
320
|
+
RUBY
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def description_for(input_case, observation)
|
|
324
|
+
named = descriptions[input_case.id]
|
|
325
|
+
return named if named
|
|
326
|
+
|
|
327
|
+
outcome =
|
|
328
|
+
case observation["status"]
|
|
329
|
+
when "raised" then "raises #{observation.dig('error', 'class')}"
|
|
330
|
+
else "returns the pinned #{value_shape(observation['return_value'])}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
"#{@target.method_name} #{outcome} (#{input_case.id}, #{input_case.origin})"
|
|
334
|
+
end
|
|
335
|
+
|
|
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
|
+
|
|
351
|
+
def value_shape(value)
|
|
352
|
+
case value && value["t"]
|
|
353
|
+
when "record" then "#{value['class']} it creates"
|
|
354
|
+
when "str" then "string"
|
|
355
|
+
when "int", "float", "decimal" then "number"
|
|
356
|
+
when "nil" then "nil"
|
|
357
|
+
when "array", "relation" then "collection"
|
|
358
|
+
else value && value["t"] || "value"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def subject_line(input_case)
|
|
363
|
+
"subject(:pinned) { #{invocation(input_case)} }"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def invocation(input_case)
|
|
367
|
+
receiver =
|
|
368
|
+
case @target.construction_kind
|
|
369
|
+
when :class_method then @target.class_name
|
|
370
|
+
when :model_instance then @plan.binding_for(:__subject__) || "subject_record"
|
|
371
|
+
else "#{@target.class_name}.new(#{arguments(input_case.ctor_args, input_case.ctor_kwargs)})"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
arguments = arguments(input_case.args, input_case.kwargs)
|
|
375
|
+
arguments.empty? ? "#{receiver}.#{@target.method_name}" : "#{receiver}.#{@target.method_name}(#{arguments})"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def arguments(positional, keyword)
|
|
379
|
+
parts = Array(positional).map { |value| ruby_literal(value) }
|
|
380
|
+
parts.concat(Hash(keyword).map { |name, value| "#{name}: #{ruby_literal(value)}" })
|
|
381
|
+
parts.join(", ")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def ruby_literal(tagged)
|
|
385
|
+
return canonical_literal(tagged) unless tagged.is_a?(Hash) && tagged.key?("t")
|
|
386
|
+
|
|
387
|
+
case tagged["t"]
|
|
388
|
+
when "ref" then tagged["v"]
|
|
389
|
+
when "decimal" then "BigDecimal(#{tagged['v'].inspect})"
|
|
390
|
+
when "sym" then ":#{tagged['v']}"
|
|
391
|
+
when "nil" then "nil"
|
|
392
|
+
when "date" then "Date.parse(#{tagged['v'].inspect})"
|
|
393
|
+
when "time" then "Time.parse(#{tagged['v'].inspect})"
|
|
394
|
+
when "array" then "[#{tagged['v'].map { |inner| ruby_literal(inner) }.join(', ')}]"
|
|
395
|
+
when "hash" then "{ #{tagged['v'].map { |k, v| "#{ruby_literal(k)} => #{ruby_literal(v)}" }.join(', ')} }"
|
|
396
|
+
else canonical_literal(tagged["v"])
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def expectations(observation)
|
|
401
|
+
parts = []
|
|
402
|
+
raised = observation["status"] == "raised"
|
|
403
|
+
|
|
404
|
+
parts << (raised ? error_expectation(observation) : return_expectation(observation)) if
|
|
405
|
+
wants?(raised ? :error : :return)
|
|
406
|
+
parts << jobs_expectation(observation) if wants?(:jobs) && !observation["enqueued_jobs"].to_a.empty?
|
|
407
|
+
parts << mail_expectation(observation) if wants?(:mail) && !observation["mail_deliveries"].to_a.empty?
|
|
408
|
+
|
|
409
|
+
parts << pending_aspect if parts.empty?
|
|
410
|
+
|
|
411
|
+
parts.join("\n")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def wants?(aspect)
|
|
415
|
+
@only_aspect.nil? || @only_aspect.to_sym == aspect
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def pending_aspect
|
|
419
|
+
<<~RUBY
|
|
420
|
+
it "has no #{@only_aspect} aspect in this case" do
|
|
421
|
+
skip "this case pins nothing on the #{@only_aspect} aspect"
|
|
422
|
+
end
|
|
423
|
+
RUBY
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def return_expectation(observation)
|
|
427
|
+
<<~RUBY
|
|
428
|
+
it "returns the pinned value" do
|
|
429
|
+
# Normalized WITHOUT the returned record in the ref table, because the
|
|
430
|
+
# probe serializes the return value before registering it. The sink
|
|
431
|
+
# expectations below use the table that includes it. Same operations in
|
|
432
|
+
# the same order, in both hosts - which is the whole of section 4c.
|
|
433
|
+
expect(
|
|
434
|
+
PinspecSerializer.normalize(pinned, refs: pinspec_refs(pinspec_records), fk_map: pinspec_fk_map)
|
|
435
|
+
).to eq(#{snapshot(observation['return_value'])})
|
|
436
|
+
end
|
|
437
|
+
RUBY
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def error_expectation(observation)
|
|
441
|
+
error = observation["error"]
|
|
442
|
+
|
|
443
|
+
<<~RUBY
|
|
444
|
+
it "raises the pinned error" do
|
|
445
|
+
expect { pinned }.to raise_error(#{error['class']}, #{error['message'].inspect})
|
|
446
|
+
end
|
|
447
|
+
RUBY
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def jobs_expectation(observation)
|
|
451
|
+
<<~RUBY
|
|
452
|
+
it "enqueues the pinned jobs" do
|
|
453
|
+
_result, jobs = pinspec_jobs_from(pinspec_refs_for, pinspec_fk_map) { pinned }
|
|
454
|
+
|
|
455
|
+
expect(jobs).to eq(#{snapshot(observation['enqueued_jobs'])})
|
|
456
|
+
end
|
|
457
|
+
RUBY
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def mail_expectation(observation)
|
|
461
|
+
<<~RUBY
|
|
462
|
+
it "delivers the pinned mail" do
|
|
463
|
+
pinspec_clear_sinks
|
|
464
|
+
pinned
|
|
465
|
+
|
|
466
|
+
expect(pinspec_deliveries).to eq(#{snapshot(observation['mail_deliveries'])})
|
|
467
|
+
end
|
|
468
|
+
RUBY
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def snapshot(value)
|
|
472
|
+
canonical_literal(JSON.parse(JSON.generate(value)))
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def canonical_literal(value)
|
|
476
|
+
case value
|
|
477
|
+
when Hash
|
|
478
|
+
return "{}" if value.empty?
|
|
479
|
+
|
|
480
|
+
"{" + value.map { |key, inner| "#{canonical_literal(key)} => #{canonical_literal(inner)}" }.join(", ") + "}"
|
|
481
|
+
when Array
|
|
482
|
+
"[" + value.map { |inner| canonical_literal(inner) }.join(", ") + "]"
|
|
483
|
+
else
|
|
484
|
+
value.inspect
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def indent(text, spaces)
|
|
489
|
+
return "" if text.to_s.empty?
|
|
490
|
+
|
|
491
|
+
pad = " " * spaces
|
|
492
|
+
text.to_s.lines.map { |line| line.strip.empty? ? line : "#{pad}#{line}" }.join
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def underscore(str)
|
|
496
|
+
str.to_s
|
|
497
|
+
.gsub("::", "/")
|
|
498
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
499
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
500
|
+
.downcase
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Emit
|
|
7
|
+
class StabilityFilter
|
|
8
|
+
COMPARED = %w[status return_value error enqueued_jobs mail_deliveries db_delta].freeze
|
|
9
|
+
|
|
10
|
+
OPTIONAL = %w[sql_fingerprints].freeze
|
|
11
|
+
|
|
12
|
+
NEVER_COMPARED = %w[duration_ms flags setup_error].freeze
|
|
13
|
+
|
|
14
|
+
MAX_DIFF_LINES = 10
|
|
15
|
+
|
|
16
|
+
Verdict = Data.define(:case_id, :stable, :cause, :diff, :observation) do
|
|
17
|
+
def stable?
|
|
18
|
+
stable
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
stable? ? "#{case_id} stable" : "#{case_id} unstable (#{cause})"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Report = Data.define(:verdicts, :runs, :compared_fields) do
|
|
27
|
+
def stable
|
|
28
|
+
verdicts.select(&:stable?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def unstable
|
|
32
|
+
verdicts.reject(&:stable?)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def causes
|
|
36
|
+
unstable.group_by(&:cause).transform_values(&:size)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def nothing_to_pin?
|
|
40
|
+
stable.empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(compare_sql: false)
|
|
45
|
+
@compare_sql = compare_sql
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compared_fields
|
|
49
|
+
@compare_sql ? COMPARED + OPTIONAL : COMPARED
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filter(runs)
|
|
53
|
+
first, *rest = Array(runs)
|
|
54
|
+
raise ArgumentError, "stability needs at least one run" if first.nil?
|
|
55
|
+
|
|
56
|
+
verdicts = first.observations.map do |observation|
|
|
57
|
+
others = rest.map { |run| run.observation(observation["case_id"]) }
|
|
58
|
+
|
|
59
|
+
verdict_for(observation, others)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Report.new(verdicts: verdicts, runs: Array(runs).size, compared_fields: compared_fields)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def verdict_for(observation, others)
|
|
68
|
+
case_id = observation["case_id"]
|
|
69
|
+
|
|
70
|
+
if observation["status"] == "setup_error"
|
|
71
|
+
return unstable(case_id, :setup_error, setup_diff(observation), observation)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if observation["flags"].to_a.include?("escaped_transaction")
|
|
75
|
+
return unstable(case_id, :escaped_transaction, "", observation)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
missing = others.any?(&:nil?)
|
|
79
|
+
return unstable(case_id, :missing_from_run, "", observation) if missing
|
|
80
|
+
|
|
81
|
+
difference = others.filter_map { |other| first_difference(observation, other) }.first
|
|
82
|
+
return Verdict.new(case_id: case_id, stable: true, cause: nil, diff: nil, observation: observation) if difference.nil?
|
|
83
|
+
|
|
84
|
+
field, mine, theirs = difference
|
|
85
|
+
unstable(case_id, classify(field, mine, theirs), diff_excerpt(field, mine, theirs), observation)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unstable(case_id, cause, diff, observation)
|
|
89
|
+
Verdict.new(case_id: case_id, stable: false, cause: cause, diff: diff, observation: observation)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def first_difference(mine, theirs)
|
|
93
|
+
compared_fields.each do |field|
|
|
94
|
+
return [field, mine[field], theirs[field]] unless mine[field] == theirs[field]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def classify(field, mine, theirs)
|
|
101
|
+
kinds = differing_tags(mine, theirs)
|
|
102
|
+
|
|
103
|
+
return :identity_churn if kinds.any? && kinds.all? { |kind| %w[int gid seq].include?(kind) }
|
|
104
|
+
return :time if kinds.any? { |kind| %w[time date].include?(kind) }
|
|
105
|
+
return :float_noise if float_noise?(mine, theirs)
|
|
106
|
+
return :random if kinds.include?("str")
|
|
107
|
+
return :order_dependent if reordered?(mine, theirs)
|
|
108
|
+
return :side_effect_churn if %w[enqueued_jobs mail_deliveries].include?(field)
|
|
109
|
+
|
|
110
|
+
:external_io
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def differing_tags(mine, theirs)
|
|
114
|
+
left = tag_pairs(mine)
|
|
115
|
+
right = tag_pairs(theirs)
|
|
116
|
+
|
|
117
|
+
(left.keys | right.keys).filter_map do |path|
|
|
118
|
+
next if left[path] == right[path]
|
|
119
|
+
|
|
120
|
+
(left[path] || right[path])&.first
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def tag_pairs(value, path = "", out = {})
|
|
125
|
+
case value
|
|
126
|
+
when Hash
|
|
127
|
+
out[path] = [value["t"], value["v"]] if value.key?("t")
|
|
128
|
+
value.each { |key, inner| tag_pairs(inner, "#{path}/#{key}", out) unless key == "t" }
|
|
129
|
+
when Array
|
|
130
|
+
value.each_with_index { |inner, index| tag_pairs(inner, "#{path}[#{index}]", out) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
out
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def float_noise?(mine, theirs)
|
|
137
|
+
left = tag_pairs(mine).select { |_, (tag, _)| tag == "float" }
|
|
138
|
+
right = tag_pairs(theirs)
|
|
139
|
+
|
|
140
|
+
return false if left.empty?
|
|
141
|
+
|
|
142
|
+
left.all? do |path, (_, value)|
|
|
143
|
+
other = right[path]
|
|
144
|
+
other && other[0] == "float" && (value.to_f - other[1].to_f).abs < 1e-6
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def reordered?(mine, theirs)
|
|
149
|
+
return false unless mine.is_a?(Array) && theirs.is_a?(Array)
|
|
150
|
+
|
|
151
|
+
mine.sort_by(&:to_s) == theirs.sort_by(&:to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def setup_diff(observation)
|
|
155
|
+
error = observation.dig("setup_error", "error") || {}
|
|
156
|
+
|
|
157
|
+
"#{error['class']}: #{error['message']}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def diff_excerpt(field, mine, theirs)
|
|
161
|
+
left = render(mine).lines
|
|
162
|
+
right = render(theirs).lines
|
|
163
|
+
lines = ["field: #{field}"]
|
|
164
|
+
|
|
165
|
+
[left.size, right.size].max.times do |index|
|
|
166
|
+
break if lines.size + 2 > MAX_DIFF_LINES
|
|
167
|
+
next if left[index] == right[index]
|
|
168
|
+
|
|
169
|
+
lines << " run1: #{left[index].to_s.chomp}"
|
|
170
|
+
lines << " run2: #{right[index].to_s.chomp}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
lines.join("\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def render(value)
|
|
177
|
+
JSON.pretty_generate(value)
|
|
178
|
+
rescue StandardError
|
|
179
|
+
value.inspect
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|