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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +235 -0
- data/README.md +191 -0
- data/bin/holdfast +4 -0
- data/lib/holdfast/callers.rb +124 -0
- data/lib/holdfast/change.rb +59 -0
- data/lib/holdfast/check.rb +473 -0
- data/lib/holdfast/cli.rb +105 -0
- data/lib/holdfast/draft.rb +303 -0
- data/lib/holdfast/drawn.rb +186 -0
- data/lib/holdfast/harness.rb +90 -0
- data/lib/holdfast/inputs.rb +105 -0
- data/lib/holdfast/record.rb +132 -0
- data/lib/holdfast/runtime/check_support.rb +666 -0
- data/lib/holdfast/runtime/observe.rb +136 -0
- data/lib/holdfast/runtime/reach.rb +104 -0
- data/lib/holdfast/sequences/propose.rb +1506 -0
- data/lib/holdfast/sequences/run.rb +197 -0
- data/lib/holdfast/sequences/sequences.rb +1325 -0
- data/lib/holdfast/stages/diff.rb +213 -0
- data/lib/holdfast/stages/fill.rb +194 -0
- data/lib/holdfast/stages/fold.rb +256 -0
- data/lib/holdfast/stages/hunt.rb +176 -0
- data/lib/holdfast/stages/observe.rb +109 -0
- data/lib/holdfast/stages/select.rb +94 -0
- data/lib/holdfast/tools.rb +33 -0
- data/lib/holdfast/verdict.rb +69 -0
- data/lib/holdfast/version.rb +5 -0
- data/lib/holdfast.rb +44 -0
- data/skills/holdfast/SKILL.md +96 -0
- metadata +122 -0
|
@@ -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
|
data/lib/holdfast/cli.rb
ADDED
|
@@ -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
|