pinspec 0.1.0 → 0.3.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.
@@ -18,7 +18,7 @@ module Pinspec
18
18
  Result = Data.define(:spec_path, :support_paths, :pinned_cases, :aspects)
19
19
 
20
20
  def initialize(app_root:, target:, plan:, corpus:, stability:, fk_map:, force: false,
21
- only_aspect: nil, spec_dir: nil, namer: nil)
21
+ only_aspect: nil, spec_dir: nil)
22
22
  @app_root = app_root
23
23
  @target = target
24
24
  @plan = plan
@@ -28,7 +28,18 @@ module Pinspec
28
28
  @force = force
29
29
  @only_aspect = only_aspect
30
30
  @spec_dir = spec_dir || SPEC_DIR
31
- @namer = namer
31
+ end
32
+
33
+ # The class half of a pin's filename, so a caller can find the pin for a class
34
+ # without knowing which method it froze.
35
+ def self.class_stem(class_name)
36
+ class_name.to_s
37
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
38
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
39
+ .downcase
40
+ .tr("/", "_")
41
+ .tr(":", "_")
42
+ .squeeze("_")
32
43
  end
33
44
 
34
45
  def spec_path
@@ -101,7 +112,7 @@ module Pinspec
101
112
  end
102
113
 
103
114
  def file_stem
104
- underscore(@target.class_name).tr("/", "_") + "_" + @target.method_name.to_s.gsub(/[^a-z0-9_]/i, "")
115
+ self.class.class_stem(@target.class_name) + "_" + @target.method_name.to_s.gsub(/[^a-z0-9_]/i, "")
105
116
  end
106
117
 
107
118
  def render
@@ -206,8 +217,13 @@ module Pinspec
206
217
  fingerprint = @plan.env_fingerprint
207
218
 
208
219
  <<~RUBY
220
+ # travel_to and travel_back live here. rspec-rails includes them only when a
221
+ # suite opts in, and a pin must not depend on that.
222
+ include ActiveSupport::Testing::TimeHelpers
223
+
209
224
  before do
210
225
  #{clock_guard(fingerprint)} pinspec_clear_sinks
226
+ PinspecFactory.reset_sequences!
211
227
 
