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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Pinspec
8
+ module Runner
9
+ class Sandbox
10
+ PROBE_DIR = "tmp/pinspec"
11
+ PROBE_FILE = "probe.rb"
12
+
13
+ DEFAULT_TIMEOUT = 600
14
+
15
+ FORCED_ENV = {
16
+ "DISABLE_SPRING" => "1",
17
+ "TZ" => "UTC",
18
+ "RAILS_ENV" => "test"
19
+ }.freeze
20
+
21
+ SCRUBBED_ENV = {
22
+ "BUNDLE_GEMFILE" => nil,
23
+ "BUNDLE_PATH" => nil,
24
+ "BUNDLE_BIN_PATH" => nil,
25
+ "BUNDLE_APP_CONFIG" => nil,
26
+ "BUNDLER_VERSION" => nil,
27
+ "BUNDLER_SETUP" => nil,
28
+ "RUBYOPT" => nil,
29
+ "RUBYLIB" => nil,
30
+ "GEM_HOME" => nil,
31
+ "GEM_PATH" => nil
32
+ }.freeze
33
+
34
+ Result = Data.define(:run, :observations, :env, :plan_id, :stderr) do
35
+ def observation(case_id)
36
+ observations.find { |o| o["case_id"] == case_id }
37
+ end
38
+ end
39
+
40
+ def initialize(app_root:, probe_source:, timeout: DEFAULT_TIMEOUT, runner: nil, env: {})
41
+ @app_root = app_root
42
+ @probe_source = probe_source
43
+ @timeout = timeout
44
+ @runner = runner
45
+ @env = env
46
+ end
47
+
48
+ def probe_path
49
+ File.join(@app_root, PROBE_DIR, PROBE_FILE)
50
+ end
51
+
52
+ def write_probe!
53
+ FileUtils.mkdir_p(File.dirname(probe_path))
54
+ File.write(probe_path, @probe_source)
55
+ probe_path
56
+ end
57
+
58
+ def capture(boots: 2)
59
+ write_probe!
60
+
61
+ (1..boots).map do |run|
62
+ execute(run: run, seed: 40 + run)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def execute(run:, seed:)
69
+ command = runner_command
70
+ env = SCRUBBED_ENV
71
+ .merge(FORCED_ENV)
72
+ .merge("PINSPEC_SHUFFLE_SEED" => seed.to_s)
73
+ .merge(@env)
74
+
75
+ stdout, stderr, status = Open3.capture3(env, *command, chdir: @app_root)
76
+
77
+ unless status.success?
78
+ raise ProbeFailure,
79
+ "the probe exited #{status.exitstatus} in #{@app_root}.\n" \
80
+ "#{stderr.to_s.lines.last(15).join}"
81
+ end
82
+
83
+ parse(stdout, stderr, run)
84
+ end
85
+
86
+ def parse(stdout, stderr, run)
87
+ json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
88
+
89
+ if json.nil?
90
+ raise ProbeFailure,
91
+ "the probe produced no observations.\n" \
92
+ "stdout: #{stdout.to_s.lines.last(10).join}\nstderr: #{stderr.to_s.lines.last(10).join}"
93
+ end
94
+
95
+ parsed = JSON.parse(json)
96
+
97
+ Result.new(
98
+ run: run,
99
+ observations: parsed["observations"],
100
+ env: parsed["env"],
101
+ plan_id: parsed["plan_id"],
102
+ stderr: stderr
103
+ )
104
+ rescue JSON::ParserError => e
105
+ raise ProbeFailure, "the probe's output was not JSON (#{e.message}): #{json.to_s[0, 200]}"
106
+ end
107
+
108
+ def runner_command
109
+ return @runner if @runner
110
+
111
+ relative = File.join(PROBE_DIR, PROBE_FILE)
112
+
113
+ if File.file?(File.join(@app_root, "Gemfile"))
114
+ ["bundle", "exec", "rails", "runner", relative]
115
+ else
116
+ ["rails", "runner", relative]
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pinspec
6
+ module Setup
7
+ class ContextBuilder
8
+ MAX_GENERATIONS = 3
9
+
10
+ class << self
11
+ def build(target:, profile:, imports: [], generation: 1, tz: nil)
12
+ new(target: target, profile: profile, imports: imports, generation: generation, tz: tz).build
13
+ end
14
+ end
15
+
16
+ def initialize(target:, profile:, imports: [], generation: 1, tz: nil)
17
+ @target = target
18
+ @profile = profile
19
+ @imports = imports
20
+ @generation = generation
21
+ @tz = tz || Runner::Sandbox::FORCED_ENV.fetch("TZ")
22
+ @resolver = DependencyResolver.new(profile.schema, profile.factories)
23
+ end
24
+
25
+ def build
26
+ @steps = []
27
+ @bindings = {}
28
+ @notes = []
29
+ @counters = Hash.new(0)
30
+ @refs = {}
31
+
32
+ refuse_unsupported_tenancy!
33
+ refuse_attachment_targets!
34
+
35
+ environment_steps
36
+ record_steps
37
+ import_steps
38
+ context_steps
39
+ subject_step
40
+
41
+ plan
42
+ end
43
+
44
+ private
45
+
46
+ def refuse_unsupported_tenancy!
47
+ return unless @profile.tenancy == :apartment
48
+
49
+ raise UnresolvableSetup.new(
50
+ :apartment,
51
+ "this app uses ros-apartment, which switches PostgreSQL schemas per " \
52
+ "tenant. pinspec cannot tell which schema a target expects, and building " \
53
+ "a world in the wrong one would pin behaviour that never happens."
54
+ )
55
+ end
56
+
57
+ def refuse_attachment_targets!
58
+ return if @profile.attachments.empty?
59
+
60
+ touched = attachment_findings_for_target
61
+ return if touched.empty?
62
+
63
+ finding = touched.first
64
+ raise UnresolvableSetup.new(
65
+ :attachment,
66
+ "#{@target.class_name} carries an attachment (#{finding.kind} at " \
67
+ "#{finding.file}:#{finding.line}) and #{@target.qualified_name} reads it. " \
68
+ "pinspec cannot synthesize a blob, so it will not pin a target running " \
69
+ "against an empty attachment."
70
+ )
71
+ end
72
+
73
+ def attachment_findings_for_target
74
+ attachment_kinds = %i[active_storage carrierwave paperclip]
75
+
76
+ @profile.model_findings.select do |finding|
77
+ next false unless attachment_kinds.include?(finding.kind)
78
+ next false unless finding.model == @target.class_name
79
+
80
+ references_attachment?
81
+ end
82
+ end
83
+
84
+ def references_attachment?
85
+ @target.referenced_constants.any? { |c| c.start_with?("ActiveStorage") } ||
86
+ @target.method_name.to_s.match?(/attach|upload|file|blob|avatar|photo|document/)
87
+ end
88
+
89
+ def environment_steps
90
+ add(:freeze_time, at: PINSPEC_EPOCH)
91
+ add(:seed_random, seed: PINSPEC_SEED)
92
+ add(:set_locale, locale: @profile.default_locale)
93
+ add(:set_zone, zone: @profile.default_zone)
94
+
95
+ flag_steps
96
+ end
97
+
98
+ def flag_steps
99
+ return unless @profile.flags == :flipper
100
+
101
+ referenced_flags.each do |flag|
102
+ add(:set_flag, flag: flag, enabled: false)
103
+ end
104
+ end
105
+
106
+ def referenced_flags
107
+ return [] unless @target.referenced_constants.include?("Flipper")
108
+
109
+ source = File.file?(@target.file_path) ? Analyzer::Source.read(@target.file_path) : ""
110
+ first, last = @target.source_range
111
+ body = source.lines[(first - 1)..(last - 1)].to_a.join
112
+
113
+ body.scan(/Flipper\.enabled\?\(\s*:(\w+)/).flatten.map(&:to_sym).uniq
114
+ end
115
+
116
+ def record_steps
117
+ roots = root_tables
118
+ @target.input_params.each do |param|
119
+ refuse_unresolvable_model_param!(param) if table_for_param(param).nil?
120
+ end
121
+
122
+ return if roots.empty?
123
+
124
+ order = @resolver.creation_order(
125
+ roots,
126
+ prune: ->(table_name) { !@resolver.factory_for(table_name).nil? }
127
+ )
128
+
129
+ order.each { |table_name| create_record_for(table_name) }
130
+
131
+ bind_parameters
132
+ end
133
+
134
+ def root_tables
135
+ tables = @target.input_params.filter_map { |param| table_for_param(param)&.name }
136
+
137
+ if @target.construction_kind == :model_instance
138
+ subject_table = @resolver.table_for(@target.class_name)
139
+ tables = [subject_table.name] + tables if subject_table
140
+ end
141
+
142
+ tables.uniq
143
+ end
144
+
145
+ def table_for_param(param)
146
+ @resolver.table_for_type_hint(param.type_hint)
147
+ end
148
+
149
+ NON_MODEL_HINTS = %w[
150
+ String Symbol Integer Float Numeric Decimal BigDecimal Boolean TrueClass
151
+ FalseClass Hash Array Range Time Date DateTime Proc Class Module Object
152
+ NilClass Params Param Options Option Attributes Attribute Id Ids Name
153
+ Amount Quantity Count Total Price Rate Percentage Status State Kind Type
154
+ ].freeze
155
+
156
+ def refuse_unresolvable_model_param!(param)
157
+ hint = param.type_hint.to_s
158
+ return if hint.empty?
159
+ return unless hint.match?(/\A[A-Z]/)
160
+ return if NON_MODEL_HINTS.include?(hint)
161
+ return unless param.default_source.nil?
162
+ return if %i[rest keyrest block].include?(param.kind)
163
+
164
+ return if unambiguous_column_for(param.name)
165
+
166
+ raise UnresolvableSetup.new(
167
+ :unresolvable_parameter,
168
+ "parameter `#{param.name}` looks like a #{hint}, but no table, model or " \
169
+ "factory in this application answers to that name, so pinspec has nothing " \
170
+ "to pass. It will not pass nil instead: the target would raise on nil and " \
171
+ "that error would be pinned as though the application produced it. Add a " \
172
+ "factory named :#{underscore_word(hint)} (a declared `class:` is enough), or " \
173
+ "pin a target whose arguments can be built."
174
+ )
175
+ end
176
+
177
+ def unambiguous_column_for(name)
178
+ matches = @profile.schema.tables.filter_map { |table| table.column(name) }
179
+ return nil if matches.empty?
180
+
181
+ matches.map(&:type).uniq.size == 1 ? matches.first : nil
182
+ end
183
+
184
+ def underscore_word(str)
185
+ str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
186
+ end
187
+
188
+ def bind_parameters
189
+ bound = {}
190
+
191
+ @target.input_params.each do |param|
192
+ table = table_for_param(param)
193
+ next if table.nil?
194
+
195
+ @bindings[param.name] =
196
+ if bound[table.name]
197
+ create_record_for(table.name, additional: true)
198
+ else
199
+ bound[table.name] = true
200
+ @refs[table.name]
201
+ end
202
+ end
203
+
204
+ return unless @target.construction_kind == :model_instance
205
+
206
+ subject_table = @resolver.table_for(@target.class_name)
207
+ @bindings[:__subject__] = @refs[subject_table.name] if subject_table
208
+ end
209
+
210
+ def create_record_for(table_name, additional: false)
211
+ return @refs[table_name] if @refs.key?(table_name) && !additional
212
+
213
+ table = @profile.schema.table(table_name)
214
+ refuse_unfillable_columns!(table)
215
+
216
+ if @resolver.self_referential_required?(table)
217
+ raise UnresolvableSetup.new(
218
+ :association_cycle,
219
+ "#{table_name} has a NOT NULL foreign key to itself, so its first row " \
220
+ "would have to exist before it is created."
221
+ )
222
+ end
223
+
224
+ factory = @resolver.factory_for(table_name)
225
+ note_declined_factory(table_name)
226
+
227
+ ref = next_ref(table_name)
228
+ @refs[table_name] = ref unless additional
229
+
230
+ if factory
231
+ add(:create_record, name: ref, factory: factory.name, model: factory.model, attrs: {}, assoc_refs: {})
232
+ else
233
+ add(:create_record,
234
+ name: ref,
235
+ factory: nil,
236
+ model: @resolver.model_for(table_name),
237
+ attrs: schema_attributes(table),
238
+ assoc_refs: association_refs(table))
239
+ end
240
+
241
+ ref
242
+ end
243
+
244
+ def refuse_unfillable_columns!(table)
245
+ return if table.nil?
246
+
247
+ unfillable = table.required_columns.select(&:unknown_type?)
248
+ return if unfillable.empty?
249
+
250
+ column = unfillable.first
251
+ raise UnresolvableSetup.new(
252
+ :unknown_column_type,
253
+ "#{table.name}.#{column.name} is NOT NULL with type #{column.type} " \
254
+ "(#{table.name} is needed to build #{@target.qualified_name}), and " \
255
+ "pinspec has no value it can honestly supply for that type."
256
+ )
257
+ end
258
+
259
+ def schema_attributes(table)
260
+ unique = @resolver.unique_columns(table)
261
+
262
+ @resolver.required_scalars(table).each_with_object({}) do |column, attrs|
263
+ uniquifier = unique.include?(column.name) ? uniquifier_for(table) : nil
264
+ value = @resolver.placeholder_for(column, frozen_time: PINSPEC_EPOCH, uniquifier: uniquifier)
265
+ attrs[column.name] = value unless value.nil?
266
+ end
267
+ end
268
+
269
+ def uniquifier_for(table)
270
+ "p#{@generation}-#{@counters[table.name]}"
271
+ end
272
+
273
+ def association_refs(table)
274
+ @resolver.required_associations(table).each_with_object({}) do |(column, parent), refs|
275
+ parent_ref = @refs[parent]
276
+ refs[column] = parent_ref if parent_ref
277
+ end
278
+ end
279
+
280
+ def note_declined_factory(table_name)
281
+ declined = @resolver.declined_factory_for(table_name)
282
+ return if declined.nil?
283
+ return if @resolver.factory_for(table_name)
284
+
285
+ note(:factory_declined,
286
+ "factory :#{declined.name} never persists " \
287
+ "(#{declined.hazards.map(&:first).join(', ')}), so #{table_name} is " \
288
+ "built from the schema instead")
289
+ end
290
+
291
+ def import_steps
292
+ @imports.each do |cluster|
293
+ add(:import_record,
294
+ name: cluster.name,
295
+ model: cluster.model,
296
+ attrs: cluster.attrs,
297
+ source: cluster.source,
298
+ redacted: cluster.redacted,
299
+ flags: cluster.flags)
300
+
301
+ @refs[cluster.table] ||= cluster.name
302
+ end
303
+ end
304
+
305
+ def context_steps
306
+ tenant_step
307
+ auth_step
308
+ whodunnit_step
309
+ end
310
+
311
+ def tenant_step
312
+ return unless @profile.tenancy == :acts_as_tenant
313
+
314
+ tenant_finding = @profile.findings(:acts_as_tenant).first
315
+ return note(:tenancy_unresolved, "acts_as_tenant is in use but no tenanted model was found") if tenant_finding.nil?
316
+
317
+ tenant_ref = tenant_ref_for(tenant_finding)
318
+ return note(:tenancy_unresolved,
319
+ "acts_as_tenant is in use but pinspec could not identify the " \
320
+ "tenant record for #{tenant_finding.model}") if tenant_ref.nil?
321
+
322
+ add(:set_tenant, record_ref: tenant_ref)
323
+ end
324
+
325
+ def tenant_ref_for(finding)
326
+ table = @resolver.table_for(finding.model)
327
+ return nil if table.nil?
328
+
329
+ tenant_tables = @resolver.required_associations(table).map(&:last)
330
+ tenant_table = tenant_tables.first
331
+ return nil if tenant_table.nil?
332
+
333
+ create_record_for(tenant_table)
334
+ end
335
+
336
+ def auth_step
337
+ if %i[devise current_attributes].include?(@profile.auth) && !target_may_read_current_user?
338
+ note(:current_user_not_built,
339
+ "#{@profile.auth} is in use, but this target does not mention a current " \
340
+ "user, so none is created - the smallest world that can exist is the one " \
341
+ "least likely to surprise a reader. Honest limit: this scans the target's " \
342
+ "own file, so a transitive callee that reads one will see nil.")
343
+ return auth_note
344
+ end
345
+
346
+ case @profile.auth
347
+ when :devise
348
+ add(:stub_current, kind: :devise_user, record_ref: user_ref)
349
+ when :current_attributes
350
+ add(:stub_current, kind: :current_attributes, record_ref: user_ref)
351
+ end
352
+
353
+ auth_note
354
+ end
355
+
356
+ def auth_note
357
+ return if @profile.authz == :none
358
+
359
+ note(:authz_present,
360
+ "#{@profile.authz} is in use; pinspec pins whatever it decides rather " \
361
+ "than bypassing it")
362
+ end
363
+
364
+ CURRENT_USER_READS = /
365
+ current_user | current_spree_user | spree_current_user | current_admin
366
+ | Current\s*\. | whodunnit | PaperTrail\s*\.\s*request
367
+ /x
368
+
369
+ def target_may_read_current_user?
370
+ return false unless File.file?(@target.file_path)
371
+
372
+ Analyzer::Source.read(@target.file_path).match?(CURRENT_USER_READS)
373
+ end
374
+
375
+ def whodunnit_step
376
+ return unless @profile.versioning == :paper_trail
377
+
378
+ ref = @steps.find { |step| step.kind == :stub_current }&.payload&.dig(:record_ref)
379
+
380
+ if ref.nil?
381
+ return note(:whodunnit_unset,
382
+ "paper_trail is in use but no current user was built, so versions " \
383
+ "this target creates record a null whodunnit. Both hosts do this, " \
384
+ "so the pin is consistent; it is production that differs.")
385
+ end
386
+
387
+ add(:set_whodunnit, record_ref: ref)
388
+ end
389
+
390
+ def user_ref
391
+ table = %w[users accounts people admins].find { |name| @profile.schema.table(name) } ||
392
+ %w[User Account Person Admin].filter_map { |hint| @resolver.table_for_type_hint(hint)&.name }.first
393
+
394
+ if table.nil?
395
+ note(:user_table_missing,
396
+ "no users/accounts/people table found, so the current user is left " \
397
+ "unset; a target that reads it will see nil")
398
+ return nil
399
+ end
400
+
401
+ create_record_for(table)
402
+ end
403
+
404
+ def subject_step
405
+ return unless @target.needs_subject?
406
+
407
+ add(:construct_subject,
408
+ class: @target.class_name,
409
+ kind: @target.construction_kind,
410
+ params: @target.initializer_params.map(&:name))
411
+ end
412
+
413
+ def plan
414
+ SetupPlan.new(
415
+ steps: @steps,
416
+ isolation: @profile.isolation,
417
+ env_fingerprint: env_fingerprint,
418
+ bindings: @bindings,
419
+ notes: @notes,
420
+ generation: @generation,
421
+ plan_id: nil
422
+ ).then { |draft| draft.with(plan_id: fingerprint(draft)) }
423
+ end
424
+
425
+ def env_fingerprint
426
+ {
427
+ tz: @tz,
428
+ locale: @profile.default_locale,
429
+ zone: @profile.default_zone,
430
+ rails: @profile.rails_version,
431
+ ruby: @profile.ruby_version,
432
+ serializer: SERIALIZER_VERSION
433
+ }
434
+ end
435
+
436
+ def fingerprint(draft)
437
+ canonical = [
438
+ @target.qualified_name,
439
+ draft.isolation,
440
+ draft.generation,
441
+ draft.env_fingerprint.sort.map { |k, v| "#{k}=#{v}" }.join(","),
442
+ draft.steps.map { |step| "#{step.kind}:#{canonical_payload(step.payload)}" }
443
+ ].flatten.join("|")
444
+
445
+ Digest::SHA256.hexdigest(canonical)[0, 12]
446
+ end
447
+
448
+ def canonical_payload(payload)
449
+ payload.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}=#{value.inspect}" }.join(",")
450
+ end
451
+
452
+ def add(kind, **payload)
453
+ raise PinspecInternalError, "unknown setup step #{kind.inspect}" unless STEP_KINDS.include?(kind)
454
+
455
+ @steps << SetupStep.new(kind: kind, payload: payload)
456
+ end
457
+
458
+ def next_ref(table_name)
459
+ singular = Analyzer::Inflector.singular_candidates(table_name).first
460
+ @counters[table_name] += 1
461
+
462
+ "#{singular}_#{@counters[table_name]}"
463
+ end
464
+
465
+ def note(kind, detail)
466
+ @notes << { kind: kind, detail: detail }
467
+ nil
468
+ end
469
+ end
470
+ end
471
+ end