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,662 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pinspec
6
+ module Runner
7
+ class ProbeGenerator
8
+ SERIALIZER_TEMPLATE = File.expand_path("../../../templates/serializer.rb", __dir__)
9
+ FACTORY_TEMPLATE = File.expand_path("../../../templates/factory_build.rb", __dir__)
10
+
11
+ class << self
12
+ def generate(plan:, corpus:, fk_map:, target:, max_collection: 50)
13
+ new(plan: plan, corpus: corpus, fk_map: fk_map, target: target,
14
+ max_collection: max_collection).generate
15
+ end
16
+
17
+ def serializer_source
18
+ File.read(SERIALIZER_TEMPLATE)
19
+ end
20
+
21
+ def factory_source
22
+ File.read(FACTORY_TEMPLATE)
23
+ end
24
+ end
25
+
26
+ def initialize(plan:, corpus:, fk_map:, target:, max_collection: 50)
27
+ @plan = plan
28
+ @corpus = corpus
29
+ @fk_map = fk_map
30
+ @target = target
31
+ @max_collection = max_collection
32
+ end
33
+
34
+ def payload
35
+ {
36
+ "pinspec_probe_version" => PROBE_VERSION,
37
+ "serializer" => SERIALIZER_VERSION,
38
+ "plan_id" => @plan.plan_id,
39
+ "isolation" => @plan.isolation.to_s,
40
+ "env_fingerprint" => stringify(@plan.env_fingerprint),
41
+ "fk_map" => @fk_map,
42
+ "max_collection" => @max_collection,
43
+ "target" => {
44
+ "class" => @target.class_name,
45
+ "method" => @target.method_name.to_s,
46
+ "construction" => @target.construction_kind.to_s,
47
+ "visibility" => @target.visibility.to_s
48
+ },
49
+ "setup_plan" => @plan.steps.map { |step| { "kind" => step.kind.to_s, "payload" => stringify(step.payload) } },
50
+ "cases" => @corpus.cases.map { |input_case| case_payload(input_case) }
51
+ }
52
+ end
53
+
54
+ def generate
55
+ <<~RUBY
56
+ # frozen_string_literal: true
57
+ #
58
+ # Generated by pinspec #{VERSION}. Do not edit: regenerate it.
59
+ #
60
+ # Runs inside the target application via `rails runner`. Requires only the
61
+ # standard library beyond what the app already loads, and is held to a Ruby
62
+ # 2.6 syntax floor because it runs in the app's Ruby, not pinspec's.
63
+ #
64
+ # Every case is wrapped in the plan's isolation regime and reversed
65
+ # afterwards. Nothing here writes outside a transaction unless the plan says
66
+ # the suite is untransacted, in which case the tables it touched are
67
+ # truncated instead.
68
+
69
+ require "json"
70
+
71
+ #{indent(self.class.serializer_source, 0)}
72
+
73
+ #{indent(self.class.factory_source, 0)}
74
+
75
+ PINSPEC = JSON.parse(<<'PINSPEC_PAYLOAD_JSON')
76
+ #{JSON.pretty_generate(payload)}
77
+ PINSPEC_PAYLOAD_JSON
78
+
79
+ #{indent(runtime, 0)}
80
+ RUBY
81
+ end
82
+
83
+ private
84
+
85
+ def case_payload(input_case)
86
+ {
87
+ "id" => input_case.id,
88
+ "origin" => input_case.origin.to_s,
89
+ "ctor_args" => input_case.ctor_args,
90
+ "ctor_kwargs" => input_case.ctor_kwargs,
91
+ "args" => input_case.args,
92
+ "kwargs" => input_case.kwargs
93
+ }
94
+ end
95
+
96
+ def stringify(value)
97
+ case value
98
+ when Hash then value.each_with_object({}) { |(key, inner), out| out[key.to_s] = stringify(inner) }
99
+ when Array then value.map { |inner| stringify(inner) }
100
+ when Symbol then value.to_s
101
+ else value
102
+ end
103
+ end
104
+
105
+ def indent(text, _spaces)
106
+ text.to_s
107
+ end
108
+
109
+ def runtime
110
+ [
111
+ runtime_environment,
112
+ runtime_isolation,
113
+ runtime_sinks,
114
+ runtime_sql,
115
+ runtime_steps,
116
+ runtime_invoke,
117
+ runtime_main
118
+ ].join("\n")
119
+ end
120
+
121
+ def runtime_environment
122
+ <<~'RUBY'
123
+ # --------------------------------------------------------------- guards --
124
+
125
+ unless defined?(ActiveRecord::Base)
126
+ abort("pinspec probe: ActiveRecord is not loaded; is this a Rails application?")
127
+ end
128
+
129
+ unless (defined?(Rails) && Rails.env.test?) || ENV["PINSPEC_UNSAFE"] == "1"
130
+ abort("pinspec probe: refusing to run outside RAILS_ENV=test (got " +
131
+ (defined?(Rails) ? Rails.env.to_s : "no Rails") + ")")
132
+ end
133
+
134
+ require "active_support/testing/time_helpers"
135
+
136
+ module PinspecClock
137
+ extend ActiveSupport::Testing::TimeHelpers
138
+ end
139
+
140
+ # Forced once, for the whole process: a factory callback that enqueues during
141
+ # setup must land in a test sink rather than reaching a real queue.
142
+ ActiveJob::Base.queue_adapter = :test if defined?(ActiveJob::Base)
143
+ if defined?(ActionMailer::Base)
144
+ ActionMailer::Base.delivery_method = :test
145
+ ActionMailer::Base.perform_deliveries = true
146
+ end
147
+ RUBY
148
+ end
149
+
150
+ def runtime_isolation
151
+ <<~'RUBY'
152
+ # ------------------------------------------------------------ isolation --
153
+
154
+ # Raised to end a case early without it looking like a failure.
155
+ class PinspecCaseDone < StandardError; end
156
+ # The regime the PLAN chose, not the one this file would prefer. It has to
157
+ # be the plan's, because the emitted spec will run under the same one:
158
+ #
159
+ # :transaction wrap and roll back. after_commit callbacks never fire,
160
+ # in either host, and the divergence from production is
161
+ # documented rather than faked.
162
+ # :truncation do not wrap. after_commit callbacks DO fire - which is
163
+ # the whole reason this branch exists, because a suite that
164
+ # truncates would fire them in the emitted spec too, and a
165
+ # capture taken inside a transaction would have missed
166
+ # them. Reversed by truncating afterwards instead.
167
+ def pinspec_with_isolation
168
+ if PINSPEC["isolation"] == "truncation"
169
+ begin
170
+ yield
171
+ rescue PinspecCaseDone
172
+ nil
173
+ ensure
174
+ pinspec_truncate!
175
+ end
176
+ else
177
+ ActiveRecord::Base.transaction(requires_new: true) do
178
+ begin
179
+ yield
180
+ rescue PinspecCaseDone
181
+ nil
182
+ end
183
+
184
+ raise ActiveRecord::Rollback
185
+ end
186
+ end
187
+ end
188
+
189
+ # Everything the app owns, minus Rails' own bookkeeping. The safety claim
190
+ # is that a capture is reversible; under truncation that means leaving no
191
+ # rows behind rather than leaving no transaction behind.
192
+ def pinspec_truncate!
193
+ connection = ActiveRecord::Base.connection
194
+ tables = connection.tables - %w[schema_migrations ar_internal_metadata]
195
+ return if tables.empty?
196
+
197
+ if connection.respond_to?(:truncate_tables)
198
+ connection.truncate_tables(*tables)
199
+ else
200
+ tables.each { |table| connection.execute("DELETE FROM " + connection.quote_table_name(table)) }
201
+ end
202
+ rescue StandardError => e
203
+ warn("pinspec probe: could not truncate after a case: " + e.class.to_s + ": " + e.message.to_s)
204
+ end
205
+ RUBY
206
+ end
207
+
208
+ def runtime_sinks
209
+ <<~'RUBY'
210
+ # ---------------------------------------------------------------- sinks --
211
+
212
+ # Cleared after setup and before the target runs, so setup noise is never
213
+ # attributed to the target.
214
+ def pinspec_clear_sinks
215
+ if defined?(ActiveJob::Base) && ActiveJob::Base.queue_adapter.respond_to?(:enqueued_jobs)
216
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear
217
+ ActiveJob::Base.queue_adapter.performed_jobs.clear
218
+ end
219
+
220
+ ActionMailer::Base.deliveries.clear if defined?(ActionMailer::Base)
221
+ end
222
+
223
+ def pinspec_enqueued_jobs
224
+ return [] unless defined?(ActiveJob::Base)
225
+ adapter = ActiveJob::Base.queue_adapter
226
+ return [] unless adapter.respond_to?(:enqueued_jobs)
227
+
228
+ adapter.enqueued_jobs.map do |job|
229
+ {
230
+ "job" => job[:job].to_s,
231
+ "queue" => job[:queue].to_s,
232
+ "args" => PinspecSerializer.encode(job[:args], refs: $pinspec_refs,
233
+ fk_map: PINSPEC["fk_map"],
234
+ max_collection: PINSPEC["max_collection"])
235
+ }
236
+ end
237
+ end
238
+
239
+ def pinspec_mail_deliveries
240
+ return [] unless defined?(ActionMailer::Base)
241
+
242
+ ActionMailer::Base.deliveries.map do |mail|
243
+ { "to" => Array(mail.to).join(","), "subject" => mail.subject.to_s }
244
+ end
245
+ end
246
+ RUBY
247
+ end
248
+
249
+ def runtime_sql
250
+ <<~'RUBY'
251
+ # ------------------------------------------------------------------ sql --
252
+
253
+ PINSPEC_IGNORED_TABLES = %w[
254
+ versions ar_internal_metadata schema_migrations
255
+ ].freeze
256
+
257
+ # A breach is a statement that escapes the reversal pinspec relies on, and
258
+ # what counts depends on the regime. Under :transaction a COMMIT means the
259
+ # target broke out of the envelope. Under :truncation there IS no envelope
260
+ # - every write commits, and pinspec's own TRUNCATE follows the case - so
261
+ # only DDL and advisory locks are breaches there.
262
+ PINSPEC_BREACH =
263
+ if PINSPEC["isolation"] == "truncation"
264
+ /\A\s*(CREATE|ALTER|DROP)\b|pg_advisory_lock/i
265
+ else
266
+ /\A\s*(COMMIT|CREATE|ALTER|DROP|TRUNCATE)\b|pg_advisory_lock/i
267
+ end
268
+
269
+ # Rails logs its own column introspection as name "SCHEMA", once per model
270
+ # class per process - so the first case to touch each model would otherwise
271
+ # diverge from every later one and from the second boot. TRANSACTION covers
272
+ # BEGIN/COMMIT/SAVEPOINT, and payload[:cached] covers query-cache hits.
273
+ def pinspec_ignore_sql?(payload)
274
+ return true if %w[SCHEMA TRANSACTION].include?(payload[:name].to_s)
275
+ return true if payload[:cached]
276
+
277
+ sql = payload[:sql].to_s
278
+ return true if sql =~ /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE SAVEPOINT)/i
279
+ return true if PINSPEC_IGNORED_TABLES.any? { |table| sql.include?(table) }
280
+
281
+ false
282
+ end
283
+
284
+ def pinspec_fingerprint(sql)
285
+ text = sql.to_s.dup
286
+ text.gsub!(/'[^']*'/, "?")
287
+ text.gsub!(/\b\d+\b/, "?")
288
+ text.gsub!(/\$\d+/, "?")
289
+ text.gsub!(/\s+/, " ")
290
+ text.gsub!(/IN \((?:\?,\s*)*\?\)/i, "IN (?+)")
291
+ text.strip
292
+ end
293
+ RUBY
294
+ end
295
+
296
+ def runtime_steps
297
+ <<~'RUBY'
298
+ # ---------------------------------------------------------------- steps --
299
+
300
+ # Serializer-v3 tagged values arrive from cases.json; refs are resolved
301
+ # against the records this plan just built.
302
+ def pinspec_decode(tagged, records)
303
+ return tagged unless tagged.is_a?(Hash) && tagged.key?("t")
304
+
305
+ case tagged["t"]
306
+ when "nil" then nil
307
+ when "bool", "int", "float", "str" then tagged["v"]
308
+ when "sym" then tagged["v"].to_sym
309
+ when "decimal" then BigDecimal(tagged["v"])
310
+ when "date" then Date.parse(tagged["v"])
311
+ when "time" then Time.parse(tagged["v"])
312
+ when "nan" then Float::NAN
313
+ when "inf" then tagged["sign"].to_i < 0 ? -Float::INFINITY : Float::INFINITY
314
+ when "array" then tagged["v"].map { |element| pinspec_decode(element, records) }
315
+ when "hash"
316
+ out = {}
317
+ tagged["v"].each { |pair| out[pinspec_decode(pair[0], records)] = pinspec_decode(pair[1], records) }
318
+ out
319
+ when "ref"
320
+ records.fetch(tagged["v"]) do
321
+ raise "pinspec probe: case references unknown record " + tagged["v"].to_s
322
+ end
323
+ when "bin" then tagged["v"].unpack("m0").first
324
+ else tagged["v"]
325
+ end
326
+ end
327
+
328
+ def pinspec_run_steps(records, refs)
329
+ PINSPEC["setup_plan"].each do |step|
330
+ payload = step["payload"]
331
+
332
+ case step["kind"]
333
+ when "freeze_time"
334
+ PinspecClock.travel_to(Time.parse(payload["at"]))
335
+ when "seed_random"
336
+ srand(payload["seed"].to_i)
337
+ when "set_locale"
338
+ I18n.locale = payload["locale"] if defined?(I18n)
339
+ when "set_zone"
340
+ Time.zone = payload["zone"] if Time.respond_to?(:zone=)
341
+ when "set_flag"
342
+ if defined?(Flipper)
343
+ payload["enabled"] ? Flipper.enable(payload["flag"].to_sym) : Flipper.disable(payload["flag"].to_sym)
344
+ end
345
+ when "create_record"
346
+ record = pinspec_create_record(payload, records)
347
+ pinspec_register(record, records, refs, payload["name"])
348
+ when "import_record"
349
+ record = pinspec_import_record(payload, records)
350
+ pinspec_register(record, records, refs, payload["name"]) if record
351
+ when "set_tenant"
352
+ if defined?(ActsAsTenant)
353
+ ActsAsTenant.current_tenant = records[payload["record_ref"]]
354
+ end
355
+ when "stub_current"
356
+ pinspec_stub_current(payload, records)
357
+ when "set_whodunnit"
358
+ if defined?(PaperTrail)
359
+ PaperTrail.request.whodunnit = records[payload["record_ref"]].try(:id).to_s
360
+ end
361
+ when "construct_subject"
362
+ nil # built per case, since its arguments are per case
363
+ end
364
+ end
365
+ end
366
+
367
+ # A real application bundles factory_bot with `require: false` and requires it
368
+ # from `rails_helper` - OFN does exactly this - and `rails runner` never
369
+ # reads rails_helper. So the probe loads it, rather than reporting that an
370
+ # app which plainly has 113 factories has none.
371
+ #
372
+ # `factory_bot_rails` is not enough on its own here: its railtie calls
373
+ # find_definitions during initialize!, which has already happened by the
374
+ # time this runs. So the definitions are found explicitly.
375
+ def pinspec_factory_module
376
+ return FactoryBot if defined?(FactoryBot)
377
+ return FactoryGirl if defined?(FactoryGirl)
378
+
379
+ begin
380
+ require "factory_bot"
381
+ rescue LoadError
382
+ begin
383
+ require "factory_girl"
384
+ rescue LoadError
385
+ return nil
386
+ end
387
+ end
388
+
389
+ mod = defined?(FactoryBot) ? FactoryBot : (defined?(FactoryGirl) ? FactoryGirl : nil)
390
+ return nil if mod.nil?
391
+
392
+ # Definitions are loaded once per process. A second call would raise
393
+ # DuplicateDefinitionError and take the whole probe down with it.
394
+ unless $pinspec_factories_found
395
+ $pinspec_factories_found = true
396
+ mod.find_definitions
397
+ end
398
+
399
+ mod
400
+ end
401
+
402
+ def pinspec_register(record, records, refs, name)
403
+ records[name] = record
404
+ refs[record.class.table_name.to_s + ":" + record.id.to_s] = name
405
+ end
406
+
407
+ def pinspec_create_record(payload, records)
408
+ attrs = {}
409
+ (payload["attrs"] || {}).each { |column, value| attrs[column] = value }
410
+ (payload["assoc_refs"] || {}).each do |column, ref|
411
+ attrs[column] = pinspec_decode(ref, records).id
412
+ end
413
+
414
+ if payload["factory"]
415
+ factory_module = pinspec_factory_module
416
+ raise "pinspec probe: plan wants factory :" + payload["factory"].to_s + " but factory_bot could not be loaded. " \
417
+ "It is bundled with require: false, and this probe runs through rails runner, which does not read rails_helper" if factory_module.nil?
418
+
419
+ return PinspecFactory.create(payload["factory"], attrs.empty? ? {} : symbolize(attrs), factory_module)
420
+ end
421
+
422
+ payload["model"].constantize.create!(attrs)
423
+ end
424
+
425
+ # insert_all skips validations AND callbacks, which is the only honest way
426
+ # to recreate a legacy row: those rows routinely fail today's validations,
427
+ # and a before_save would rewrite the very attributes being imported.
428
+ def pinspec_import_record(payload, records)
429
+ model = payload["model"].constantize
430
+ attrs = {}
431
+
432
+ (payload["attrs"] || {}).each do |column, tagged|
433
+ value = pinspec_decode(tagged, records)
434
+ value = value.id if value.is_a?(ActiveRecord::Base)
435
+ attrs[column] = value
436
+ end
437
+
438
+ result = model.insert_all([attrs], returning: [model.primary_key])
439
+ key = result.rows.first && result.rows.first.first
440
+ return nil if key.nil?
441
+
442
+ found = model.find_by(model.primary_key => key)
443
+
444
+ # Refetch and diff: a fallback path that fires callbacks would silently
445
+ # rewrite what was imported, and that has to be visible.
446
+ (payload["attrs"] || {}).each do |column, tagged|
447
+ wanted = pinspec_decode(tagged, records)
448
+ next if wanted.is_a?(ActiveRecord::Base)
449
+ next if found[column].to_s == wanted.to_s
450
+
451
+ $pinspec_flags << "import_mutated"
452
+ end
453
+
454
+ found
455
+ end
456
+
457
+ def pinspec_stub_current(payload, records)
458
+ record = records[payload["record_ref"]]
459
+ return if record.nil?
460
+
461
+ if payload["kind"] == "current_attributes" && defined?(Current)
462
+ Current.user = record if Current.respond_to?(:user=)
463
+ end
464
+ end
465
+
466
+ def symbolize(hash)
467
+ out = {}
468
+ hash.each { |key, value| out[key.to_sym] = value }
469
+ out
470
+ end
471
+ RUBY
472
+ end
473
+
474
+ def runtime_invoke
475
+ <<~'RUBY'
476
+ # --------------------------------------------------------------- invoke --
477
+
478
+ def pinspec_build_subject(input_case, records)
479
+ construction = PINSPEC["target"]["construction"]
480
+ klass = PINSPEC["target"]["class"].constantize
481
+
482
+ return klass if construction == "class_method"
483
+
484
+ args = input_case["ctor_args"].map { |value| pinspec_decode(value, records) }
485
+ kwargs = {}
486
+ input_case["ctor_kwargs"].each { |name, value| kwargs[name.to_sym] = pinspec_decode(value, records) }
487
+
488
+ return records.values.last if construction == "model_instance"
489
+
490
+ pinspec_construct(klass, construction, args, kwargs)
491
+ end
492
+
493
+ def pinspec_construct(klass, construction, args, kwargs)
494
+ case construction
495
+ when "interactor" then klass
496
+ when "struct", "dry_initializer", "new"
497
+ if kwargs.empty?
498
+ args.empty? ? klass.new : klass.new(*args)
499
+ else
500
+ klass.new(*args, **kwargs)
501
+ end
502
+ else
503
+ klass.new
504
+ end
505
+ end
506
+
507
+ # One shim, because keyword forwarding of an EMPTY hash differs before Ruby
508
+ # 2.7 and open-coding it at each call site is how that bites.
509
+ def pinspec_invoke(subject, method_name, args, kwargs)
510
+ if kwargs.empty?
511
+ subject.send(method_name, *args)
512
+ else
513
+ subject.send(method_name, *args, **kwargs)
514
+ end
515
+ end
516
+ RUBY
517
+ end
518
+
519
+ def runtime_main
520
+ <<~'RUBY'
521
+ # ----------------------------------------------------------------- main --
522
+
523
+ $pinspec_refs = {}
524
+ $pinspec_flags = []
525
+
526
+ # Under truncation the plan's values are deterministic but the database is
527
+ # not: whatever ran before - the app's own suite, a previous pinspec run,
528
+ # seed data - is still there, and a leftover row collides with the plan on
529
+ # any unique index. Starting clean is not an extra liberty either: a suite
530
+ # in this regime has already declared its test database disposable.
531
+ pinspec_truncate! if PINSPEC["isolation"] == "truncation"
532
+
533
+ observations = []
534
+ order = (0...PINSPEC["cases"].length).to_a
535
+ order = order.shuffle(random: Random.new(ENV.fetch("PINSPEC_SHUFFLE_SEED", "42").to_i))
536
+
537
+ order.each do |index|
538
+ input_case = PINSPEC["cases"][index]
539
+
540
+ records = {}
541
+ refs = {}
542
+ $pinspec_refs = refs
543
+ $pinspec_flags = []
544
+ fingerprints = []
545
+ deltas = { "inserts" => 0, "updates" => 0, "deletes" => 0 }
546
+ started = Time.now.to_f
547
+
548
+ observation = {
549
+ "case_id" => input_case["id"],
550
+ "status" => "returned",
551
+ "return_value" => nil,
552
+ "error" => nil,
553
+ "setup_error" => nil,
554
+ "enqueued_jobs" => [],
555
+ "mail_deliveries" => [],
556
+ "sql_fingerprints" => [],
557
+ "db_delta" => deltas,
558
+ "flags" => [],
559
+ "duration_ms" => 0
560
+ }
561
+
562
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
563
+ payload = args.last
564
+ unless pinspec_ignore_sql?(payload)
565
+ sql = payload[:sql].to_s
566
+ $pinspec_flags << "escaped_transaction" if sql =~ PINSPEC_BREACH
567
+ fingerprints << pinspec_fingerprint(sql)
568
+ deltas["inserts"] += 1 if sql =~ /\A\s*INSERT/i
569
+ deltas["updates"] += 1 if sql =~ /\A\s*UPDATE/i
570
+ deltas["deletes"] += 1 if sql =~ /\A\s*DELETE/i
571
+ end
572
+ end
573
+
574
+ begin
575
+ pinspec_with_isolation do
576
+ begin
577
+ pinspec_run_steps(records, refs)
578
+ rescue StandardError => setup_error
579
+ observation["status"] = "setup_error"
580
+ observation["setup_error"] = {
581
+ "error" => { "class" => setup_error.class.name.to_s, "message" => setup_error.message.to_s }
582
+ }
583
+ raise PinspecCaseDone
584
+ end
585
+
586
+ # After setup, before the target: setup noise is not the target's.
587
+ pinspec_clear_sinks
588
+ fingerprints.clear
589
+ deltas["inserts"] = 0
590
+ deltas["updates"] = 0
591
+ deltas["deletes"] = 0
592
+
593
+ subject = pinspec_build_subject(input_case, records)
594
+ args = input_case["args"].map { |value| pinspec_decode(value, records) }
595
+ kwargs = {}
596
+ input_case["kwargs"].each { |name, value| kwargs[name.to_sym] = pinspec_decode(value, records) }
597
+
598
+ begin
599
+ value = pinspec_invoke(subject, PINSPEC["target"]["method"], args, kwargs)
600
+ observation["status"] = "returned"
601
+ observation["return_value"] = PinspecSerializer.encode(
602
+ value, refs: refs, fk_map: PINSPEC["fk_map"],
603
+ max_collection: PINSPEC["max_collection"]
604
+ )
605
+
606
+ # Registered BEFORE the sinks are read: the commonest side effect
607
+ # is perform_later(the_record_the_target_just_created.id), and
608
+ # that id churns between runs unless it can name something.
609
+ if value.is_a?(ActiveRecord::Base) && !value.id.nil?
610
+ refs[value.class.table_name.to_s + ":" + value.id.to_s] = "__returned__"
611
+ end
612
+ rescue StandardError, ScriptError => target_error
613
+ observation["status"] = "raised"
614
+ observation["error"] = {
615
+ "class" => target_error.class.name.to_s,
616
+ "message" => target_error.message.to_s
617
+ }
618
+ end
619
+
620
+ observation["enqueued_jobs"] = pinspec_enqueued_jobs
621
+ observation["mail_deliveries"] = pinspec_mail_deliveries
622
+ end
623
+ rescue StandardError => outer
624
+ observation["status"] = "setup_error"
625
+ observation["setup_error"] = {
626
+ "error" => { "class" => outer.class.name.to_s, "message" => outer.message.to_s }
627
+ }
628
+ ensure
629
+ ActiveSupport::Notifications.unsubscribe(subscriber)
630
+ end
631
+
632
+ observation["sql_fingerprints"] = fingerprints
633
+ observation["flags"] = $pinspec_flags.uniq
634
+ observation["duration_ms"] = ((Time.now.to_f - started) * 1000).round
635
+
636
+ observations << observation
637
+
638
+ # Between cases: anything memoized in a cache or a CurrentAttributes slot
639
+ # would otherwise leak into the next case.
640
+ Rails.cache.clear if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
641
+ ActiveSupport::CurrentAttributes.reset_all if defined?(ActiveSupport::CurrentAttributes)
642
+ pinspec_clear_sinks
643
+ PinspecClock.travel_back
644
+ end
645
+
646
+ puts JSON.generate(
647
+ "pinspec_probe_version" => PINSPEC["pinspec_probe_version"],
648
+ "serializer" => PINSPEC["serializer"],
649
+ "plan_id" => PINSPEC["plan_id"],
650
+ "env" => {
651
+ "ruby" => RUBY_VERSION,
652
+ "rails" => (defined?(Rails) ? Rails.version : nil),
653
+ "tz" => ENV["TZ"].to_s,
654
+ "shuffle_seed" => ENV.fetch("PINSPEC_SHUFFLE_SEED", "42")
655
+ },
656
+ "observations" => observations.sort_by { |observation| observation["case_id"] }
657
+ )
658
+ RUBY
659
+ end
660
+ end
661
+ end
662
+ end