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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Pinspec
8
+ module Inputs
9
+ class SampleRunner
10
+ SCRIPT_PATH = "tmp/pinspec/sampler.rb"
11
+
12
+ Result = Data.define(:env, :counts, :rows, :stratified, :errors) do
13
+ def total_rows
14
+ counts.values.sum
15
+ end
16
+
17
+ def empty?
18
+ total_rows.zero?
19
+ end
20
+
21
+ def rows_for(table)
22
+ seen = {}
23
+
24
+ (Array(rows[table]) + Array(stratified[table])).each do |row|
25
+ seen[row["id"] || row[:id] || row.hash] = row
26
+ end
27
+
28
+ seen.values
29
+ end
30
+
31
+ def all_rows
32
+ counts.keys.each_with_object({}) { |table, out| out[table] = rows_for(table) }
33
+ end
34
+ end
35
+
36
+ def initialize(app_root:, env: {}, rails_env: "development", timeout: 300)
37
+ @app_root = app_root
38
+ @env = env
39
+ @rails_env = rails_env
40
+ @timeout = timeout
41
+ end
42
+
43
+ def fetch(requests)
44
+ write_script!(Sampler.script_for(requests))
45
+
46
+ stdout, stderr, status = Open3.capture3(environment, *command, chdir: @app_root)
47
+
48
+ unless status.success?
49
+ raise ProbeFailure,
50
+ "the sampler exited #{status.exitstatus} in #{@app_root}.\n" \
51
+ "#{stderr.to_s.lines.last(12).join}"
52
+ end
53
+
54
+ parse(stdout, stderr)
55
+ end
56
+
57
+ private
58
+
59
+ def write_script!(source)
60
+ path = File.join(@app_root, SCRIPT_PATH)
61
+ FileUtils.mkdir_p(File.dirname(path))
62
+ File.write(path, source)
63
+ path
64
+ end
65
+
66
+ def command
67
+ return ["rails", "runner", SCRIPT_PATH] unless File.file?(File.join(@app_root, "Gemfile"))
68
+
69
+ ["bundle", "exec", "rails", "runner", SCRIPT_PATH]
70
+ end
71
+
72
+ def environment
73
+ Runner::Sandbox::SCRUBBED_ENV
74
+ .merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
75
+ .merge(@env)
76
+ end
77
+
78
+ def parse(stdout, stderr)
79
+ json = stdout.to_s.lines.reverse.find { |line| line.strip.start_with?("{") }
80
+
81
+ if json.nil?
82
+ raise ProbeFailure,
83
+ "the sampler produced no rows.\nstdout: #{stdout.to_s.lines.last(8).join}" \
84
+ "stderr: #{stderr.to_s.lines.last(8).join}"
85
+ end
86
+
87
+ parsed = JSON.parse(json)
88
+
89
+ Result.new(
90
+ env: parsed["env"], counts: parsed["counts"] || {}, rows: parsed["rows"] || {},
91
+ stratified: parsed["stratified"] || {}, errors: parsed["errors"] || []
92
+ )
93
+ rescue JSON::ParserError => e
94
+ raise ProbeFailure, "the sampler's output was not JSON (#{e.message})"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pinspec
6
+ module Inputs
7
+ class Sampler
8
+ SCRIPT_PATH = "tmp/pinspec/sampler.rb"
9
+
10
+ ENV_PREFERENCE = %w[development test].freeze
11
+
12
+ PRODUCTION_HINTS = [
13
+ /(?<![a-z])prod(uction)?(?![a-z])/i,
14
+ /(?<![a-z])live(?![a-z])/i,
15
+ /(?<![a-z])master(?![a-z])/i
16
+ ].freeze
17
+
18
+ MAX_DISTINCT_STATUSES = 6
19
+
20
+ class << self
21
+ def choose_env(available:, counts: {}, override: nil)
22
+ return override if override
23
+
24
+ populated = ENV_PREFERENCE.find do |env|
25
+ available.include?(env) && counts.fetch(env, 0).positive?
26
+ end
27
+
28
+ populated || ENV_PREFERENCE.find { |env| available.include?(env) } || available.first
29
+ end
30
+
31
+ def production_like?(name)
32
+ PRODUCTION_HINTS.any? { |hint| name.to_s.match?(hint) }
33
+ end
34
+
35
+ def guard_production!(name, confirmed: false)
36
+ return unless production_like?(name)
37
+ return if confirmed
38
+
39
+ raise EnvironmentRefused,
40
+ "#{name.inspect} looks like a production database. pinspec only ever " \
41
+ "SELECTs, but it will not read production without being told to: " \
42
+ "re-run with --sample-db pointed somewhere else, or confirm explicitly."
43
+ end
44
+
45
+ def script_for(requests)
46
+ new(requests).script
47
+ end
48
+ end
49
+
50
+ def initialize(requests)
51
+ @requests = Array(requests)
52
+ end
53
+
54
+ def script
55
+ <<~RUBY
56
+ # frozen_string_literal: true
57
+ #
58
+ # Generated by pinspec #{VERSION}. Do not edit: regenerate it.
59
+ #
60
+ # READ-ONLY. This script issues SELECT statements and nothing else. It runs
61
+ # inside the target application via `bundle exec rails runner` so that the
62
+ # app's own adapter and database.yml decide what it connects to.
63
+ #
64
+ # Ruby 2.6 syntax floor: this runs in the app's Ruby, not pinspec's.
65
+
66
+ require "json"
67
+
68
+ unless defined?(ActiveRecord::Base)
69
+ abort("pinspec sampler: ActiveRecord is not loaded; is this a Rails application?")
70
+ end
71
+
72
+ PINSPEC_REQUESTS = JSON.parse(<<'PINSPEC_REQUESTS_JSON')
73
+ #{JSON.pretty_generate(@requests.map { |request| stringify(request) })}
74
+ PINSPEC_REQUESTS_JSON
75
+
76
+ def pinspec_connection
77
+ ActiveRecord::Base.connection
78
+ end
79
+
80
+ def pinspec_count(table)
81
+ quoted = pinspec_connection.quote_table_name(table)
82
+ pinspec_connection.select_value("SELECT COUNT(*) FROM " + quoted).to_i
83
+ rescue StandardError => e
84
+ warn("pinspec sampler: cannot count " + table + ": " + e.class.to_s)
85
+ 0
86
+ end
87
+
88
+ # Deterministic positions rather than random ones: the same database always
89
+ # yields the same corpus, so a plan stays content-addressable.
90
+ def pinspec_offsets(total, wanted)
91
+ return (0...total).to_a if total <= wanted
92
+
93
+ fractions = [0.0, 1.0, 0.25, 0.5, 0.75]
94
+ offsets = []
95
+ fractions.each do |fraction|
96
+ offset = (fraction * (total - 1)).round
97
+ offsets.push(offset) unless offsets.include?(offset)
98
+ break if offsets.length >= wanted
99
+ end
100
+ offsets
101
+ end
102
+
103
+ def pinspec_rows_at(table, pk, offsets)
104
+ quoted = pinspec_connection.quote_table_name(table)
105
+ order = pinspec_connection.quote_column_name(pk)
106
+ rows = []
107
+
108
+ offsets.each do |offset|
109
+ sql = "SELECT * FROM " + quoted + " ORDER BY " + order +
110
+ " LIMIT 1 OFFSET " + offset.to_i.to_s
111
+ row = pinspec_connection.select_all(sql).to_a.first
112
+ rows.push(row) if row
113
+ end
114
+
115
+ rows
116
+ end
117
+
118
+ # One row per distinct status value: the single highest-coverage win for the
119
+ # ubiquitous `case status` service object.
120
+ def pinspec_stratified(table, column)
121
+ quoted = pinspec_connection.quote_table_name(table)
122
+ col = pinspec_connection.quote_column_name(column)
123
+ values = pinspec_connection.select_values(
124
+ "SELECT DISTINCT " + col + " FROM " + quoted +
125
+ " LIMIT #{MAX_DISTINCT_STATUSES}"
126
+ )
127
+
128
+ rows = []
129
+ values.each do |value|
130
+ next if value.nil?
131
+
132
+ sql = "SELECT * FROM " + quoted + " WHERE " + col + " = " +
133
+ pinspec_connection.quote(value) + " LIMIT 1"
134
+ row = pinspec_connection.select_all(sql).to_a.first
135
+ rows.push(row) if row
136
+ end
137
+
138
+ rows
139
+ end
140
+
141
+ def pinspec_primary_key(table)
142
+ pinspec_connection.primary_key(table) || "id"
143
+ rescue StandardError
144
+ "id"
145
+ end
146
+
147
+ result = {
148
+ "pinspec_sampler_version" => 1,
149
+ "env" => (defined?(Rails) ? Rails.env.to_s : ENV["RAILS_ENV"].to_s),
150
+ "counts" => {},
151
+ "rows" => {},
152
+ "stratified" => {},
153
+ "errors" => []
154
+ }
155
+
156
+ PINSPEC_REQUESTS.each do |request|
157
+ table = request["table"]
158
+
159
+ begin
160
+ total = pinspec_count(table)
161
+ result["counts"][table] = total
162
+ next if total.zero?
163
+
164
+ pk = pinspec_primary_key(table)
165
+ wanted = (request["limit"] || 5).to_i
166
+ result["rows"][table] = pinspec_rows_at(table, pk, pinspec_offsets(total, wanted))
167
+
168
+ if request["status_column"]
169
+ result["stratified"][table] = pinspec_stratified(table, request["status_column"])
170
+ end
171
+ rescue StandardError => e
172
+ result["errors"].push(table + ": " + e.class.to_s + ": " + e.message.to_s)
173
+ end
174
+ end
175
+
176
+ puts JSON.generate(result)
177
+ RUBY
178
+ end
179
+
180
+ private
181
+
182
+ def stringify(request)
183
+ request.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pinspec
6
+ module Report
7
+ class Summary
8
+ OUTPUT = "tmp/pinspec/report.md"
9
+
10
+ def initialize(app_root:, profile:, target: nil, plan: nil, corpus: nil, stability: nil,
11
+ written: nil, outcomes: nil, scores: nil)
12
+ @app_root = app_root
13
+ @profile = profile
14
+ @target = target
15
+ @plan = plan
16
+ @corpus = corpus
17
+ @stability = stability
18
+ @written = written
19
+ @outcomes = outcomes
20
+ @scores = scores
21
+ end
22
+
23
+ def path
24
+ File.join(@app_root, OUTPUT)
25
+ end
26
+
27
+ def write!
28
+ FileUtils.mkdir_p(File.dirname(path))
29
+ File.write(path, render)
30
+ path
31
+ end
32
+
33
+ def render
34
+ sections = [
35
+ heading,
36
+ verdict,
37
+ what_was_pinned,
38
+ what_was_refused,
39
+ isolation_note,
40
+ coverage_caveats,
41
+ redactions,
42
+ provenance,
43
+ hazards,
44
+ mutation_scores,
45
+ footer
46
+ ]
47
+
48
+ sections.compact.join("\n")
49
+ end
50
+
51
+ private
52
+
53
+ def heading
54
+ <<~MD
55
+ # pinspec characterization report
56
+
57
+ **Target:** `#{@target&.qualified_name || '(none)'}`
58
+ **Application:** `#{File.expand_path(@app_root)}`
59
+ **Rails:** #{@profile.rails_version || 'unknown'} | **Ruby:** #{@profile.ruby_version || 'unknown'}
60
+ **Plan:** `#{@plan&.plan_id}` | **Isolation:** #{@plan&.isolation}
61
+ **pinspec:** #{VERSION} (probe v#{PROBE_VERSION}, serializer v#{SERIALIZER_VERSION})
62
+
63
+ This report describes behaviour that exists TODAY. Nothing here is a
64
+ judgement that the behaviour is correct: pinspec pins bugs on purpose, so
65
+ that a refactor cannot change them silently.
66
+ MD
67
+ end
68
+
69
+ def verdict
70
+ return not_verified if @outcomes.nil?
71
+
72
+ rows = @outcomes.map do |outcome|
73
+ state = outcome.green? ? "green" : "**#{outcome.status}** (#{outcome.diagnosis})"
74
+
75
+ "| #{outcome.config} | #{state} | #{outcome.examples || '-'} |"
76
+ end
77
+
78
+ <<~MD
79
+
80
+ ## Verification
81
+
82
+ The emitted spec was run in three environments, because one run on the
83
+ machine that captured it proves repeatability rather than portability.
84
+
85
+ | configuration | result | examples |
86
+ |---|---|---|
87
+ #{rows.join("\n")}
88
+
89
+ - **isolated** - the file alone, as captured.
90
+ - **hostile** - a different timezone, locale and RSpec seed.
91
+ - **neighbored** - the file twice in one process, so accumulated state shows.
92
+ MD
93
+ end
94
+
95
+ def not_verified
96
+ <<~MD
97
+
98
+ ## Verification
99
+
100
+ **Not run in this pass.** This report was written by a command that scores
101
+ pins rather than one that verifies them, so nothing here attests that the
102
+ emitted spec runs green. Run `pinspec pin` for the three-configuration
103
+ matrix.
104
+ MD
105
+ end
106
+
107
+ def what_was_pinned
108
+ return nil if @stability.nil?
109
+
110
+ lines = @stability.stable.map do |verdict|
111
+ observation = verdict.observation
112
+ outcome = observation["status"] == "raised" ? "raises #{observation.dig('error', 'class')}" : "returns a value"
113
+ jobs = observation["enqueued_jobs"].to_a.size
114
+ mail = observation["mail_deliveries"].to_a.size
115
+ effects = [jobs.positive? ? "#{jobs} job(s)" : nil, mail.positive? ? "#{mail} mail" : nil].compact
116
+
117
+ "- `#{verdict.case_id}` #{outcome}#{effects.empty? ? '' : ", #{effects.join(', ')}"}"
118
+ end
119
+
120
+ <<~MD
121
+
122
+ ## What was pinned
123
+
124
+ #{@stability.stable.size} of #{@corpus&.size} input cases were stable across
125
+ #{@stability.runs} separate probe boots and are therefore pinned.
126
+
127
+ #{lines.empty? ? '_Nothing._' : lines.join("\n")}
128
+
129
+ #{@written ? "Pinned file: `#{@written.spec_path}`" : "This pass did not write the pin itself; run `pinspec pin` for that."}
130
+ MD
131
+ end
132
+
133
+ def what_was_refused
134
+ return nil if @stability.nil? || @stability.unstable.empty?
135
+
136
+ lines = @stability.unstable.map do |verdict|
137
+ excerpt = verdict.diff.to_s.lines.first(4).map { |line| " #{line.chomp}" }.join("\n")
138
+
139
+ "- `#{verdict.case_id}` - **#{verdict.cause}**\n#{excerpt}"
140
+ end
141
+
142
+ <<~MD
143
+
144
+ ## What was NOT pinned
145
+
146
+ These cases produced different results on two runs of the same code, so
147
+ pinning them would freeze an accident.
148
+
149
+ #{lines.join("\n")}
150
+ MD
151
+ end
152
+
153
+ def isolation_note
154
+ return nil if @plan.nil?
155
+
156
+ if @plan.isolation == :truncation
157
+ <<~MD
158
+
159
+ ## Isolation: truncation
160
+
161
+ This suite does not wrap examples in a transaction (#{@profile.isolation_source}),
162
+ so `after_commit` callbacks **do** fire - during the capture and in the
163
+ emitted spec alike. The capture therefore wrote to the test database and
164
+ truncated afterwards.
165
+ MD
166
+ else
167
+ note = @profile.after_commit_models.empty? ? "" : <<~EXTRA
168
+
169
+ #{@profile.after_commit_models.size} model(s) declare `after_commit`
170
+ (#{@profile.after_commit_models.map(&:model).uniq.join(', ')}). Under this
171
+ regime those callbacks never fire - not in the capture, and not in the
172
+ emitted spec. pinspec does not fake them, so this is a real and
173
+ documented divergence from production.
174
+ EXTRA
175
+
176
+ <<~MD
177
+
178
+ ## Isolation: transaction
179
+
180
+ Every case ran inside a transaction that was rolled back (#{@profile.isolation_source}).
181
+ #{note}
182
+ MD
183
+ end
184
+ end
185
+
186
+ def coverage_caveats
187
+ caveats = []
188
+ caveats << seq_caveat
189
+ caveats << truncated_caveat
190
+ caveats << quarantine_caveat
191
+ caveats << clock_caveat
192
+ caveats = caveats.compact
193
+
194
+ return nil if caveats.empty?
195
+
196
+ <<~MD
197
+
198
+ ## Coverage caveats
199
+
200
+ Places where a pin asserts less than it appears to.
201
+
202
+ #{caveats.map { |caveat| "- #{caveat}" }.join("\n")}
203
+ MD
204
+ end
205
+
206
+ def seq_caveat
207
+ count = rendered_observations.scan('"seq"').size
208
+ return nil if count.zero?
209
+
210
+ "#{count} value(s) are pinned as `{\"t\":\"seq\"}` - an autoincrement-shaped " \
211
+ "integer that could not be resolved to a record. The pin asserts an integer " \
212
+ "is present and nothing about which one, because sequence values are not " \
213
+ "reproducible."
214
+ end
215
+
216
+ def truncated_caveat
217
+ count = rendered_observations.scan('"truncated"').size
218
+ return nil if count.zero?
219
+
220
+ "#{count} value(s) were truncated at the serializer's depth limit, so anything " \
221
+ "deeper than that is unpinned."
222
+ end
223
+
224
+ def quarantine_caveat
225
+ return nil if @stability.nil?
226
+
227
+ quarantined = @stability.unstable.select { |verdict| verdict.cause == :setup_error }
228
+ return nil if quarantined.empty?
229
+
230
+ "#{quarantined.size} case(s) could not have a world built for them and were " \
231
+ "dropped rather than pinned."
232
+ end
233
+
234
+ def clock_caveat
235
+ return nil unless @target&.clock_dependent?
236
+
237
+ sites = @target.clock_sites.map { |site| "`#{site.call}` (line #{site.line})" }.join(", ")
238
+
239
+ "The target reads the process clock at #{sites}. `Time.zone` does not govern " \
240
+ "those, so these pins hold only under `TZ=#{@plan&.env_fingerprint&.dig(:tz)}`. " \
241
+ "The emitted spec guards this rather than letting it pass for the wrong reason."
242
+ end
243
+
244
+ def redactions
245
+ clusters = import_steps
246
+ redacted = clusters.flat_map { |step| Array(step.payload[:redacted]) }.uniq
247
+ return nil if clusters.empty?
248
+
249
+ <<~MD
250
+
251
+ ## Personal data
252
+
253
+ #{clusters.size} row(s) were imported from a real database. Rewritten
254
+ attributes: #{redacted.empty? ? 'none' : redacted.map { |name| "`#{name}`" }.join(', ')}.
255
+
256
+ Rewrites preserve **domain and length** - an email keeps its domain and its
257
+ character count - so a target that routes on a domain or validates a length
258
+ sees the same behaviour it always did. A redactor that changed behaviour
259
+ would be worse than none, because it would look correct.
260
+
261
+ Honest limit: read detection scans the target's own file. It cannot see a
262
+ transitive callee, so no warning is not proof of no read.
263
+ MD
264
+ end
265
+
266
+ def provenance
267
+ steps = import_steps
268
+ return nil if steps.empty?
269
+
270
+ rows = steps.map { |step| "| `#{step.payload[:name]}` | #{step.payload[:model]} | `#{step.payload[:source]}` |" }
271
+
272
+ <<~MD
273
+
274
+ ## Imported row provenance
275
+
276
+ | ref | model | source |
277
+ |---|---|---|
278
+ #{rows.join("\n")}
279
+
280
+ Sources are hashed. A spec file committed to a repository does not map its
281
+ fixtures back to production row ids.
282
+ MD
283
+ end
284
+
285
+ def hazards
286
+ warnings = @profile.warnings
287
+ return nil if warnings.empty?
288
+
289
+ <<~MD
290
+
291
+ ## Application hazards
292
+
293
+ Found while profiling the application, independently of this target.
294
+
295
+ #{warnings.map { |warning| "- #{warning}" }.join("\n\n")}
296
+ MD
297
+ end
298
+
299
+ def mutation_scores
300
+ return nil if @scores.nil?
301
+
302
+ rows = @scores.scores.map do |score|
303
+ value = score.score.nil? ? "not scored" : "#{score.score}% (#{score.verdict})"
304
+
305
+ "| #{score.aspect} | #{value} | #{score.killed || '-'} | #{score.survived || '-'} |"
306
+ end
307
+
308
+ gaps = @scores.surviving_all_aspects
309
+
310
+ <<~MD
311
+
312
+ ## Mutation scores, by aspect
313
+
314
+ Each aspect is graded separately because they are blind to different
315
+ things: a return pin does not notice a deleted `perform_later`, and a job
316
+ pin does not notice the arithmetic.
317
+
318
+ | aspect | score | killed | survived |
319
+ |---|---|---|---|
320
+ #{rows.join("\n")}
321
+
322
+ #{gaps.empty? ? 'No mutant survived every aspect: together, these pins cover the target.' : "#{gaps.size} mutant(s) survived every aspect, which is the real gap:\n" + gaps.map { |m| "- `#{m['operator']}` at line #{m['line']} - `#{m['token']}`" }.join("\n")}
323
+ MD
324
+ end
325
+
326
+ def footer
327
+ <<~MD
328
+
329
+ ---
330
+
331
+ Generated by pinspec #{VERSION}. `pinspec pin` regenerates the pin and this
332
+ report; `pinspec validate` rewrites it with mutation scores. Neither file is
333
+ meant to be edited by hand.
334
+ MD
335
+ end
336
+
337
+ def import_steps
338
+ @plan ? @plan.steps_of(:import_record) : []
339
+ end
340
+
341
+ def rendered_observations
342
+ return "" if @stability.nil?
343
+
344
+ @rendered_observations ||= @stability.stable.map { |verdict| verdict.observation.to_s }.join
345
+ end
346
+ end
347
+ end
348
+ end