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,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