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
data/lib/pinspec/cli.rb
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
def self.exit_on_failure?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "version", "Print the pinspec version"
|
|
12
|
+
def version
|
|
13
|
+
puts "pinspec #{Pinspec::VERSION} " \
|
|
14
|
+
"(probe v#{Pinspec::PROBE_VERSION}, serializer v#{Pinspec::SERIALIZER_VERSION})"
|
|
15
|
+
end
|
|
16
|
+
map %w[--version -v] => :version
|
|
17
|
+
|
|
18
|
+
desc "plan FILE#METHOD", "Resolve a target and print the SetupPlan that would build its world"
|
|
19
|
+
method_option :app, type: :string, default: ".", desc: "target app root"
|
|
20
|
+
method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES,
|
|
21
|
+
desc: "max input cases per method"
|
|
22
|
+
def plan(target)
|
|
23
|
+
guarded do
|
|
24
|
+
file, method = Analyzer::TargetParser.split_target(target)
|
|
25
|
+
target_profile = Analyzer::TargetParser.parse(file, method)
|
|
26
|
+
|
|
27
|
+
print_profile(target_profile)
|
|
28
|
+
puts
|
|
29
|
+
|
|
30
|
+
app_profile = Analyzer::AppProfileReader.read(options[:app])
|
|
31
|
+
setup_plan = Setup::ContextBuilder.build(target: target_profile, profile: app_profile)
|
|
32
|
+
corpus = Inputs::Corpus.build(
|
|
33
|
+
target: target_profile,
|
|
34
|
+
plan: setup_plan,
|
|
35
|
+
schema: app_profile.schema,
|
|
36
|
+
max_cases: options[:cases]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
print_plan(setup_plan)
|
|
40
|
+
puts
|
|
41
|
+
print_corpus(corpus)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "analyze [APP_PATH]", "App profile: schema, factories, auth, tenancy, hazards"
|
|
46
|
+
def analyze(app_path = ".")
|
|
47
|
+
guarded do
|
|
48
|
+
profile = Analyzer::AppProfileReader.read(app_path)
|
|
49
|
+
|
|
50
|
+
print_app(profile)
|
|
51
|
+
puts
|
|
52
|
+
print_schema(profile.schema)
|
|
53
|
+
puts
|
|
54
|
+
print_factories(profile.factories)
|
|
55
|
+
print_warnings(profile)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc "capture FILE#METHOD", "Run the probe, write observations.json"
|
|
60
|
+
method_option :app, type: :string, default: ".", desc: "target app root"
|
|
61
|
+
method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
|
|
62
|
+
method_option :boots, type: :numeric, default: 2,
|
|
63
|
+
desc: "probe boots; 2 is the default because one process shares warm caches"
|
|
64
|
+
method_option :"compare-sql", type: :boolean, default: false,
|
|
65
|
+
desc: "include SQL fingerprints in the stability decision"
|
|
66
|
+
method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
|
|
67
|
+
desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
|
|
68
|
+
method_option :sample, type: :boolean, default: false,
|
|
69
|
+
desc: "read real rows through a generated read-only script in the app"
|
|
70
|
+
method_option :"no-redact", type: :boolean, default: false,
|
|
71
|
+
desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
|
|
72
|
+
def capture(target)
|
|
73
|
+
guarded do
|
|
74
|
+
file, method = Analyzer::TargetParser.split_target(target)
|
|
75
|
+
|
|
76
|
+
result = Runner::Capture.new(
|
|
77
|
+
app_root: options[:app],
|
|
78
|
+
target: file,
|
|
79
|
+
method: method,
|
|
80
|
+
max_cases: options[:cases],
|
|
81
|
+
boots: options[:boots],
|
|
82
|
+
compare_sql: options[:"compare-sql"],
|
|
83
|
+
sandbox_env: app_env,
|
|
84
|
+
sample: options[:sample],
|
|
85
|
+
redact: !options[:"no-redact"]
|
|
86
|
+
).run
|
|
87
|
+
|
|
88
|
+
print_capture(result)
|
|
89
|
+
|
|
90
|
+
raise NothingStableToPin, nothing_stable_message(result.stability) if result.stability.nothing_to_pin?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
desc "pin FILE#METHOD", "Plan + capture + emit + verify"
|
|
95
|
+
method_option :app, type: :string, default: ".", desc: "target app root"
|
|
96
|
+
method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
|
|
97
|
+
method_option :boots, type: :numeric, default: 2
|
|
98
|
+
method_option :"verify-level", type: :string, default: "full", enum: %w[full isolated]
|
|
99
|
+
method_option :"skip-verify", type: :boolean, default: false
|
|
100
|
+
method_option :force, type: :boolean, default: false,
|
|
101
|
+
desc: "overwrite a pin file that has been hand-edited"
|
|
102
|
+
method_option :snapshot, type: :string, default: "inline", enum: %w[inline insta approvals],
|
|
103
|
+
desc: "snapshot backend"
|
|
104
|
+
method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
|
|
105
|
+
desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
|
|
106
|
+
method_option :sample, type: :boolean, default: false,
|
|
107
|
+
desc: "read real rows through a generated read-only script in the app"
|
|
108
|
+
method_option :"no-redact", type: :boolean, default: false,
|
|
109
|
+
desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
|
|
110
|
+
def pin(target)
|
|
111
|
+
guarded do
|
|
112
|
+
refuse_unbuilt_backend!
|
|
113
|
+
warn_about_redaction!
|
|
114
|
+
file, method = Analyzer::TargetParser.split_target(target)
|
|
115
|
+
|
|
116
|
+
capture = Runner::Capture.new(
|
|
117
|
+
app_root: options[:app], target: file, method: method,
|
|
118
|
+
max_cases: options[:cases], boots: options[:boots], sandbox_env: app_env,
|
|
119
|
+
sample: options[:sample], redact: !options[:"no-redact"]
|
|
120
|
+
).run
|
|
121
|
+
|
|
122
|
+
print_capture(capture)
|
|
123
|
+
raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
|
|
124
|
+
|
|
125
|
+
written = Emit::SpecWriter.new(
|
|
126
|
+
app_root: options[:app], target: capture.target, plan: capture.plan,
|
|
127
|
+
corpus: capture.corpus, stability: capture.stability,
|
|
128
|
+
fk_map: Analyzer::AppProfileReader.read(options[:app]).schema.fk_map,
|
|
129
|
+
force: options[:force]
|
|
130
|
+
).write!
|
|
131
|
+
|
|
132
|
+
puts
|
|
133
|
+
print_written(written)
|
|
134
|
+
|
|
135
|
+
return if options[:"skip-verify"]
|
|
136
|
+
|
|
137
|
+
outcomes = Verify::Verifier.new(
|
|
138
|
+
app_root: options[:app], spec_path: written.spec_path,
|
|
139
|
+
level: options[:"verify-level"].to_sym, env: app_env,
|
|
140
|
+
captured_tz: capture.plan.env_fingerprint[:tz]
|
|
141
|
+
).verify
|
|
142
|
+
|
|
143
|
+
puts
|
|
144
|
+
print_verification(outcomes)
|
|
145
|
+
|
|
146
|
+
report_path = Report::Summary.new(
|
|
147
|
+
app_root: options[:app], profile: Analyzer::AppProfileReader.read(options[:app]),
|
|
148
|
+
target: capture.target, plan: capture.plan, corpus: capture.corpus,
|
|
149
|
+
stability: capture.stability, written: written, outcomes: outcomes
|
|
150
|
+
).write!
|
|
151
|
+
|
|
152
|
+
puts
|
|
153
|
+
row "report", report_path
|
|
154
|
+
|
|
155
|
+
raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
desc "validate FILE#METHOD", "Mutation-score a pin, one aspect at a time"
|
|
160
|
+
method_option :app, type: :string, default: ".", desc: "target app root"
|
|
161
|
+
method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
|
|
162
|
+
method_option :"test-command", type: :string,
|
|
163
|
+
desc: "run the app's suite in its own runtime (for apps on Ruby < 3.4)"
|
|
164
|
+
method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
|
|
165
|
+
desc: "environment for the app's own runtime"
|
|
166
|
+
def validate(target)
|
|
167
|
+
guarded do
|
|
168
|
+
file, method = Analyzer::TargetParser.split_target(target)
|
|
169
|
+
|
|
170
|
+
capture = Runner::Capture.new(
|
|
171
|
+
app_root: options[:app], target: file, method: method,
|
|
172
|
+
max_cases: options[:cases], sandbox_env: app_env
|
|
173
|
+
).run
|
|
174
|
+
|
|
175
|
+
raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
|
|
176
|
+
|
|
177
|
+
profile = Analyzer::AppProfileReader.read(options[:app])
|
|
178
|
+
|
|
179
|
+
report = Validate::PinScorer.new(
|
|
180
|
+
app_root: options[:app], target: capture.target, plan: capture.plan,
|
|
181
|
+
corpus: capture.corpus, stability: capture.stability,
|
|
182
|
+
fk_map: profile.schema.fk_map,
|
|
183
|
+
env: app_env, test_command: options[:"test-command"]
|
|
184
|
+
).run
|
|
185
|
+
|
|
186
|
+
print_scores(report)
|
|
187
|
+
|
|
188
|
+
summary = Report::Summary.new(
|
|
189
|
+
app_root: options[:app], profile: profile, target: capture.target,
|
|
190
|
+
plan: capture.plan, corpus: capture.corpus, stability: capture.stability,
|
|
191
|
+
scores: report
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
puts
|
|
195
|
+
puts " report #{summary.write!}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
desc "report", "Print the last run's markdown report"
|
|
200
|
+
method_option :app, type: :string, default: ".", desc: "target app root"
|
|
201
|
+
def report
|
|
202
|
+
guarded do
|
|
203
|
+
path = File.join(options[:app], Report::Summary::OUTPUT)
|
|
204
|
+
|
|
205
|
+
unless File.file?(path)
|
|
206
|
+
raise TargetNotFound,
|
|
207
|
+
"no report at #{path}. Run `pinspec pin` first (or `pinspec validate`, " \
|
|
208
|
+
"which adds the mutation scores); either writes one every time."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
puts Analyzer::Source.read(path)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def app_env
|
|
218
|
+
Array(options[:"app-env"]).each_with_object({}) do |pair, out|
|
|
219
|
+
key, value = pair.split("=", 2)
|
|
220
|
+
out[key] = value.to_s
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def refuse_unbuilt_backend!
|
|
225
|
+
backend = options[:snapshot]
|
|
226
|
+
return if backend.nil? || backend == "inline"
|
|
227
|
+
|
|
228
|
+
raise VerifyFailed,
|
|
229
|
+
"the #{backend} snapshot backend is not built yet; only `inline` is. " \
|
|
230
|
+
"Inline snapshots keep the pinned value in the spec file, where a reviewer " \
|
|
231
|
+
"can read it - which is why it is the default. Re-run without --snapshot."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def warn_about_redaction!
|
|
235
|
+
return unless options[:"no-redact"]
|
|
236
|
+
|
|
237
|
+
warn "pinspec: --no-redact means real personal data will be written into a spec " \
|
|
238
|
+
"file that gets committed. Every sampled value is reproduced verbatim."
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def guarded
|
|
242
|
+
yield
|
|
243
|
+
rescue Pinspec::Error => e
|
|
244
|
+
warn "pinspec: #{e.message}"
|
|
245
|
+
warn " reason: #{e.reason}" if e.respond_to?(:reason)
|
|
246
|
+
exit e.exit_code
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def print_schema(graph)
|
|
250
|
+
heuristic = graph.foreign_keys.select(&:heuristic?)
|
|
251
|
+
|
|
252
|
+
puts "schema"
|
|
253
|
+
row "tables", graph.tables.size
|
|
254
|
+
row "columns", graph.tables.sum { |t| t.columns.size }
|
|
255
|
+
row "foreign keys", foreign_key_summary(graph, heuristic)
|
|
256
|
+
row "hazards", graph.skipped_statements.empty? ? "none" : graph.skipped_statements.size
|
|
257
|
+
|
|
258
|
+
unless heuristic.empty?
|
|
259
|
+
puts
|
|
260
|
+
puts " inferred from a column name (no constraint declares these):"
|
|
261
|
+
heuristic.each { |fk| puts " #{fk.key} -> #{fk.to_table}" }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
return if graph.skipped_statements.empty?
|
|
265
|
+
|
|
266
|
+
puts
|
|
267
|
+
puts " hazards (relevance is decided once a plan exists, in M-05):"
|
|
268
|
+
graph.skipped_statements.each { |statement| puts " #{statement}" }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def foreign_key_summary(graph, heuristic)
|
|
272
|
+
return "none" if graph.foreign_keys.empty?
|
|
273
|
+
return graph.foreign_keys.size.to_s if heuristic.empty?
|
|
274
|
+
|
|
275
|
+
"#{graph.foreign_keys.size} (#{heuristic.size} inferred)"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def print_plan(plan)
|
|
279
|
+
puts "setup plan #{plan.plan_id} (generation #{plan.generation}, isolation #{plan.isolation})"
|
|
280
|
+
|
|
281
|
+
plan.steps.each_with_index do |step, index|
|
|
282
|
+
puts format(" %2d. %s", index + 1, step)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
unless plan.bindings.empty?
|
|
286
|
+
puts
|
|
287
|
+
puts " parameter bindings:"
|
|
288
|
+
plan.bindings.each { |param, ref| puts " #{param} -> #{ref}" }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
unless plan.notes.empty?
|
|
292
|
+
puts
|
|
293
|
+
puts " plan notes:"
|
|
294
|
+
plan.notes.each { |note| puts " #{note[:kind]}: #{note[:detail]}" }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def print_capture(result)
|
|
300
|
+
stability = result.stability
|
|
301
|
+
|
|
302
|
+
puts "capture #{result.target.qualified_name}"
|
|
303
|
+
row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
|
|
304
|
+
row "runs", "#{stability.runs} boots"
|
|
305
|
+
row "cases", result.corpus.size
|
|
306
|
+
row "stable", "#{stability.stable.size} of #{result.corpus.size}"
|
|
307
|
+
row "compared", stability.compared_fields.join(", ")
|
|
308
|
+
row "observations", result.output_path
|
|
309
|
+
|
|
310
|
+
unless stability.stable.empty?
|
|
311
|
+
puts
|
|
312
|
+
puts " stable, and therefore pinnable:"
|
|
313
|
+
stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
return if stability.unstable.empty?
|
|
317
|
+
|
|
318
|
+
puts
|
|
319
|
+
puts " unstable (each with the first field that differed):"
|
|
320
|
+
stability.unstable.each do |verdict|
|
|
321
|
+
puts " #{verdict.case_id} #{verdict.cause}"
|
|
322
|
+
verdict.diff.to_s.lines.each { |line| puts " #{line.chomp}" } unless verdict.diff.to_s.empty?
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def summarize(observation)
|
|
327
|
+
case observation["status"]
|
|
328
|
+
when "raised" then "raised #{observation.dig('error', 'class')}"
|
|
329
|
+
when "returned"
|
|
330
|
+
parts = ["returned #{observation.dig('return_value', 't')}"]
|
|
331
|
+
jobs = observation["enqueued_jobs"].to_a.size
|
|
332
|
+
mail = observation["mail_deliveries"].to_a.size
|
|
333
|
+
parts << "#{jobs} job(s)" if jobs.positive?
|
|
334
|
+
parts << "#{mail} mail" if mail.positive?
|
|
335
|
+
parts.join(", ")
|
|
336
|
+
else observation["status"]
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def nothing_stable_message(stability)
|
|
341
|
+
histogram = stability.causes.map { |cause, count| "#{count} #{cause}" }.join(", ")
|
|
342
|
+
|
|
343
|
+
"no case was stable across #{stability.runs} boots (#{histogram}). " \
|
|
344
|
+
"pinspec will not emit a spec it cannot stand behind."
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def print_written(written)
|
|
348
|
+
puts "emitted #{written.spec_path}"
|
|
349
|
+
written.support_paths.each { |path| row "support", path }
|
|
350
|
+
row "pinned", written.pinned_cases.join(", ")
|
|
351
|
+
row "aspects", written.aspects.reject { |_, count| count.zero? }
|
|
352
|
+
.map { |aspect, count| "#{count} #{aspect}" }.join(", ")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def print_verification(outcomes)
|
|
356
|
+
puts "verify"
|
|
357
|
+
|
|
358
|
+
outcomes.each do |outcome|
|
|
359
|
+
row outcome.config.to_s, outcome.green? ? "green (#{outcome.examples} examples)" : "#{outcome.status} - #{outcome.diagnosis}"
|
|
360
|
+
next if outcome.green?
|
|
361
|
+
|
|
362
|
+
outcome.detail.to_s.lines.first(12).each { |line| puts " #{line.chomp}" }
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def verify_failed_message(outcomes)
|
|
367
|
+
failed = outcomes.reject(&:green?)
|
|
368
|
+
|
|
369
|
+
"the emitted spec did not run green in #{failed.map { |o| o.config }.join(', ')} " \
|
|
370
|
+
"(#{failed.map { |o| o.diagnosis }.uniq.join(', ')}). The pin was written, but " \
|
|
371
|
+
"pinspec will not claim a spec is green when it is not."
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def print_scores(report)
|
|
375
|
+
puts "mutation score #{report.subject}"
|
|
376
|
+
row "aspects scored", report.scores.map(&:aspect).join(", ")
|
|
377
|
+
row "strong", "#{report.strong_ratio}% of scored aspects"
|
|
378
|
+
|
|
379
|
+
puts
|
|
380
|
+
report.scores.each do |score|
|
|
381
|
+
if score.score.nil?
|
|
382
|
+
row score.aspect.to_s, "not scored - #{score.note.to_s.lines.first.to_s.strip}"
|
|
383
|
+
next
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
row score.aspect.to_s, "#{score.score}% #{score.verdict} " \
|
|
387
|
+
"(#{score.killed} killed, #{score.survived} survived)"
|
|
388
|
+
puts " caveat: #{score.note}" if score.note
|
|
389
|
+
score.survivors.first(3).each do |survivor|
|
|
390
|
+
puts " survived: #{survivor['operator']} at line #{survivor['line']} - #{survivor['token']}"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
print_cross_aspect(report)
|
|
395
|
+
|
|
396
|
+
return if report.skipped.empty?
|
|
397
|
+
|
|
398
|
+
puts
|
|
399
|
+
puts " not asserted by this pin, so not scored: #{report.skipped.join(', ')}"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def print_cross_aspect(report)
|
|
403
|
+
return if report.scored.size < 2
|
|
404
|
+
|
|
405
|
+
gaps = report.surviving_all_aspects
|
|
406
|
+
covered = report.covered_by_another_aspect
|
|
407
|
+
|
|
408
|
+
puts
|
|
409
|
+
unless covered.empty?
|
|
410
|
+
puts " #{covered.size} mutant(s) survived one aspect but were killed by another,"
|
|
411
|
+
puts " which is the aspects dividing the work rather than a gap:"
|
|
412
|
+
covered.first(4).each { |m| puts " #{m['operator']} at line #{m['line']} - #{m['token']}" }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
if gaps.empty?
|
|
416
|
+
puts " nothing survived every aspect: together, the pins cover this target."
|
|
417
|
+
else
|
|
418
|
+
puts " #{gaps.size} mutant(s) survived EVERY aspect - the real gap:"
|
|
419
|
+
gaps.each { |m| puts " #{m['operator']} at line #{m['line']} - #{m['token']}" }
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def print_corpus(corpus)
|
|
424
|
+
puts "input cases #{corpus.size} (#{corpus.origins.map { |o, n| "#{n} #{o}" }.join(', ')})"
|
|
425
|
+
|
|
426
|
+
corpus.cases.each { |input_case| puts " #{input_case}" }
|
|
427
|
+
|
|
428
|
+
puts
|
|
429
|
+
puts "Sampled rows and import clusters need a database. `plan` never opens one;"
|
|
430
|
+
puts "pass --sample to `capture` or `pin` to read real rows through a generated"
|
|
431
|
+
puts "read-only script in the app's own runtime."
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def print_app(profile)
|
|
435
|
+
stack = profile.test_stack
|
|
436
|
+
|
|
437
|
+
puts "app"
|
|
438
|
+
row "rails", profile.rails_version || "unknown (no Gemfile.lock)"
|
|
439
|
+
row "ruby", profile.ruby_version || "unknown"
|
|
440
|
+
row "isolation", "#{profile.isolation} (#{profile.isolation_source})"
|
|
441
|
+
row "locale / zone", "#{profile.default_locale.inspect} / #{profile.default_zone.inspect}"
|
|
442
|
+
row "auth / authz", "#{profile.auth} / #{profile.authz}"
|
|
443
|
+
row "tenancy", profile.tenancy
|
|
444
|
+
row "soft delete", profile.soft_delete
|
|
445
|
+
row "versioning", profile.versioning
|
|
446
|
+
row "feature flags", profile.flags
|
|
447
|
+
row "attachments", profile.attachments.empty? ? "none" : profile.attachments.join(", ")
|
|
448
|
+
row "multi-database", profile.multi_db
|
|
449
|
+
row "test stack", test_stack_summary(stack)
|
|
450
|
+
row "queue adapter", profile.queue_adapter_in_tests&.inspect || "unset (Rails default)"
|
|
451
|
+
|
|
452
|
+
print_model_findings(profile)
|
|
453
|
+
print_notes(profile)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def test_stack_summary(stack)
|
|
457
|
+
parts = [stack.framework.to_s]
|
|
458
|
+
parts << "webmock" if stack.webmock
|
|
459
|
+
parts << "vcr" if stack.vcr
|
|
460
|
+
parts << "database_cleaner" if stack.database_cleaner_gem
|
|
461
|
+
parts.concat(stack.snapshot_backends.map(&:to_s))
|
|
462
|
+
parts.join(" + ")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def print_model_findings(profile)
|
|
466
|
+
return if profile.model_findings.empty?
|
|
467
|
+
|
|
468
|
+
puts
|
|
469
|
+
puts " model hazards:"
|
|
470
|
+
profile.model_findings.group_by(&:kind).each do |kind, findings|
|
|
471
|
+
puts " #{kind}: #{findings.map { |f| "#{f.model} (#{f.file}:#{f.line})" }.join(', ')}"
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def print_notes(profile)
|
|
476
|
+
return if profile.notes.empty?
|
|
477
|
+
|
|
478
|
+
puts
|
|
479
|
+
puts " could not read:"
|
|
480
|
+
profile.notes.each { |note| puts " #{note[:kind]}: #{note[:detail]}" }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def print_warnings(profile)
|
|
484
|
+
warnings = profile.warnings
|
|
485
|
+
return if warnings.empty?
|
|
486
|
+
|
|
487
|
+
puts
|
|
488
|
+
puts "warnings"
|
|
489
|
+
warnings.each_with_index do |warning, index|
|
|
490
|
+
puts " #{index + 1}. #{wrap(warning)}"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def wrap(text, width: 76, indent: " ")
|
|
495
|
+
words = text.split(/\s+/)
|
|
496
|
+
lines = words.each_with_object([[]]) do |word, acc|
|
|
497
|
+
if (acc.last + [word]).join(" ").length > width
|
|
498
|
+
acc << [word]
|
|
499
|
+
else
|
|
500
|
+
acc.last << word
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
lines.map { |line| line.join(" ") }.join("\n#{indent}")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def print_factories(index)
|
|
508
|
+
with_callbacks = index.factories.select(&:fires_callbacks?)
|
|
509
|
+
non_persisting = index.factories.reject(&:persists?)
|
|
510
|
+
|
|
511
|
+
puts "factories (#{index.dsl_module})"
|
|
512
|
+
row "factories", index.factories.empty? ? "none found" : index.factories.size
|
|
513
|
+
row "traits", index.factories.sum { |f| f.traits.size }
|
|
514
|
+
row "unreadable", index.skipped.empty? ? "none" : index.skipped.size
|
|
515
|
+
|
|
516
|
+
unless with_callbacks.empty?
|
|
517
|
+
puts
|
|
518
|
+
puts " callbacks fire while the plan builds records, so the probe attributes"
|
|
519
|
+
puts " them to setup rather than to the target:"
|
|
520
|
+
with_callbacks.each do |factory|
|
|
521
|
+
puts " #{factory.name}: #{factory.callbacks.map(&:to_s).join(', ')} " \
|
|
522
|
+
"(#{factory.file}:#{factory.callbacks.first.line})"
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
unless non_persisting.empty?
|
|
527
|
+
puts
|
|
528
|
+
puts " these factories never persist a row, so a plan cannot build on them:"
|
|
529
|
+
non_persisting.each do |factory|
|
|
530
|
+
puts " #{factory.name}: #{factory.hazards.map(&:first).join(', ')}"
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
return if index.skipped.empty?
|
|
535
|
+
|
|
536
|
+
puts
|
|
537
|
+
puts " unreadable factory files (pinspec will act as if these factories do not exist):"
|
|
538
|
+
index.skipped.each { |entry| puts " #{entry[:file]}: #{entry[:kind]} - #{entry[:detail]}" }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def print_profile(profile)
|
|
542
|
+
start_line, end_line = profile.source_range
|
|
543
|
+
|
|
544
|
+
puts profile.qualified_name
|
|
545
|
+
row "file", "#{profile.file_path}:#{start_line}-#{end_line}"
|
|
546
|
+
row "construction", construction_summary(profile)
|
|
547
|
+
row "ctor params", params_summary(profile.initializer_params)
|
|
548
|
+
row "method params", params_summary(profile.params)
|
|
549
|
+
row "visibility", profile.visibility
|
|
550
|
+
row "constants", list(profile.referenced_constants)
|
|
551
|
+
row "clock sites", clock_summary(profile)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def construction_summary(profile)
|
|
555
|
+
return "#{profile.construction_kind} (no subject needed)" unless profile.needs_subject?
|
|
556
|
+
|
|
557
|
+
args = profile.initializer_params.map(&:to_s).join(", ")
|
|
558
|
+
"#{profile.construction_kind}: #{profile.class_name}.new(#{args})"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def params_summary(params)
|
|
562
|
+
return "(none)" if params.empty?
|
|
563
|
+
|
|
564
|
+
params.map do |param|
|
|
565
|
+
hint = param.type_hint ? " [#{param.type_hint}]" : ""
|
|
566
|
+
"#{param}#{hint}"
|
|
567
|
+
end.join(", ")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def clock_summary(profile)
|
|
571
|
+
return "(none)" unless profile.clock_dependent?
|
|
572
|
+
|
|
573
|
+
sites = profile.clock_sites.map { |s| "#{s.call} (line #{s.line})" }.join(", ")
|
|
574
|
+
"#{sites}; reads the process clock, not Time.zone, so pins will be TZ-dependent"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def list(values)
|
|
578
|
+
values.empty? ? "(none)" : values.join(", ")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def row(label, value)
|
|
582
|
+
puts format(" %-14s %s", label, value)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|