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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +206 -0
- data/README.md +80 -18
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +272 -34
- data/lib/pinspec/batch.rb +80 -0
- data/lib/pinspec/cli.rb +347 -59
- data/lib/pinspec/config.rb +92 -0
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +2 -2
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +19 -6
- data/lib/pinspec/inputs/sampler.rb +0 -32
- data/lib/pinspec/report/summary.rb +30 -0
- data/lib/pinspec/runner/capture.rb +3 -1
- data/lib/pinspec/runner/probe_generator.rb +4 -1
- data/lib/pinspec/runner/runtime.rb +131 -0
- data/lib/pinspec/runner/sandbox.rb +45 -27
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +40 -5
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +5 -2
- data/lib/pinspec/emit/namer.rb +0 -103
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 =
|
|
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
|
-
|
|
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::
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
data/lib/pinspec/version.rb
CHANGED
data/templates/factory_build.rb
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
module PinspecFactory
|
|
5
5
|
MAX_ATTEMPTS = 8
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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/
|
|
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
|
data/lib/pinspec/emit/namer.rb
DELETED
|
@@ -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
|