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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. 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