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,256 @@
1
+ module Holdfast
2
+ module Stages
3
+ # Turns the test outcomes, the lines each test file ran, the pins and the
4
+ # gaps into one row per changed method, plus one row per changed value
5
+ # for the readers that sit outside every changed method. A row is
6
+ # compared only when a test or a pin ran on both sides and reached the
7
+ # target's changed lines in this run; a test that only names the method
8
+ # is listed and does not count.
9
+ module Fold
10
+ ORIGIN_ORDER = ["drafted pin", "inputs pin", "evidence", "authored", "kept"].freeze
11
+
12
+ def self.run(change, targets, diff, pins, gaps)
13
+ lines = changed_lines(change)
14
+ methods = targets.select(&:method?)
15
+ readers = targets.select { _1.kind == "reader" }
16
+ inside, outside = readers.partition { |r| methods.any? { within?(r, _1) } }
17
+ owner = owners(diff, methods, lines)
18
+ rows = methods.map do |t|
19
+ row = method_row(t, diff, lines, owner, pins.find { _1.target == t }, gaps.find { _1.target == t }, inside.select { within?(_1, t) }, change)
20
+ # An unchanged method's row says which changed value it reads; a
21
+ # caller's row ranks first.
22
+ t.unchanged? && t.why[1] ? row.with(stage: t.caller? ? "caller" : row.stage, reason: "#{t.why[1]}; #{row.reason}") : row
23
+ end
24
+ rows += outside.group_by { _1.reader ? _1.reader["value"] : _1.name }.map { |value, rs| value_row(value, rs, diff, owner, change) }
25
+ # A failure a value row already lists (its file ran a reader's line)
26
+ # is not repeated as a row of its own.
27
+ stray = diff.outcomes.each_with_index.select { |o, i| o.kind != "clean" && owner[i].nil? && outside.none? { reads_line?(diff, o, _1) } }
28
+ # A new spec with no base side states nothing to compare; a test
29
+ # that reached no changed line and did not break on head is not
30
+ # something the change can reach. Both stay in verdicts.jsonl only.
31
+ rows += stray.map { |o, _| stray_row(o) }
32
+ fold_into_failures(rows)
33
+ end
34
+
35
+ # An added method whose only comparison is a failing caller is part of
36
+ # that caller's row, not a row of its own.
37
+ def self.fold_into_failures(rows)
38
+ folded, kept = rows.partition { _1.stage.start_with?("under:") }
39
+ kept.map do |row|
40
+ mine = folded.select { _1.stage == "under:#{row.target}" }
41
+ next row if mine.empty?
42
+
43
+ row.with(evidence: row.evidence.first(1) + ["it runs through added #{mine.map(&:target).join(", ")}, which have no base side of their own"] + row.evidence.drop(1))
44
+ end
45
+ end
46
+
47
+ # file => { "base" => [lines], "head" => [lines] } from the diff hunks.
48
+ def self.changed_lines(change)
49
+ diff = Change.git(change.root, "diff", "--unified=0", change.base, change.head, "--", "app", "lib")
50
+ out = Hash.new { |h, k| h[k] = { "base" => [], "head" => [] } }
51
+ file = nil
52
+ diff.each_line do |l|
53
+ file = l[6..].strip if l.start_with?("+++ b/")
54
+ next unless (m = l.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)) && file
55
+
56
+ out[file]["base"] += (m[1].to_i...(m[1].to_i + (m[2] || 1).to_i)).to_a
57
+ out[file]["head"] += (m[3].to_i...(m[3].to_i + (m[4] || 1).to_i)).to_a
58
+ end
59
+ out
60
+ end
61
+
62
+ # The changed lines of a method's body. Ruby runs a `def` line when
63
+ # the file loads, so a test that only loaded the file would count as
64
+ # reaching it; a change to the signature alone counts the whole body.
65
+ def self.method_lines(t, lines)
66
+ span = t.span
67
+ # An unchanged method that reads a changed value has no changed line
68
+ # of its own; running any line of its body is reaching it.
69
+ if t.unchanged?
70
+ whole = ->(from, to) { from && to ? (to > from + 1 ? ((from + 1)..(to - 1)) : (from..to)).to_a : [] }
71
+ return { "head" => whole.(span["new_start"], span["new_end"]), "base" => whole.(span["old_start"], span["old_end"]) }
72
+ end
73
+ all = lines[t.file]
74
+ body = ->(from, to, changed) do
75
+ return [] unless from && to
76
+
77
+ inner = (to > from + 1 ? ((from + 1)..(to - 1)) : (from..to)).to_a
78
+ (changed & inner).then { _1.empty? && (changed & (from..to).to_a).any? ? inner : _1 }
79
+ end
80
+ { "head" => body.(span["new_start"], span["new_end"], all["head"]), "base" => body.(span["old_start"], span["old_end"], all["base"]) }
81
+ end
82
+
83
+ def self.within?(reader, t) = reader.file == t.file && t.span["new_start"] && (t.span["new_start"]..t.span["new_end"]).cover?(reader.line)
84
+
85
+ def self.hits(diff, outcome, side, file) = Diff.ran(diff.reach, outcome, side == "base" ? "1" : "2").fetch(file, [])
86
+
87
+ def self.reached?(diff, outcome, t, lines)
88
+ want = method_lines(t, lines)
89
+ %w[head base].any? { |side| (hits(diff, outcome, side, t.file) & want[side]).any? }
90
+ end
91
+
92
+ def self.reads_line?(diff, outcome, reader) = hits(diff, outcome, "head", reader.file).include?(reader.line)
93
+
94
+ # Each outcome that is not clean belongs to one row, so a failure is
95
+ # never listed twice: its own target for a pin or an evidence test,
96
+ # else the first changed method whose lines its file ran.
97
+ def self.owners(diff, methods, lines)
98
+ diff.outcomes.each_with_index.to_h do |o, i|
99
+ own = methods.find { o.source.origin !~ /kept|authored/ && o.source.targets.include?(_1.name) }
100
+ hit = methods.select { reached?(diff, o, _1, lines) }
101
+ stem = File.basename(o.source.path).sub(/_(spec|test)\.rb\z/, "")
102
+ own ||= hit.find { |m| m.name.split(/[#.]/).first.split("::").map { Holdfast.snake(_1) }.include?(stem) } || hit.first
103
+ [i, own&.name]
104
+ end
105
+ end
106
+
107
+ def self.draw_step(t, undrawn, gap)
108
+ value = undrawn.find(&:value)
109
+ return "send a request that takes #{undrawn.first.label}: write it as a suspicion for holdfast check, or as a request spec; then holdfast run" if t.file.start_with?("app/controllers/") && !value
110
+ how = value ? "an input whose record holds #{value.label}" : "an input that takes #{undrawn.first.label}"
111
+ place = gap&.draft ? "fill #{rel(gap.draft.path)} and copy it to .holdfast/evidence/" : "add it to .holdfast/evidence/#{Holdfast.slug(t.name)}.inputs.json"
112
+ "draw #{how}: #{place}, or write it as a suspicion for holdfast check; then holdfast run"
113
+ end
114
+
115
+ def self.method_row(t, diff, lines, owner, pin, gap, readers, change)
116
+ want = method_lines(t, lines)
117
+ span = (want["head"].empty? ? want["base"] : want["head"]).then { _1.empty? ? "" : " #{t.file.split("/").last}:#{_1.min}#{"-#{_1.max}" if _1.max > _1.min}" }.then { _1.empty? ? " its changed lines" : _1 }
118
+ mine = diff.outcomes.each_with_index.select { |o, i| owner[i] == t.name || (o.kind == "clean" && (reached?(diff, o, t, lines) || o.source.targets.include?(t.name))) }.map(&:first)
119
+ reached, named = mine.partition { reached?(diff, _1, t, lines) }
120
+ details = groups(reached).map { "#{_1} reach#{"es" if _1.start_with?("1 ")} the changed lines" } +
121
+ groups(named).map { "#{_1} name#{"s" if _1.start_with?("1 ")} it but did not reach the changed lines" }
122
+ details += pin_details(pin) if pin
123
+ new_specs = (reached + named).select { _1.kind == "no_base" }
124
+ if new_specs.any?
125
+ files = new_specs.map { Diff.shown_path(_1.source) }.uniq
126
+ details << "new spec, no base side: #{new_specs.size} example(s) in #{files.first(2).join(", ")}#{" +#{files.size - 2}" if files.size > 2} pass on head; base cannot load what they call, so they are not a comparison"
127
+ end
128
+ details += readers.map { |r| "reader #{r.file}:#{r.line} (#{r.why.last}): #{diff.outcomes.any? { reads_line?(diff, _1, r) } ? "reached by a test" : "no test reached it"}" }
129
+ bad = reached.select { _1.kind == "unintended_change" } + named.select { _1.kind == "unintended_change" }
130
+ meant = reached.select { _1.kind == "intended_change" }
131
+ agree = reached.select { _1.kind == "clean" }
132
+ base = { target: t.name, stage: "diff", scope: (reached + named).map(&:place).uniq.join(", ") }
133
+ if bad.any?
134
+ first = bad.min_by { ORIGIN_ORDER.index(_1.source.origin) || 9 }
135
+ Verdict.of(**base, kind: "unintended_change", reason: "#{count(bad, first)} passes on base and fails on head",
136
+ evidence: ["#{first.test["name"].to_s.sub(/\A.*? input /, "input ")}: #{difference(first, 4)}"] + details, repro: first.repro, next_step: first.next_step)
137
+ elsif t.added? && (elsewhere = reached_failures(diff, t, lines, owner)).any?
138
+ Verdict.of(**base, stage: "under:#{elsewhere.first}", kind: "unchecked", reason: "added; its callers are its only comparison, and #{elsewhere.first} fails",
139
+ evidence: details, next_step: "settle #{elsewhere.first} first, then holdfast run")
140
+ elsif meant.any?
141
+ first = meant.first
142
+ Verdict.of(**base, kind: "intended_change", reason: "#{count(meant, first)} differs as marked: #{difference(first, 1)}",
143
+ evidence: details, repro: first.repro, next_step: "confirm the head value is the one the intent wants")
144
+ elsif agree.any? && (undrawn = Drawn.missing(Drawn.needs(change, t, lines), reached, diff.reach)).any?
145
+ # Its tests ran its lines, but none drew a value the change added
146
+ # or removed, so agreeing says nothing about that value.
147
+ Verdict.of(**base, kind: "unchecked", reason: "#{agree.size} test(s) ran it on both sides and agree, but no input drew #{undrawn.first(3).map(&:label).join(", ")}#{" (+#{undrawn.size - 3} more)" if undrawn.size > 3}",
148
+ evidence: undrawn.map { "not drawn: #{_1.label} (#{_1.why})" } + Drawn.one_sided(undrawn, reached, diff.reach) + details,
149
+ next_step: draw_step(t, undrawn, gap))
150
+ elsif agree.empty? && t.added? && (fresh = reached.select { _1.kind == "no_base" }).any?
151
+ # Base has no such method, so a spec that calls it has no base side:
152
+ # what it states is the new behavior, to read against the intent.
153
+ places = fresh.map(&:place).uniq
154
+ Verdict.of(**base, kind: "intended_change", reason: "added; only new spec(s) with no base side run it: #{places.first(2).join(", ")}#{" +#{places.size - 2}" if places.size > 2}",
155
+ evidence: details, next_step: "confirm those specs state what the intent wants #{t.name} to do")
156
+ elsif agree.any? && !t.added? && (fresh = Drawn.missing(Drawn.needs(change, t, lines), agree, diff.reach)).any?
157
+ # Only a spec with no base side took what the change added, so the
158
+ # tests that agree never ran it: the head behavior is new, not unchanged.
159
+ by = new_specs.select { |o| Drawn.missing(fresh, [o], diff.reach).size < fresh.size }.map(&:place).uniq
160
+ Verdict.of(**base, kind: "intended_change", reason: "#{fresh.first(3).map(&:label).join(", ")} runs only in new spec(s) with no base side#{": #{by.first(2).join(", ")}" if by.any?}; the #{agree.size} test(s) that agree never took it",
161
+ evidence: fresh.map { "only head-only specs took #{_1.label} (#{_1.why})" } + details,
162
+ next_step: "confirm the new spec states what the intent wants for #{fresh.first.label}")
163
+ elsif agree.any?
164
+ Verdict.of(**base, kind: "clean", reason: t.added? ? "added; #{agree.size} test(s) of its callers ran on both sides, reached it on head, and agree" : "#{agree.size} test(s) ran on both sides, reached#{span}, and agree",
165
+ evidence: details, next_step: "nothing; the claim covers only the tests in scope")
166
+ else
167
+ unchecked_method(t, base, details, span, pin, gap)
168
+ end
169
+ end
170
+
171
+ def self.reached_failures(diff, t, lines, owner)
172
+ diff.outcomes.each_with_index.filter_map { |o, i| owner[i] if o.kind == "unintended_change" && owner[i] != t.name && reached?(diff, o, t, lines) }.uniq
173
+ end
174
+
175
+ def self.unchecked_method(t, base, details, span, pin, gap, why: nil)
176
+ why ||= t.added? ? "added, and no test reached it on head" : "no test or pin ran on both sides and reached#{span}"
177
+ step = if gap&.draft
178
+ "fill #{gap.draft.holes.join(", ")} in #{rel(gap.draft.path)}, copy it to .holdfast/evidence/, then holdfast run"
179
+ elsif pin
180
+ "the #{pin.origin} inputs (#{rel(pin.inputs.file)}) did not reach#{span}; add inputs that take it, then holdfast run"
181
+ elsif t.added?
182
+ "write a test that states what #{t.name} does in .holdfast/evidence/#{Holdfast.slug(t.name)}/, or name it from a caller's pin"
183
+ else
184
+ "write a test in .holdfast/evidence/#{Holdfast.slug(t.name)}/ that reaches#{span}, then holdfast run"
185
+ end
186
+ details += ["draft: #{gap.reason}"] if gap && !gap.draft
187
+ Verdict.of(**base, kind: "unchecked", reason: why, evidence: details, next_step: step)
188
+ end
189
+
190
+ def self.pin_details(pin)
191
+ out = ["#{pin.observed.count { !_1["input_error"] }} #{pin.origin == "drafted" ? "drafted" : "given"} input(s) pinned at #{pin.rev[0, 7]} from #{rel(pin.inputs.file)}"]
192
+ out << "#{pin.input_errors.size} input(s) did not build: #{pin.input_errors.first}" if pin.input_errors.any?
193
+ frontier = Array(pin.report["frontier"]).reject { |f| Array(pin.report["covered"]).any? { _1["id"] == f["id"] } }.uniq { _1["id"] }
194
+ out << "arms no input took: #{frontier.first(3).map { "#{_1["loc"]} #{_1["sense"]} `#{_1["cond"]}`" }.join("; ")}" if frontier.any?
195
+ out
196
+ end
197
+
198
+ def self.value_row(value, readers, diff, owner, change)
199
+ ran = diff.outcomes.each_with_index.select { |o, _| readers.any? { reads_line?(diff, o, _1) } }
200
+ bad = ran.select { |o, i| o.kind == "unintended_change" && owner[i].nil? }.map(&:first)
201
+ unreached = readers.reject { |r| ran.any? { |o, _| reads_line?(diff, o, r) } }
202
+ details = readers.map { |r| "#{r.file}:#{r.line} #{r.why.first.sub(/ \(.*\)\z/, "")}: #{unreached.include?(r) ? "no test reached it" : "reached by #{ran.count { |o, _| reads_line?(diff, o, r) }} test(s)"}" }
203
+ base = { target: value, stage: "readers", evidence: details }
204
+ if bad.any?
205
+ Verdict.of(**base, evidence: ["#{bad.size} failing test(s), first #{bad.first.place}"] + details,
206
+ kind: "unintended_change", reason: "a test that reads it fails on head: #{difference(bad.first)}", repro: bad.first.repro, next_step: bad.first.next_step)
207
+ elsif unreached.empty? && (undrawn = Drawn.missing(Drawn.lookup(Drawn.constants(change), value.to_s)&.then { Drawn.members_needs(value.to_s, _1) } || [], ran.map(&:first), diff.reach)).any?
208
+ Verdict.of(**base, kind: "unchecked", reason: "every reader was reached by a test, but no input drew #{undrawn.first(3).map(&:label).join(", ")}",
209
+ evidence: undrawn.map { "not drawn: #{_1.label} (#{_1.why})" } + details,
210
+ next_step: "write a test or a suspicion (holdfast check) whose record holds #{undrawn.first.label} and that runs #{readers.first.name}; then holdfast run")
211
+ elsif unreached.empty?
212
+ Verdict.of(**base, kind: "clean", reason: "every reader outside the changed methods was reached by a test that ran on both sides",
213
+ next_step: "nothing; the claim covers only the tests that reached each line")
214
+ else
215
+ r = unreached.first
216
+ sentence = r.reader ? r.reader["test"] : "Write a test that runs #{r.name}."
217
+ Verdict.of(**base, kind: "unchecked", reason: "#{unreached.size} of #{readers.size} reader(s) no test reached",
218
+ next_step: "#{sentence} Put it in .holdfast/evidence/#{Holdfast.slug(r.name)}/, then holdfast run")
219
+ end
220
+ end
221
+
222
+ def self.stray_row(o)
223
+ kind = o.kind == "no_base" ? "unchecked" : o.kind
224
+ Verdict.of(target: o.place, stage: "diff", kind:, reason: "#{o.source.origin} test, which reached no changed method: #{o.reason}",
225
+ evidence: [o.evidence], repro: o.repro, shown: o.kind == "unintended_change",
226
+ next_step: o.kind == "no_base" ? "nothing to compare; check what it exercises through an existing caller or a pin" : o.next_step)
227
+ end
228
+
229
+ def self.groups(outcomes)
230
+ outcomes.group_by { _1.source.origin }.sort_by { ORIGIN_ORDER.index(_1.first) || 9 }.map do |origin, os|
231
+ files = os.map { Diff.shown_path(_1.source) }.uniq
232
+ tally = os.map(&:kind).tally.map { |k, n| "#{n} #{k == "clean" ? "agree" : k == "no_base" ? "with no base side" : k.tr("_", " ")}" }.join(", ")
233
+ "#{files.size} #{origin} file#{"s" if files.size > 1} (#{tally}; #{files.first(2).join(", ")}#{" +#{files.size - 2}" if files.size > 2})"
234
+ end
235
+ end
236
+
237
+ def self.count(list, first)
238
+ same = list.count { _1.source.origin == first.source.origin }
239
+ same > 1 ? "#{same} #{first.source.origin} examples (first: #{first.place})" : "#{first.source.origin} #{first.place}"
240
+ end
241
+
242
+ # A pin failure lists the paths that differ; a test failure is its message.
243
+ def self.difference(o, limit = 3)
244
+ text = [o.test.dig("head", "message"), o.test.dig("base", "message")].compact.find { !_1.strip.empty? }.to_s
245
+ if text.include?("differs: ")
246
+ parts = text[/differs: (.*)/, 1].strip.split(" | ")
247
+ return parts.first(limit).join(" | ") + (parts.size > limit ? " (+#{parts.size - limit} more)" : "")
248
+ end
249
+
250
+ text.lines.map(&:strip).reject(&:empty?).first(2).join(" ")[0, 200].then { _1.empty? ? o.evidence : _1 }
251
+ end
252
+
253
+ def self.rel(path) = path.to_s.sub(%r{\A.*?/\.holdfast/}, ".holdfast/")
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,176 @@
1
+ module Holdfast
2
+ module Stages
3
+ # The hunt stage: the sequences library drafts one definition per write
4
+ # area of the app (routes, Grape endpoints, jobs), then runs random
5
+ # orders of those actions against the real database under invariants,
6
+ # time zones and second doors. Its definitions live in
7
+ # .holdfast/sequences/, outside the app's tree, so a hunt never makes the
8
+ # next run see a change. Each area's verdict becomes one row:
9
+ # FAILURE -> bug_suspect, with the log that holds the shortest sequence
10
+ # partly checked -> unchecked, with what was not covered
11
+ # unchecked -> unchecked, with why it never ran
12
+ # clean -> clean, scoped to the seeds and cases that ran
13
+ # In a change, only the areas that touch a changed file run.
14
+ module Hunt
15
+ DEFAULT_BUDGET = 600
16
+
17
+ def self.run(change, record, budget:, files: nil)
18
+ budget ||= DEFAULT_BUDGET
19
+ dir = File.join(Holdfast.dir(change.root), "sequences")
20
+ work = File.join(Holdfast.dir(change.root), "runs", "sequences")
21
+ FileUtils.mkdir_p([dir, work])
22
+ env = { "RAILS_ENV" => "test", "SEQUENCES_OUT" => dir, "SEQUENCES_WORK" => work,
23
+ "SEQUENCES_LIB" => File.join(SEQUENCES, "sequences.rb") }
24
+ env["SEQUENCES_DATABASE_URL"] = record.database_url if record.database_url
25
+ bundle = overlay(change, env)
26
+ return [bundle] if bundle.is_a?(Verdict)
27
+
28
+ drafted = draft(change, record, dir, env)
29
+ return [drafted] if drafted.is_a?(Verdict)
30
+
31
+ areas = Dir.glob(File.join(dir, "*.rb")).reject { File.basename(_1) == "invariants.rb" }.sort
32
+ areas = touching(areas, files) if files
33
+ return [row("(sequences)", "unchecked", "no area touches the changed files", [], "nothing to hunt for this change")] if areas.empty?
34
+
35
+ per_area = [budget * 4 / [areas.size, 4].max, 60].max
36
+ cases = budget >= 900 ? 300 : 60
37
+ # Shrinking a failure takes minutes; a short hunt reports it as found.
38
+ env["SEQ_NO_SHRINK"] = "1" if budget < 900
39
+ json = File.join(work, "table.json")
40
+ FileUtils.rm_f(json)
41
+ out, = Open3.capture2e(env, RbConfig.ruby, File.join(SEQUENCES, "run.rb"), "-j", "4", "--cases", cases.to_s, "--seeds", "1",
42
+ "--timeout", per_area.to_s, "--json", json, *areas, chdir: change.root)
43
+ File.write(File.join(work, "run.log"), out)
44
+ unless File.file?(json)
45
+ return [row("(sequences)", "unchecked", "the sequences run printed no table: #{out.lines.grep(/Error|error/).first.to_s.strip}", ["log: #{Fold.rel(File.join(work, "run.log"))}"], "read the log, fix the error it names, then holdfast run")]
46
+ end
47
+
48
+ table = JSON.parse(File.read(json))
49
+ rows = table.map { area_row(change, _1) }
50
+ [reach_row(change, table)].compact + rows
51
+ end
52
+
53
+ # The first line of a hunt says how much of it ran: an action that was
54
+ # refused every time, or never drawn, checked nothing, and an area that
55
+ # reached under half its actions is not listed as clean. An area that
56
+ # failed stopped at its failure, so its reach says nothing about setup.
57
+ def self.reach_row(change, table)
58
+ counts = table.filter_map { |r| r["verdict"] != "FAILURE" && r["reached"].to_s[%r{(\d+)/(\d+)}] && [r, $1.to_i, $2.to_i] }
59
+ total = counts.sum { _1[2] }
60
+ reached = counts.sum { _1[1] }
61
+ return nil if total.zero? || reached == total
62
+
63
+ thin = counts.select { |_, a, b| a * 2 < b }.sort_by { |_, a, b| a.fdiv(b) }
64
+ # Each thin area names its file and the first actions that checked
65
+ # nothing, with the refusal, so the fix starts at a line, not a count.
66
+ evidence = thin.map do |r, a, b|
67
+ missing = r["text"].to_s.lines.drop(1).map { _1.strip.delete_prefix("- ") }.reject(&:empty?)
68
+ "#{r["area"]} (#{r["file"].to_s.delete_prefix("#{change.root}/")}): #{a}/#{b} actions reached#{"; first: #{missing.first(2).join("; ")}" if missing.any?}"
69
+ end
70
+ worst = thin.first&.first
71
+ step = worst ? "start with #{worst["area"]}: make the records or the sign-in its unreached actions need in #{worst["file"].to_s.delete_prefix("#{change.root}/")}, then holdfast run" :
72
+ "read each area's row below for the refusals, fix the setup or the sign-in in its file, then holdfast run"
73
+ row("(hunt)", "unchecked", "unreached: #{total - reached} of #{total} actions (refused every time or never drawn); #{thin.size} of #{counts.size} areas reached under half",
74
+ evidence, "#{step}; until then the hunt says little about these areas")
75
+ end
76
+
77
+ # The sequences library needs hegeltest, which the app's bundle may
78
+ # not list. A Gemfile of holdfast's own evaluates the app's and adds
79
+ # it, locked from the gems already installed, so the app's Gemfile and
80
+ # lock stay untouched.
81
+ def self.overlay(change, env)
82
+ dir = File.join(Holdfast.dir(change.root), "bundle")
83
+ FileUtils.mkdir_p(dir)
84
+ gemfile = File.join(dir, "Gemfile")
85
+ app_lock = File.join(change.root, "Gemfile.lock")
86
+ if File.read(app_lock).match?(/^ hegeltest /)
87
+ env["BUNDLE_GEMFILE"] = File.join(change.root, "Gemfile")
88
+ return nil
89
+ end
90
+ File.write(gemfile, %(eval_gemfile #{File.join(change.root, "Gemfile").inspect}\ngem "hegeltest", "#{Holdfast::HEGELTEST_VERSION}", require: false\n))
91
+ FileUtils.cp(app_lock, "#{gemfile}.lock") unless File.file?("#{gemfile}.lock")
92
+ out, status = Open3.capture2e({ "BUNDLE_GEMFILE" => gemfile }, "bundle", "lock", "--local", chdir: change.root)
93
+ return row("(sequences)", "unchecked", "could not add hegeltest to a bundle of holdfast's own: #{out.lines.last.to_s.strip}", [], "install hegeltest #{Holdfast::HEGELTEST_VERSION} for this Ruby, then holdfast run") unless status.success?
94
+
95
+ env["BUNDLE_GEMFILE"] = gemfile
96
+ nil
97
+ end
98
+
99
+ def self.draft(change, record, dir, env)
100
+ probe_env = env.merge(record.database_url ? { "DATABASE_URL" => "#{record.database_url}_seq0" } : {})
101
+ if record.side_setup
102
+ _, status = Open3.capture2e(probe_env, "/bin/sh", "-c", record.side_setup, chdir: change.root)
103
+ return row("(sequences)", "unchecked", "the probe database did not set up", [], "holdfast run --learn") unless status.success?
104
+ end
105
+ return nil if Dir.glob(File.join(dir, "*.rb")).size > 1
106
+
107
+ out, status = Open3.capture2e(probe_env, "bundle", "exec", "ruby", File.join(SEQUENCES, "propose.rb"), "--all", chdir: change.root)
108
+ return nil if status.success?
109
+
110
+ row("(sequences)", "unchecked", "drafting the areas failed: #{out.lines.grep(/Error|error/).first.to_s.strip}", [],
111
+ "fix the error it names in the app or its factories, then holdfast run")
112
+ end
113
+
114
+ # An area runs for a change when its definition names a changed file's
115
+ # table, constant or route resource.
116
+ def self.touching(areas, files)
117
+ words = files.flat_map do |f|
118
+ base = File.basename(f, ".rb").delete_suffix("_controller").delete_suffix("_api")
119
+ [base, base.end_with?("s") ? base : "#{base}s"]
120
+ end.uniq
121
+ areas.select { |a| text = File.read(a); words.any? { text.include?(_1) } }
122
+ end
123
+
124
+ def self.area_row(change, r)
125
+ text = r["text"].to_s
126
+ lines = text.lines.map(&:strip)
127
+ scope = "#{r["seeds"].to_s.split(",").size} seed(s) x #{r["cases"]} cases, #{r["reached"]} actions reached"
128
+ cut = text[/cut by the time limit after \S+ of \d+ cases/]
129
+ scope = "#{cut}, #{r["reached"]} actions reached" if cut
130
+ log = r["log"].to_s.delete_prefix("#{change.root}/")
131
+ area = "sequences: #{r["area"]}"
132
+ case r["verdict"]
133
+ when "FAILURE"
134
+ # The failure text is the first line under "Sequences::Failure:" in
135
+ # RSpec's report, kept whole: a cut message hid which row broke.
136
+ logged = File.file?(r["log"].to_s) ? File.readlines(r["log"], encoding: "UTF-8").map { _1.scrub.strip } : []
137
+ at = logged.index { _1.end_with?("Sequences::Failure:") }
138
+ found = at ? logged[at + 1] : logged.find { _1.match?(/ raised |invariant \w+ broken|differ|disagree/) && !_1.start_with?("#", "1)") }
139
+ reason = found ? found.sub(/\A.*?in '[^']+': /, "").sub(/ \(Sequences::Failure\)\z/, "") : lines.first.delete_prefix("FAILURE: ")
140
+ short = reason.size > 300 ? "#{reason[0, 300]}... (whole text in evidence)" : reason
141
+ # An error raised in holdfast's own code, not the app's, says nothing
142
+ # about the app: report it as holdfast's, with where it was raised.
143
+ frame = logged.find { _1.start_with?("# /") }
144
+ if at.nil? && frame&.include?(File.expand_path("..", __dir__))
145
+ return row(area, "unchecked", "holdfast failed while hunting here: #{short}", ["at: #{frame.delete_prefix("# ")}", "log: #{log}"],
146
+ "report it to holdfast with the log; the hunt checked nothing in this area", scope:)
147
+ end
148
+ # Broken before any action ran, the world the setup drafted already
149
+ # breaks the rule: the setup or the drafted rule is wrong, not the app.
150
+ if (rule = reason[/invariant (\w+) broken after setup/, 1])
151
+ return row(area, "unchecked", "the drafted setup already breaks #{rule}, before any action; the hunt checked nothing here",
152
+ ["failure: #{reason}", "log: #{log}"],
153
+ "make the setup in #{r["file"].to_s.delete_prefix("#{change.root}/")} build records that keep #{rule}, or drop the rule from .holdfast/sequences/invariants.rb if the app does not promise it; then holdfast run", scope:)
154
+ end
155
+ row(area, "bug_suspect", short, ["failure: #{reason}", "log: #{log} (the shortest sequence and an `it` block that replays it)"],
156
+ "paste the `it` block from the log into #{r["file"].to_s.delete_prefix("#{change.root}/")}, see it fail, fix the code, run again", scope:)
157
+ when "partly checked"
158
+ missing = lines.drop(1).map { _1.delete_prefix("- ") }
159
+ row(area, "unchecked", "partly checked (#{scope}); #{missing.size} action(s) checked nothing", missing,
160
+ "make the record in setup, narrow a pool, or change the param draws in #{r["file"].to_s.delete_prefix("#{change.root}/")}, then holdfast run", scope:)
161
+ when "clean"
162
+ a, b = r["reached"].to_s.scan(/\d+/).map(&:to_i)
163
+ return row(area, "unchecked", "reached only #{a}/#{b} actions (#{scope})", [], "make the records the other actions need in #{r["file"].to_s.delete_prefix("#{change.root}/")}, then holdfast run", scope:) if a && b && a * 2 < b
164
+
165
+ row(area, "clean", "every action, offset replay and door pair reached its target (#{scope})", [], "nothing; the claim covers only these seeds and cases", scope:)
166
+ else
167
+ row(area, "unchecked", lines.first.to_s.delete_prefix("unchecked: "), ["log: #{log}"], "read the log, fix the setup it names, then holdfast run")
168
+ end
169
+ end
170
+
171
+ def self.row(target, kind, reason, evidence, next_step, scope: nil)
172
+ Verdict.of(target:, stage: "sequences", kind:, reason:, evidence:, next_step:, scope:)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,109 @@
1
+ module Holdfast
2
+ module Stages
3
+ # A behavior diff that starts from inputs, not from changed methods: the
4
+ # hunt's area definitions (drafted on head) run every ordering of their
5
+ # actions up to three steps, with fixed draws, once in a checkout of base
6
+ # and once in a checkout of head, each on its own database. Every step
7
+ # records how it ended, the rows it wrote by attributes, and the fakes it
8
+ # touched. Where the two sides differ is behavior that changed, whether
9
+ # or not a changed method is anywhere near it; an unchanged caller of a
10
+ # changed value shows up because its own observation moves.
11
+ #
12
+ # Only the first differing step of a sequence counts (the steps after it
13
+ # start from another state). Each difference is matched against the
14
+ # words of --intent: one that names it is shown to confirm, any other is
15
+ # unintended.
16
+ module Observe
17
+ Diff = Data.define(:area, :action, :what, :seq, :step, :base, :head, :args)
18
+
19
+ def self.run(change, record, intent:, files:)
20
+ dir = File.join(Holdfast.dir(change.root), "sequences")
21
+ work = File.join(Holdfast.dir(change.root), "runs", "observe")
22
+ FileUtils.rm_rf(work) if work.end_with?("/.holdfast/runs/observe")
23
+ FileUtils.mkdir_p([dir, work])
24
+ env = { "RAILS_ENV" => "test", "SEQUENCES_OUT" => dir, "SEQUENCES_LIB" => File.join(SEQUENCES, "sequences.rb") }
25
+ env["SEQUENCES_DATABASE_URL"] = record.database_url if record.database_url
26
+ bundle = Hunt.overlay(change, env)
27
+ return [bundle] if bundle.is_a?(Verdict)
28
+
29
+ drafted = Hunt.draft(change, record, dir, env)
30
+ return [drafted] if drafted.is_a?(Verdict)
31
+
32
+ areas = Hunt.touching(Dir.glob(File.join(dir, "*.rb")).reject { File.basename(_1) == "invariants.rb" }.sort, files)
33
+ return [row("(observe)", "unchecked", "no drafted area touches the changed files", [], "nothing to observe for this change")] if areas.empty?
34
+
35
+ sides = { "base" => change.base, "head" => change.head }.to_h do |side, rev|
36
+ [side, Thread.new { observe_side(change, record, env, side, rev, areas, work) }]
37
+ end.transform_values(&:value)
38
+ failed = sides.select { |_, r| r[:error] }
39
+ return failed.map { |side, r| row("(observe)", "unchecked", "the #{side} side did not run: #{r[:error]}", [], "read #{Fold.rel(r[:log])}, fix the setup, then holdfast run --observe") } if failed.any?
40
+
41
+ diffs = compare(sides["base"][:lines], sides["head"][:lines])
42
+ steps = sides["head"][:lines].size
43
+ return [row("(observe)", "clean", "#{steps} step(s) in #{areas.size} area(s) observed the same on both sides", [],
44
+ "nothing; the claim covers only these orderings and draws", scope: areas.map { File.basename(_1, ".rb") }.join(", "))] if diffs.empty?
45
+
46
+ words = intent.to_s.downcase.scan(/[a-z][a-z_]{3,}/).uniq - %w[that with from this have should when then into only also each same than they them]
47
+ diffs.group_by { [_1.area, _1.action, _1.what] }.map { |(area, action, what), ds| diff_row(area, action, what, ds, words) }
48
+ end
49
+
50
+ def self.observe_side(change, record, env, side, rev, areas, work)
51
+ tree = File.join(Dir.mktmpdir("holdfast-observe"), "tree")
52
+ Change.git(change.root, "worktree", "add", "--quiet", "--detach", tree, rev)
53
+ number = Record::SIDES.fetch(side)
54
+ side_env = env.merge(record.env_for(side)).merge("BUNDLE_GEMFILE" => env.fetch("BUNDLE_GEMFILE", File.join(tree, "Gemfile")))
55
+ log = File.join(work, "#{side}.log")
56
+ if record.side_setup
57
+ out, status = Open3.capture2e(side_env, "/bin/sh", "-c", record.side_setup, chdir: tree)
58
+ File.write(log, out)
59
+ return { error: "database setup failed", log: } unless status.success?
60
+ end
61
+ lines = []
62
+ areas.each do |area|
63
+ out_file = File.join(work, "#{side}-#{File.basename(area, ".rb")}.jsonl")
64
+ out, = Open3.capture2e(side_env.merge("SEQ_OBSERVE" => out_file, "TEST_ENV_NUMBER" => number), "bundle", "exec", "rspec", area, chdir: tree)
65
+ File.write(log, out, mode: "a")
66
+ lines += File.readlines(out_file).map { JSON.parse(_1) } if File.file?(out_file)
67
+ end
68
+ { lines:, log: }
69
+ ensure
70
+ if tree && File.directory?(tree)
71
+ system("git", "worktree", "remove", "--force", tree, chdir: change.root, err: File::NULL)
72
+ FileUtils.rm_rf(File.dirname(tree))
73
+ end
74
+ end
75
+
76
+ def self.compare(base, head)
77
+ by_key = base.to_h { [[_1["area"], _1["index"], _1["step"]], _1] }
78
+ head.group_by { [_1["area"], _1["index"]] }.filter_map do |_, steps|
79
+ steps.sort_by { _1["step"] }.each do |h|
80
+ b = by_key[[h["area"], h["index"], h["step"]]] or break nil
81
+ what = %w[outcome writes outside].find { b[_1] != h[_1] } or next
82
+ break Diff.new(area: h["area"], action: h["action"], what:, seq: h["seq"], step: h["step"], base: b[what], head: h[what], args: h["args"])
83
+ end.then { _1.is_a?(Diff) ? _1 : nil }
84
+ end
85
+ end
86
+
87
+ def self.diff_row(area, action, what, ds, words)
88
+ first = ds.min_by { [_1.seq.size, _1.step] }
89
+ named = words.select { |w| [action, JSON.generate(first.base), JSON.generate(first.head)].any? { _1.to_s.downcase.include?(w) } }
90
+ evidence = ["shortest: #{first.seq.first(first.step).join(" -> ")} (step #{first.step}: #{action} #{first.args})",
91
+ "base #{what}: #{show(first.base)}", "head #{what}: #{show(first.head)}",
92
+ "#{ds.size} ordering(s) differ first at #{action}"]
93
+ if named.any?
94
+ row("observe: #{area} #{action}", "intended_change", "#{what} differs at #{action}, and the intent names #{named.first(3).join(", ")}", evidence,
95
+ "confirm the head #{what} is what the intent wants")
96
+ else
97
+ row("observe: #{area} #{action}", "unintended_change", "#{what} differs at #{action} after #{first.step - 1} earlier step(s); the intent does not name it", evidence,
98
+ "fix the code, or say it in --intent; the orderings are in .holdfast/runs/observe/")
99
+ end
100
+ end
101
+
102
+ def self.show(v) = (v.is_a?(String) ? v : JSON.generate(v)).then { _1.size > 400 ? "#{_1[0, 400]}..." : _1 }
103
+
104
+ def self.row(target, kind, reason, evidence, next_step, scope: nil)
105
+ Verdict.of(target:, stage: "observe", kind:, reason:, evidence:, next_step:, scope:)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,94 @@
1
+ module Holdfast
2
+ # A thing the run checks: a changed method, a reader of a changed value
3
+ # that is not a plain call site, or a method the caller named before a
4
+ # change. +tests+ are the existing test files that exercise it, at base.
5
+ Target = Data.define(:name, :kind, :file, :line, :why, :tests, :source, :reader, :span, :reads) do
6
+ def initialize(reads: nil, **rest) = super(reads:, **rest)
7
+ def caller? = why.first == "caller"
8
+ def unchanged? = %w[caller affected].include?(why.first)
9
+ def added? = why.first == "added"
10
+ def method? = name.match?(/\A[A-Z][\w:]*[#.][a-z_]\w*[?!=]?\z/)
11
+ end
12
+
13
+ module Stages
14
+ # Which targets this run checks and which tests belong to each. The
15
+ # changed methods and their tests come from jitcatch's change brief
16
+ # (grep, or a jitcatch-coverage-v1 file when the record names one); the
17
+ # readers of each changed value come from the readers list.
18
+ module Select
19
+ Result = Data.define(:targets, :verdicts, :notes)
20
+
21
+ def self.run(change, record)
22
+ jc = jitcatch(change, record)
23
+ brief = jc.build
24
+ methods = brief["methods"]
25
+ methods = methods.select { |m| change.named.any? { named_match?(_1, m) } } unless change.changed?
26
+ methods = methods.reject { _1["file"].start_with?("db/") || accessor_only?(_1) }
27
+ removed, kept = methods.partition { _1["change"] == "removed" }
28
+ targets = kept.map do |m|
29
+ Target.new(name: m["name"], kind: "method", file: m["file"], line: m["start_line"],
30
+ why: [m["change"], ("reads #{brief["constants"].find { |k| k["id"] == m["via_constant"] }&.dig("name")}, which the change edits" if m["via_constant"])].compact,
31
+ tests: (m["existing_tests"] + m["entry_point_tests"]).map { _1["file"] }.uniq,
32
+ source: [m["old_source"], m["new_source"]].compact.join("\n"), reader: nil,
33
+ span: m.slice("old_start", "old_end", "new_start", "new_end"),
34
+ reads: Callers.value_of(m, brief, change)&.then { |v| { "constant" => v.constant, "values" => v.values, "model" => v.model, "column" => v.column, "outside" => v.outside }.compact })
35
+ end
36
+ targets = Callers.targets(jc, brief, change) + targets if change.changed?
37
+ verdicts = []
38
+ unless change.changed?
39
+ missing = change.named.reject { |n| kept.any? { named_match?(n, _1) } }
40
+ verdicts += missing.map do |n|
41
+ Verdict.of(target: n, stage: "select", kind: "unchecked", reason: "no method or file named #{n} at #{change.short(change.head)}",
42
+ next_step: "name it as Const#method, Const.method, or a path under app/, then run holdfast run --target again")
43
+ end
44
+ end
45
+ targets += readers(brief, test_files(change, record)) if change.changed?
46
+ notes = removed.map { "removed: #{_1["name"]}" } + (change.changed? ? brief["warnings"].map { "jitcatch warning: #{_1}" } : [])
47
+ notes << "tests chosen by #{brief["existing_tests_via"]}" unless change.changed?
48
+ notes << "tests chosen by #{brief["existing_tests_via"]}#{" (#{record.coverage})" if record.coverage}; a row counts a test only when it reached the changed lines in this run" if change.changed?
49
+ Result.new(targets:, verdicts:, notes:)
50
+ end
51
+
52
+ def self.jitcatch(change, record)
53
+ base = change.changed? ? change.base : empty_parent(change)
54
+ JitCatch::Changes.new(JitCatch::GitRepo.new(change.root), base, change.head, readers: change.changed?,
55
+ coverage: record.coverage && File.join(change.root, record.coverage))
56
+ end
57
+
58
+ # Reading from the first commit's parent makes every method "added", so
59
+ # the same brief lists each method of head with its tests.
60
+ def self.empty_parent(change)
61
+ first = Change.git(change.root, "rev-list", "--max-parents=0", change.head).split.last
62
+ "#{first}^"
63
+ end
64
+
65
+ # A member a Data.define or attr_reader line adds holds no logic of its
66
+ # own; the methods that read it are the targets.
67
+ def self.accessor_only?(m) = m["change"] == "added" && !m["new_source"].to_s.lstrip.start_with?("def ")
68
+
69
+ def self.named_match?(named, method) = method["name"] == named || method["file"] == named
70
+
71
+ def self.test_files(change, record)
72
+ Change.git(change.root, "ls-tree", "-r", "--name-only", change.base, "--", "spec", "test")
73
+ .lines.map(&:strip).select { _1.end_with?(record.test_suffix) }
74
+ end
75
+
76
+ # The readers list names a line, not a test. The tests named after the
77
+ # reader's file (closures_controller.rb, views/closures/ -> closures_spec)
78
+ # are the nearest the harness can pick without coverage; a row says so.
79
+ def self.tests_named_after(file, tests)
80
+ stem = file.start_with?("app/views/") ? file.split("/")[2] : File.basename(file, ".*").sub(/_controller\z/, "")
81
+ tests.select { File.basename(_1).sub(/_(spec|test)\.rb\z/, "").sub(/_controller\z/, "") == stem }
82
+ end
83
+
84
+ # jitcatch's readers: code that reads a changed value without calling it
85
+ # by name (a scope's SQL, a validation, a default, a page).
86
+ def self.readers(brief, tests)
87
+ Array(brief.dig("readers", "rows")).reject { _1["file"].start_with?("spec/", "test/") }.map do |row|
88
+ Target.new(name: "#{row["file"]}:#{row["line"]}", kind: "reader", file: row["file"], line: row["line"],
89
+ why: ["#{row["rank"]} (#{row["value"]})", row["why"]], tests: tests_named_after(row["file"], tests), source: nil, reader: row, span: {})
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end