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.
data/lib/pinspec/types.rb CHANGED
@@ -1,19 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pinspec
4
- PARAM_KINDS = %i[req opt rest keyreq key keyrest].freeze
5
4
 
6
5
  OPTIONAL_PARAM_KINDS = %i[opt key rest keyrest].freeze
7
6
  POSITIONAL_PARAM_KINDS = %i[req opt rest].freeze
8
7
 
9
- CONSTRUCTION_KINDS = %i[
10
- new
11
- class_method
12
- interactor
13
- dry_initializer
14
- struct
15
- model_instance
16
- ].freeze
17
8
 
18
9
  Param = Data.define(:name, :kind, :default_source, :type_hint) do
19
10
  def positional?
@@ -49,7 +40,8 @@ module Pinspec
49
40
  :takes_block,
50
41
  :source_range,
51
42
  :referenced_constants,
52
- :clock_sites
43
+ :clock_sites,
44
+ :construction_source
53
45
  ) do
54
46
  def singleton?
55
47
  construction_kind == :class_method
@@ -365,38 +357,6 @@ module Pinspec
365
357
  def dsl_module
366
358
  legacy_dsl ? "FactoryGirl" : "FactoryBot"
367
359
  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
360
  end
401
361
 
402
362
  Column = Data.define(
@@ -450,13 +410,7 @@ module Pinspec
450
410
  end
451
411
 
452
412
  SkippedStatement = Data.define(:kind, :table, :column, :references, :file, :line, :relevant) do
453
- def relevant?
454
- relevant == true
455
- end
456
413
 
457
- def tables_touched
458
- ([table] + Array(references)).compact.uniq
459
- end
460
414
 
461
415
  def to_s
462
416
  subject = column ? "#{table}.#{column}" : table
@@ -486,12 +440,5 @@ module Pinspec
486
440
  tables.flat_map { |t| t.columns.select(&:unknown_type?).map { |c| [t.name, c] } }
487
441
  end
488
442
 
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
443
  end
497
444
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "pathname"
3
5
  require "json"
4
6
  require "open3"
5
7
 
@@ -8,6 +10,8 @@ module Pinspec
8
10
  class Verifier
9
11
  CONFIGS = %i[isolated hostile neighbored].freeze
10
12
 
13
+ NEIGHBOUR_DIR = "tmp/pinspec"
14
+
11
15
  HOSTILE_TZ_CANDIDATES = ["Etc/GMT+8", "Etc/GMT-6"].freeze
12
16
 
13
17
  Outcome = Data.define(:config, :status, :diagnosis, :detail, :examples, :failures) do
@@ -52,6 +56,7 @@ module Pinspec
52
56
  def run(config)
53
57
  args, extra_env = arguments_for(config)
54
58
  stdout, stderr, _status = Open3.capture3(environment.merge(extra_env), *args, chdir: @app_root)
59
+ FileUtils.rm_f(@neighbour) if @neighbour
55
60
 
56
61
  summary = parse(stdout)
57
62
  return failure(config, stdout, stderr, summary) if summary.nil? || summary["failure_count"].to_i.positive?
@@ -72,7 +77,7 @@ module Pinspec
72
77
  end
73
78
 
74
79
  def arguments_for(config)
75
- relative = @spec_path.sub("#{@app_root}/", "")
80
+ relative = relative_spec_path
76
81
  base = ["bundle", "exec", "rspec", "--format", "json"]
77
82
 
78
83
  case config
@@ -81,18 +86,48 @@ module Pinspec
81
86
  when :hostile
82
87
  [base + ["--seed", "7", relative], { "TZ" => hostile_tz, "LANG" => "C", "LC_ALL" => "C" }]
83
88
  when :neighbored
84
- [base + [relative, relative], {}]
89
+ # A COPY alongside the original, because RSpec loads a given path once
90
+ # however many times it is named - so passing it twice re-ran nothing, and
91
+ # this configuration silently checked exactly what :isolated checks.
92
+ [base + [relative, neighbour_of(relative)], {}]
85
93
  end
86
94
  end
87
95
 
96
+ # Pathname does this properly: a string sub leaves an absolute path untouched
97
+ # when it does not start with the app root, which is exactly the case when
98
+ # someone runs `pinspec verify spec/foo_spec.rb` from inside their app.
99
+ def relative_spec_path
100
+ Pathname.new(File.expand_path(@spec_path))
101
+ .relative_path_from(Pathname.new(File.expand_path(@app_root)))
102
+ .to_s
103
+ rescue ArgumentError
104
+ @spec_path
105
+ end
106
+
107
+ # Written into the app's tmp, with its relative requires rewritten to point back
108
+ # at the support files the original sits beside. Removed again after the run -
109
+ # pinspec does not leave files in somebody's repository.
110
+ def neighbour_of(relative)
111
+ source = File.expand_path(relative, @app_root)
112
+ target = File.join(@app_root, NEIGHBOUR_DIR, "neighbour_#{File.basename(relative)}")
113
+ support = File.dirname(source)
114
+
115
+ FileUtils.mkdir_p(File.dirname(target))
116
+ File.write(target, Analyzer::Source.read(source)
117
+ .gsub('require_relative "support/', %(require_relative "#{support}/support/)))
118
+ @neighbour = target
119
+
120
+ File.join(NEIGHBOUR_DIR, File.basename(target))
121
+ end
122
+
88
123
  def hostile_tz
89
124
  HOSTILE_TZ_CANDIDATES.find { |zone| zone != @captured_tz } || HOSTILE_TZ_CANDIDATES.first
90
125
  end
91
126
 
92
127
  def environment
93
- Runner::Sandbox::SCRUBBED_ENV
94
- .merge("RAILS_ENV" => "test", "DISABLE_SPRING" => "1", "TZ" => @captured_tz)
95
- .merge(@env)
128
+ Runner::Runtime.for(@app_root).env
129
+ .merge("RAILS_ENV" => "test", "DISABLE_SPRING" => "1", "TZ" => @captured_tz)
130
+ .merge(@env)
96
131
  end
97
132
 
98
133
  def parse(stdout)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pinspec
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
 
6
6
  PROBE_VERSION = 3
7
7
  SERIALIZER_VERSION = 3
@@ -4,12 +4,16 @@
4
4
  module PinspecFactory
5
5
  MAX_ATTEMPTS = 8
6
6
 
7
- def self.attempts
8
- @attempts ||= {}
9
- end
10
-
11
- def self.reset_attempts!
12
- @attempts = {}
7
+ # factory_bot sequences are process-global and monotonic, so a pin whose world uses
8
+ # one is only reproducible while nothing else in the process built that factory
9
+ # first. Running the same pin twice in one process produced INV-1 then INV-2 and the
10
+ # second run failed against its own snapshot. Both hosts rewind before every case,
11
+ # so a case always sees the same sequence values.
12
+ def self.reset_sequences!
13
+ mod = defined?(FactoryBot) ? FactoryBot : (defined?(FactoryGirl) ? FactoryGirl : nil)
14
+ return unless mod.respond_to?(:rewind_sequences)
15
+
16
+ mod.rewind_sequences
13
17
  end
14
18
 
15
19
  def self.default_module
@@ -31,7 +35,6 @@ module PinspecFactory
31
35
  factory_module.create(name.to_sym, attrs)
32
36
  end
33
37
 
34
- attempts[name.to_s] = attempt
35
38
  return record
36
39
  rescue StandardError => e
37
40
  raise unless retryable?(e)
@@ -40,7 +43,6 @@ module PinspecFactory
40
43
  end
41
44
  end
42
45
 
43
- attempts[name.to_s] = MAX_ATTEMPTS
44
46
  raise last_error
45
47
  end
46
48
 
@@ -48,7 +50,4 @@ module PinspecFactory
48
50
  defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
49
51
  end
50
52
 
51
- def self.fragile
52
- attempts.select { |_name, count| count > 1 }
53
- end
54
53
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rehan Munir
@@ -75,13 +75,15 @@ files:
75
75
  - exe/pinspec
76
76
  - lib/pinspec.rb
77
77
  - lib/pinspec/analyzer/app_profile_reader.rb
78
+ - lib/pinspec/analyzer/discovery.rb
78
79
  - lib/pinspec/analyzer/factory_registry.rb
79
80
  - lib/pinspec/analyzer/inflector.rb
80
81
  - lib/pinspec/analyzer/schema_reader.rb
81
82
  - lib/pinspec/analyzer/source.rb
82
83
  - lib/pinspec/analyzer/target_parser.rb
84
+ - lib/pinspec/batch.rb
83
85
  - lib/pinspec/cli.rb
84
- - lib/pinspec/emit/namer.rb
86
+ - lib/pinspec/config.rb
85
87
  - lib/pinspec/emit/spec_writer.rb
86
88
  - lib/pinspec/emit/stability_filter.rb
87
89
  - lib/pinspec/errors.rb
@@ -94,6 +96,7 @@ files:
94
96
  - lib/pinspec/report/summary.rb
95
97
  - lib/pinspec/runner/capture.rb
96
98
  - lib/pinspec/runner/probe_generator.rb
99
+ - lib/pinspec/runner/runtime.rb
97
100
  - lib/pinspec/runner/sandbox.rb
98
101
  - lib/pinspec/setup/context_builder.rb
99
102
  - lib/pinspec/setup/dependency_resolver.rb
@@ -1,103 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Pinspec
6
- module Emit
7
- class Namer
8
- MAX_DESCRIPTION = 120
9
-
10
- Facts = Data.define(:case_id, :origin, :outcome, :method_name, :class_name, :parameters)
11
-
12
- def initialize(target:, enabled: false, client: nil)
13
- @target = target
14
- @enabled = enabled
15
- @client = client
16
- end
17
-
18
- def enabled?
19
- @enabled && !@client.nil?
20
- end
21
-
22
- def describe(cases)
23
- facts = cases.map { |input_case, observation| facts_for(input_case, observation) }
24
- fallback = facts.to_h { |fact| [fact.case_id, deterministic(fact)] }
25
-
26
- return fallback unless enabled?
27
-
28
- improved = request(facts)
29
- fallback.merge(sanitize(improved, fallback))
30
- rescue StandardError
31
- fallback
32
- end
33
-
34
- def payload(facts)
35
- {
36
- "task" => "Describe each characterization test case in one short phrase.",
37
- "constraints" => [
38
- "Describe only the INPUTS and the kind of outcome.",
39
- "Never invent or state a concrete expected value.",
40
- "One phrase per case, at most #{MAX_DESCRIPTION} characters."
41
- ],
42
- "cases" => facts.map do |fact|
43
- {
44
- "id" => fact.case_id,
45
- "origin" => fact.origin.to_s,
46
- "outcome_kind" => fact.outcome.to_s,
47
- "method" => fact.method_name.to_s,
48
- "class" => fact.class_name.to_s,
49
- "parameter_names" => fact.parameters.map(&:to_s)
50
- }
51
- end
52
- }
53
- end
54
-
55
- private
56
-
57
- def facts_for(input_case, observation)
58
- Facts.new(
59
- case_id: input_case.id,
60
- origin: input_case.origin,
61
- outcome: observation["status"] == "raised" ? :raises : :returns,
62
- method_name: @target.method_name,
63
- class_name: @target.class_name,
64
- parameters: @target.input_params.map(&:name)
65
- )
66
- end
67
-
68
- def deterministic(fact)
69
- verb = fact.outcome == :raises ? "raises" : "returns the pinned value"
70
-
71
- "#{fact.method_name} #{verb} (#{fact.case_id}, #{fact.origin})"
72
- end
73
-
74
- def request(facts)
75
- response = @client.call(payload(facts))
76
-
77
- parsed = response.is_a?(String) ? JSON.parse(response) : response
78
- Array(parsed["cases"]).to_h { |entry| [entry["id"], entry["description"]] }
79
- end
80
-
81
- def sanitize(improved, fallback)
82
- improved.each_with_object({}) do |(case_id, description), out|
83
- next unless fallback.key?(case_id)
84
- next unless description.is_a?(String)
85
-
86
- text = description.strip
87
- next if text.empty? || text.length > MAX_DESCRIPTION
88
- next if suspicious?(text)
89
-
90
- out[case_id] = "#{text} (#{case_id})"
91
- end
92
- end
93
-
94
- def suspicious?(text)
95
- return true if text.match?(/=>|\{"t"|\bexpect\b|\beq\(/)
96
- return true if text.match?(/\d{3,}/)
97
- return true if text.count('"') > 2
98
-
99
- false
100
- end
101
- end
102
- end
103
- end