holdfast 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.
@@ -0,0 +1,473 @@
1
+ module Holdfast
2
+ # `holdfast check`: the agent reads the code and writes what it suspects;
3
+ # holdfast turns each suspicion into a spec that builds the world with the
4
+ # app's factories, runs the action (or races two), and checks `expect`.
5
+ # In a change it runs on base and on head; otherwise once on the tree.
6
+ # Each suspicion is one row: confirmed (the spec fails, and is kept in
7
+ # spec/holdfast/<branch>/), refuted (what was observed), or could_not_run (the
8
+ # exact error, and which part of `given` or `do` it came from).
9
+ module Check
10
+ KEYS = %w[says given do race pause expect].freeze
11
+ Suspicion = Data.define(:id, :says, :given, :acts, :pause, :expects, :calls, :errors, :source, :shared)
12
+ SUPPORT = %w[spec/holdfast spec/support spec/factories spec/rails_helper.rb spec/spec_helper.rb test/support test/factories test/test_helper.rb].freeze
13
+ SIDE_NUMBERS = { "base" => "c1", "head" => "c2" }.freeze
14
+ COMPARE = %i[== != < <= > >= === =~ include? eql? equal?].freeze
15
+
16
+ def self.run(root, files:, inline:, base: nil, here: false)
17
+ timings = {}
18
+ record = Harness.timed(timings, "record") { Record.load_or_learn(root) }
19
+ raise Holdfast::Error, "project setup failed (#{record.failure}); fix it, then holdfast run --learn" unless record.ok?
20
+
21
+ suspicions = parse(files, inline)
22
+ change = Change.read(root, base_ref: base || record.base_branch, named: [])
23
+ sides = change.changed? && !here ? { "base" => change.base, "head" => nil } : { "head" => nil }
24
+ place = Place.of(root)
25
+ dir = File.join(root, place.specs)
26
+ FileUtils.mkdir_p(dir)
27
+ File.write(File.join(dir, "holdfast_check.rb"), File.read(File.join(RUNTIME, "check_support.rb")))
28
+ suspicions.each { File.write(File.join(dir, "#{_1.id}_spec.rb"), spec(_1, record)) }
29
+ reports = Harness.timed(timings, "run") do
30
+ sides.map { |side, rev| Thread.new { [side, run_side(root, record, side, rev, suspicions, place)] } }.map(&:value).to_h
31
+ end
32
+ rows = suspicions.map { row(_1, reports, change, sides, record, place) }
33
+ header = ["check: #{suspicions.size} suspicion(s); #{scope_line(change, here)}",
34
+ "kept specs: #{place.specs}/ (#{place.label}); rows: #{place.out}/check.jsonl",
35
+ "seconds: #{timings.map { "#{_1} #{_2}" }.join(", ")}"]
36
+ [rows, header, place]
37
+ end
38
+
39
+ # Specs and outputs of one branch, so a branch switch neither mixes kept
40
+ # specs nor shows another branch's rows. A detached HEAD is keyed by its
41
+ # commit.
42
+ Place = Data.define(:key, :label) do
43
+ def self.of(root)
44
+ branch = Change.git(root, "rev-parse", "--abbrev-ref", "HEAD")
45
+ branch = nil if branch == "HEAD"
46
+ sha = Change.git(root, "rev-parse", "--short=7", "HEAD")
47
+ new(key: branch ? Holdfast.slug(branch) : sha, label: branch ? "branch #{branch}" : "detached HEAD #{sha}")
48
+ end
49
+
50
+ def specs = "spec/holdfast/#{key}"
51
+ def out = ".holdfast/check/#{key}"
52
+ end
53
+
54
+ # What the rows were checked on, so a run that compared with a base says
55
+ # which files made it a change, and one that did not says so.
56
+ def self.scope_line(change, here)
57
+ base = "#{change.base_label} (#{change.short(change.base)})"
58
+ return "no change: checks run on the current tree only (#{change.short(change.head)}); app, lib, config, db and the Gemfile match #{base}" unless change.changed?
59
+ return "--here: checks run on the current tree only (#{change.short(change.head)}); the change against #{base} is not compared" if here
60
+
61
+ files = Change.git(change.root, "diff", "--name-only", change.base, change.head, "--", *Holdfast::CODE).lines.map(&:strip)
62
+ "change: each runs on base #{base} and on the working tree (#{change.short(change.head)}); #{files.size} file(s) differ: #{files.first(5).join(", ")}#{" and #{files.size - 5} more" if files.size > 5}"
63
+ end
64
+
65
+ # A file holds a list of suspicions (or `suspicions:` with one); each
66
+ # --suspect is one suspicion as a YAML flow mapping.
67
+ def self.parse(files, inline)
68
+ raw = files.flat_map do |f|
69
+ doc = begin
70
+ YAML.safe_load(quote_ruby(File.read(f)), aliases: false)
71
+ rescue SystemCallError => e
72
+ raise Holdfast::Error, "#{f}: #{e.message.sub(/ @ \w+ - .*/, "")}"
73
+ rescue Psych::SyntaxError => e
74
+ raise Holdfast::Error, "#{f}: line #{e.line} column #{e.column}: #{e.problem}; a value that holds `: ` or starts with a quote must be quoted ('...')"
75
+ end
76
+ doc = doc["suspicions"] if doc.is_a?(Hash) && doc.key?("suspicions")
77
+ Array(doc).map { [_1, f] }
78
+ end
79
+ raw += inline.map { [YAML.safe_load(quote_ruby(_1)), "--suspect"] }
80
+ raise Holdfast::Error, "no suspicion given; pass a YAML file or --suspect" if raw.empty?
81
+
82
+ raw.each_with_index.map { |(h, from), i| suspicion(h, i, from) }
83
+ end
84
+
85
+ # `do: x.call(a: 1)` is Ruby, not a YAML mapping: a plain value of says,
86
+ # do, expect or pause is quoted before YAML reads it. So is an item of a
87
+ # block list under race or expect, and any other plain value that holds
88
+ # `: ` (a given written as Ruby), which YAML would refuse.
89
+ def self.quote_ruby(text)
90
+ quote = ->(s) { "'#{s.gsub("'", "''")}'" }
91
+ # In `expect: [no error, calls(X, :y).empty?]` each item is quoted,
92
+ # since `:y` in a YAML flow list is not a value.
93
+ text = text.gsub(/^(\s*(?:-\s+)?(?:race|expect):[ \t]+)\[(.*)\][ \t]*$/) do
94
+ "#{$1}[#{flow_items($2).map { |item| Prism.parse(item).value.statements.body.then { _1.size == 1 && _1.first.is_a?(Prism::StringNode) } ? item : quote.(item) }.join(", ")}]"
95
+ end
96
+ text = text.gsub(/^(\s*(?:-\s+)?(?:says|do|expect|pause):[ \t]+)(?!['"\[{|>])(\S.*?)[ \t]*$/) { "#{$1}#{quote.($2)}" }
97
+ text = text.gsub(/^(\s*(?:race|expect):[ \t]*\n)((?:\s+-[ \t]+.*\n?)+)/) do
98
+ $1 + $2.gsub(/^(\s+-[ \t]+)(?!['"\[{|>])(\S.*?)[ \t]*$/) { "#{$1}#{quote.($2)}" }
99
+ end
100
+ text.gsub(/^(\s+\w+:[ \t]+)(?!['"\[{|>&*!])(\S.*: .*?)[ \t]*$/) { "#{$1}#{quote.($2)}" }
101
+ end
102
+
103
+ # The items of a flow list, split at the commas outside brackets and
104
+ # quotes.
105
+ def self.flow_items(text)
106
+ items = [+""]
107
+ depth = 0
108
+ quote = nil
109
+ text.each_char do |c|
110
+ if quote
111
+ quote = nil if c == quote
112
+ elsif %w[" '].include?(c) then quote = c
113
+ elsif "([{".include?(c) then depth += 1
114
+ elsif ")]}".include?(c) then depth -= 1
115
+ elsif c == "," && depth.zero?
116
+ items << +""
117
+ next
118
+ end
119
+ items.last << c
120
+ end
121
+ items.map(&:strip).reject(&:empty?)
122
+ end
123
+
124
+ def self.suspicion(h, i, from)
125
+ raise Holdfast::Error, "suspicion #{i + 1} in #{from} is not a mapping" unless h.is_a?(Hash)
126
+
127
+ h = h.transform_keys(&:to_s)
128
+ unknown = h.keys - KEYS
129
+ raise Holdfast::Error, "suspicion #{i + 1}: unknown key(s) #{unknown.join(", ")}; the keys are #{KEYS.join(", ")}" if unknown.any?
130
+ raise Holdfast::Error, "suspicion #{i + 1}: give `do` (one action) or `race` (two)" unless h["do"].nil? ^ h["race"].nil?
131
+ raise Holdfast::Error, "suspicion #{i + 1}: `race` takes two actions" if h["race"] && Array(h["race"]).size != 2
132
+ raise Holdfast::Error, "suspicion #{i + 1}: `expect` is missing" if h["expect"].to_s.strip.empty? && !h["expect"].is_a?(Array)
133
+
134
+ # In a flow list (`expect: [a, x.where(id: 1).none?]`) YAML reads
135
+ # `id: 1` as a mapping; the Ruby is joined back.
136
+ ruby = ->(v) { v.is_a?(Hash) && v.size == 1 ? "#{v.keys.first}: #{v.values.first}" : v.to_s }
137
+ %w[do expect race].each { |k| h[k] = h[k].is_a?(Array) ? h[k].map(&ruby) : ruby.(h[k]) if h.key?(k) }
138
+ says = h["says"].to_s.strip
139
+ calls = []
140
+ errors = false
141
+ expects = Array(h["expect"]).map(&:to_s).map do |text|
142
+ e = expectation(text)
143
+ calls |= e[:calls]
144
+ errors ||= e[:errors]
145
+ e
146
+ end
147
+ acts = h["race"] ? h["race"].map(&:to_s) : [h["do"].to_s]
148
+ label = "suspicion #{i + 1}#{" (#{says})" unless says.empty?}"
149
+ acts.each { |a| ruby!(a, "#{label}: `#{h["race"] ? "race" : "do"}`") }
150
+ expects.each { |e| ruby!(e[:ruby], "#{label}: `expect: #{e[:text]}`") }
151
+ # `do` and `expect` share the locals `do` sets and the instance
152
+ # variables of the object it runs on; a name `given` already has stays
153
+ # the record, so the given blocks still read it.
154
+ shared = acts.flat_map { Prism.parse(_1).value.locals }.uniq - Hash(h["given"]).keys.map(&:to_sym)
155
+ set = acts.flat_map { ivars(_1, :write) }
156
+ unset = expects.flat_map { ivars(_1[:ruby], :read) }.uniq - set
157
+ raise Holdfast::Error, "#{label}: `expect` reads #{unset.join(", ")}, which no `do` sets; set it in `do`, or read the record by its `given` name" if unset.any?
158
+ Suspicion.new(id: "s#{i + 1}_#{Holdfast.slug(says.empty? ? acts.first : says).tr("-", "_")[0, 48].sub(/_\z/, "")}",
159
+ says:, given: Hash(h["given"]), acts:, pause: h["pause"]&.to_s,
160
+ expects:, calls:, errors:, source: from, shared:)
161
+ end
162
+
163
+ # A line that is not Ruby fails here, before any database work, naming
164
+ # the line, instead of as a spec rspec cannot load.
165
+ def self.ruby!(code, where)
166
+ error = Prism.parse(code).errors.first or return
167
+ raise Holdfast::Error, "#{where} is not Ruby (#{error.message} at column #{error.location.start_column + 1}): #{code}"
168
+ end
169
+
170
+ IVAR_NODES = { write: [Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode,
171
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode],
172
+ read: [Prism::InstanceVariableReadNode] }.freeze
173
+
174
+ def self.ivars(code, kind)
175
+ walk = ->(n) { [n, *n.compact_child_nodes.flat_map(&walk)] }
176
+ walk.(Prism.parse(code).value).select { |n| IVAR_NODES.fetch(kind).any? { n.is_a?(_1) } }.map(&:name)
177
+ end
178
+
179
+ # The few lines an agent writes most often, and Ruby for the rest. Each
180
+ # operand of a top-level `&&` or `||` may be a short form too, so
181
+ # `no error && fine.paid?` is two checks, not a call to `no`.
182
+ def self.expectation(text)
183
+ t = text.strip
184
+ pieces, ops = logic_split(t)
185
+ return short_form(t) || ruby_form(t) if ops.empty?
186
+
187
+ forms = pieces.map { short_form(_1) || ruby_form(_1) }
188
+ ruby = forms.each_with_index.map { |f, i| "#{" #{ops[i - 1]} " if i.positive?}#{short_form(f[:text]) ? "(#{f[:ruby]})" : f[:ruby]}" }.join
189
+ parts = if ops.uniq.size == 1 then forms.map { [_1[:text], _1[:ruby], _1[:observe] || observed_side(_1[:ruby])] }
190
+ else operands(ruby).map { [_1, _1, observed_side(_1)] }
191
+ end
192
+ { text: t, ruby:, observe: nil, parts:, calls: forms.flat_map { _1[:calls] }.uniq, errors: forms.any? { _1[:errors] } }
193
+ end
194
+
195
+ def self.short_form(t)
196
+ case t
197
+ when /\Ano error\z/i then { text: t, ruby: "error.nil?", observe: "error", calls: [], errors: true }
198
+ when /\Araises ([A-Z][\w:]*)\z/ then { text: t, ruby: "error.is_a?(#{$1})", observe: "error", calls: [], errors: true }
199
+ when /\Ano ([A-Z][\w:]*) (\w+)\z/
200
+ { text: t, ruby: "calls(#{$1}, :#{$2}).empty?", observe: "calls(#{$1}, :#{$2})", calls: [[$1, $2]], errors: false }
201
+ when /\A([A-Z][\w:]*) (\w+)(?: (once|twice|(\d+) times))?\z/
202
+ n = { "once" => 1, "twice" => 2 }[$3] || $4&.to_i
203
+ { text: t, ruby: n ? "calls(#{$1}, :#{$2}).size == #{n}" : "calls(#{$1}, :#{$2}).any?", observe: "calls(#{$1}, :#{$2})", calls: [[$1, $2]], errors: false }
204
+ end
205
+ end
206
+
207
+ def self.ruby_form(t)
208
+ parts = operands(t)
209
+ { text: t, ruby: t, observe: (observed_side(t) if parts.empty?), parts: parts.map { [_1, _1, observed_side(_1)] },
210
+ calls: [], errors: t.match?(/\berror(s?)\b/) }
211
+ end
212
+
213
+ # The operands of the `&&` and `||` outside brackets and quotes, and the
214
+ # operators between them. Text, not Prism: a short form is not Ruby.
215
+ def self.logic_split(text)
216
+ pieces = [+""]
217
+ ops = []
218
+ depth = 0
219
+ quote = nil
220
+ i = 0
221
+ while i < text.size
222
+ c = text[i]
223
+ if quote
224
+ quote = nil if c == quote && text[i - 1] != "\\"
225
+ elsif %w[" '].include?(c) then quote = c
226
+ elsif "([{".include?(c) then depth += 1
227
+ elsif ")]}".include?(c) then depth -= 1
228
+ elsif depth.zero? && %w[&& ||].include?(text[i, 2])
229
+ ops << text[i, 2]
230
+ pieces << +""
231
+ i += 2
232
+ next
233
+ end
234
+ pieces.last << c
235
+ i += 1
236
+ end
237
+ pieces = pieces.map(&:strip)
238
+ pieces.any?(&:empty?) ? [[text], []] : [pieces, ops]
239
+ end
240
+
241
+ def self.top_node(ruby)
242
+ node = Prism.parse(ruby).value.statements.body.first
243
+ node = node.body.body.first while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) && node.body.body.size == 1
244
+ node
245
+ end
246
+
247
+ # `a && b && c` is three operands, so a failed line can name the one
248
+ # that failed; a single comparison has none.
249
+ def self.operands(ruby)
250
+ node = top_node(ruby)
251
+ return [] unless node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
252
+
253
+ kind = node.class
254
+ flat = ->(n) { n.is_a?(kind) ? flat.(n.left) + flat.(n.right) : [n.slice] }
255
+ flat.(node)
256
+ rescue StandardError
257
+ []
258
+ end
259
+
260
+ # For `a.b == c` show what `a.b` was.
261
+ def self.observed_side(ruby)
262
+ node = top_node(ruby)
263
+ return ruby unless node.is_a?(Prism::CallNode) && COMPARE.include?(node.name) && node.receiver
264
+
265
+ node.receiver.slice
266
+ rescue StandardError
267
+ ruby
268
+ end
269
+
270
+ RELATIVE = /\A([+-]?)(\d+)\s*(s|m|min|h|d|w)\z/
271
+ UNITS = { "s" => "seconds", "m" => "minutes", "min" => "minutes", "h" => "hours", "d" => "days", "w" => "weeks" }.freeze
272
+
273
+ def self.value(v, names)
274
+ case v
275
+ when RELATIVE
276
+ sign, n, unit = $1, $2.to_i, UNITS.fetch($3)
277
+ "#{n}.#{n == 1 ? unit.chomp("s") : unit}.#{sign == "-" ? "ago" : "from_now"}"
278
+ when String then names.include?(v) ? v : v.inspect
279
+ when Hash
280
+ raise Holdfast::Error, "a nested value in `given` is {ruby: \"...\"}" unless v.keys.map(&:to_s) == ["ruby"]
281
+
282
+ v.values.first.to_s
283
+ when Array then "[#{v.map { value(_1, names) }.join(", ")}]"
284
+ else v.inspect
285
+ end
286
+ end
287
+
288
+ def self.given_lines(given)
289
+ names = []
290
+ given.map do |name, attrs|
291
+ name = name.to_s
292
+ # `{ruby: "..."}` in place of the attributes is the same Ruby a bare
293
+ # string is; any other plain value is that value.
294
+ attrs = attrs.values.first.to_s if attrs.is_a?(Hash) && attrs.keys.map(&:to_s) == ["ruby"]
295
+ if attrs.is_a?(String) || !(attrs.is_a?(Hash) || attrs.nil?)
296
+ names << name
297
+ ruby = attrs.is_a?(String) ? attrs : attrs.inspect
298
+ next "build :#{name}, #{ruby.inspect} do #{ruby} end"
299
+ end
300
+ attrs = Hash(attrs).transform_keys(&:to_s)
301
+ opts = []
302
+ opts << "factory: :#{attrs.delete("factory")}" if attrs.key?("factory")
303
+ opts << "traits: #{Array(attrs.delete("traits")).map { ":#{_1}" }.join(", ").then { "[#{_1}]" }}" if attrs.key?("traits")
304
+ opts += attrs.map { |k, v| "#{k}: #{value(v, names)}" }
305
+ names << name
306
+ "make #{[":#{name}", *opts].join(", ")}"
307
+ end
308
+ end
309
+
310
+ def self.spec(s, record)
311
+ check = lambda do |e|
312
+ observe = e[:observe] ? ", observe: proc { #{e[:observe]} }" : ""
313
+ parts = Array(e[:parts]).map { |text, ruby, seen| "[#{text.inspect}, proc { #{ruby} }, #{seen ? "proc { #{seen} }" : "nil"}]" }
314
+ parts = parts.any? ? ",\n parts: [#{parts.join(", ")}]" : ""
315
+ "expectation(#{e[:text].inspect}#{observe}#{parts}) { #{e[:ruby]} }"
316
+ end
317
+ calls = s.calls.map { "[#{_1}, :#{_2}]" }
318
+ <<~RUBY
319
+ # holdfast check: #{s.says}
320
+ # This example fails while the suspicion holds. Keep it once the code is fixed.
321
+ require #{record.helper.inspect}
322
+ require_relative "holdfast_check"
323
+
324
+ RSpec.describe "suspicion #{s.id}", type: :request do
325
+ include HoldfastCheck
326
+ #{" self.use_transactional_tests = false\n" if s.acts.size > 1}
327
+ it #{s.says.inspect} do
328
+ #{" #{s.shared.join(" = ")} = nil # set by `do`, read by `expect`\n" if s.shared.any?} holdfast_suspicion #{s.id.inspect},
329
+ given: proc {
330
+ #{given_lines(s.given).map { " #{_1}" }.join("\n")}
331
+ },
332
+ acts: [
333
+ #{s.acts.map { " act(#{_1.inspect}) { #{_1} }" }.join(",\n")}
334
+ ],#{"\n pause: #{s.pause.inspect}," if s.acts.size > 1 && s.pause}#{"\n calls: [#{calls.join(", ")}]," if calls.any?}#{"\n errors: true," if s.errors}#{"\n shared: binding," if s.shared.any?}
335
+ expects: [
336
+ #{s.expects.map { " #{check.(_1)}" }.join(",\n")}
337
+ ]
338
+ end
339
+ end
340
+ RUBY
341
+ end
342
+
343
+ def self.run_side(root, record, side, rev, suspicions, place)
344
+ out = File.join(root, place.out, "runs", side)
345
+ FileUtils.rm_rf(out) if out.include?("/.holdfast/check/")
346
+ FileUtils.mkdir_p(out)
347
+ tree = root
348
+ if rev
349
+ tree = File.join(Dir.mktmpdir("holdfast-check"), "tree")
350
+ Change.git(root, "worktree", "add", "--quiet", "--detach", tree, rev)
351
+ # Both sides build the world with the same factories and helpers:
352
+ # test support is not the behavior under comparison.
353
+ SUPPORT.each do |part|
354
+ src = File.join(root, part)
355
+ next unless File.exist?(src)
356
+
357
+ FileUtils.rm_rf(File.join(tree, part)) if File.directory?(src)
358
+ FileUtils.mkdir_p(File.dirname(File.join(tree, part)))
359
+ FileUtils.cp_r(src, File.join(tree, part))
360
+ end
361
+ end
362
+ env = Record.side_env(record.data, SIDE_NUMBERS.fetch(side)).merge("BUNDLE_GEMFILE" => File.join(tree, "Gemfile"), "HOLDFAST_CHECK_OUT" => out)
363
+ log = []
364
+ if record.side_setup && !schema_current?(root, tree, side)
365
+ o, st = Open3.capture2e(env, "/bin/sh", "-c", record.side_setup, chdir: tree)
366
+ log << o
367
+ return { "failure" => "setup on #{side}: `#{record.side_setup}` exited #{st.exitstatus}: #{Record.failure_lines(o).join(" ")}" } unless st.success?
368
+
369
+ schema_mark(root, tree, side)
370
+ end
371
+ files = suspicions.map { "#{place.specs}/#{_1.id}_spec.rb" }
372
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
373
+ o, = Open3.capture2e(env, "/bin/sh", "-c", "#{record.bundle}rspec #{files.join(" ")} --format progress --format json --out #{File.join(out, "rspec.json")}", chdir: tree)
374
+ File.write(File.join(out, "rspec.log"), o)
375
+ rspec = JSON.parse(File.read(File.join(out, "rspec.json"))) rescue {}
376
+ reports = suspicions.to_h { |s| [s.id, (JSON.parse(File.read(File.join(out, "#{s.id}.json"))) rescue nil)] }
377
+ examples = Array(rspec["examples"]).to_h { [File.basename(_1["file_path"].to_s, "_spec.rb"), _1] }
378
+ { "rev" => rev, "out" => out, "reports" => reports, "examples" => examples, "log" => o,
379
+ "seconds" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1) }
380
+ ensure
381
+ if rev && tree && tree != root && tree.start_with?(Dir.tmpdir)
382
+ system("git", "worktree", "remove", "--force", tree, chdir: root, out: File::NULL, err: File::NULL)
383
+ FileUtils.rm_rf(File.dirname(tree))
384
+ end
385
+ end
386
+
387
+ # A side's database is set up again only when its schema file changed.
388
+ def self.schema_digest(tree) = %w[db/schema.rb db/structure.sql].filter_map { File.file?(File.join(tree, _1)) && Digest::SHA1.file(File.join(tree, _1)).hexdigest }.join
389
+ def self.schema_mark_path(root, side) = File.join(Holdfast.dir(root), "check-#{side}.schema")
390
+ def self.schema_current?(root, tree, side) = File.file?(schema_mark_path(root, side)) && File.read(schema_mark_path(root, side)) == schema_digest(tree)
391
+ def self.schema_mark(root, tree, side) = File.write(schema_mark_path(root, side), schema_digest(tree))
392
+
393
+ def self.side_result(run, s)
394
+ return { "result" => "could_not_run", "phase" => "setup", "error" => run["failure"] } if run["failure"]
395
+
396
+ report = run["reports"][s.id]
397
+ ex = run["examples"][s.id]
398
+ return report if report && report["result"]
399
+
400
+ # The spec ended before it wrote a result: the first line rspec saw
401
+ # and the first frame of the kept spec stand in, never an empty reason.
402
+ msg = ex&.dig("exception", "message").to_s.lines.first.to_s.strip
403
+ msg = run["log"].to_s.lines.grep(/Error|error/).first.to_s.strip if msg.empty?
404
+ frame = Array(ex&.dig("exception", "backtrace")).find { _1.include?("spec/holdfast/") }
405
+ msg = "rspec printed nothing for it; read #{run["out"]}/rspec.log" if msg.empty?
406
+ { "result" => "could_not_run", "phase" => "spec", "error" => "the spec did not finish: #{ex&.dig("exception", "class")} #{msg[0, 400]}#{" at #{frame[%r{spec/holdfast/\S+}]}" if frame}".squeeze(" ") }
407
+ end
408
+
409
+ def self.describe(r)
410
+ case r["result"]
411
+ when "could_not_run" then "could not run (#{r["phase"] || "spec"}): #{r["error"]}"
412
+ when "confirmed" then "confirmed: #{r["why"]}"
413
+ else
414
+ seen = Array(r["checks"]).map { "#{_1["expect"]} holds#{" (observed #{_1["observed"]})" if _1["observed"]}" }.join("; ")
415
+ (r["why"] || seen).start_with?("refuted") ? (r["why"] || seen) : "refuted: #{r["why"] || seen}"
416
+ end
417
+ end
418
+
419
+ def self.check_line(c) = "#{c["expect"]} #{c["holds"] ? "holds" : "fails"}#{" (#{c["observed"]})" if c["observed"]}#{": #{c["failed"].join("; ")}" if c["failed"]}"
420
+
421
+ def self.row(s, reports, change, sides, record, place)
422
+ head = side_result(reports["head"], s)
423
+ evidence = []
424
+ evidence << "pause points tried: #{head["pauses"].join("; ")}" if head["pauses"]
425
+ Array(head["orders"]).sort_by { |label, _| label == head["order"] ? 0 : 1 }.each { |label, run| evidence << "#{label}: #{run["result"] == "could_not_run" ? "could not run: #{run["error"]}" : "#{Array(run["outcomes"]).join("; ")}#{"; #{run["paused"]}" if run["paused"]}; #{Array(run["checks"]).map { check_line(_1) }.join("; ")}"}" }
426
+ evidence += Array(head["outcomes"]).map { "ran: #{_1}" } unless head["orders"]
427
+ evidence += Array(head["checks"]).map { check_line(_1) } unless head["orders"]
428
+ head_only = false
429
+ if sides.key?("base")
430
+ base = side_result(reports["base"], s)
431
+ # A base that has no such method, constant, attribute or route
432
+ # predates the feature: it is not evidence either way.
433
+ absent = base["result"] == "could_not_run" && base["absent"] && base["absent"] != head["absent"]
434
+ head_only = (absent || base["result"] == "could_not_run") && head["result"] != "could_not_run"
435
+ line = if absent then "base cannot run it (feature absent: #{base["absent"]})"
436
+ elsif base["result"] == "could_not_run" then "base could not run it (#{base["phase"] || "spec"}: #{base["error"]})"
437
+ else
438
+ tail = if head["result"] == "confirmed" then base["result"] == "confirmed" ? "; it was there before the change" : "; the change brings it in"
439
+ elsif base["result"] == "confirmed" then "; the change removes it"
440
+ else ""
441
+ end
442
+ "#{describe(base)}#{tail}"
443
+ end
444
+ evidence.unshift("base #{change.short(change.base)}: #{line}")
445
+ end
446
+ ex = reports.dig("head", "examples", s.id)
447
+ spec_path = "#{place.specs}/#{s.id}_spec.rb"
448
+ # The database the head side ran on, so the line runs as printed even
449
+ # where the app's own test database was never created.
450
+ db = Record.side_env(record.data, SIDE_NUMBERS.fetch("head")).slice("DATABASE_URL", "TEST_ENV_NUMBER").map { "#{_1}=#{_2}" }.join(" ")
451
+ repro = "#{db} #{record.bundle}rspec #{spec_path}".strip
452
+ case head["result"]
453
+ when "confirmed"
454
+ failure = ex&.dig("exception", "message").to_s.strip
455
+ evidence << "spec output: #{failure.lines.first(4).map(&:strip).join(" ")}" unless failure.empty?
456
+ Verdict.of(target: s.says, stage: "check", kind: "confirmed", reason: "#{"confirmed on head only: " if head_only}#{head["why"]}", evidence:, repro:,
457
+ scope: spec_path, next_step: "keep #{spec_path}; it fails until the code is fixed")
458
+ when "refuted"
459
+ Verdict.of(target: s.says, stage: "check", kind: "refuted", reason: "#{"on head (base cannot run it): " if head_only}#{describe(head).delete_prefix("refuted: ")}", evidence:, scope: spec_path,
460
+ next_step: "nothing to fix for this suspicion; delete #{spec_path} or keep it as a test")
461
+ else
462
+ step = case head["phase"]
463
+ when ->(_) { head["absent"] } then "this tree has #{head["absent"]}; check the name, or run where the change that adds it is checked out"
464
+ when "given" then "fix the named part of `given` (an attribute, a factory: or a traits: key), then holdfast check again"
465
+ when "do" then "the action raised before `expect` ran; if that error is the bug, write `expect: no error`, else fix `do`"
466
+ when "pause" then "name a method the first action passes through as `pause: Class#method`"
467
+ else "read the error, fix the suspicion, then holdfast check again"
468
+ end
469
+ Verdict.of(target: s.says, stage: "check", kind: "could_not_run", reason: "#{head["phase"] || "spec"}: #{head["error"]}", evidence:, scope: spec_path, repro:, next_step: step)
470
+ end
471
+ end
472
+ end
473
+ end
@@ -0,0 +1,105 @@
1
+ require "optparse"
2
+ require "time"
3
+ require "prism"
4
+
5
+ module Holdfast
6
+ # The stated intent belongs to the branch it was given on, so a run on
7
+ # another branch does not show a stale one.
8
+ module Intent
9
+ def self.keep(root, text)
10
+ path = File.join(Holdfast.dir(root), "intent.json")
11
+ branch = Change.git(root, "rev-parse", "--abbrev-ref", "HEAD")
12
+ File.write(path, JSON.generate({ "branch" => branch, "text" => text })) if text
13
+ kept = JSON.parse(File.read(path)) rescue {}
14
+ kept["branch"] == branch ? kept["text"] : nil
15
+ end
16
+ end
17
+
18
+ # `run` is the entry: it decides what to check from the working tree. `show`
19
+ # prints the last summary again. Exit 2 when a row needs a decision
20
+ # (unintended_change or bug_suspect), 1 on a usage error, 0 otherwise.
21
+ module CLI
22
+ USAGE = <<~TEXT
23
+ usage:
24
+ holdfast run [--base REF] [--target NAME]... [--only NAME]... [--intent TEXT] [--learn] [--sequences] [--observe] [--budget SECONDS]
25
+ holdfast check [FILE]... [--suspect YAML]... [--base REF] [--here]
26
+ holdfast show
27
+ run checks the working tree against REF (default: the record's base branch).
28
+ With nothing changed and --target, it pins NAME's current behavior first.
29
+ With nothing changed and no target, it hunts with sequences (--budget caps the seconds).
30
+ --sequences also hunts over the changed areas, before a pull request.
31
+ --observe runs the areas' action orderings on base and head and reports each step that differs.
32
+ check turns each suspicion (says, given, do or race, expect) into a spec and runs it on base
33
+ and head (or once on the tree with no change, or with --here); confirmed, refuted or could_not_run.
34
+ --repo DIR run in DIR (default: the working directory)
35
+ TEXT
36
+
37
+ def self.start(argv)
38
+ opts = { repo: Dir.pwd, named: [], only: [], suspects: [] }
39
+ args = OptionParser.new do |o|
40
+ o.on("--repo DIR") { opts[:repo] = File.expand_path(_1) }
41
+ o.on("--base REF") { opts[:base] = _1 }
42
+ o.on("--target NAME") { opts[:named] << _1 }
43
+ o.on("--only NAME") { opts[:only] << _1 }
44
+ o.on("--intent TEXT") { opts[:intent] = _1 }
45
+ o.on("--learn") { opts[:learn] = true }
46
+ o.on("--sequences") { opts[:sequences] = true }
47
+ o.on("--observe") { opts[:observe] = true }
48
+ o.on("--budget SECONDS", Integer) { opts[:budget] = _1 }
49
+ o.on("--suspect YAML") { opts[:suspects] << _1 }
50
+ o.on("--here") { opts[:here] = true }
51
+ end.parse(argv)
52
+ Dir.chdir(opts[:repo]) do
53
+ case args.shift
54
+ when "run" then run(opts)
55
+ when "show" then show
56
+ when "check" then check(opts, args)
57
+ else
58
+ $stderr.puts USAGE
59
+ 1
60
+ end
61
+ end
62
+ rescue OptionParser::ParseError, Holdfast::Error, *Tools.errors => e
63
+ $stderr.puts "holdfast: #{e.message}"
64
+ 1
65
+ end
66
+
67
+ def self.run(opts)
68
+ root = Dir.pwd
69
+ dir = Holdfast.dir(root)
70
+ FileUtils.mkdir_p(dir)
71
+ intent = Intent.keep(root, opts[:intent])
72
+ result = Harness.run(root, base: opts[:base], named: opts[:named], relearn: opts[:learn], only: opts[:only],
73
+ sequences: opts[:sequences], budget: opts[:budget], observe: opts[:observe], intent:)
74
+ header = [result.change.header, ("intent: #{intent}" if intent),
75
+ *result.notes.map { "note: #{_1}" },
76
+ "seconds: #{result.seconds.map { "#{_1} #{_2}" }.join(", ")}"].compact
77
+ File.write(File.join(dir, "verdicts.jsonl"), result.verdicts.map(&:to_json_line).join("\n") + "\n")
78
+ File.write(File.join(dir, "summary.md"), Summary.markdown(header, result.verdicts, mode: result.change.mode))
79
+ show
80
+ result.verdicts.any? { %w[unintended_change bug_suspect].include?(_1.kind) } ? 2 : 0
81
+ end
82
+
83
+ def self.check(opts, files)
84
+ root = Dir.pwd
85
+ FileUtils.mkdir_p(Holdfast.dir(root))
86
+ rows, header, place = Check.run(root, files: files.map { File.expand_path(_1) }, inline: opts[:suspects], base: opts[:base], here: opts[:here])
87
+ out = File.join(root, place.out)
88
+ FileUtils.mkdir_p(out)
89
+ File.write(File.join(out, "check.jsonl"), rows.map(&:to_json_line).join("\n") + "\n")
90
+ File.write(File.join(out, "check.md"), Summary.markdown(header, rows, rows: "check.jsonl"))
91
+ puts File.read(File.join(out, "check.md"))
92
+ puts "rows: #{place.out}/check.jsonl; kept specs: #{place.specs}/"
93
+ rows.any? { _1.kind == "confirmed" } ? 2 : 0
94
+ end
95
+
96
+ def self.show
97
+ path = File.join(Holdfast.dir(Dir.pwd), "summary.md")
98
+ raise Holdfast::Error, "no run yet; run holdfast run" unless File.file?(path)
99
+
100
+ puts File.read(path)
101
+ puts "rows: .holdfast/verdicts.jsonl"
102
+ 0
103
+ end
104
+ end
105
+ end