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,213 @@
|
|
|
1
|
+
module Holdfast
|
|
2
|
+
module Stages
|
|
3
|
+
# Runs every test that speaks for a target on base and on head (jitcatch's
|
|
4
|
+
# runner) and returns one Outcome per test, with the app lines each test
|
|
5
|
+
# file ran on each side. Fold decides what an outcome says about a target;
|
|
6
|
+
# Diff only runs and classifies. Four sources:
|
|
7
|
+
# kept base's copy of each selected test the change did not edit
|
|
8
|
+
# authored head's copy of each test file the change added or edited;
|
|
9
|
+
# it states the new behavior, so it counts as meant to change
|
|
10
|
+
# evidence .holdfast/evidence/<target>/ tests the agent wrote
|
|
11
|
+
# pin the pin tests Fill wrote (inputs or drafted)
|
|
12
|
+
# A path segment named `change` marks a test whose difference is intended.
|
|
13
|
+
module Diff
|
|
14
|
+
Source = Data.define(:path, :targets, :origin, :edited)
|
|
15
|
+
Outcome = Data.define(:source, :test, :kind, :reason, :evidence, :repro, :next_step) do
|
|
16
|
+
def place = "#{Diff.shown_path(source)}#{":#{test["line"]}" if test["line"]}"
|
|
17
|
+
end
|
|
18
|
+
Result = Data.define(:outcomes, :reach, :failure)
|
|
19
|
+
# Where read_reach keeps the values a test file's inputs wrote.
|
|
20
|
+
DREW = "(drew)"
|
|
21
|
+
# Prefix of the key that holds the branch arms a test took in one file,
|
|
22
|
+
# each as "line:column-line:column", the start and end of the arm.
|
|
23
|
+
ARMS = "(arms)"
|
|
24
|
+
DISCARDS = {
|
|
25
|
+
"signature_change" => "the test calls something whose arguments the change altered",
|
|
26
|
+
"load_error" => "the test does not load on head",
|
|
27
|
+
"no_table" => "a table the test needs is missing on one side",
|
|
28
|
+
"no_database" => "the test database was not reachable on one side",
|
|
29
|
+
"timeout" => "the test timed out on one side"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def self.run(change, record, targets, pins, values: [])
|
|
33
|
+
work = File.join(Holdfast.dir(change.root), "runs", "diff")
|
|
34
|
+
FileUtils.rm_rf(work) if work.end_with?("/.holdfast/runs/diff")
|
|
35
|
+
reach_dir = File.join(work, "reach")
|
|
36
|
+
FileUtils.mkdir_p(reach_dir)
|
|
37
|
+
sources = gather(change, record, targets, pins, work)
|
|
38
|
+
outcomes = []
|
|
39
|
+
failure = nil
|
|
40
|
+
sources.each_slice(JitCatch::Runner::FILE_CAP).with_index do |chunk, i|
|
|
41
|
+
dir = File.join(work, "run#{i}")
|
|
42
|
+
chunk.each { |s| FileUtils.mkdir_p(File.dirname(File.join(dir, s.path))) && FileUtils.cp(File.join(work, "all", s.path), File.join(dir, s.path)) }
|
|
43
|
+
got, failure = compare(change, record, chunk, dir, reach_dir, values)
|
|
44
|
+
outcomes += got
|
|
45
|
+
end
|
|
46
|
+
Result.new(outcomes:, reach: read_reach(reach_dir), failure:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# test path (as in the run directory) => { "1" => {file => [lines]}, "2" => ... },
|
|
50
|
+
# and the same for "<test path>:<example line>" when the recorder could
|
|
51
|
+
# tell the examples of a file apart.
|
|
52
|
+
# What one outcome ran on one side ("1" base, "2" head): its own
|
|
53
|
+
# example's lines when they were kept, else its whole file's.
|
|
54
|
+
def self.ran(reach, outcome, side)
|
|
55
|
+
own = "#{outcome.source.path}:#{outcome.test["line"]}"
|
|
56
|
+
(reach.key?(own) && reach[own].key?(side) ? reach[own] : reach.fetch(outcome.source.path, {})).fetch(side, {})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.read_reach(dir)
|
|
60
|
+
reach = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = Hash.new { |h3, k3| h3[k3] = [] } } }
|
|
61
|
+
Dir.glob(File.join(dir, "reach-*.json")).each do |file|
|
|
62
|
+
row = JSON.parse(File.read(file))
|
|
63
|
+
row["hits"].each { |path, lines| reach[row["test"]][row["side"]][path] |= lines }
|
|
64
|
+
reach[row["test"]][row["side"]][DREW] |= Array(row["drew"])
|
|
65
|
+
Hash(row["arms"]).each { |path, arms| reach[row["test"]][row["side"]][ARMS + path] |= arms }
|
|
66
|
+
Hash(row["examples"]).each do |line, seen|
|
|
67
|
+
own = reach["#{row["test"]}:#{line}"][row["side"]]
|
|
68
|
+
seen["hits"].each { |path, lines| own[path] |= lines }
|
|
69
|
+
seen["arms"].each { |path, arms| own[ARMS + path] |= arms }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
reach
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.gather(change, record, targets, pins, work)
|
|
76
|
+
all = File.join(work, "all")
|
|
77
|
+
edited = Change.git(change.root, "diff", "--name-only", "--diff-filter=AM", change.base, change.head, "--", "spec", "test")
|
|
78
|
+
.lines.map(&:strip).select { _1.end_with?(record.test_suffix) }
|
|
79
|
+
by_test = Hash.new { |h, k| h[k] = [] }
|
|
80
|
+
targets.each { |t| t.tests.select { _1.end_with?(record.test_suffix) }.each { by_test[_1] << t.name } }
|
|
81
|
+
sources = []
|
|
82
|
+
(by_test.keys - edited).each do |file|
|
|
83
|
+
text = show(change, change.base, file) or next
|
|
84
|
+
sources << write(all, "kept/#{file}", text, by_test[file], "kept")
|
|
85
|
+
end
|
|
86
|
+
edited.each do |file|
|
|
87
|
+
sources << write(all, "change/authored/#{file}", show(change, change.head, file), by_test[file], "authored", edited_lines(change, file))
|
|
88
|
+
end
|
|
89
|
+
targets.each do |t|
|
|
90
|
+
dir = File.join(Holdfast.dir(change.root), "evidence", Holdfast.slug(t.name))
|
|
91
|
+
Dir.glob("**/*{_spec,_test}.rb", base: dir).each do |file|
|
|
92
|
+
sources << write(all, "t-#{Holdfast.slug(t.name)}/evidence/#{file}", File.read(File.join(dir, file)), [t.name], "evidence", nil)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
pins.each do |p|
|
|
96
|
+
Dir.glob("**/*{_spec,_test}.rb", base: p.dir).each do |file|
|
|
97
|
+
sources << write(all, "t-#{Holdfast.slug(p.target.name)}/#{p.origin}-pin/#{file}", File.read(File.join(p.dir, file)), [p.target.name], "#{p.origin} pin", nil)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
sources
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.show(change, rev, file)
|
|
104
|
+
out, status = Open3.capture2("git", "show", "#{rev}:#{file}", chdir: change.root)
|
|
105
|
+
status.success? ? out : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.write(all, path, text, targets, origin, edited = nil)
|
|
109
|
+
FileUtils.mkdir_p(File.dirname(File.join(all, path)))
|
|
110
|
+
File.write(File.join(all, path), text)
|
|
111
|
+
Source.new(path:, targets:, origin:, edited:)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Head line numbers the change added or rewrote in one test file.
|
|
115
|
+
def self.edited_lines(change, file)
|
|
116
|
+
diff = Change.git(change.root, "diff", "--unified=0", change.base, change.head, "--", file)
|
|
117
|
+
diff.scan(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/).flat_map { |start, count| (start.to_i...(start.to_i + (count || 1).to_i)).to_a }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.compare(change, record, chunk, dir, reach_dir, values = [])
|
|
121
|
+
env_names = record.env_names + %w[RUBYOPT HOLDFAST_REACH_DIR HOLDFAST_VALUES] + (record.database_url ? ["DATABASE_URL"] : [])
|
|
122
|
+
saved = ENV.to_h.slice("RUBYOPT", "HOLDFAST_REACH_DIR", "DATABASE_URL", "HOLDFAST_VALUES")
|
|
123
|
+
ENV["HOLDFAST_VALUES"] = JSON.generate(values)
|
|
124
|
+
ENV["RUBYOPT"] = "-r#{File.join(RUNTIME, "reach.rb")}"
|
|
125
|
+
ENV["HOLDFAST_REACH_DIR"] = reach_dir
|
|
126
|
+
ENV["DATABASE_URL"] = record.database_url if record.database_url
|
|
127
|
+
doc = JitCatch::Assessor.apply(JitCatch::Runner.run(change.root, change.base, change.head, dir, env: env_names.uniq,
|
|
128
|
+
setup: record.side_setup, timeout: 60, number_database_url: !record.database_url.nil?))
|
|
129
|
+
by_path = chunk.to_h { [_1.path, _1] }
|
|
130
|
+
starts = doc["tests"].group_by { _1["file"] }.transform_values { |ts| ts.filter_map { _1["line"] }.sort }
|
|
131
|
+
outcomes = doc["tests"].map do |test|
|
|
132
|
+
source = by_path[test["file"]] || Source.new(path: test["file"], targets: [], origin: "unknown", edited: nil)
|
|
133
|
+
meant = test["file"].split("/").include?("change") && (source.edited.nil? || example_edited?(test, starts[test["file"]], source.edited))
|
|
134
|
+
# An example the change edited that still passes on base states kept
|
|
135
|
+
# behavior in new words; only marks the agent wrote can be wrong.
|
|
136
|
+
meant = false if source.origin == "authored" && test["classification"] == "no_signal"
|
|
137
|
+
kind, reason, next_step = classify(test, meant)
|
|
138
|
+
Outcome.new(source:, test:, kind:, reason:, evidence: evidence(test), next_step: next_step || MARK.fetch(source.origin.split.first, MARK["evidence"]),
|
|
139
|
+
repro: "#{record.bundle}rspec #{File.join(dir, test["file"]).delete_prefix("#{change.root}/")}#{":#{test["line"]}" if test["line"]}")
|
|
140
|
+
end
|
|
141
|
+
[outcomes, nil]
|
|
142
|
+
rescue JitCatch::EnvironmentError => e
|
|
143
|
+
[[], "setup failed on #{e.doc["setup_failed"].map { "#{_1["side"]}: #{Array(_1["stderr"]).last}" }.join("; ")}"]
|
|
144
|
+
ensure
|
|
145
|
+
%w[RUBYOPT HOLDFAST_REACH_DIR DATABASE_URL HOLDFAST_VALUES].each { ENV.delete(_1) }
|
|
146
|
+
ENV.update(saved)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.shown_path(source)
|
|
150
|
+
case source.origin
|
|
151
|
+
when "kept" then source.path.delete_prefix("kept/")
|
|
152
|
+
when "authored" then source.path.delete_prefix("change/authored/")
|
|
153
|
+
else source.path
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
MARK = {
|
|
158
|
+
"kept" => "fix the code; if the intent covers it, edit this test in the change to state the new behavior",
|
|
159
|
+
"authored" => "fix the code; if the intent covers it, edit this example in the change to state the new behavior",
|
|
160
|
+
"drafted" => "fix the code; if the intent covers it, copy the draft to evidence/ and mark the input \"change\": true or add a change_when",
|
|
161
|
+
"inputs" => "fix the code; if the intent covers it, mark the input \"change\": true or cover it with change_when",
|
|
162
|
+
"evidence" => "fix the code; if the intent covers it, move the test under change/"
|
|
163
|
+
}.freeze
|
|
164
|
+
|
|
165
|
+
def self.example_edited?(test, starts, edited)
|
|
166
|
+
return edited.any? unless test["line"]
|
|
167
|
+
|
|
168
|
+
stop = starts.find { _1 > test["line"] } || Float::INFINITY
|
|
169
|
+
edited.any? { (test["line"]...stop).cover?(_1) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.classify(test, meant)
|
|
173
|
+
head_passes = %w[pass skip].include?(test.dig("head", "status"))
|
|
174
|
+
case [test["classification"], test["disposition"]]
|
|
175
|
+
in ["fail_on_base", _] if head_passes && no_base?(test)
|
|
176
|
+
# Base cannot even name what the test calls, so its failure there
|
|
177
|
+
# says nothing about behavior; what the test exercises is compared
|
|
178
|
+
# through the existing callers and the pins instead.
|
|
179
|
+
["no_base", "new spec, no base side (base: #{test.dig("base", "message").to_s.lines.first.to_s.strip})", nil]
|
|
180
|
+
in ["no_signal", _] if meant
|
|
181
|
+
["unchecked", "marked as meant to change, but passes on both sides", "make it state the new behavior so that it fails on base, or drop the change mark"]
|
|
182
|
+
in ["no_signal", _] then ["clean", "agrees"]
|
|
183
|
+
in ["candidate", "reported"] if meant
|
|
184
|
+
["intended_change", "differs, as marked", "confirm the head value is the one the intent wants"]
|
|
185
|
+
in ["candidate", "reported"]
|
|
186
|
+
["unintended_change", "passes on base, fails on head, not marked", nil]
|
|
187
|
+
in ["candidate", _]
|
|
188
|
+
reason = test["tags"].grep(/\Adiscard:/).map { DISCARDS.fetch(_1.delete_prefix("discard:"), _1) }.join("; ")
|
|
189
|
+
["unchecked", "differs, but #{reason}", "rewrite the call for head's signature with the same expectation in .holdfast/evidence/<target>/"]
|
|
190
|
+
in ["fail_on_base", _] if meant && head_passes
|
|
191
|
+
["intended_change", "fails on base, passes on head, as marked", "confirm the head value is the one the intent wants"]
|
|
192
|
+
in ["fail_on_base", _] if meant
|
|
193
|
+
["unchecked", "fails on both sides", "read the head failure; fix the code or the test"]
|
|
194
|
+
in ["fail_on_base", _]
|
|
195
|
+
["unchecked", "fails on base, so it states no behavior to keep", "fix the test, or mark it as meant to change"]
|
|
196
|
+
in ["flaky", _]
|
|
197
|
+
["unchecked", "results differ between runs", "make the test deterministic"]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
NO_BASE = /uninitialized constant|undefined (method|local variable)|unknown attribute|has no attribute|UndefinedColumn|unknown keyword|No route matches/
|
|
202
|
+
|
|
203
|
+
def self.no_base?(test) = test.dig("base", "message").to_s.match?(NO_BASE)
|
|
204
|
+
|
|
205
|
+
def self.evidence(test)
|
|
206
|
+
base, head = test["base"] || {}, test["head"] || {}
|
|
207
|
+
failed = [head, base].find { !_1["message"].to_s.strip.empty? }
|
|
208
|
+
detail = failed ? " (#{failed["message"].to_s.lines.first(3).map(&:strip).join(" ")[0, 220]})" : ""
|
|
209
|
+
"#{test["name"]}: base #{base["status"]}, head #{head["status"]}#{detail}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
module Holdfast
|
|
2
|
+
module Stages
|
|
3
|
+
# Adds the tests that are missing. Each method target gets inputs: the
|
|
4
|
+
# agent's file under evidence/ when there is one, else a draft (see
|
|
5
|
+
# Draft); a complete draft runs at once. One rspec process in a checkout
|
|
6
|
+
# of the pinned side runs every input through Observe and writes a pin
|
|
7
|
+
# test per input that holds what it returned, raised and wrote, as JSON.
|
|
8
|
+
# The same process lets branchout run the inputs too, only for its list
|
|
9
|
+
# of the branch arms no input took.
|
|
10
|
+
#
|
|
11
|
+
# Before a change, each input is varied: an Integer, a String or a
|
|
12
|
+
# boolean argument is redrawn at the method's boundaries while every
|
|
13
|
+
# argument written as Ruby (a record) stays as given.
|
|
14
|
+
module Fill
|
|
15
|
+
Pins = Data.define(:target, :origin, :dir, :rev, :report, :inputs, :observed, :input_errors)
|
|
16
|
+
Gap = Data.define(:target, :draft, :reason)
|
|
17
|
+
COMPARISON = %i[< <= > >=].freeze
|
|
18
|
+
|
|
19
|
+
def self.run(change, record, targets)
|
|
20
|
+
rev = change.changed? ? change.base : change.head
|
|
21
|
+
gaps = []
|
|
22
|
+
jobs = targets.select(&:method?).filter_map do |target|
|
|
23
|
+
next if target.why.first == "added" && change.changed?
|
|
24
|
+
next if target.name.end_with?("#initialize")
|
|
25
|
+
|
|
26
|
+
bounds = boundaries(target.source)
|
|
27
|
+
file = Inputs.path(change.root, target.name)
|
|
28
|
+
origin = "inputs"
|
|
29
|
+
unless File.file?(file)
|
|
30
|
+
draft = Draft.for(change, rev, target)
|
|
31
|
+
if draft.path.nil?
|
|
32
|
+
gaps << Gap.new(target:, draft: nil, reason: draft.holes.join(", "))
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
Draft.new(change, rev, target).write(draft)
|
|
36
|
+
unless draft.complete?
|
|
37
|
+
gaps << Gap.new(target:, draft:, reason: "the draft needs #{draft.holes.join(", ")}")
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
file, origin = draft.path, "drafted"
|
|
41
|
+
end
|
|
42
|
+
inputs = Inputs.new(file, JSON.parse(File.read(file)), bounds)
|
|
43
|
+
entries = inputs.entries
|
|
44
|
+
entries += variants(entries, bounds) if change.mode == "before"
|
|
45
|
+
[target, inputs, origin, entries]
|
|
46
|
+
rescue Holdfast::Error, JSON::ParserError => e
|
|
47
|
+
gaps << Gap.new(target:, draft: nil, reason: e.message)
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
return [[], gaps] if jobs.empty?
|
|
51
|
+
|
|
52
|
+
[explore(change, record, rev, jobs), gaps]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Integer literals a comparison in the target's source names.
|
|
56
|
+
def self.boundaries(source)
|
|
57
|
+
found = []
|
|
58
|
+
walk = ->(node) do
|
|
59
|
+
if node.is_a?(Prism::CallNode) && COMPARISON.include?(node.name) && node.receiver && node.arguments&.arguments&.size == 1
|
|
60
|
+
[node.receiver, node.arguments.arguments.first].each { found << _1.value if _1.is_a?(Prism::IntegerNode) }
|
|
61
|
+
end
|
|
62
|
+
node.compact_child_nodes.each(&walk)
|
|
63
|
+
end
|
|
64
|
+
walk.call(Prism.parse(source.to_s).value)
|
|
65
|
+
found.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Each JSON scalar, positional or keyword, is redrawn on its own; every
|
|
69
|
+
# value written as Ruby (a record, a date) stays exactly as given.
|
|
70
|
+
def self.variants(entries, bounds)
|
|
71
|
+
values = (bounds.flat_map { [_1 - 1, _1, _1 + 1] } + [0, -1]).uniq
|
|
72
|
+
redraw = lambda do |v|
|
|
73
|
+
case v
|
|
74
|
+
when Integer then values - [v]
|
|
75
|
+
when String then ["", "#{v}x"]
|
|
76
|
+
when true, false then [!v]
|
|
77
|
+
else []
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
entries.flat_map do |e|
|
|
81
|
+
args = Array(e.input["args"])
|
|
82
|
+
kwargs = e.input.fetch("kwargs", {})
|
|
83
|
+
by_arg = args.each_index.flat_map do |i|
|
|
84
|
+
redraw.(args[i]).map { |v| [e.input.merge("args" => args.dup.tap { _1[i] = v }), "arg #{i + 1} to #{v.inspect}"] }
|
|
85
|
+
end
|
|
86
|
+
by_kwarg = kwargs.flat_map do |k, old|
|
|
87
|
+
redraw.(old).map { |v| [e.input.merge("kwargs" => kwargs.merge(k => v)), "#{k}: to #{v.inspect}"] }
|
|
88
|
+
end
|
|
89
|
+
(by_arg + by_kwarg).map { |input, what| Inputs::Entry.new(input: input.merge("note" => "varied #{what}"), change: e.change, note: "varied #{what}") }
|
|
90
|
+
end.uniq { _1.input }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.explore(change, record, rev, jobs)
|
|
94
|
+
tree = File.join(Dir.mktmpdir("holdfast-pin"), "tree")
|
|
95
|
+
Change.git(change.root, "worktree", "add", "--quiet", "--detach", tree, rev)
|
|
96
|
+
planned = jobs.map do |target, inputs, origin, entries|
|
|
97
|
+
run_dir = File.join(Holdfast.dir(change.root), "runs", Holdfast.slug(target.name))
|
|
98
|
+
FileUtils.rm_rf(run_dir) if run_dir.include?("/.holdfast/runs/")
|
|
99
|
+
FileUtils.mkdir_p(run_dir)
|
|
100
|
+
File.write(File.join(run_dir, "inputs.json"), JSON.pretty_generate({ "inputs" => entries.map(&:input) }))
|
|
101
|
+
[target, inputs, origin, entries, run_dir]
|
|
102
|
+
end
|
|
103
|
+
File.write(File.join(tree, "holdfast_fill_spec.rb"), spec_source(record, planned))
|
|
104
|
+
env = record.env_for("pin").merge("BUNDLE_GEMFILE" => File.join(tree, "Gemfile"))
|
|
105
|
+
steps = [record.side_setup, "#{record.bundle}rspec holdfast_fill_spec.rb"].compact
|
|
106
|
+
failure = nil
|
|
107
|
+
steps.each do |command|
|
|
108
|
+
step_env = command == record.side_setup ? env : env.merge("RUBYOPT" => "-I#{Tools.branchout_lib} -rbundler/setup -rbranchout/coverage")
|
|
109
|
+
out, status = Open3.capture2e(step_env, "/bin/sh", "-c", command, chdir: tree)
|
|
110
|
+
next if status.success? || command != steps.first && planned.all? { File.file?(File.join(_1[4], "observed.json")) }
|
|
111
|
+
|
|
112
|
+
failure = "pinning at #{change.short(rev)} failed: `#{command}`: #{failure_line(out)}"
|
|
113
|
+
break
|
|
114
|
+
end
|
|
115
|
+
planned.map do |target, inputs, origin, entries, run_dir|
|
|
116
|
+
observed = File.file?(File.join(run_dir, "observed.json")) ? JSON.parse(File.read(File.join(run_dir, "observed.json"))) : []
|
|
117
|
+
report = File.file?(File.join(run_dir, "report.json")) ? JSON.parse(File.read(File.join(run_dir, "report.json"))) : {}
|
|
118
|
+
errors = observed.filter_map { _1["input_error"] }
|
|
119
|
+
errors << failure if failure && observed.empty?
|
|
120
|
+
write_pins(record, target, entries, observed, File.join(run_dir, "pinned"))
|
|
121
|
+
Pins.new(target:, origin:, dir: File.join(run_dir, "pinned"), rev:, report:, inputs:, observed:, input_errors: errors.uniq)
|
|
122
|
+
end
|
|
123
|
+
ensure
|
|
124
|
+
if tree && File.directory?(tree) && tree.start_with?(Dir.tmpdir)
|
|
125
|
+
system("git", "worktree", "remove", "--force", tree, chdir: change.root)
|
|
126
|
+
FileUtils.rm_rf(File.dirname(tree))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.spec_source(record, planned)
|
|
131
|
+
examples = planned.map do |target, _inputs, _origin, _entries, run_dir|
|
|
132
|
+
<<~RUBY
|
|
133
|
+
it #{target.name.inspect} do
|
|
134
|
+
inputs = JSON.parse(File.read(#{File.join(run_dir, "inputs.json").inspect}))["inputs"]
|
|
135
|
+
observed = inputs.map do |input|
|
|
136
|
+
Holdfast::Observe.observe(self, input, #{target.name.inspect})
|
|
137
|
+
rescue StandardError, SyntaxError => e
|
|
138
|
+
{ "input_error" => "\#{e.class}: \#{e.message.lines.first.to_s.strip}" }
|
|
139
|
+
end
|
|
140
|
+
File.write(#{File.join(run_dir, "observed.json").inspect}, JSON.generate(observed))
|
|
141
|
+
report = begin
|
|
142
|
+
Branchout.explore(#{target.name.inspect}, within: self, json: true, inputs: #{File.join(run_dir, "inputs.json").inspect})
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
JSON.generate({ "error" => e.message })
|
|
145
|
+
end
|
|
146
|
+
File.write(#{File.join(run_dir, "report.json").inspect}, report)
|
|
147
|
+
end
|
|
148
|
+
RUBY
|
|
149
|
+
end
|
|
150
|
+
<<~RUBY
|
|
151
|
+
require #{record.helper.inspect}
|
|
152
|
+
require "branchout"
|
|
153
|
+
require #{File.join(RUNTIME, "observe").inspect}
|
|
154
|
+
RSpec.describe "holdfast fill", type: :model do
|
|
155
|
+
#{examples.join}
|
|
156
|
+
end
|
|
157
|
+
RUBY
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# One pin test per input that ran; an input whose own Ruby failed (a
|
|
161
|
+
# factory, a typo) pins nothing and is listed instead.
|
|
162
|
+
def self.write_pins(record, target, entries, observed, dir)
|
|
163
|
+
FileUtils.mkdir_p(dir)
|
|
164
|
+
groups = entries.zip(observed).reject { _2.nil? || _2["input_error"] }.group_by { _1.first.change ? "change/change" : "keep" }
|
|
165
|
+
groups.each do |group, rows|
|
|
166
|
+
examples = rows.each_with_index.map do |(entry, seen), i|
|
|
167
|
+
label = "#{target.name} input #{i + 1}#{" (#{entry.note.delete_prefix("drawn ")})" if entry.note}"
|
|
168
|
+
<<~RUBY
|
|
169
|
+
it #{label.inspect} do
|
|
170
|
+
input = JSON.parse(#{JSON.generate(entry.input).inspect})
|
|
171
|
+
expected = JSON.parse(#{JSON.generate(seen).inspect})
|
|
172
|
+
differs = Holdfast::Observe.differences(expected, Holdfast::Observe.observe(self, input, #{target.name.inspect}))
|
|
173
|
+
expect(differs).to be_empty, "differs: \#{differs.first(3).join(" | ")}"
|
|
174
|
+
end
|
|
175
|
+
RUBY
|
|
176
|
+
end
|
|
177
|
+
path = File.join(dir, "#{group}#{record.test_suffix}")
|
|
178
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
179
|
+
File.write(path, <<~RUBY)
|
|
180
|
+
require #{record.helper.inspect}
|
|
181
|
+
require #{File.join(RUNTIME, "observe").inspect}
|
|
182
|
+
RSpec.describe #{"pins #{target.name}".inspect} do
|
|
183
|
+
#{examples.join}
|
|
184
|
+
end
|
|
185
|
+
RUBY
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.failure_line(out)
|
|
190
|
+
out.lines.map(&:strip).grep(/Error|error:|expected/).first(2).join(" ").then { _1.empty? ? out.lines.last(2).join(" ").strip : _1 }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|