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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Analyzer
5
+ module Inflector
6
+ IRREGULAR_PLURALS = {
7
+ "person" => "people",
8
+ "child" => "children",
9
+ "man" => "men",
10
+ "woman" => "women",
11
+ "foot" => "feet",
12
+ "tooth" => "teeth",
13
+ "mouse" => "mice",
14
+ "goose" => "geese",
15
+ "ox" => "oxen",
16
+ "datum" => "data",
17
+ "medium" => "media",
18
+ "criterion" => "criteria",
19
+ "analysis" => "analyses",
20
+ "diagnosis" => "diagnoses",
21
+ "basis" => "bases",
22
+ "index" => "indices",
23
+ "matrix" => "matrices",
24
+ "vertex" => "vertices",
25
+ "status" => "statuses"
26
+ }.freeze
27
+
28
+ IRREGULAR_SINGULARS = IRREGULAR_PLURALS.invert.freeze
29
+
30
+ UNCOUNTABLE = %w[
31
+ data metadata series species information equipment money news
32
+ settings preferences credentials
33
+ ].freeze
34
+
35
+ class << self
36
+ def table_candidates(stem)
37
+ stem = stem.to_s
38
+ return [stem] if stem.empty?
39
+
40
+ candidates = []
41
+ candidates << IRREGULAR_PLURALS[stem] if IRREGULAR_PLURALS.key?(stem)
42
+ candidates << stem if UNCOUNTABLE.include?(stem)
43
+ candidates.concat(regular_plurals(stem))
44
+ candidates << stem
45
+
46
+ candidates.compact.uniq
47
+ end
48
+
49
+ def singular_candidates(table)
50
+ table = table.to_s
51
+ return [table] if table.empty?
52
+
53
+ candidates = []
54
+ candidates << IRREGULAR_SINGULARS[table] if IRREGULAR_SINGULARS.key?(table)
55
+ candidates << table if UNCOUNTABLE.include?(table)
56
+
57
+ candidates << "#{table[0..-4]}y" if table.end_with?("ies") && table.length > 3
58
+ candidates << table[0..-2] if table.end_with?("s") && !table.end_with?("ss")
59
+ candidates << table[0..-3] if table.end_with?("es") && table.length > 2
60
+ candidates << table
61
+
62
+ candidates.compact.uniq
63
+ end
64
+
65
+ private
66
+
67
+ def regular_plurals(stem)
68
+ out = []
69
+
70
+ if stem.match?(/[^aeiou]y\z/)
71
+ out << "#{stem[0..-2]}ies"
72
+ elsif stem.match?(/(s|x|z|ch|sh)\z/)
73
+ out << "#{stem}es"
74
+ elsif stem.match?(/(?:[^f]f|fe)\z/)
75
+ out << "#{stem.sub(/fe?\z/, '')}ves"
76
+ out << "#{stem}s"
77
+ end
78
+
79
+ out << "#{stem}s"
80
+ out
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Pinspec
6
+ module Analyzer
7
+ class SchemaReader
8
+ include Source
9
+
10
+ KNOWN_TYPES = %i[
11
+ string text citext
12
+ integer bigint smallint tinyint float decimal numeric money
13
+ datetime timestamp timestamptz time date
14
+ binary blob boolean
15
+ json jsonb hstore xml
16
+ uuid inet cidr macaddr
17
+ daterange numrange tsrange tstzrange int4range int8range
18
+ interval bit bit_varying oid ltree
19
+ primary_key serial bigserial virtual enum
20
+ ].freeze
21
+
22
+ HANDLED_STATEMENTS = %i[create_table add_index add_foreign_key enable_extension].freeze
23
+
24
+ NON_COLUMN_CALLS = %i[index check_constraint exclusion_constraint unique_constraint].freeze
25
+
26
+ DEFAULT_ID_TYPE = :bigint
27
+
28
+ class << self
29
+ def read(app_root = ".")
30
+ schema = File.join(app_root, "db", "schema.rb")
31
+ return parse(schema) if File.file?(schema)
32
+
33
+ structure = File.join(app_root, "db", "structure.sql")
34
+ if File.file?(structure)
35
+ raise SchemaFormatUnsupported, structure_sql_message(structure)
36
+ end
37
+
38
+ raise TargetNotFound,
39
+ "no db/schema.rb under #{app_root} (and no db/structure.sql either); " \
40
+ "is this a Rails application root?"
41
+ end
42
+
43
+ def parse(path)
44
+ new(path).parse
45
+ end
46
+
47
+ def structure_sql_message(path)
48
+ "#{path} is a SQL-format schema; pinspec reads the Ruby schema DSL. " \
49
+ "Generate one with `bin/rails db:schema:dump` after setting " \
50
+ "`config.active_record.schema_format = :ruby`, or keep both formats " \
51
+ "while pinspec runs. SQL-format support is spec open question 2."
52
+ end
53
+ end
54
+
55
+ def initialize(path)
56
+ @path = path
57
+ end
58
+
59
+ def parse
60
+ raise SchemaFormatUnsupported, self.class.structure_sql_message(@path) if @path.end_with?(".sql")
61
+
62
+ read_source
63
+ collect_statements
64
+
65
+ tables = @raw_tables.map { |raw| build_table(raw) }
66
+ foreign_keys = resolve_foreign_keys(tables)
67
+
68
+ SchemaGraph.new(
69
+ tables: tables,
70
+ fk_map: foreign_keys.to_h { |fk| [fk.key, fk.to_table] },
71
+ foreign_keys: foreign_keys,
72
+ skipped_statements: build_skipped(tables.map(&:name))
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ def read_source
79
+ raise TargetNotFound, "no such file: #{@path}" unless File.file?(@path)
80
+
81
+ result = Prism.parse(Source.read(@path))
82
+
83
+ unless result.success?
84
+ first = result.errors.first
85
+ raise UnparsableSource,
86
+ "#{@path} is not valid Ruby: #{first.message} (line #{first.location.start_line})"
87
+ end
88
+
89
+ @program = result.value
90
+ end
91
+
92
+ def collect_statements
93
+ @raw_tables = []
94
+ @raw_indexes = []
95
+ @raw_fks = []
96
+ @assoc_refs = []
97
+ @skipped = []
98
+
99
+ Array(define_block_body).each { |statement| visit_top_level(statement) }
100
+ end
101
+
102
+ def define_block_body
103
+ found = nil
104
+
105
+ each_node(@program) do |node|
106
+ next if found
107
+ next unless node.is_a?(Prism::CallNode) && node.name == :define
108
+ next unless node.receiver&.slice.to_s.start_with?("ActiveRecord::Schema")
109
+
110
+ found = node.block
111
+ end
112
+
113
+ unless found
114
+ raise UnparsableSource,
115
+ "#{@path} has no ActiveRecord::Schema.define block; " \
116
+ "pinspec expected a Rails schema dump."
117
+ end
118
+
119
+ found.body&.body
120
+ end
121
+
122
+ def visit_top_level(node)
123
+ return unless node.is_a?(Prism::CallNode)
124
+
125
+ unless HANDLED_STATEMENTS.include?(node.name)
126
+ return skip(
127
+ node.name,
128
+ table: first_string_argument(node),
129
+ line: node.location.start_line,
130
+ sql: keyword_options(Array(node.arguments&.arguments))[:sql_definition]
131
+ )
132
+ end
133
+
134
+ case node.name
135
+ when :create_table then collect_table(node)
136
+ when :add_index then collect_add_index(node)
137
+ when :add_foreign_key then collect_add_foreign_key(node)
138
+ when :enable_extension then nil
139
+ end
140
+ end
141
+
142
+ def collect_table(node)
143
+ args = Array(node.arguments&.arguments)
144
+ name = decode(args.first).to_s
145
+ options = keyword_options(args)
146
+
147
+ raw = {
148
+ name: name,
149
+ options: options,
150
+ line: node.location.start_line,
151
+ columns: [],
152
+ indexes: []
153
+ }
154
+
155
+ Array(node.block&.body&.body).each { |statement| visit_column(statement, raw) }
156
+
157
+ @raw_tables << raw
158
+ end
159
+
160
+ def visit_column(node, raw)
161
+ return unless node.is_a?(Prism::CallNode)
162
+ return unless node.receiver.is_a?(Prism::LocalVariableReadNode)
163
+
164
+ args = Array(node.arguments&.arguments)
165
+ options = keyword_options(args)
166
+ line = node.location.start_line
167
+
168
+ case node.name
169
+ when :index
170
+ raw[:indexes] << build_index(args, options)
171
+ when :references, :belongs_to
172
+ collect_reference(args, options, raw, line)
173
+ when :timestamps
174
+ %w[created_at updated_at].each do |timestamp|
175
+ raw[:columns] << column(timestamp, :datetime, options.merge(null: false), line: line)
176
+ end
177
+ when :column
178
+ raw[:columns] << typed_column(decode(args[0]).to_s, decode(args[1]), options, line: line)
179
+ when *NON_COLUMN_CALLS
180
+ nil
181
+ else
182
+ raw[:columns] << typed_column(decode(args.first).to_s, node.name, options, line: line)
183
+ end
184
+ end
185
+
186
+ def collect_reference(args, options, raw, line)
187
+ stem = decode(args.first).to_s
188
+ type = options.fetch(:type, DEFAULT_ID_TYPE)
189
+ type = type.to_sym if type.respond_to?(:to_sym)
190
+
191
+ raw[:columns] << column("#{stem}_id", type, options, line: line)
192
+
193
+ if options[:polymorphic] == true
194
+ raw[:columns] << column("#{stem}_type", :string, options, line: line)
195
+
196
+ return
197
+ end
198
+
199
+ @assoc_refs << {
200
+ from_table: raw[:name],
201
+ column: "#{stem}_id",
202
+ stem: stem,
203
+ line: line
204
+ }
205
+ end
206
+
207
+ def collect_add_index(node)
208
+ args = Array(node.arguments&.arguments)
209
+ options = keyword_options(args)
210
+
211
+ @raw_indexes << {
212
+ table: decode(args.first).to_s,
213
+ index: build_index(args.drop(1), options)
214
+ }
215
+ end
216
+
217
+ def collect_add_foreign_key(node)
218
+ args = Array(node.arguments&.arguments)
219
+ options = keyword_options(args)
220
+ from, to = decode(args[0]).to_s, decode(args[1]).to_s
221
+
222
+ @raw_fks << {
223
+ from_table: from,
224
+ to_table: to,
225
+ column: options[:column]&.to_s,
226
+ primary_key: options[:primary_key]&.to_s,
227
+ on_delete: options[:on_delete]
228
+ }
229
+ end
230
+
231
+ def build_table(raw)
232
+ options = raw[:options]
233
+
234
+ columns = raw[:columns]
235
+ if implicit_id?(options)
236
+ id_column = column("id", id_type(options) || DEFAULT_ID_TYPE, { null: false }, line: raw[:line])
237
+ columns = [id_column] + columns
238
+ end
239
+
240
+ columns.each do |col|
241
+ next unless col.unknown_type?
242
+
243
+ skip(:unknown_column_type, table: raw[:name], column: col.name, line: col.line)
244
+ end
245
+
246
+ Table.new(
247
+ name: raw[:name],
248
+ primary_key: primary_key(options),
249
+ id_type: id_type(options),
250
+ columns: columns,
251
+ indexes: raw[:indexes] + indexes_for(raw[:name])
252
+ )
253
+ end
254
+
255
+ def implicit_id?(options)
256
+ return false if options[:id] == false
257
+ return false if options.key?(:primary_key) && options[:id] == false
258
+
259
+ true
260
+ end
261
+
262
+ def primary_key(options)
263
+ return options[:primary_key].then { |pk| pk.is_a?(Array) ? pk.map(&:to_s) : pk.to_s } if options[:primary_key]
264
+ return nil if options[:id] == false
265
+
266
+ "id"
267
+ end
268
+
269
+ def id_type(options)
270
+ declared = options[:id]
271
+ return nil if declared == false
272
+ return DEFAULT_ID_TYPE if declared.nil? || declared == true
273
+
274
+ declared.to_sym
275
+ end
276
+
277
+ def indexes_for(table_name)
278
+ @raw_indexes.select { |entry| entry[:table] == table_name }.map { |entry| entry[:index] }
279
+ end
280
+
281
+ def resolve_foreign_keys(tables)
282
+ table_names = tables.map(&:name)
283
+ out = {}
284
+
285
+ @raw_fks.each do |fk|
286
+ column = fk[:column] || implicit_fk_column(fk[:from_table], fk[:to_table], tables)
287
+
288
+ unless column
289
+ skip(:unattachable_foreign_key, table: fk[:from_table], line: 0)
290
+ next
291
+ end
292
+
293
+ out["#{fk[:from_table]}.#{column}"] = ForeignKey.new(
294
+ from_table: fk[:from_table],
295
+ column: column,
296
+ to_table: fk[:to_table],
297
+ primary_key: fk[:primary_key] || "id",
298
+ on_delete: fk[:on_delete],
299
+ source: :foreign_key
300
+ )
301
+ end
302
+
303
+ @assoc_refs.each do |ref|
304
+ key = "#{ref[:from_table]}.#{ref[:column]}"
305
+ next if out.key?(key)
306
+
307
+ target = match_table(ref[:stem], table_names)
308
+ next unless target
309
+
310
+ out[key] = ForeignKey.new(
311
+ from_table: ref[:from_table],
312
+ column: ref[:column],
313
+ to_table: target,
314
+ primary_key: "id",
315
+ on_delete: nil,
316
+ source: :references
317
+ )
318
+ end
319
+
320
+ heuristic_foreign_keys(table_names).each do |fk|
321
+ out[fk.key] = fk unless out.key?(fk.key)
322
+ end
323
+
324
+ out.values.sort_by(&:key)
325
+ end
326
+
327
+ def heuristic_foreign_keys(table_names)
328
+ @raw_tables.flat_map do |raw|
329
+ column_names = raw[:columns].map(&:name)
330
+
331
+ raw[:columns].filter_map do |col|
332
+ next unless col.name.end_with?("_id")
333
+ next unless integerish?(col.type)
334
+
335
+ stem = col.name.delete_suffix("_id")
336
+
337
+ next if column_names.include?("#{stem}_type")
338
+
339
+ target = match_table(stem, table_names)
340
+ next unless target
341
+
342
+ ForeignKey.new(
343
+ from_table: raw[:name],
344
+ column: col.name,
345
+ to_table: target,
346
+ primary_key: "id",
347
+ on_delete: nil,
348
+ source: :heuristic
349
+ )
350
+ end
351
+ end
352
+ end
353
+
354
+ def integerish?(type)
355
+ %i[integer bigint smallint uuid].include?(type)
356
+ end
357
+
358
+ def match_table(stem, table_names)
359
+ Inflector.table_candidates(stem).find { |candidate| table_names.include?(candidate) }
360
+ end
361
+
362
+ def implicit_fk_column(from_table, to_table, tables)
363
+ existing = tables.find { |t| t.name == from_table }&.columns&.map(&:name) || []
364
+
365
+ Inflector.singular_candidates(to_table)
366
+ .map { |singular| "#{singular}_id" }
367
+ .find { |candidate| existing.include?(candidate) }
368
+ end
369
+
370
+ def build_index(args, options)
371
+ columns = decode(args.first)
372
+ columns = [columns].compact unless columns.is_a?(Array)
373
+
374
+ Index.new(
375
+ columns: columns.map(&:to_s),
376
+ name: options[:name]&.to_s,
377
+ unique: options[:unique] == true,
378
+ where: options[:where]
379
+ )
380
+ end
381
+
382
+ def typed_column(name, type, options, line: 0)
383
+ normalized = normalize_type(type)
384
+
385
+ column(name, normalized || type, options, unknown: normalized.nil?, line: line)
386
+ end
387
+
388
+ def normalize_type(type)
389
+ text = type.to_s.strip.downcase
390
+ return nil if text.empty?
391
+
392
+ candidate = text.split(/[\s(]/).first.to_sym
393
+ KNOWN_TYPES.include?(candidate) ? candidate : nil
394
+ end
395
+
396
+ def column(name, type, options, unknown: false, line: 0)
397
+ Column.new(
398
+ name: name,
399
+ type: type.to_s.to_sym,
400
+ null: options[:null],
401
+ default: options[:default],
402
+ limit: options[:limit],
403
+ precision: options[:precision],
404
+ scale: options[:scale],
405
+ array: options[:array] == true,
406
+ unknown_type: unknown,
407
+ line: line
408
+ )
409
+ end
410
+
411
+ def first_string_argument(node)
412
+ Array(node.arguments&.arguments).grep(Prism::StringNode).first&.unescaped
413
+ end
414
+
415
+ def skip(kind, table: nil, column: nil, line: 0, sql: nil)
416
+ @skipped << { kind: kind.to_sym, table: table, column: column, line: line, sql: sql }
417
+ end
418
+
419
+ def build_skipped(table_names)
420
+ @skipped
421
+ .map do |raw|
422
+ SkippedStatement.new(
423
+ kind: raw[:kind],
424
+ table: raw[:table],
425
+ column: raw[:column],
426
+ references: referenced_tables(raw[:sql], table_names) - [raw[:table]],
427
+ file: @path,
428
+ line: raw[:line],
429
+ relevant: nil
430
+ )
431
+ end
432
+ .sort_by { |statement| [statement.line, statement.kind.to_s, statement.column.to_s] }
433
+ end
434
+
435
+ def referenced_tables(sql, table_names)
436
+ return [] if sql.nil?
437
+
438
+ text = sql.to_s.downcase
439
+ table_names.select { |name| text.match?(/\b#{Regexp.escape(name.downcase)}\b/) }
440
+ end
441
+
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Pinspec
6
+ module Analyzer
7
+ module Source
8
+ def self.read(path)
9
+ File.read(path, mode: "rb:BOM|UTF-8").scrub
10
+ end
11
+
12
+ private
13
+
14
+ def read_source(path)
15
+ Source.read(path)
16
+ end
17
+
18
+ def each_node(node, &block)
19
+ return unless node
20
+
21
+ block.call(node)
22
+ node.compact_child_nodes.each { |child| each_node(child, &block) }
23
+ end
24
+
25
+ def keyword_options(args)
26
+ hash = Array(args).grep(Prism::KeywordHashNode).first
27
+ return {} unless hash
28
+
29
+ hash.elements.grep(Prism::AssocNode).each_with_object({}) do |assoc, out|
30
+ next unless assoc.key.is_a?(Prism::SymbolNode)
31
+
32
+ out[assoc.key.unescaped.to_sym] = decode(assoc.value)
33
+ end
34
+ end
35
+
36
+ def decode(node)
37
+ case node
38
+ when nil then nil
39
+ when Prism::TrueNode then true
40
+ when Prism::FalseNode then false
41
+ when Prism::NilNode then nil
42
+ when Prism::IntegerNode then node.value
43
+ when Prism::FloatNode then node.value
44
+ when Prism::StringNode then node.unescaped
45
+ when Prism::SymbolNode then node.unescaped.to_sym
46
+ when Prism::ArrayNode then node.elements.map { |element| decode(element) }
47
+ else node.slice
48
+ end
49
+ end
50
+
51
+ def camelize(str)
52
+ str.to_s.split("_").map { |part| part.sub(/\A[a-z]/, &:upcase) }.join
53
+ end
54
+ end
55
+ end
56
+ end