212
228
  travel_to Time.parse(#{PINSPEC_EPOCH.inspect})
213
229
  srand(#{PINSPEC_SEED})
@@ -254,11 +270,16 @@ module Pinspec
254
270
  end
255
271
 
256
272
  def create_call(payload)
273
+ attrs = (payload[:attrs] || {}).merge(assoc_arguments(payload))
274
+
257
275
  if payload[:factory]
258
- return "PinspecFactory.create(:#{payload[:factory]})"
276
+ # The probe passes these to the factory too. Dropping them here built a
277
+ # different world in the emitted spec than the one that was captured.
278
+ return "PinspecFactory.create(:#{payload[:factory]})" if attrs.empty?
279
+
280
+ return "PinspecFactory.create(:#{payload[:factory]}, #{render_attrs(attrs)})"
259
281
  end
260
282
 
261
- attrs = (payload[:attrs] || {}).merge(assoc_arguments(payload))
262
283
  "#{payload[:model]}.create!(#{render_attrs(attrs)})"
263
284
  end
264
285
 
@@ -321,9 +342,6 @@ module Pinspec
321
342
  end
322
343
 
323
344
  def description_for(input_case, observation)
324
- named = descriptions[input_case.id]
325
- return named if named
326
-
327
345
  outcome =
328
346
  case observation["status"]
329
347
  when "raised" then "raises #{observation.dig('error', 'class')}"
@@ -333,20 +351,6 @@ module Pinspec
333
351
  "#{@target.method_name} #{outcome} (#{input_case.id}, #{input_case.origin})"
334
352
  end
335
353
 
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
354
 
351
355
  def value_shape(value)
352
356
  case value && value["t"]
@@ -75,8 +75,8 @@ module Pinspec
75
75
  self.exit_code = 10
76
76
  end
77
77
 
78
- class EnvironmentRefused < Error
79
- self.exit_code = 11
78
+ class ConfigInvalid < Error
79
+ self.exit_code = 13
80
80
  end
81
81
 
82
82
  class PinspecInternalError < Error
@@ -19,11 +19,10 @@ module Pinspec
19
19
  DECIMAL_HINTS = %w[BigDecimal Decimal].freeze
20
20
 
21
21
  class << self
22
- def values_for(param, column: nil, default_first: true)
22
+ def values_for(param, column: nil)
23
23
  declared = default_value(param)
24
24
  edges = edges_for(param, column)
25
-
26
- ordered = default_first ? [declared, *edges] : [*edges, declared]
25
+ ordered = [declared, *edges]
27
26
 
28
27
  present(ordered).map { |value| encode(value, param) }.uniq
29
28
  end
@@ -48,7 +48,7 @@ module Pinspec
48
48
  unless status.success?
49
49
  raise ProbeFailure,
50
50
  "the sampler exited #{status.exitstatus} in #{@app_root}.\n" \
51
- "#{stderr.to_s.lines.last(12).join}"
51
+ "#{excerpt(stderr)}"
52
52
  end
53
53
 
54
54
  parse(stdout, stderr)
@@ -63,6 +63,20 @@ module Pinspec
63
63
  path
64
64
  end
65
65
 
66
+ # A boot failure says WHAT went wrong on its first line and where on the rest.
67
+ # Showing only the last N lines shows the bottom of a backtrace - gem_prelude,
68
+ # rubygems.rb - which is identical for every failure and identifies none of them.
69
+ # Diagnosing a real application's failure took three commands and a read of
70
+ # pinspec's own internals because of that.
71
+ def excerpt(text, head: 6, tail: 6)
72
+ lines = text.to_s.lines
73
+ return lines.join if lines.size <= head + tail
74
+
75
+ lines.first(head).join +
76
+ " ... #{lines.size - head - tail} more line(s) ...\n" +
77
+ lines.last(tail).join
78
+ end
79
+
66
80
  def command
67
81
  return ["rails", "runner", SCRIPT_PATH] unless File.file?(File.join(@app_root, "Gemfile"))
68
82
 
@@ -70,9 +84,9 @@ module Pinspec
70
84
  end
71
85
 
72
86
  def environment
73
- Runner::Sandbox::SCRUBBED_ENV
74
- .merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
75
- .merge(@env)
87
+ Runner::Runtime.for(@app_root).env
88
+ .merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
89
+ .merge(@env)
76
90
  end
77
91
 
78
92
  def parse(stdout, stderr)
@@ -80,8 +94,7 @@ module Pinspec
80
94
 
81
95
  if json.nil?
82
96
  raise ProbeFailure,
83
- "the sampler produced no rows.\nstdout: #{stdout.to_s.lines.last(8).join}" \
84
- "stderr: #{stderr.to_s.lines.last(8).join}"
97
+ "the sampler produced no rows.\nstdout: #{excerpt(stdout)}\nstderr: #{excerpt(stderr)}"
85
98
  end
86
99
 
87
100
  parsed = JSON.parse(json)
@@ -7,41 +7,9 @@ module Pinspec
7
7
  class Sampler
8
8
  SCRIPT_PATH = "tmp/pinspec/sampler.rb"
9
9
 
10
- ENV_PREFERENCE = %w[development test].freeze
11
-
12
- PRODUCTION_HINTS = [
13
- /(?<![a-z])prod(uction)?(?![a-z])/i,
14
- /(?<![a-z])live(?![a-z])/i,
15
- /(?<![a-z])master(?![a-z])/i
16
- ].freeze
17
-
18
10
  MAX_DISTINCT_STATUSES = 6
19
11
 
20
12
  class << self
21
- def choose_env(available:, counts: {}, override: nil)
22
- return override if override
23
-
24
- populated = ENV_PREFERENCE.find do |env|
25
- available.include?(env) && counts.fetch(env, 0).positive?
26
- end
27
-
28
- populated || ENV_PREFERENCE.find { |env| available.include?(env) } || available.first
29
- end
30
-
31
- def production_like?(name)
32
- PRODUCTION_HINTS.any? { |hint| name.to_s.match?(hint) }
33
- end
34
-
35
- def guard_production!(name, confirmed: false)
36
- return unless production_like?(name)
37
- return if confirmed
38
-
39
- raise EnvironmentRefused,
40
- "#{name.inspect} looks like a production database. pinspec only ever " \
41
- "SELECTs, but it will not read production without being told to: " \
42
- "re-run with --sample-db pointed somewhere else, or confirm explicitly."
43
- end
44
-
45
13
  def script_for(requests)
46
14
  new(requests).script
47
15
  end
@@ -185,6 +185,7 @@ module Pinspec
185
185
 
186
186
  def coverage_caveats
187
187
  caveats = []
188
+ caveats << vacuous_caveat
188
189
  caveats << seq_caveat
189
190
  caveats << truncated_caveat
190
191
  caveats << quarantine_caveat
@@ -203,6 +204,35 @@ module Pinspec
203
204
  MD
204
205
  end
205
206
 
207
+ # The honest half of pin strength, and the free half. A case that returned an
208
+ # empty collection, wrote no rows, enqueued nothing and sent nothing did run -
209
+ # but almost any change to the target would still satisfy it. Measured on real
210
+ # code, these are exactly the pins that score weak or worthless.
211
+ def vacuous_caveat
212
+ return nil if @stability.nil?
213
+
214
+ count = @stability.stable.count { |verdict| vacuous?(verdict.observation) }
215
+ return nil if count.zero?
216
+
217
+ "#{count} pinned case(s) observed nothing happening: an empty or absent return " \
218
+ "value, no rows written, no jobs, no mail. The pin holds, but almost any " \
219
+ "change to the target would still satisfy it. Give the target a world with " \
220
+ "something in it - `--sample` reads real rows - before relying on these."
221
+ end
222
+
223
+ EMPTY_RETURNS = [[], {}, nil, false, ""].freeze
224
+
225
+ def vacuous?(observation)
226
+ return false unless observation["status"] == "returned"
227
+ return false unless observation["enqueued_jobs"].to_a.empty?
228
+ return false unless observation["mail_deliveries"].to_a.empty?
229
+ return false unless observation["db_delta"].to_h.values.all? { |n| n.to_i.zero? }
230
+
231
+ EMPTY_RETURNS.include?(Tags.decode(observation["return_value"]))
232
+ rescue StandardError
233
+ false
234
+ end
235
+
206
236
  def seq_caveat
207
237
  count = rendered_observations.scan('"seq"').size
208
238
  return nil if count.zero?
@@ -17,7 +17,9 @@ module Pinspec
17
17
  @target_file = target
18
18
  @method = method
19
19
  @max_cases = max_cases
20
- @boots = boots
20
+ # Two is the floor, not the default. One boot has nothing to compare against,
21
+ # so every case would be called stable by virtue of never being checked.
22
+ @boots = [boots.to_i, 2].max
21
23
  @compare_sql = compare_sql
22
24
  @sandbox_env = sandbox_env
23
25
  @output_dir = output_dir || File.join(app_root, Sandbox::PROBE_DIR)
@@ -574,6 +574,7 @@ module Pinspec
574
574
  begin
575
575
  pinspec_with_isolation do
576
576
  begin
577
+ PinspecFactory.reset_sequences!
577
578
  pinspec_run_steps(records, refs)
578
579
  rescue StandardError => setup_error
579
580
  observation["status"] = "setup_error"
@@ -638,7 +639,9 @@ module Pinspec
638
639
  # Between cases: anything memoized in a cache or a CurrentAttributes slot
639
640
  # would otherwise leak into the next case.
640
641
  Rails.cache.clear if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
641
- ActiveSupport::CurrentAttributes.reset_all if defined?(ActiveSupport::CurrentAttributes)
642
+ if defined?(ActiveSupport::CurrentAttributes) && ActiveSupport::CurrentAttributes.respond_to?(:reset_all)
643
+ ActiveSupport::CurrentAttributes.reset_all
644
+ end
642
645
  pinspec_clear_sinks
643
646
  PinspecClock.travel_back
644
647
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Runner
5
+ class Runtime
6
+ MANAGERS = %i[rvm rbenv asdf mise chruby].freeze
7
+
8
+ Result = Data.define(:env, :ruby_version, :manager, :note) do
9
+ def detected?
10
+ !manager.nil?
11
+ end
12
+
13
+ def same_ruby?
14
+ manager.nil? && note.nil?
15
+ end
16
+ end
17
+
18
+ BUNDLER_VARS = %w[
19
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLE_APP_CONFIG
20
+ BUNDLER_VERSION BUNDLER_SETUP RUBYOPT RUBYLIB
21
+ ].freeze
22
+
23
+ def self.for(app_root, home: Dir.home, current: RUBY_VERSION)
24
+ new(app_root, home: home, current: current).resolve
25
+ end
26
+
27
+ def initialize(app_root, home: Dir.home, current: RUBY_VERSION)
28
+ @app_root = app_root
29
+ @home = home
30
+ @current = current
31
+ end
32
+
33
+ def resolve
34
+ wanted = declared_version
35
+ return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if wanted.nil?
36
+ return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if satisfied_by_current?(wanted)
37
+
38
+ found = MANAGERS.filter_map { |manager| send(:"#{manager}_env", wanted)&.then { |e| [manager, e] } }.first
39
+
40
+ if found.nil?
41
+ return Result.new(
42
+ env: scrub, ruby_version: wanted, manager: nil,
43
+ note: "#{@app_root} declares Ruby #{wanted} and this is #{@current}, but no " \
44
+ "installation of #{wanted} was found under rvm, rbenv, asdf, mise or " \
45
+ "chruby. Pass the app's runtime explicitly:\n" \
46
+ " --app-env PATH=/path/to/ruby-#{wanted}/bin:$PATH"
47
+ )
48
+ end
49
+
50
+ manager, env = found
51
+ Result.new(env: scrub.merge(env), ruby_version: wanted, manager: manager, note: nil)
52
+ end
53
+
54
+ def declared_version
55
+ from_file(".ruby-version") || from_tool_versions
56
+ end
57
+
58
+ private
59
+
60
+ # Only bundler's variables are removed. GEM_HOME and GEM_PATH are left alone:
61
+ # dropping them is what forced every invocation to hand the app's gemset back
62
+ # by hand, and they are only wrong when the app runs on a different Ruby - in
63
+ # which case the detected runtime overwrites them anyway.
64
+ def scrub
65
+ BUNDLER_VARS.to_h { |name| [name, nil] }
66
+ end
67
+
68
+ def from_file(name)
69
+ path = File.join(@app_root, name)
70
+ return nil unless File.file?(path)
71
+
72
+ Analyzer::Source.read(path).strip[/\d+\.\d+(\.\d+)?/]
73
+ end
74
+
75
+ def from_tool_versions
76
+ path = File.join(@app_root, ".tool-versions")
77
+ return nil unless File.file?(path)
78
+
79
+ Analyzer::Source.read(path)[/^\s*ruby\s+(\S+)/, 1]&.[](/\d+\.\d+(\.\d+)?/)
80
+ end
81
+
82
+ # A patch-level difference is not worth relocating the runtime for: 3.3.0 and
83
+ # 3.3.6 run the same code. A minor difference is.
84
+ def satisfied_by_current?(wanted)
85
+ @current == wanted || series(@current) == series(wanted)
86
+ end
87
+
88
+ def series(version)
89
+ version.to_s.split(".").first(2).join(".")
90
+ end
91
+
92
+ def rvm_env(wanted)
93
+ root = File.join(@home, ".rvm")
94
+ ruby = File.join(root, "rubies", "ruby-#{wanted}", "bin")
95
+ return nil unless File.directory?(ruby)
96
+
97
+ gems = File.join(root, "gems", "ruby-#{wanted}")
98
+ env = { "PATH" => prepend(ruby) }
99
+ if File.directory?(gems)
100
+ env["GEM_HOME"] = gems
101
+ env["GEM_PATH"] = "#{gems}:#{gems}@global"
102
+ end
103
+ env
104
+ end
105
+
106
+ def rbenv_env(wanted)
107
+ bin = File.join(@home, ".rbenv", "versions", wanted, "bin")
108
+ File.directory?(bin) ? { "PATH" => prepend(bin), "RBENV_VERSION" => wanted } : nil
109
+ end
110
+
111
+ def asdf_env(wanted)
112
+ bin = File.join(@home, ".asdf", "installs", "ruby", wanted, "bin")
113
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
114
+ end
115
+
116
+ def mise_env(wanted)
117
+ bin = File.join(@home, ".local", "share", "mise", "installs", "ruby", wanted, "bin")
118
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
119
+ end
120
+
121
+ def chruby_env(wanted)
122
+ bin = File.join(@home, ".rubies", "ruby-#{wanted}", "bin")
123
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
124
+ end
125
+
126
+ def prepend(bin)
127
+ [bin, ENV.fetch("PATH", "")].reject(&:empty?).join(File::PATH_SEPARATOR)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -8,9 +8,17 @@ module Pinspec
8
8
  module Runner
9
9
  class Sandbox
10
10
  PROBE_DIR = "tmp/pinspec"
11
- PROBE_FILE = "probe.rb"
12
11
 
13
- DEFAULT_TIMEOUT = 600
12
+ # One probe file per run. A fixed path means two pinspec runs against the same
13
+ # application overwrite each other's probe between boots, and the result is not
14
+ # an error: the stability filter compares one target's observations against
15
+ # another's and reports the target as unstable, or crashes looking up a case id
16
+ # that belongs to a different corpus. Silent and wrong, which is worse than a
17
+ # lock. `pinspec pin app/services` is sequential, but a developer with two
18
+ # terminals or a CI that fans out is not.
19
+ def self.probe_filename(pid: Process.pid, object_id: nil)
20
+ "probe-#{pid}-#{object_id || rand(1 << 24)}.rb"
21
+ end
14
22
 
15
23
  FORCED_ENV = {
16
24
  "DISABLE_SPRING" => "1",
@@ -18,18 +26,7 @@ module Pinspec
18
26
  "RAILS_ENV" => "test"
19
27
  }.freeze
20
28
 
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
29
+ SCRUBBED_ENV = Runtime::BUNDLER_VARS.to_h { |name| [name, nil] }.freeze
33
30
 
34
31
  Result = Data.define(:run, :observations, :env, :plan_id, :stderr) do
35
32
  def observation(case_id)
@@ -37,16 +34,14 @@ module Pinspec
37
34
  end
38
35
  end
39
36
 
40
- def initialize(app_root:, probe_source:, timeout: DEFAULT_TIMEOUT, runner: nil, env: {})
37
+ def initialize(app_root:, probe_source:, env: {})
41
38
  @app_root = app_root
42
39
  @probe_source = probe_source
43
- @timeout = timeout
44
- @runner = runner
45
40
  @env = env
46
41
  end
47
42
 
48
43
  def probe_path
49
- File.join(@app_root, PROBE_DIR, PROBE_FILE)
44
+ @probe_path ||= File.join(@app_root, PROBE_DIR, self.class.probe_filename(object_id: object_id))
50
45
  end
51
46
 
52
47
  def write_probe!
@@ -58,38 +53,63 @@ module Pinspec
58
53
  def capture(boots: 2)
59
54
  write_probe!
60
55
 
61
- (1..boots).map do |run|
62
- execute(run: run, seed: 40 + run)
63
- end
56
+ results = (1..boots).map { |run| execute(run: run, seed: 40 + run) }
57
+
58
+ # Removed only on success. A probe left behind after a failure is the
59
+ # evidence; one left behind after every run just fills tmp/ with files a
60
+ # reader cannot tell apart.
61
+ FileUtils.rm_f(probe_path)
62
+ results
63
+ end
64
+
65
+ def runtime
66
+ @runtime ||= Runtime.for(@app_root)
64
67
  end
65
68
 
66
69
  private
67
70
 
68
71
  def execute(run:, seed:)
69
72
  command = runner_command
70
- env = SCRUBBED_ENV
73
+ env = runtime.env
71
74
  .merge(FORCED_ENV)
72
75
  .merge("PINSPEC_SHUFFLE_SEED" => seed.to_s)
73
76
  .merge(@env)
74
77
 
75
78
  stdout, stderr, status = Open3.capture3(env, *command, chdir: @app_root)
79
+ stdout = stdout.to_s.dup.force_encoding(Encoding::UTF_8).scrub
80
+ stderr = stderr.to_s.dup.force_encoding(Encoding::UTF_8).scrub
76
81
 
77
82
  unless status.success?
78
83
  raise ProbeFailure,
79
84
  "the probe exited #{status.exitstatus} in #{@app_root}.\n" \
80
- "#{stderr.to_s.lines.last(15).join}"
85
+ "#{runtime.note ? "#{runtime.note}\n\n" : ''}" \
86
+ "#{excerpt(stderr)}"
81
87
  end
82
88
 
83
89
  parse(stdout, stderr, run)
84
90
  end
85
91
 
92
+ # A boot failure says WHAT went wrong on its first line and where on the rest.
93
+ # Showing only the last N lines shows the bottom of a backtrace - gem_prelude,
94
+ # rubygems.rb - which is identical for every failure and identifies none of them.
95
+ # Diagnosing a real application's failure took three commands and a read of
96
+ # pinspec's own internals because of that.
97
+ def excerpt(text, head: 6, tail: 6)
98
+ lines = text.to_s.lines
99
+ return lines.join if lines.size <= head + tail
100
+
101
+ lines.first(head).join +
102
+ " ... #{lines.size - head - tail} more line(s) ...\n" +
103
+ lines.last(tail).join
104
+ end
105
+
86
106
  def parse(stdout, stderr, run)
87
107
  json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
88
108
 
89
109
  if json.nil?
90
110
  raise ProbeFailure,
91
111
  "the probe produced no observations.\n" \
92
- "stdout: #{stdout.to_s.lines.last(10).join}\nstderr: #{stderr.to_s.lines.last(10).join}"
112
+ "stdout: #{excerpt(stdout)}\nstderr: #{excerpt(stderr)}"
93
113
  end
94
114
 
95
115
  parsed = JSON.parse(json)
@@ -106,9 +126,7 @@ module Pinspec
106
126
  end
107
127
 
108
128
  def runner_command
109
- return @runner if @runner
110
-
111
- relative = File.join(PROBE_DIR, PROBE_FILE)
129
+ relative = File.join(PROBE_DIR, File.basename(probe_path))
112
130
 
113
131
  if File.file?(File.join(@app_root, "Gemfile"))
114
132
  ["bundle", "exec", "rails", "runner", relative]
@@ -5,8 +5,6 @@ require "digest"
5
5
  module Pinspec
6
6
  module Setup
7
7
  class ContextBuilder
8
- MAX_GENERATIONS = 3
9
-
10
8
  class << self
11
9
  def build(target:, profile:, imports: [], generation: 1, tz: nil)
12
10
  new(target: target, profile: profile, imports: imports, generation: generation, tz: tz).build
@@ -33,8 +31,11 @@ module Pinspec
33
31
  refuse_attachment_targets!
34
32
 
35
33
  environment_steps
36
- record_steps
34
+ # Imports first: create_record_for skips a table whose ref already exists, so
35
+ # building factory rows first meant every binding pointed at the factory
36
+ # record and the sampled row - the whole point of --sample - went unused.
37
37
  import_steps
38
+ record_steps
38
39
  context_steps
39
40
  subject_step
40
41
 
@@ -146,11 +147,13 @@ module Pinspec
146
147
  @resolver.table_for_type_hint(param.type_hint)
147
148
  end
148
149
 
150
+ # Only the hints that actually occur. Measured over 841 real service files, these
151
+ # ten are the only ones ever produced; the other thirty-one fired zero times and
152
+ # none of them has a Boundary value, so a hint that did somehow reach the list
153
+ # would be exempted from the refusal and then passed as nil - manufacturing the
154
+ # exact fiction the refusal exists to prevent.
149
155
  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
156
+ String Symbol Integer Float Boolean Hash Array Time Date Object
154
157
  ].freeze
155
158
 
156
159
  def refuse_unresolvable_model_param!(param)