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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
PARAM_KINDS = %i[req opt rest keyreq key keyrest].freeze
|
|
5
|
+
|
|
6
|
+
OPTIONAL_PARAM_KINDS = %i[opt key rest keyrest].freeze
|
|
7
|
+
POSITIONAL_PARAM_KINDS = %i[req opt rest].freeze
|
|
8
|
+
|
|
9
|
+
CONSTRUCTION_KINDS = %i[
|
|
10
|
+
new
|
|
11
|
+
class_method
|
|
12
|
+
interactor
|
|
13
|
+
dry_initializer
|
|
14
|
+
struct
|
|
15
|
+
model_instance
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
Param = Data.define(:name, :kind, :default_source, :type_hint) do
|
|
19
|
+
def positional?
|
|
20
|
+
POSITIONAL_PARAM_KINDS.include?(kind)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def optional?
|
|
24
|
+
OPTIONAL_PARAM_KINDS.include?(kind)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
case kind
|
|
29
|
+
when :req then name.to_s
|
|
30
|
+
when :opt then "#{name} = #{default_source}"
|
|
31
|
+
when :rest then "*#{name}"
|
|
32
|
+
when :keyreq then "#{name}:"
|
|
33
|
+
when :key then "#{name}: #{default_source}"
|
|
34
|
+
when :keyrest then "**#{name}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
ClockSite = Data.define(:call, :line)
|
|
40
|
+
|
|
41
|
+
TargetProfile = Data.define(
|
|
42
|
+
:file_path,
|
|
43
|
+
:class_name,
|
|
44
|
+
:method_name,
|
|
45
|
+
:params,
|
|
46
|
+
:initializer_params,
|
|
47
|
+
:construction_kind,
|
|
48
|
+
:visibility,
|
|
49
|
+
:takes_block,
|
|
50
|
+
:source_range,
|
|
51
|
+
:referenced_constants,
|
|
52
|
+
:clock_sites
|
|
53
|
+
) do
|
|
54
|
+
def singleton?
|
|
55
|
+
construction_kind == :class_method
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def needs_subject?
|
|
59
|
+
!%i[class_method model_instance].include?(construction_kind)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def qualified_name
|
|
63
|
+
singleton? ? "#{class_name}.#{method_name}" : "#{class_name}##{method_name}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clock_dependent?
|
|
67
|
+
!clock_sites.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def input_params
|
|
71
|
+
initializer_params + params
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
InputCase = Data.define(:id, :ctor_args, :ctor_kwargs, :args, :kwargs, :origin) do
|
|
76
|
+
def signature
|
|
77
|
+
[ctor_args, ctor_kwargs, args, kwargs]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_s
|
|
81
|
+
ctor = render(ctor_args, ctor_kwargs)
|
|
82
|
+
meth = render(args, kwargs)
|
|
83
|
+
|
|
84
|
+
receiver = ctor_args.empty? && ctor_kwargs.empty? ? "" : "new(#{ctor})."
|
|
85
|
+
invocation = meth.empty? ? "call" : "call(#{meth})"
|
|
86
|
+
|
|
87
|
+
"#{id} (#{origin}) #{receiver}#{invocation}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def render(positional, keyword)
|
|
93
|
+
(Array(positional).map { |v| Tags.describe(v) } +
|
|
94
|
+
Hash(keyword).map { |name, v| "#{name}: #{Tags.describe(v)}" }).join(", ")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
InputCorpus = Data.define(:cases, :setup_plan) do
|
|
99
|
+
def size
|
|
100
|
+
cases.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def origins
|
|
104
|
+
cases.group_by(&:origin).transform_values(&:size)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
ImportCluster = Data.define(:model, :table, :name, :attrs, :source, :redacted, :flags) do
|
|
109
|
+
def redaction_read?
|
|
110
|
+
Array(flags).include?(:redaction_read)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
PINSPEC_EPOCH = "2026-01-01T12:00:00Z"
|
|
115
|
+
PINSPEC_SEED = 42
|
|
116
|
+
|
|
117
|
+
STEP_KINDS = %i[
|
|
118
|
+
freeze_time seed_random set_locale set_zone set_flag
|
|
119
|
+
create_record import_record
|
|
120
|
+
set_tenant stub_current set_whodunnit
|
|
121
|
+
construct_subject
|
|
122
|
+
].freeze
|
|
123
|
+
|
|
124
|
+
SetupStep = Data.define(:kind, :payload) do
|
|
125
|
+
def ref
|
|
126
|
+
payload[:name]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_s
|
|
130
|
+
case kind
|
|
131
|
+
when :freeze_time then "freeze_time #{payload[:at]}"
|
|
132
|
+
when :seed_random then "seed_random #{payload[:seed]}"
|
|
133
|
+
when :set_locale then "set_locale #{payload[:locale].inspect}"
|
|
134
|
+
when :set_zone then "set_zone #{payload[:zone].inspect}"
|
|
135
|
+
when :set_flag then "set_flag #{payload[:flag].inspect} = #{payload[:enabled]}"
|
|
136
|
+
when :create_record then "create_record #{payload[:name]} <- #{create_source}"
|
|
137
|
+
when :import_record then "import_record #{payload[:name]} <- #{payload[:source]}"
|
|
138
|
+
when :set_tenant then "set_tenant #{payload[:record_ref]}"
|
|
139
|
+
when :stub_current then "stub_current #{payload[:kind]} #{payload[:record_ref]}"
|
|
140
|
+
when :set_whodunnit then "set_whodunnit #{payload[:record_ref]}"
|
|
141
|
+
when :construct_subject then "construct_subject #{payload[:class]} (#{payload[:kind]})"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def create_source
|
|
148
|
+
base = payload[:factory] ? "factory(:#{payload[:factory]})" : "#{payload[:model]}.create!"
|
|
149
|
+
refs = payload[:assoc_refs]
|
|
150
|
+
refs.nil? || refs.empty? ? base : "#{base} #{refs.map { |c, r| "#{c}=>#{r}" }.join(', ')}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
SetupPlan = Data.define(
|
|
155
|
+
:steps, :isolation, :env_fingerprint, :bindings, :notes, :generation, :plan_id
|
|
156
|
+
) do
|
|
157
|
+
def steps_of(kind)
|
|
158
|
+
steps.select { |step| step.kind == kind }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def refs
|
|
162
|
+
steps.filter_map(&:ref)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_steps
|
|
166
|
+
steps.select { |step| %i[create_record import_record].include?(step.kind) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def subject_step
|
|
170
|
+
steps.find { |step| step.kind == :construct_subject }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def binding_for(param_name)
|
|
174
|
+
bindings[param_name.to_sym]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
MULTI_DB_ROLLBACK_WARNING =
|
|
179
|
+
"This app declares more than one writing database. pinspec's per-case " \
|
|
180
|
+
"rollback covers the PRIMARY writing connection only - writes made through " \
|
|
181
|
+
"any other connection are not rolled back, and pins that depend on them are " \
|
|
182
|
+
"not trustworthy."
|
|
183
|
+
|
|
184
|
+
ModelFinding = Data.define(:kind, :model, :file, :line) do
|
|
185
|
+
def to_s
|
|
186
|
+
"#{model} #{kind} at #{file}:#{line}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
TestStack = Data.define(:framework, :webmock, :vcr, :snapshot_backends, :database_cleaner_gem) do
|
|
191
|
+
def stubs_http?
|
|
192
|
+
webmock || vcr
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
AppProfile = Data.define(
|
|
197
|
+
:rails_version, :ruby_version, :rails_floor_ok,
|
|
198
|
+
:auth, :authz, :tenancy, :soft_delete, :versioning, :flags, :attachments,
|
|
199
|
+
:multi_db, :spring,
|
|
200
|
+
:model_findings,
|
|
201
|
+
:default_locale, :default_zone,
|
|
202
|
+
:db_cleaner, :transactional_fixtures, :queue_adapter_in_tests,
|
|
203
|
+
:test_stack, :schema, :factories,
|
|
204
|
+
:notes
|
|
205
|
+
) do
|
|
206
|
+
def findings(kind)
|
|
207
|
+
model_findings.select { |f| f.kind == kind }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def after_commit_models
|
|
211
|
+
findings(:after_commit)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def rails_floor_ok?
|
|
215
|
+
rails_floor_ok == true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def isolation
|
|
219
|
+
case db_cleaner
|
|
220
|
+
when :truncation then :truncation
|
|
221
|
+
when :transaction then :transaction
|
|
222
|
+
else transactional_fixtures == false ? :truncation : :transaction
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def isolation_source
|
|
227
|
+
case db_cleaner
|
|
228
|
+
when :truncation, :transaction then "DatabaseCleaner.strategy = #{db_cleaner.inspect}"
|
|
229
|
+
else "use_transactional_fixtures = #{transactional_fixtures.inspect}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def warnings
|
|
234
|
+
[
|
|
235
|
+
multi_db_warning,
|
|
236
|
+
floor_warning,
|
|
237
|
+
isolation_warning,
|
|
238
|
+
queue_adapter_warning,
|
|
239
|
+
apartment_warning,
|
|
240
|
+
default_scope_warning,
|
|
241
|
+
attachment_warning,
|
|
242
|
+
spring_warning
|
|
243
|
+
].compact
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def multi_db_warning
|
|
249
|
+
MULTI_DB_ROLLBACK_WARNING if multi_db
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def floor_warning
|
|
253
|
+
return if rails_floor_ok
|
|
254
|
+
|
|
255
|
+
if rails_version.nil?
|
|
256
|
+
"No Gemfile.lock was read, so gem detection is unavailable and the Rails " \
|
|
257
|
+
"version could not be checked against the 6.0 floor."
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def isolation_warning
|
|
262
|
+
if isolation == :truncation
|
|
263
|
+
"This suite does not wrap examples in a transaction " \
|
|
264
|
+
"(#{isolation_source}), so after_commit callbacks DO fire. pinspec will " \
|
|
265
|
+
"run the probe under the same regime and the capture will mutate the " \
|
|
266
|
+
"test database."
|
|
267
|
+
elsif !after_commit_models.empty?
|
|
268
|
+
"#{after_commit_models.size} model(s) use after_commit. Under " \
|
|
269
|
+
"transactional isolation these never fire in the probe or in the " \
|
|
270
|
+
"emitted spec, and pinspec will not fake them - the divergence from " \
|
|
271
|
+
"production is real and documented."
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def queue_adapter_warning
|
|
276
|
+
return if queue_adapter_in_tests.nil? || queue_adapter_in_tests == :test
|
|
277
|
+
|
|
278
|
+
"The suite sets ActiveJob's queue adapter to #{queue_adapter_in_tests.inspect}, " \
|
|
279
|
+
"which executes jobs instead of enqueuing them. Emitted specs force :test " \
|
|
280
|
+
"so that job pins can see anything at all."
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def apartment_warning
|
|
284
|
+
"ros-apartment tenancy is not supported; targets on tenanted models will " \
|
|
285
|
+
"be refused rather than run against the wrong schema." if tenancy == :apartment
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def default_scope_warning
|
|
289
|
+
suspects = findings(:default_scope)
|
|
290
|
+
return if suspects.empty?
|
|
291
|
+
|
|
292
|
+
"#{suspects.size} model(s) declare default_scope " \
|
|
293
|
+
"(#{suspects.map(&:model).uniq.join(', ')}); records a plan creates may be " \
|
|
294
|
+
"invisible to the target, which reads as a bug in the target."
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def attachment_warning
|
|
298
|
+
return if attachments.empty?
|
|
299
|
+
|
|
300
|
+
"Attachments present (#{attachments.join(', ')}). pinspec cannot synthesize " \
|
|
301
|
+
"blobs, so a target that reads an attachment is refused rather than run " \
|
|
302
|
+
"against an empty one."
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def spring_warning
|
|
306
|
+
"Spring is bundled; the sandbox exports DISABLE_SPRING=1 so a stale " \
|
|
307
|
+
"preloader cannot serve yesterday's code." if spring
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
FactoryAttribute = Data.define(:name, :kind, :source, :factory, :line) do
|
|
312
|
+
def association?
|
|
313
|
+
kind == :association
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def transient?
|
|
317
|
+
kind == :transient
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
FactoryTrait = Data.define(:name, :attributes, :line)
|
|
322
|
+
|
|
323
|
+
FactoryCallback = Data.define(:hook, :stage, :line) do
|
|
324
|
+
def to_s
|
|
325
|
+
"#{hook}(:#{stage})"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
Factory = Data.define(
|
|
330
|
+
:name, :model, :parent, :aliases, :attributes, :traits, :callbacks, :hazards, :file, :line
|
|
331
|
+
) do
|
|
332
|
+
def attribute(name)
|
|
333
|
+
attributes.find { |a| a.name == name.to_sym }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def trait(name)
|
|
337
|
+
traits.find { |t| t.name == name.to_sym }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def associations
|
|
341
|
+
attributes.select(&:association?)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def persists?
|
|
345
|
+
!hazards.map(&:first).include?(:skip_create)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def fires_callbacks?
|
|
349
|
+
!callbacks.empty?
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
FactoryIndex = Data.define(:factories, :legacy_dsl, :skipped) do
|
|
354
|
+
def factory(name)
|
|
355
|
+
wanted = name.to_sym
|
|
356
|
+
|
|
357
|
+
factories.find { |f| f.name == wanted } ||
|
|
358
|
+
factories.find { |f| f.aliases.include?(wanted) }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def for_model(model)
|
|
362
|
+
factories.select { |f| f.model == model.to_s }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def dsl_module
|
|
366
|
+
legacy_dsl ? "FactoryGirl" : "FactoryBot"
|
|
367
|
+
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
|
+
end
|
|
401
|
+
|
|
402
|
+
Column = Data.define(
|
|
403
|
+
:name, :type, :null, :default, :limit, :precision, :scale, :array, :unknown_type, :line
|
|
404
|
+
) do
|
|
405
|
+
def nullable?
|
|
406
|
+
null != false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def unknown_type?
|
|
410
|
+
unknown_type == true
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def required?
|
|
414
|
+
!nullable? && default.nil?
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
Index = Data.define(:columns, :name, :unique, :where) do
|
|
419
|
+
def unique?
|
|
420
|
+
unique == true
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def partial?
|
|
424
|
+
!where.nil?
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
Table = Data.define(:name, :primary_key, :id_type, :columns, :indexes) do
|
|
429
|
+
def column(name)
|
|
430
|
+
columns.find { |c| c.name == name.to_s }
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def unique_indexes
|
|
434
|
+
indexes.select(&:unique?)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def required_columns
|
|
438
|
+
columns.reject { |c| Array(primary_key).include?(c.name) }.select(&:required?)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
ForeignKey = Data.define(:from_table, :column, :to_table, :primary_key, :on_delete, :source) do
|
|
443
|
+
def heuristic?
|
|
444
|
+
source == :heuristic
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def key
|
|
448
|
+
"#{from_table}.#{column}"
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
SkippedStatement = Data.define(:kind, :table, :column, :references, :file, :line, :relevant) do
|
|
453
|
+
def relevant?
|
|
454
|
+
relevant == true
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def tables_touched
|
|
458
|
+
([table] + Array(references)).compact.uniq
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def to_s
|
|
462
|
+
subject = column ? "#{table}.#{column}" : table
|
|
463
|
+
via = Array(references).empty? ? "" : " on #{Array(references).join(', ')}"
|
|
464
|
+
"#{kind}#{subject ? " (#{subject})" : ""}#{via} at #{file}:#{line}"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
SchemaGraph = Data.define(:tables, :fk_map, :foreign_keys, :skipped_statements) do
|
|
469
|
+
def table(name)
|
|
470
|
+
tables.find { |t| t.name == name.to_s }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def table_names
|
|
474
|
+
tables.map(&:name)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def fk_for(table_name, column_name)
|
|
478
|
+
foreign_keys.find { |fk| fk.from_table == table_name.to_s && fk.column == column_name.to_s }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def foreign_keys_from(table_name)
|
|
482
|
+
foreign_keys.select { |fk| fk.from_table == table_name.to_s }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def unknown_columns
|
|
486
|
+
tables.flat_map { |t| t.columns.select(&:unknown_type?).map { |c| [t.name, c] } }
|
|
487
|
+
end
|
|
488
|
+
|
|
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
|
+
end
|
|
497
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Pinspec
|
|
8
|
+
module Validate
|
|
9
|
+
class MutationAdapter
|
|
10
|
+
REQUIRED_RUBY = "3.4"
|
|
11
|
+
|
|
12
|
+
def self.available?
|
|
13
|
+
Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(REQUIRED_RUBY) &&
|
|
14
|
+
!find_executable.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.find_executable
|
|
18
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, "mutineer") }
|
|
19
|
+
.find { |candidate| File.executable?(candidate) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.refuse_unless_available!
|
|
23
|
+
return if available?
|
|
24
|
+
|
|
25
|
+
raise UnsupportedRailsVersion,
|
|
26
|
+
"`--validate` needs the mutineer backend, which requires Ruby >= " \
|
|
27
|
+
"#{REQUIRED_RUBY} (this is #{RUBY_VERSION}) and a `mutineer` on PATH. " \
|
|
28
|
+
"Everything else in pinspec runs on Ruby 3.2 and up; only scoring is " \
|
|
29
|
+
"gated. Install it with `gem install mutineer` on a 3.4+ Ruby."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Outcome = Data.define(:subject, :score, :killed, :survived, :total, :survivors, :raw) do
|
|
33
|
+
def scored?
|
|
34
|
+
!score.nil?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(source_path:, subject:, cwd: ".", env: {}, test_command: nil)
|
|
39
|
+
@source_path = source_path
|
|
40
|
+
@subject = subject
|
|
41
|
+
@cwd = cwd
|
|
42
|
+
@env = env
|
|
43
|
+
@test_command = test_command
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def score(spec_path)
|
|
47
|
+
args = command_for(spec_path)
|
|
48
|
+
stdout, stderr, status = Open3.capture3(environment, *args, chdir: @cwd)
|
|
49
|
+
|
|
50
|
+
parsed = parse(stdout)
|
|
51
|
+
return unscored(stderr.to_s.empty? ? stdout : stderr) if parsed.nil?
|
|
52
|
+
|
|
53
|
+
summary = parsed["summary"] || {}
|
|
54
|
+
|
|
55
|
+
Outcome.new(
|
|
56
|
+
subject: @subject,
|
|
57
|
+
score: summary["score"],
|
|
58
|
+
killed: summary["killed"],
|
|
59
|
+
survived: summary["survived"],
|
|
60
|
+
total: summary["total"],
|
|
61
|
+
survivors: Array(parsed["survivors"]).map { |s| { "operator" => s["operator"], "line" => s["line"], "token" => s["token"] } },
|
|
62
|
+
raw: status.exitstatus
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def command_for(spec_path)
|
|
69
|
+
base = [
|
|
70
|
+
self.class.find_executable || "mutineer", "run", relative(@source_path),
|
|
71
|
+
"--test", relative(spec_path),
|
|
72
|
+
"--framework", "rspec",
|
|
73
|
+
"--only", @subject,
|
|
74
|
+
"--format", "json",
|
|
75
|
+
"--strategy", @test_command ? "reload" : "redefine"
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
@test_command ? base + ["--test-command", @test_command] : base
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def relative(path)
|
|
82
|
+
path.to_s.sub("#{File.expand_path(@cwd)}/", "").sub("#{@cwd}/", "")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def environment
|
|
86
|
+
{
|
|
87
|
+
"BUNDLE_GEMFILE" => nil, "BUNDLE_PATH" => nil, "BUNDLE_BIN_PATH" => nil,
|
|
88
|
+
"BUNDLER_VERSION" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil,
|
|
89
|
+
"RAILS_ENV" => "test", "DISABLE_SPRING" => "1"
|
|
90
|
+
}.merge(@env)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse(stdout)
|
|
94
|
+
json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
|
|
95
|
+
return nil if json.nil?
|
|
96
|
+
|
|
97
|
+
JSON.parse(json)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unscored(detail)
|
|
103
|
+
Outcome.new(subject: @subject, score: nil, killed: nil, survived: nil, total: nil,
|
|
104
|
+
survivors: [], raw: detail.to_s.lines.last(8).join)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|