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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Setup
5
+ class DependencyResolver
6
+ PLACEHOLDERS = {
7
+ string: "pinspec",
8
+ text: "pinspec",
9
+ citext: "pinspec",
10
+ integer: 1,
11
+ bigint: 1,
12
+ smallint: 1,
13
+ tinyint: 1,
14
+ float: 1.0,
15
+ decimal: "1.0",
16
+ numeric: "1.0",
17
+ money: "1.0",
18
+ boolean: false,
19
+ json: {},
20
+ jsonb: {},
21
+ hstore: {},
22
+ xml: "<pinspec/>",
23
+ uuid: "00000000-0000-4000-8000-000000000001",
24
+ inet: "127.0.0.1",
25
+ cidr: "127.0.0.0/8",
26
+ macaddr: "00:00:00:00:00:01",
27
+ binary: "pinspec",
28
+ blob: "pinspec",
29
+ interval: "P1D",
30
+ ltree: "pinspec",
31
+ oid: 1,
32
+ bit: "0",
33
+ bit_varying: "0"
34
+ }.freeze
35
+
36
+ TEMPORAL_TYPES = %i[datetime timestamp timestamptz date time].freeze
37
+
38
+ def initialize(schema, factories)
39
+ @schema = schema
40
+ @factories = factories
41
+ end
42
+
43
+ def table_for(model_name)
44
+ return nil if model_name.nil?
45
+
46
+ table_candidates_for(model_name).each do |candidate|
47
+ table = @schema.table(candidate)
48
+ return table if table
49
+ end
50
+
51
+ nil
52
+ end
53
+
54
+ def table_for_type_hint(hint)
55
+ return nil if hint.nil?
56
+
57
+ words = hint.to_s.scan(/[A-Z][a-z0-9]*/)
58
+
59
+ if words.size < 2
60
+ direct = table_for(hint)
61
+ return direct if direct
62
+ else
63
+ words.each_index do |index|
64
+ table = table_for(words[index..].join)
65
+ return table if table
66
+ end
67
+ end
68
+
69
+ table_from_factory_class(hint) || table_from_unique_prefix(hint)
70
+ end
71
+
72
+ def table_from_factory_class(hint)
73
+ wanted = underscore(hint.to_s.split("::").last)
74
+
75
+ @factories.factories.each do |factory|
76
+ next unless factory.name.to_s == wanted
77
+ next if factory.model.nil?
78
+
79
+ table = table_for(factory.model)
80
+ return table if table
81
+ end
82
+
83
+ nil
84
+ end
85
+
86
+ def table_from_unique_prefix(hint)
87
+ candidates = Analyzer::Inflector.table_candidates(underscore(hint.to_s.split("::").last))
88
+ matches = @schema.tables.select do |table|
89
+ candidates.any? { |candidate| table.name.end_with?("_#{candidate}") }
90
+ end
91
+
92
+ matches.size == 1 ? matches.first : nil
93
+ end
94
+
95
+ def table_candidates_for(model_name)
96
+ parts = model_name.to_s.split("::")
97
+ demodulized = underscore(parts.last)
98
+ namespaced = parts.map { |part| underscore(part) }.join("_")
99
+
100
+ (Analyzer::Inflector.table_candidates(demodulized) +
101
+ Analyzer::Inflector.table_candidates(namespaced)).uniq
102
+ end
103
+
104
+ def model_for(table_name)
105
+ declared = @factories.factories.find { |f| table_for(f.model)&.name == table_name.to_s }
106
+ return declared.model if declared
107
+
108
+ camelize(Analyzer::Inflector.singular_candidates(table_name).first)
109
+ end
110
+
111
+ def factory_for(table_name)
112
+ candidates = @factories.factories.select { |f| table_for(f.model)&.name == table_name.to_s }
113
+
114
+ candidates.find(&:persists?)
115
+ end
116
+
117
+ def declined_factory_for(table_name)
118
+ @factories.factories
119
+ .select { |f| table_for(f.model)&.name == table_name.to_s }
120
+ .reject(&:persists?)
121
+ .first
122
+ end
123
+
124
+ def required_associations(table)
125
+ return [] if table.nil?
126
+
127
+ table.required_columns.filter_map do |column|
128
+ fk = @schema.fk_for(table.name, column.name)
129
+ next unless fk
130
+
131
+ [column.name, fk.to_table]
132
+ end
133
+ end
134
+
135
+ def required_scalars(table)
136
+ return [] if table.nil?
137
+
138
+ association_columns = required_associations(table).map(&:first)
139
+ table.required_columns.reject { |column| association_columns.include?(column.name) }
140
+ end
141
+
142
+ def creation_order(root_tables, prune: ->(_table) { false })
143
+ ordered = []
144
+ state = {}
145
+
146
+ walk = lambda do |table_name, path|
147
+ case state[table_name]
148
+ when :done then return
149
+ when :visiting
150
+ raise UnresolvableSetup.new(
151
+ :association_cycle,
152
+ "#{path.join(' -> ')} -> #{table_name}: these tables require each " \
153
+ "other through NOT NULL foreign keys, so no row can be created " \
154
+ "first. Making one side optional would break the cycle."
155
+ )
156
+ end
157
+
158
+ state[table_name] = :visiting
159
+
160
+ unless prune.call(table_name)
161
+ table = @schema.table(table_name)
162
+ required_associations(table).each do |_column, parent|
163
+ next if parent == table_name
164
+
165
+ walk.call(parent, path + [table_name])
166
+ end
167
+ end
168
+
169
+ state[table_name] = :done
170
+ ordered << table_name
171
+ end
172
+
173
+ Array(root_tables).compact.uniq.each { |name| walk.call(name, []) }
174
+ ordered
175
+ end
176
+
177
+ def self_referential_required?(table)
178
+ required_associations(table).any? { |_column, parent| parent == table&.name }
179
+ end
180
+
181
+ def placeholder_for(column, frozen_time:, uniquifier: nil)
182
+ return frozen_value(column, frozen_time) if TEMPORAL_TYPES.include?(column.type)
183
+
184
+ base = PLACEHOLDERS[column.type]
185
+ base = apply_uniquifier(base, column, uniquifier) if uniquifier
186
+ clamp_to_limit(base, column)
187
+ end
188
+
189
+ def unique_columns(table)
190
+ return [] if table.nil?
191
+
192
+ table.unique_indexes.flat_map(&:columns).uniq
193
+ end
194
+
195
+ private
196
+
197
+ def frozen_value(column, frozen_time)
198
+ case column.type
199
+ when :date then frozen_time[0, 10]
200
+ when :time then frozen_time[11, 8]
201
+ else frozen_time
202
+ end
203
+ end
204
+
205
+ def apply_uniquifier(base, column, uniquifier)
206
+ case base
207
+ when String then "#{base}-#{uniquifier}"
208
+ when Integer then base + uniquifier_ordinal(uniquifier)
209
+ else base
210
+ end
211
+ end
212
+
213
+ def uniquifier_ordinal(uniquifier)
214
+ uniquifier.to_s.scan(/\d+/).map(&:to_i).sum
215
+ end
216
+
217
+ def clamp_to_limit(value, column)
218
+ return value unless value.is_a?(String)
219
+ return value if column.limit.nil? || !column.limit.is_a?(Integer)
220
+
221
+ value[0, column.limit]
222
+ end
223
+
224
+ def underscore(str)
225
+ str.to_s
226
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
227
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
228
+ .downcase
229
+ end
230
+
231
+ def camelize(str)
232
+ str.to_s.split("_").map { |part| part.sub(/\A[a-z]/, &:upcase) }.join
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Pinspec
6
+ module Tags
7
+ VALUED = %w[int float str sym decimal date time bin ref seq relation cycle inf].freeze
8
+
9
+ class << self
10
+ def encode(value)
11
+ case value
12
+ when nil then { "t" => "nil" }
13
+ when true then { "t" => "bool", "v" => true }
14
+ when false then { "t" => "bool", "v" => false }
15
+ when Integer then { "t" => "int", "v" => value }
16
+ when Float then encode_float(value)
17
+ when Symbol then { "t" => "sym", "v" => value.to_s }
18
+ when String then encode_string(value)
19
+ when Array then { "t" => "array", "v" => value.map { |element| encode(element) } }
20
+ when Hash then encode_hash(value)
21
+ when Date then { "t" => "date", "v" => value.iso8601 }
22
+ else { "t" => "str", "v" => value.to_s }
23
+ end
24
+ end
25
+
26
+ def ref(name)
27
+ { "t" => "ref", "v" => name.to_s }
28
+ end
29
+
30
+ def decimal(value)
31
+ { "t" => "decimal", "v" => value.to_s }
32
+ end
33
+
34
+ def tagged?(value)
35
+ value.is_a?(Hash) && value.key?("t")
36
+ end
37
+
38
+ def type_of(value)
39
+ tagged?(value) ? value["t"] : nil
40
+ end
41
+
42
+ def decode(tagged)
43
+ return tagged unless tagged?(tagged)
44
+
45
+ case tagged["t"]
46
+ when "nil" then nil
47
+ when "bool" then tagged["v"]
48
+ when "int" then tagged["v"]
49
+ when "float" then tagged["v"]
50
+ when "nan" then Float::NAN
51
+ when "inf" then tagged["sign"].to_i.negative? ? -Float::INFINITY : Float::INFINITY
52
+ when "sym" then tagged["v"].to_sym
53
+ when "str" then tagged["v"]
54
+ when "decimal" then tagged["v"]
55
+ when "date" then tagged["v"]
56
+ when "time" then tagged["v"]
57
+ when "bin" then tagged["v"].unpack1("m0").force_encoding(tagged["enc"] || "ASCII-8BIT")
58
+ when "array" then tagged["v"].map { |element| decode(element) }
59
+ when "hash" then tagged["v"].to_h { |key, element| [decode(key), decode(element)] }
60
+ when "ref" then tagged["v"]
61
+ else tagged["v"]
62
+ end
63
+ end
64
+
65
+ def describe(tagged)
66
+ return tagged.inspect unless tagged?(tagged)
67
+
68
+ case tagged["t"]
69
+ when "nil" then "nil"
70
+ when "ref" then tagged["v"]
71
+ when "sym" then ":#{tagged['v']}"
72
+ when "str" then tagged["v"].inspect
73
+ when "array" then "[#{tagged['v'].map { |element| describe(element) }.join(', ')}]"
74
+ when "hash" then "{#{tagged['v'].map { |k, v| "#{describe(k)}=>#{describe(v)}" }.join(', ')}}"
75
+ when "nan" then "NaN"
76
+ when "inf" then tagged["sign"].to_i.negative? ? "-Infinity" : "Infinity"
77
+ else tagged["v"].to_s
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def encode_float(value)
84
+ return { "t" => "nan" } if value.nan?
85
+ return { "t" => "inf", "sign" => value.negative? ? -1 : 1 } if value.infinite?
86
+
87
+ { "t" => "float", "v" => value.round(10) }
88
+ end
89
+
90
+ def encode_string(value)
91
+ if value.encoding == Encoding::ASCII_8BIT || !value.valid_encoding?
92
+ return { "t" => "bin", "v" => [value].pack("m0"), "enc" => value.encoding.to_s }
93
+ end
94
+
95
+ { "t" => "str", "v" => value }
96
+ end
97
+
98
+ def encode_hash(value)
99
+ { "t" => "hash", "v" => value.map { |key, element| [encode(key), encode(element)] } }
100
+ end
101
+ end
102
+ end
103
+ end