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,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ PARAM_KINDS = %i[req opt rest keyreq key keyrest].freeze
5
+
6
+ OPTIONAL_PARAM_KINDS = %i[opt key rest keyrest].freeze
7
+ POSITIONAL_PARAM_KINDS = %i[req opt rest].freeze
8
+
9
+ CONSTRUCTION_KINDS = %i[
10
+ new
11
+ class_method
12
+ interactor
13
+ dry_initializer
14
+ struct
15
+ model_instance
16
+ ].freeze
17
+
18
+ Param = Data.define(:name, :kind, :default_source, :type_hint) do
19
+ def positional?
20
+ POSITIONAL_PARAM_KINDS.include?(kind)
21
+ end
22
+
23
+ def optional?
24
+ OPTIONAL_PARAM_KINDS.include?(kind)
25
+ end
26
+
27
+ def to_s
28
+ case kind
29
+ when :req then name.to_s
30
+ when :opt then "#{name} = #{default_source}"
31
+ when :rest then "*#{name}"
32
+ when :keyreq then "#{name}:"
33
+ when :key then "#{name}: #{default_source}"
34
+ when :keyrest then "**#{name}"
35
+ end
36
+ end
37
+ end
38
+
39
+ ClockSite = Data.define(:call, :line)
40
+
41
+ TargetProfile = Data.define(
42
+ :file_path,
43
+ :class_name,
44
+ :method_name,
45
+ :params,
46
+ :initializer_params,
47
+ :construction_kind,
48
+ :visibility,
49
+ :takes_block,
50
+ :source_range,
51
+ :referenced_constants,
52
+ :clock_sites
53
+ ) do
54
+ def singleton?
55
+ construction_kind == :class_method
56
+ end
57
+
58
+ def needs_subject?
59
+ !%i[class_method model_instance].include?(construction_kind)
60
+ end
61
+
62
+ def qualified_name
63
+ singleton? ? "#{class_name}.#{method_name}" : "#{class_name}##{method_name}"
64
+ end
65
+
66
+ def clock_dependent?
67
+ !clock_sites.empty?
68
+ end
69
+
70
+ def input_params
71
+ initializer_params + params
72
+ end
73
+ end
74
+
75
+ InputCase = Data.define(:id, :ctor_args, :ctor_kwargs, :args, :kwargs, :origin) do
76
+ def signature
77
+ [ctor_args, ctor_kwargs, args, kwargs]
78
+ end
79
+
80
+ def to_s
81
+ ctor = render(ctor_args, ctor_kwargs)
82
+ meth = render(args, kwargs)
83
+
84
+ receiver = ctor_args.empty? && ctor_kwargs.empty? ? "" : "new(#{ctor})."
85
+ invocation = meth.empty? ? "call" : "call(#{meth})"
86
+
87
+ "#{id} (#{origin}) #{receiver}#{invocation}"
88
+ end
89
+
90
+ private
91
+
92
+ def render(positional, keyword)
93
+ (Array(positional).map { |v| Tags.describe(v) } +
94
+ Hash(keyword).map { |name, v| "#{name}: #{Tags.describe(v)}" }).join(", ")
95
+ end
96
+ end
97
+
98
+ InputCorpus = Data.define(:cases, :setup_plan) do
99
+ def size
100
+ cases.size
101
+ end
102
+
103
+ def origins
104
+ cases.group_by(&:origin).transform_values(&:size)
105
+ end
106
+ end
107
+
108
+ ImportCluster = Data.define(:model, :table, :name, :attrs, :source, :redacted, :flags) do
109
+ def redaction_read?
110
+ Array(flags).include?(:redaction_read)
111
+ end
112
+ end
113
+
114
+ PINSPEC_EPOCH = "2026-01-01T12:00:00Z"
115
+ PINSPEC_SEED = 42
116
+
117
+ STEP_KINDS = %i[
118
+ freeze_time seed_random set_locale set_zone set_flag
119
+ create_record import_record
120
+ set_tenant stub_current set_whodunnit
121
+ construct_subject
122
+ ].freeze
123
+
124
+ SetupStep = Data.define(:kind, :payload) do
125
+ def ref
126
+ payload[:name]
127
+ end
128
+
129
+ def to_s
130
+ case kind
131
+ when :freeze_time then "freeze_time #{payload[:at]}"
132
+ when :seed_random then "seed_random #{payload[:seed]}"
133
+ when :set_locale then "set_locale #{payload[:locale].inspect}"
134
+ when :set_zone then "set_zone #{payload[:zone].inspect}"
135
+ when :set_flag then "set_flag #{payload[:flag].inspect} = #{payload[:enabled]}"
136
+ when :create_record then "create_record #{payload[:name]} <- #{create_source}"
137
+ when :import_record then "import_record #{payload[:name]} <- #{payload[:source]}"
138
+ when :set_tenant then "set_tenant #{payload[:record_ref]}"
139
+ when :stub_current then "stub_current #{payload[:kind]} #{payload[:record_ref]}"
140
+ when :set_whodunnit then "set_whodunnit #{payload[:record_ref]}"
141
+ when :construct_subject then "construct_subject #{payload[:class]} (#{payload[:kind]})"
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def create_source
148
+ base = payload[:factory] ? "factory(:#{payload[:factory]})" : "#{payload[:model]}.create!"
149
+ refs = payload[:assoc_refs]
150
+ refs.nil? || refs.empty? ? base : "#{base} #{refs.map { |c, r| "#{c}=>#{r}" }.join(', ')}"
151
+ end
152
+ end
153
+
154
+ SetupPlan = Data.define(
155
+ :steps, :isolation, :env_fingerprint, :bindings, :notes, :generation, :plan_id
156
+ ) do
157
+ def steps_of(kind)
158
+ steps.select { |step| step.kind == kind }
159
+ end
160
+
161
+ def refs
162
+ steps.filter_map(&:ref)
163
+ end
164
+
165
+ def record_steps
166
+ steps.select { |step| %i[create_record import_record].include?(step.kind) }
167
+ end
168
+
169
+ def subject_step
170
+ steps.find { |step| step.kind == :construct_subject }
171
+ end
172
+
173
+ def binding_for(param_name)
174
+ bindings[param_name.to_sym]
175
+ end
176
+ end
177
+
178
+ MULTI_DB_ROLLBACK_WARNING =
179
+ "This app declares more than one writing database. pinspec's per-case " \
180
+ "rollback covers the PRIMARY writing connection only - writes made through " \
181
+ "any other connection are not rolled back, and pins that depend on them are " \
182
+ "not trustworthy."
183
+
184
+ ModelFinding = Data.define(:kind, :model, :file, :line) do
185
+ def to_s
186
+ "#{model} #{kind} at #{file}:#{line}"
187
+ end
188
+ end
189
+
190
+ TestStack = Data.define(:framework, :webmock, :vcr, :snapshot_backends, :database_cleaner_gem) do
191
+ def stubs_http?
192
+ webmock || vcr
193
+ end
194
+ end
195
+
196
+ AppProfile = Data.define(
197
+ :rails_version, :ruby_version, :rails_floor_ok,
198
+ :auth, :authz, :tenancy, :soft_delete, :versioning, :flags, :attachments,
199
+ :multi_db, :spring,
200
+ :model_findings,
201
+ :default_locale, :default_zone,
202
+ :db_cleaner, :transactional_fixtures, :queue_adapter_in_tests,
203
+ :test_stack, :schema, :factories,
204
+ :notes
205
+ ) do
206
+ def findings(kind)
207
+ model_findings.select { |f| f.kind == kind }
208
+ end
209
+
210
+ def after_commit_models
211
+ findings(:after_commit)
212
+ end
213
+
214
+ def rails_floor_ok?
215
+ rails_floor_ok == true
216
+ end
217
+
218
+ def isolation
219
+ case db_cleaner
220
+ when :truncation then :truncation
221
+ when :transaction then :transaction
222
+ else transactional_fixtures == false ? :truncation : :transaction
223
+ end
224
+ end
225
+
226
+ def isolation_source
227
+ case db_cleaner
228
+ when :truncation, :transaction then "DatabaseCleaner.strategy = #{db_cleaner.inspect}"
229
+ else "use_transactional_fixtures = #{transactional_fixtures.inspect}"
230
+ end
231
+ end
232
+
233
+ def warnings
234
+ [
235
+ multi_db_warning,
236
+ floor_warning,
237
+ isolation_warning,
238
+ queue_adapter_warning,
239
+ apartment_warning,
240
+ default_scope_warning,
241
+ attachment_warning,
242
+ spring_warning
243
+ ].compact
244
+ end
245
+
246
+ private
247
+
248
+ def multi_db_warning
249
+ MULTI_DB_ROLLBACK_WARNING if multi_db
250
+ end
251
+
252
+ def floor_warning
253
+ return if rails_floor_ok
254
+
255
+ if rails_version.nil?
256
+ "No Gemfile.lock was read, so gem detection is unavailable and the Rails " \
257
+ "version could not be checked against the 6.0 floor."
258
+ end
259
+ end
260
+
261
+ def isolation_warning
262
+ if isolation == :truncation
263
+ "This suite does not wrap examples in a transaction " \
264
+ "(#{isolation_source}), so after_commit callbacks DO fire. pinspec will " \
265
+ "run the probe under the same regime and the capture will mutate the " \
266
+ "test database."
267
+ elsif !after_commit_models.empty?
268
+ "#{after_commit_models.size} model(s) use after_commit. Under " \
269
+ "transactional isolation these never fire in the probe or in the " \
270
+ "emitted spec, and pinspec will not fake them - the divergence from " \
271
+ "production is real and documented."
272
+ end
273
+ end
274
+
275
+ def queue_adapter_warning
276
+ return if queue_adapter_in_tests.nil? || queue_adapter_in_tests == :test
277
+
278
+ "The suite sets ActiveJob's queue adapter to #{queue_adapter_in_tests.inspect}, " \
279
+ "which executes jobs instead of enqueuing them. Emitted specs force :test " \
280
+ "so that job pins can see anything at all."
281
+ end
282
+
283
+ def apartment_warning
284
+ "ros-apartment tenancy is not supported; targets on tenanted models will " \
285
+ "be refused rather than run against the wrong schema." if tenancy == :apartment
286
+ end
287
+
288
+ def default_scope_warning
289
+ suspects = findings(:default_scope)
290
+ return if suspects.empty?
291
+
292
+ "#{suspects.size} model(s) declare default_scope " \
293
+ "(#{suspects.map(&:model).uniq.join(', ')}); records a plan creates may be " \
294
+ "invisible to the target, which reads as a bug in the target."
295
+ end
296
+
297
+ def attachment_warning
298
+ return if attachments.empty?
299
+
300
+ "Attachments present (#{attachments.join(', ')}). pinspec cannot synthesize " \
301
+ "blobs, so a target that reads an attachment is refused rather than run " \
302
+ "against an empty one."
303
+ end
304
+
305
+ def spring_warning
306
+ "Spring is bundled; the sandbox exports DISABLE_SPRING=1 so a stale " \
307
+ "preloader cannot serve yesterday's code." if spring
308
+ end
309
+ end
310
+
311
+ FactoryAttribute = Data.define(:name, :kind, :source, :factory, :line) do
312
+ def association?
313
+ kind == :association
314
+ end
315
+
316
+ def transient?
317
+ kind == :transient
318
+ end
319
+ end
320
+
321
+ FactoryTrait = Data.define(:name, :attributes, :line)
322
+
323
+ FactoryCallback = Data.define(:hook, :stage, :line) do
324
+ def to_s
325
+ "#{hook}(:#{stage})"
326
+ end
327
+ end
328
+
329
+ Factory = Data.define(
330
+ :name, :model, :parent, :aliases, :attributes, :traits, :callbacks, :hazards, :file, :line
331
+ ) do
332
+ def attribute(name)
333
+ attributes.find { |a| a.name == name.to_sym }
334
+ end
335
+
336
+ def trait(name)
337
+ traits.find { |t| t.name == name.to_sym }
338
+ end
339
+
340
+ def associations
341
+ attributes.select(&:association?)
342
+ end
343
+
344
+ def persists?
345
+ !hazards.map(&:first).include?(:skip_create)
346
+ end
347
+
348
+ def fires_callbacks?
349
+ !callbacks.empty?
350
+ end
351
+ end
352
+
353
+ FactoryIndex = Data.define(:factories, :legacy_dsl, :skipped) do
354
+ def factory(name)
355
+ wanted = name.to_sym
356
+
357
+ factories.find { |f| f.name == wanted } ||
358
+ factories.find { |f| f.aliases.include?(wanted) }
359
+ end
360
+
361
+ def for_model(model)
362
+ factories.select { |f| f.model == model.to_s }
363
+ end
364
+
365
+ def dsl_module
366
+ legacy_dsl ? "FactoryGirl" : "FactoryBot"
367
+ end
368
+
369
+ def traits_for(name)
370
+ ancestry(name).flat_map(&:traits)
371
+ end
372
+
373
+ def ancestry(name)
374
+ chain = []
375
+ seen = []
376
+ current = factory(name)
377
+
378
+ while current && !seen.include?(current.name)
379
+ seen << current.name
380
+ chain.unshift(current)
381
+ current = current.parent && factory(current.parent)
382
+ end
383
+
384
+ chain
385
+ end
386
+
387
+ def attributes_for(name, traits: [])
388
+ chain = ancestry(name)
389
+ merged = {}
390
+
391
+ chain.each { |f| f.attributes.each { |a| merged[a.name] = a } }
392
+
393
+ Array(traits).each do |trait_name|
394
+ found = chain.reverse.filter_map { |f| f.trait(trait_name) }.first
395
+ found&.attributes&.each { |a| merged[a.name] = a }
396
+ end
397
+
398
+ merged.values
399
+ end
400
+ end
401
+
402
+ Column = Data.define(
403
+ :name, :type, :null, :default, :limit, :precision, :scale, :array, :unknown_type, :line
404
+ ) do
405
+ def nullable?
406
+ null != false
407
+ end
408
+
409
+ def unknown_type?
410
+ unknown_type == true
411
+ end
412
+
413
+ def required?
414
+ !nullable? && default.nil?
415
+ end
416
+ end
417
+
418
+ Index = Data.define(:columns, :name, :unique, :where) do
419
+ def unique?
420
+ unique == true
421
+ end
422
+
423
+ def partial?
424
+ !where.nil?
425
+ end
426
+ end
427
+
428
+ Table = Data.define(:name, :primary_key, :id_type, :columns, :indexes) do
429
+ def column(name)
430
+ columns.find { |c| c.name == name.to_s }
431
+ end
432
+
433
+ def unique_indexes
434
+ indexes.select(&:unique?)
435
+ end
436
+
437
+ def required_columns
438
+ columns.reject { |c| Array(primary_key).include?(c.name) }.select(&:required?)
439
+ end
440
+ end
441
+
442
+ ForeignKey = Data.define(:from_table, :column, :to_table, :primary_key, :on_delete, :source) do
443
+ def heuristic?
444
+ source == :heuristic
445
+ end
446
+
447
+ def key
448
+ "#{from_table}.#{column}"
449
+ end
450
+ end
451
+
452
+ SkippedStatement = Data.define(:kind, :table, :column, :references, :file, :line, :relevant) do
453
+ def relevant?
454
+ relevant == true
455
+ end
456
+
457
+ def tables_touched
458
+ ([table] + Array(references)).compact.uniq
459
+ end
460
+
461
+ def to_s
462
+ subject = column ? "#{table}.#{column}" : table
463
+ via = Array(references).empty? ? "" : " on #{Array(references).join(', ')}"
464
+ "#{kind}#{subject ? " (#{subject})" : ""}#{via} at #{file}:#{line}"
465
+ end
466
+ end
467
+
468
+ SchemaGraph = Data.define(:tables, :fk_map, :foreign_keys, :skipped_statements) do
469
+ def table(name)
470
+ tables.find { |t| t.name == name.to_s }
471
+ end
472
+
473
+ def table_names
474
+ tables.map(&:name)
475
+ end
476
+
477
+ def fk_for(table_name, column_name)
478
+ foreign_keys.find { |fk| fk.from_table == table_name.to_s && fk.column == column_name.to_s }
479
+ end
480
+
481
+ def foreign_keys_from(table_name)
482
+ foreign_keys.select { |fk| fk.from_table == table_name.to_s }
483
+ end
484
+
485
+ def unknown_columns
486
+ tables.flat_map { |t| t.columns.select(&:unknown_type?).map { |c| [t.name, c] } }
487
+ end
488
+
489
+ def annotate_relevance(planned_tables)
490
+ wanted = Array(planned_tables).map(&:to_s)
491
+
492
+ with(skipped_statements: skipped_statements.map do |statement|
493
+ statement.with(relevant: statement.tables_touched.any? { |t| wanted.include?(t) })
494
+ end)
495
+ end
496
+ end
497
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Pinspec
8
+ module Validate
9
+ class MutationAdapter
10
+ REQUIRED_RUBY = "3.4"
11
+
12
+ def self.available?
13
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(REQUIRED_RUBY) &&
14
+ !find_executable.nil?
15
+ end
16
+
17
+ def self.find_executable
18
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, "mutineer") }
19
+ .find { |candidate| File.executable?(candidate) }
20
+ end
21
+
22
+ def self.refuse_unless_available!
23
+ return if available?
24
+
25
+ raise UnsupportedRailsVersion,
26
+ "`--validate` needs the mutineer backend, which requires Ruby >= " \
27
+ "#{REQUIRED_RUBY} (this is #{RUBY_VERSION}) and a `mutineer` on PATH. " \
28
+ "Everything else in pinspec runs on Ruby 3.2 and up; only scoring is " \
29
+ "gated. Install it with `gem install mutineer` on a 3.4+ Ruby."
30
+ end
31
+
32
+ Outcome = Data.define(:subject, :score, :killed, :survived, :total, :survivors, :raw) do
33
+ def scored?
34
+ !score.nil?
35
+ end
36
+ end
37
+
38
+ def initialize(source_path:, subject:, cwd: ".", env: {}, test_command: nil)
39
+ @source_path = source_path
40
+ @subject = subject
41
+ @cwd = cwd
42
+ @env = env
43
+ @test_command = test_command
44
+ end
45
+
46
+ def score(spec_path)
47
+ args = command_for(spec_path)
48
+ stdout, stderr, status = Open3.capture3(environment, *args, chdir: @cwd)
49
+
50
+ parsed = parse(stdout)
51
+ return unscored(stderr.to_s.empty? ? stdout : stderr) if parsed.nil?
52
+
53
+ summary = parsed["summary"] || {}
54
+
55
+ Outcome.new(
56
+ subject: @subject,
57
+ score: summary["score"],
58
+ killed: summary["killed"],
59
+ survived: summary["survived"],
60
+ total: summary["total"],
61
+ survivors: Array(parsed["survivors"]).map { |s| { "operator" => s["operator"], "line" => s["line"], "token" => s["token"] } },
62
+ raw: status.exitstatus
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def command_for(spec_path)
69
+ base = [
70
+ self.class.find_executable || "mutineer", "run", relative(@source_path),
71
+ "--test", relative(spec_path),
72
+ "--framework", "rspec",
73
+ "--only", @subject,
74
+ "--format", "json",
75
+ "--strategy", @test_command ? "reload" : "redefine"
76
+ ]
77
+
78
+ @test_command ? base + ["--test-command", @test_command] : base
79
+ end
80
+
81
+ def relative(path)
82
+ path.to_s.sub("#{File.expand_path(@cwd)}/", "").sub("#{@cwd}/", "")
83
+ end
84
+
85
+ def environment
86
+ {
87
+ "BUNDLE_GEMFILE" => nil, "BUNDLE_PATH" => nil, "BUNDLE_BIN_PATH" => nil,
88
+ "BUNDLER_VERSION" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil,
89
+ "RAILS_ENV" => "test", "DISABLE_SPRING" => "1"
90
+ }.merge(@env)
91
+ end
92
+
93
+ def parse(stdout)
94
+ json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
95
+ return nil if json.nil?
96
+
97
+ JSON.parse(json)
98
+ rescue JSON::ParserError
99
+ nil
100
+ end
101
+
102
+ def unscored(detail)
103
+ Outcome.new(subject: @subject, score: nil, killed: nil, survived: nil, total: nil,
104
+ survivors: [], raw: detail.to_s.lines.last(8).join)
105
+ end
106
+ end
107
+ end
108
+ end