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