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,303 @@
1
+ module Holdfast
2
+ # A first inputs file for one method, drafted from what the code says: the
3
+ # method's parameters (a record by a factory of its name, a date by its
4
+ # default), the conditions it branches on, and the literals and constants
5
+ # those conditions compare with. A date argument is drawn around each date
6
+ # column of a record argument that the method reads, because "before the
7
+ # due date" and "after it" are where date code differs.
8
+ #
9
+ # A draft with no placeholder left is complete: the run pins it at once,
10
+ # marked as drafted, and the agent accepts it by copying it to evidence.
11
+ # A draft with a placeholder is only written, and the row names it.
12
+ class Draft
13
+ OFFSETS = [-10, -1, 0, 1, 10].freeze
14
+ ANCHOR = "Date.new(2026, 3, 20)".freeze
15
+ COMPARE = %i[< <= > >= == !=].freeze
16
+
17
+ Result = Data.define(:path, :data, :holes, :basis) do
18
+ def complete? = holes.empty?
19
+ end
20
+
21
+ def self.path(root, target) = File.join(Holdfast.dir(root), "drafts", "#{Holdfast.slug(target)}.inputs.json")
22
+
23
+ def self.for(change, rev, target)
24
+ new(change, rev, target).result
25
+ end
26
+
27
+ def initialize(change, rev, target)
28
+ @change, @rev, @target = change, rev, target
29
+ end
30
+
31
+ def result
32
+ const_name, kind, name = @target.name.match(/\A(.+?)([#.])(\w+[?!=]?)\z/).captures
33
+ node = method_node(@target.source, name) || scope_node(@target.source)
34
+ holes = []
35
+ basis = []
36
+ return Result.new(path: nil, data: nil, holes: ["no def found for #{name}"], basis:) unless node
37
+ # A controller built with `.new` has no request, so every action fails
38
+ # inside Rails the same way on both sides, and that pin compares nothing.
39
+ if @target.file.start_with?("app/controllers/")
40
+ return Result.new(path: nil, data: nil, holes: ["#{@target.name} runs through a request; write a request spec, or a suspicion whose `do` sends that request, and run holdfast check"], basis:)
41
+ end
42
+
43
+ params = node.parameters
44
+ params = params.parameters if params.is_a?(Prism::BlockParametersNode)
45
+ records = {}
46
+ args = Array(params&.requireds).map do |p|
47
+ param_value(p.name.to_s, nil, records, holes)
48
+ end
49
+ kwargs = {}
50
+ Array(params&.keywords).each do |p|
51
+ default = p.respond_to?(:value) && p.value ? p.value.slice : nil
52
+ if date_default?(default) || date_name?(p.name.to_s)
53
+ kwargs[p.name.to_s] = :date
54
+ elsif default.nil?
55
+ kwargs[p.name.to_s] = param_value(p.name.to_s, nil, records, holes)
56
+ end
57
+ end
58
+
59
+ receiver = receiver_for(const_name, kind, holes)
60
+ # A record receiver is drafted like a record argument: its own column
61
+ # reads (`returned_at`, `due_on`) choose its factory's attributes.
62
+ if receiver.is_a?(Hash) && (model = receiver["ruby"].to_s[/\AFactoryBot\.create\(:(\w+)\)\z/, 1])
63
+ records["self"] = model
64
+ receiver = { "factory" => model, "param" => "self" }
65
+ end
66
+ reads = attribute_reads(node, records.keys)
67
+ draws = {}
68
+ overrides = Hash.new { |h, k| h[k] = {} }
69
+ records.each do |param, factory|
70
+ columns = columns_of(factory)
71
+ reads.fetch(param, []).uniq.each do |attr, bounds|
72
+ col = columns[attr] || columns["#{attr.delete_suffix("?")}_at"] && "#{attr.delete_suffix("?")}_at"
73
+ if columns[attr] == "integer" && bounds.any?
74
+ draw = "#{param}_#{attr}"
75
+ draws[draw] = { "one_of" => ([0] + bounds.flat_map { [_1 - 1, _1, _1 + 1] }).select { _1 >= 0 }.uniq.sort }
76
+ overrides[param][attr] = "%{#{draw}}"
77
+ basis << "#{param}.#{attr} around #{bounds.join(", ")}"
78
+ elsif columns[attr] == "datetime" && !attr.match?(/\A(created|updated)_at\z/)
79
+ draw = "#{param}_#{attr}"
80
+ draws[draw] = { "booleans" => true }
81
+ overrides[param][attr] = "(%{#{draw}} ? Time.utc(2026, 3, 1, 12) : nil)"
82
+ basis << "#{param}.#{attr} set and unset"
83
+ elsif attr.end_with?("?") && col.is_a?(String) && columns[col] == "datetime"
84
+ draw = "#{param}_#{col}"
85
+ draws[draw] = { "booleans" => true }
86
+ overrides[param][col] = "(%{#{draw}} ? Time.utc(2026, 3, 1, 12) : nil)"
87
+ basis << "#{param}.#{attr} both ways"
88
+ end
89
+ end
90
+ dates = columns.select { _2 == "date" }.keys
91
+ read = dates & reads.fetch(param, []).map(&:first)
92
+ next unless kwargs.value?(:date) && (anchor = read.first || dates.first)
93
+
94
+ overrides[param][anchor] = ANCHOR
95
+ @anchored ||= [param, anchor]
96
+ end
97
+ world = world_for(node, records, draws, overrides, basis)
98
+ offsets = OFFSETS | literals(node).flat_map { [_1, _1 + 1] }.select { _1.abs <= 40 }
99
+ kwargs.each_key do |key|
100
+ next unless kwargs[key] == :date
101
+
102
+ draws["#{key}_days"] = { "one_of" => offsets.sort }
103
+ kwargs[key] = { "ruby" => "#{ANCHOR} + %{#{key}_days}" }
104
+ basis << (@anchored ? "#{key}: around #{@anchored.join(".")}" : "#{key}: around #{ANCHOR}")
105
+ end
106
+ factory_args = ->(param) do
107
+ o = overrides[param]
108
+ o.empty? ? "" : ", #{o.map { "#{_1}: #{_2}" }.join(", ")}"
109
+ end
110
+ create = ->(param) { "FactoryBot.create(:#{records[param]}#{factory_args.(param)})" }
111
+ # A drafted world makes each record once, in `before`, so the record
112
+ # the world links to is the one the method receives.
113
+ fill = lambda do |v|
114
+ next v unless v.is_a?(Hash) && v["factory"]
115
+
116
+ { "ruby" => world ? "@#{v["param"]}#{v["suffix"]}" : "#{create.(v["param"])}#{v["suffix"]}" }
117
+ end
118
+ entry = { "receiver" => fill.(receiver), "args" => args.map(&fill), "kwargs" => kwargs.transform_values(&fill) }.compact
119
+ if world
120
+ made = records.keys.map { "@#{_1} = #{create.(_1)}" }
121
+ entry = { "before" => { "ruby" => (made + [world]).join("; ") } }.merge(entry)
122
+ end
123
+ entry.delete("kwargs") if entry["kwargs"].empty?
124
+ data = if draws.empty?
125
+ { "inputs" => [entry.merge("note" => "drafted by holdfast")] }
126
+ else
127
+ product = draws.values.map { (_1["one_of"] || [true, false]).size }.inject(1, :*)
128
+ { "generate" => [entry.merge("draws" => draws, "cases" => [product, 120].min, "note" => "drafted by holdfast")] }
129
+ end
130
+ Result.new(path: self.class.path(@change.root, @target.name), data:, holes:, basis:)
131
+ end
132
+
133
+ def write(result)
134
+ FileUtils.mkdir_p(File.dirname(result.path))
135
+ File.write(result.path, "#{JSON.pretty_generate(result.data)}\n")
136
+ end
137
+
138
+ private
139
+
140
+ # The changed value a caller reads through a scope (Hold.open reads
141
+ # OPEN_STATUSES as `status`): one record of the scope's model, linked to
142
+ # each record argument it belongs to, with that column drawn over every
143
+ # value either side's list holds, one value neither holds, and no record
144
+ # at all. A method that tests the constant on an argument's attribute
145
+ # (`OPEN_STATUSES.include?(hold.status)`) gets that attribute drawn.
146
+ def world_for(node, records, draws, overrides, basis)
147
+ r = @target.reads or return nil
148
+ values = Array(r["values"]) + Array(r["outside"])
149
+ return nil if values.empty?
150
+
151
+ const = r["constant"].to_s.split("::").last
152
+ walk(node) do |n|
153
+ next unless n.is_a?(Prism::CallNode) && n.name == :include? && const_name(n.receiver) == const && n.arguments
154
+
155
+ pa = param_attr(n.arguments.arguments.first, records.keys) or next
156
+ draw = "#{pa[0]}_#{pa[1]}"
157
+ draws[draw] = { "one_of" => values }
158
+ overrides[pa[0]][pa[1]] = "%{#{draw}}"
159
+ basis << "#{pa[0]}.#{pa[1]} over #{r["constant"]} on both sides"
160
+ end
161
+ return nil unless r["model"] && r["column"]
162
+
163
+ factory = Holdfast.snake(r["model"].split("::").last)
164
+ return nil unless factories.include?(factory)
165
+
166
+ owners = file_text(model_file(r["model"])).scan(/^\s*belongs_to\s+:(\w+)/).flatten
167
+ links = records.select { |param, f| param != "self" && owners.include?(f) }.map { |param, f| "#{f}: @#{param}" }
168
+ draw = "#{factory}_#{r["column"]}"
169
+ draws[draw] = { "one_of" => values + ["none"] }
170
+ basis << "a #{r["model"]} whose #{r["column"]} is drawn over #{r["constant"]} (read through #{r["via"] || @target.name})"
171
+ "FactoryBot.create(:#{factory}#{links.map { ", #{_1}" }.join}, #{r["column"]}: %{#{draw}}) unless %{#{draw}} == \"none\""
172
+ end
173
+
174
+ def const_name(n) = n.is_a?(Prism::ConstantReadNode) || n.is_a?(Prism::ConstantPathNode) ? n.name.to_s : nil
175
+
176
+ def model_file(model) = "app/models/#{model.split("::").map { Holdfast.snake(_1) }.join("/")}.rb"
177
+
178
+ def scope_node(source)
179
+ call = Prism.parse(source.to_s).value.statements&.body&.first
180
+ return nil unless call.is_a?(Prism::CallNode) && call.name == :scope && call.arguments
181
+
182
+ call.arguments.arguments[1].then { _1.is_a?(Prism::LambdaNode) ? _1 : call.block }
183
+ end
184
+
185
+ def method_node(source, name)
186
+ found = nil
187
+ walk(Prism.parse(source.to_s).value) { found ||= _1 if _1.is_a?(Prism::DefNode) && _1.name.to_s == name }
188
+ found
189
+ end
190
+
191
+ def walk(node, &block)
192
+ return unless node
193
+
194
+ yield node
195
+ node.compact_child_nodes.each { walk(_1, &block) }
196
+ end
197
+
198
+ def date_default?(src) = src.to_s.match?(/\A(Date\.(current|today)|Time\.(current|zone\.today))/)
199
+ def date_name?(name) = name.match?(/\A(on|date|day)\z|_on\z/)
200
+
201
+ def param_value(name, _default, records, holes)
202
+ stem = name.delete_suffix("_id")
203
+ # `request` names the one factory that ends with it (interlibrary_request).
204
+ factory = factories.include?(stem) ? stem : factories.grep(/_#{Regexp.escape(stem)}\z/).then { _1.size == 1 ? _1.first : nil }
205
+ if factory
206
+ records[stem] = factory
207
+ { "factory" => factory, "param" => stem, "suffix" => name.end_with?("_id") ? ".id" : nil }.compact
208
+ else
209
+ holes << name
210
+ { "fill" => "#{name}: a value the method takes" }
211
+ end
212
+ end
213
+
214
+ def receiver_for(const_name, kind, holes)
215
+ return nil if kind == "."
216
+
217
+ model = Holdfast.snake(const_name.split("::").last)
218
+ return { "ruby" => "FactoryBot.create(:#{model})" } if factories.include?(model) && model_class?(const_name)
219
+
220
+ if file_text(@target.file).match?(/^\s*module\s+#{Regexp.escape(const_name.split("::").last)}\b/) && !file_text(@target.file).match?(/^\s*class\s+#{Regexp.escape(const_name.split("::").last)}\b/)
221
+ return { "ruby" => "Object.new.extend(#{const_name})" }
222
+ end
223
+
224
+ init = method_node(file_text(@target.file), "initialize")
225
+ required = init && (Array(init.parameters&.requireds) + Array(init.parameters&.keywords).select { _1.is_a?(Prism::RequiredKeywordParameterNode) })
226
+ if required.nil? || required.empty?
227
+ { "ruby" => "#{const_name}.new" }
228
+ else
229
+ holes << "receiver"
230
+ { "fill" => "#{const_name}.new(#{required.map(&:name).join(", ")}): the receiver" }
231
+ end
232
+ end
233
+
234
+ def model_class?(const_name) = file_text(@target.file).match?(/class\s+#{const_name.split("::").last}\s*<\s*(ApplicationRecord|ActiveRecord::Base)/)
235
+
236
+ # param => [[attr, [bounds]]] for each `param.attr` the method calls,
237
+ # with the integer literals and constants a comparison sets it against.
238
+ def attribute_reads(node, params)
239
+ reads = Hash.new { |h, k| h[k] = [] }
240
+ walk(node) do |n|
241
+ next unless n.is_a?(Prism::CallNode)
242
+
243
+ if COMPARE.include?(n.name) && n.receiver && n.arguments&.arguments&.size == 1
244
+ [[n.receiver, n.arguments.arguments.first], [n.arguments.arguments.first, n.receiver]].each do |side, other|
245
+ next unless (pa = param_attr(side, params))
246
+
247
+ bound = integer_of(other)
248
+ reads[pa[0]] << [pa[1], bound ? [bound] : []]
249
+ end
250
+ elsif (pa = param_attr(n, params))
251
+ reads[pa[0]] << [pa[1], []]
252
+ end
253
+ end
254
+ reads.transform_values { |list| list.group_by(&:first).map { |attr, rows| [attr, rows.flat_map(&:last).uniq] } }
255
+ end
256
+
257
+ def param_attr(n, params)
258
+ return ["self", n.name.to_s] if params.include?("self") && n.is_a?(Prism::CallNode) && (n.receiver.nil? || n.receiver.is_a?(Prism::SelfNode)) && n.arguments.nil?
259
+ return nil unless n.is_a?(Prism::CallNode) && n.receiver.is_a?(Prism::LocalVariableReadNode) && params.include?(n.receiver.name.to_s)
260
+
261
+ [n.receiver.name.to_s, n.name.to_s]
262
+ end
263
+
264
+ def integer_of(n)
265
+ case n
266
+ when Prism::IntegerNode then n.value
267
+ when Prism::ConstantReadNode then constants[n.name.to_s]
268
+ end
269
+ end
270
+
271
+ def literals(node)
272
+ found = []
273
+ walk(node) do |n|
274
+ next unless n.is_a?(Prism::CallNode) && COMPARE.include?(n.name) && n.arguments&.arguments&.size == 1
275
+
276
+ [n.receiver, n.arguments.arguments.first].each { (v = integer_of(_1)) && found << v }
277
+ end
278
+ found.uniq
279
+ end
280
+
281
+ def constants = @constants ||= file_text(@target.file).scan(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(-?\d+)\s*$/).to_h { [_1, _2.to_i] }
282
+
283
+ def factories
284
+ @factories ||= git_files(%r{\A(spec|test)/(factories|support)/.*\.rb\z|\A(spec|test)/factories\.rb\z})
285
+ .flat_map { file_text(_1).scan(/^\s*factory\s+:(\w+)/).flatten }.uniq
286
+ end
287
+
288
+ def columns_of(factory)
289
+ table = factory.sub(/y\z/, "ie").sub(/(s|sh|ch|x)\z/, '\1e') + "s"
290
+ block = file_text("db/schema.rb")[/create_table "#{table}".*?^ end/m].to_s
291
+ block.scan(/t\.(\w+)\s+"(\w+)"/).to_h { [_2, _1] }
292
+ end
293
+
294
+ def git_files(pattern) = Change.git(@change.root, "ls-tree", "-r", "--name-only", @rev).lines.map(&:strip).grep(pattern)
295
+
296
+ def file_text(path)
297
+ return "" unless path
298
+
299
+ @files ||= {}
300
+ @files[path] ||= (Open3.capture2("git", "show", "#{@rev}:#{path}", chdir: @change.root).then { |out, st| st.success? ? out : "" })
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,186 @@
1
+ module Holdfast
2
+ # What an input has to draw before a row may say clean: each value the
3
+ # change added or removed that the target can see (a member of a changed
4
+ # list or table constant it reads, a literal a changed line compares
5
+ # with), and each branch the change added to it. Tests that reach a line
6
+ # and agree say nothing about a value none of them drew: a scope that
7
+ # stopped selecting `in_transit` agrees on every hold that is not in
8
+ # transit.
9
+ module Drawn
10
+ # arm is set for a branch whose body shares the line with its condition
11
+ # (`return x if y`): every run of that line counts it, so only branch
12
+ # coverage, matched on the body's span, can tell whether the body ran.
13
+ Need = Data.define(:value, :file, :line, :why, :arm) do
14
+ def initialize(value:, file:, line:, why:, arm: nil) = super
15
+ def label = value ? value.inspect : "the branch at #{File.basename(file)}:#{line}"
16
+ end
17
+ COMPARE = %i[== != === include? in? eql?].freeze
18
+
19
+ # Qualified constant name => { "added" => [...], "removed" => [...] } for
20
+ # every list or table constant the change edits, adds or deletes.
21
+ def self.constants(change)
22
+ @constants ||= {}
23
+ @constants[[change.base, change.head]] ||= begin
24
+ files = Change.git(change.root, "diff", "--name-only", change.base, change.head, "--", "app", "lib").lines.map(&:strip).select { _1.end_with?(".rb") }
25
+ files.each_with_object({}) do |file, out|
26
+ old = members(show(change, change.base, file))
27
+ new = members(show(change, change.head, file))
28
+ (old.keys | new.keys).each do |name|
29
+ a, r = Array(new[name]) - Array(old[name]), Array(old[name]) - Array(new[name])
30
+ out[name] = { "added" => a, "removed" => r, "file" => file } if a.any? || r.any?
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.show(change, rev, file)
37
+ out, status = Open3.capture2("git", "show", "#{rev}:#{file}", chdir: change.root, err: File::NULL)
38
+ status.success? ? out : ""
39
+ end
40
+
41
+ # NAME = %w[...] / [...] / { "k" => ... } (with or without .freeze), by
42
+ # its qualified name.
43
+ def self.members(source)
44
+ found = {}
45
+ walk = lambda do |node, nesting|
46
+ case node
47
+ when Prism::ClassNode, Prism::ModuleNode
48
+ inner = nesting + [node.constant_path.slice]
49
+ node.compact_child_nodes.each { walk.(_1, inner) }
50
+ return
51
+ when Prism::ConstantWriteNode
52
+ value = node.value
53
+ value = value.receiver if value.is_a?(Prism::CallNode) && value.name == :freeze && value.receiver
54
+ list = case value
55
+ when Prism::ArrayNode then value.elements.filter_map { literal(_1) }
56
+ when Prism::HashNode then value.elements.filter_map { literal(_1.key) if _1.is_a?(Prism::AssocNode) }
57
+ end
58
+ found[(nesting + [node.name.to_s]).join("::")] = list if list&.any?
59
+ end
60
+ node.compact_child_nodes.each { walk.(_1, nesting) }
61
+ end
62
+ walk.(Prism.parse(source).value, [])
63
+ found
64
+ end
65
+
66
+ def self.literal(node) = node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode) ? node.unescaped : nil
67
+
68
+ # The needs of one target: the changed constants it reads (by a changed
69
+ # line, or as the value a caller was walked from), the literals its
70
+ # changed lines compare with that the other side lacks, and the
71
+ # branches the change added inside it.
72
+ def self.needs(change, t, changed)
73
+ table = constants(change)
74
+ out = []
75
+ if (via = t.reads&.fetch("constant", nil)) && (entry = lookup(table, via))
76
+ out += members_needs(via, entry)
77
+ end
78
+ span = t.span || {}
79
+ head_lines = Array(changed.dig(t.file, "head")).select { |l| span["new_start"] && (span["new_start"]..span["new_end"]).cover?(l) }
80
+ base_lines = Array(changed.dig(t.file, "base")).select { |l| span["old_start"] && (span["old_start"]..span["old_end"]).cover?(l) }
81
+ head_src = show(change, change.head, t.file)
82
+ base_src = show(change, change.base, t.file)
83
+ head_ast = Prism.parse(head_src).value
84
+ base_ast = Prism.parse(base_src).value
85
+ [[head_ast, head_lines], [base_ast, base_lines]].each do |ast, lines|
86
+ nodes(ast) { |n| lines.include?(n.location.start_line) && (n.is_a?(Prism::ConstantReadNode) || n.is_a?(Prism::ConstantPathNode)) }
87
+ .each { |n| (entry = lookup(table, n.slice)) && (out += members_needs(n.slice, entry)) }
88
+ end
89
+ head_vals = compared(head_ast, head_lines)
90
+ base_vals = compared(base_ast, base_lines)
91
+ head_all = compared(head_ast, span["new_start"] ? (span["new_start"]..span["new_end"]).to_a : [])
92
+ base_all = compared(base_ast, span["old_start"] ? (span["old_start"]..span["old_end"]).to_a : [])
93
+ (head_vals - base_all).each { out << Need.new(value: _1, file: t.file, line: nil, why: "a changed line of #{t.name} compares with it") }
94
+ (base_vals - head_all).each { out << Need.new(value: _1, file: t.file, line: nil, why: "#{t.name} no longer compares with it") }
95
+ branches(head_ast, head_lines).each { |line, arm, text| out << Need.new(value: nil, file: t.file, line:, arm:, why: "the change adds `#{text}`") }
96
+ out.uniq { [_1.value, _1.line] }
97
+ end
98
+
99
+ def self.lookup(table, name)
100
+ return table[name] if table.key?(name)
101
+
102
+ hit = table.keys.select { _1 == name || _1.end_with?("::#{name.split("::").last}") && name.split("::").last == _1.split("::").last }
103
+ hit.size == 1 ? table[hit.first] : nil
104
+ end
105
+
106
+ def self.members_needs(name, entry)
107
+ entry["added"].map { Need.new(value: _1, file: entry["file"], line: nil, why: "#{name} adds it") } +
108
+ entry["removed"].map { Need.new(value: _1, file: entry["file"], line: nil, why: "#{name} drops it") }
109
+ end
110
+
111
+ # String and symbol literals in a comparison, a `when`, or an `in` on
112
+ # the given lines.
113
+ def self.compared(ast, lines)
114
+ return [] if lines.empty?
115
+
116
+ vals = []
117
+ nodes(ast) { lines.include?(_1.location.start_line) }.each do |n|
118
+ case n
119
+ when Prism::CallNode
120
+ next unless COMPARE.include?(n.name)
121
+
122
+ ([n.receiver] + Array(n.arguments&.arguments)).each do |x|
123
+ vals << literal(x) if literal(x)
124
+ vals += x.elements.filter_map { literal(_1) } if x.is_a?(Prism::ArrayNode)
125
+ end
126
+ when Prism::WhenNode then vals += n.conditions.filter_map { literal(_1) }
127
+ when Prism::InNode then vals << literal(n.pattern) if literal(n.pattern)
128
+ end
129
+ end
130
+ vals.compact.uniq
131
+ end
132
+
133
+ # Each if, unless, elsif, when or in that starts on a changed line,
134
+ # with the first line of its body: reaching it is drawing the branch. A
135
+ # body on the condition's own line also carries its span.
136
+ def self.branches(ast, lines)
137
+ nodes(ast) { |n| (n.is_a?(Prism::IfNode) || n.is_a?(Prism::UnlessNode) || n.is_a?(Prism::WhenNode) || n.is_a?(Prism::InNode)) && lines.include?(n.location.start_line) }.filter_map do |n|
138
+ body = n.statements&.body&.first or next
139
+ line = body.location.start_line
140
+ # An arm's start alone is ambiguous: the else arm of `x if y` starts where
141
+ # the whole expression does, so the arm is named by its start and end.
142
+ arm = (l = body.location; "#{l.start_line}:#{l.start_column}-#{l.end_line}:#{l.end_column}") if line == n.location.start_line && !n.is_a?(Prism::WhenNode)
143
+ [line, arm, n.slice.lines.first.strip[0, 60]]
144
+ end
145
+ end
146
+
147
+ def self.nodes(ast, &keep)
148
+ found = []
149
+ stack = [ast]
150
+ while (n = stack.pop)
151
+ found << n if keep.(n)
152
+ stack.concat(n.compact_child_nodes)
153
+ end
154
+ found
155
+ end
156
+
157
+ # A value counts as drawn when one reached test wrote it on both sides:
158
+ # the same input on base and head. Written on one side only, it came
159
+ # from the code under test, not from an input. A branch counts when a
160
+ # reached test ran its first body line on head, or, for a body on the
161
+ # condition's line, took that arm.
162
+ def self.missing(needs, outcomes, reach)
163
+ needs.reject do |need|
164
+ outcomes.any? do |o|
165
+ if need.value then sides_writing(reach, o, need.value).size == 2
166
+ elsif need.arm then Stages::Diff.ran(reach, o, "2").fetch(Stages::Diff::ARMS + need.file, []).include?(need.arm)
167
+ else Stages::Diff.ran(reach, o, "2").fetch(need.file, []).include?(need.line)
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ def self.sides_writing(reach, outcome, value) = %w[1 2].select { Array(reach.dig(outcome.source.path, _1, Stages::Diff::DREW)).include?(value) }
174
+
175
+ # "in_transit: written on base only by spec/services/member_merge_spec.rb"
176
+ def self.one_sided(needs, outcomes, reach)
177
+ needs.filter_map do |need|
178
+ next unless need.value
179
+
180
+ o = outcomes.find { sides_writing(reach, _1, need.value).size == 1 } or next
181
+ side = sides_writing(reach, o, need.value).first == "1" ? "base" : "head"
182
+ "#{need.label} was written on #{side} only, by the code that #{Stages::Diff.shown_path(o.source)} runs, and the tests still agree"
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,90 @@
1
+ module Holdfast
2
+ # One run: read the record and the change, then run the stages the change
3
+ # calls for. The harness decides what runs from the change alone; the
4
+ # caller only adds evidence (tests, pin inputs) between runs.
5
+ # change select -> fill (inputs or drafts) -> diff -> fold, one row per target
6
+ # (+ hunt over the changed areas with --sequences)
7
+ # before select the named targets -> fill and vary -> one row per target
8
+ # hunt nothing changed and nothing named: sequences over the whole app
9
+ module Harness
10
+ Result = Data.define(:change, :verdicts, :notes, :seconds)
11
+
12
+ def self.run(root, base:, named:, relearn: false, only: [], sequences: false, budget: nil, observe: false, intent: nil)
13
+ timings = {}
14
+ record = timed(timings, "record") { Record.load_or_learn(root, relearn:) }
15
+ change = Change.read(root, base_ref: base || record.base_branch, named:)
16
+ unless record.ok?
17
+ return Result.new(change:, notes: [], seconds: timings, verdicts: [
18
+ Verdict.of(target: "(all)", stage: "record", kind: "unchecked",
19
+ reason: "project setup failed (#{record.failure}); see .holdfast/record.json steps",
20
+ next_step: "fix the environment the failed step names, then holdfast run --learn")
21
+ ])
22
+ end
23
+
24
+ verdicts = []
25
+ notes = []
26
+ if change.mode == "hunt"
27
+ verdicts += timed(timings, "hunt") { Stages::Hunt.run(change, record, budget:) }
28
+ else
29
+ Tools.jitcatch!
30
+ selected = timed(timings, "select") { Stages::Select.run(change, record) }
31
+ targets = only.empty? ? selected.targets : selected.targets.select { only.include?(_1.name) }
32
+ notes += selected.notes + ["targets: #{targets.count(&:method?)} method(s), #{targets.count { _1.kind == "reader" }} reader(s)"]
33
+ verdicts += selected.verdicts
34
+ pins, gaps = timed(timings, "fill") { Stages::Fill.run(change, record, targets) }
35
+ if change.changed?
36
+ lines = Stages::Fold.changed_lines(change)
37
+ values = targets.flat_map { Drawn.needs(change, _1, lines) }.filter_map(&:value).uniq
38
+ diff = timed(timings, "diff") { Stages::Diff.run(change, record, targets, pins, values:) }
39
+ notes << "diff: #{diff.failure}" if diff.failure
40
+ verdicts += Stages::Fold.run(change, targets, diff, pins, gaps)
41
+ verdicts += timed(timings, "observe") { Stages::Observe.run(change, record, intent:, files: changed_files(change)) } if observe
42
+ verdicts += timed(timings, "sequences") { Stages::Hunt.run(change, record, budget:, files: changed_files(change)) } if sequences
43
+ else
44
+ verdicts += pins.flat_map { before_rows(change, _1) } + gaps.map { gap_row(_1) }
45
+ end
46
+ end
47
+ reach = verdicts.find { _1.target == "(hunt)" }
48
+ notes.unshift("#{reach.reason.include?("; 0 of ") ? "hunt" : "HUNT REACHED LITTLE"}: #{reach.reason}") if reach
49
+ notes << "sequences: not run; add --sequences before a pull request" if change.changed? && !sequences
50
+ Result.new(change:, verdicts: verdicts.sort_by { [_1.target == "(hunt)" ? -1 : KINDS.index(_1.kind), _1.stage == "caller" ? 0 : 1, _1.target] }, notes:, seconds: timings)
51
+ end
52
+
53
+ def self.changed_files(change) = Change.git(change.root, "diff", "--name-only", change.base, change.head, "--", "app").lines.map(&:strip)
54
+
55
+ def self.timed(timings, name)
56
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+ yield
58
+ ensure
59
+ timings[name] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1)
60
+ end
61
+
62
+ def self.before_rows(change, pins)
63
+ declared = pins.inputs.declared_errors
64
+ # An error the method raises itself (`raise Refused, ...`) is its
65
+ # behavior, and the pin holds it; only another error is a suspect.
66
+ own = ->(klass) { pins.target.source.to_s.match?(/raise\s+(#{Regexp.escape(klass)}|#{Regexp.escape(klass.split("::").last)})\b/) }
67
+ raised = pins.observed.select { _1["raises"] && !declared.include?(_1["raises"]) && !own.(_1["raises"]) }
68
+ details = Stages::Fold.pin_details(pins)
69
+ pins.observed.filter_map { _1["raises"] if _1["raises"] && own.(_1["raises"]) }.tally.each { |k, n| details << "#{n} input(s) raise #{k}, which the method raises itself; pinned" }
70
+ rows = raised.group_by { _1["raises"] }.map do |klass, same|
71
+ Verdict.of(target: pins.target.name, stage: "fill", kind: "bug_suspect",
72
+ reason: "#{same.size} input(s) raised #{klass}: #{same.first["message"]}",
73
+ evidence: details, next_step: "fix the code, or mark the input \"raises\": \"#{klass}\" in #{Stages::Fold.rel(pins.inputs.file)} if that error is the behavior")
74
+ end
75
+ ran = pins.observed.count { !_1["input_error"] }
76
+ kind = ran.zero? ? "unchecked" : "clean"
77
+ rows << Verdict.of(target: pins.target.name, stage: "fill", kind:,
78
+ reason: ran.zero? ? "no input ran" : "pinned #{ran} input(s) at #{change.short(change.head)} by what each returns, raises and writes",
79
+ evidence: details + pins.target.tests.first(3).map { "selected test: #{_1}" },
80
+ scope: pins.dir.delete_prefix("#{change.root}/"),
81
+ next_step: ran.zero? ? "fix the inputs the evidence names, then holdfast run" : "make the change, then holdfast run; these pins are compared on both sides")
82
+ rows
83
+ end
84
+
85
+ def self.gap_row(gap)
86
+ step = gap.draft ? "fill #{gap.draft.holes.join(", ")} in #{Stages::Fold.rel(gap.draft.path)}, copy it to .holdfast/evidence/, then holdfast run" : "write .holdfast/evidence/#{Holdfast.slug(gap.target.name)}.inputs.json, then holdfast run"
87
+ Verdict.of(target: gap.target.name, stage: "fill", kind: "unchecked", reason: "no pin: #{gap.reason}", next_step: step)
88
+ end
89
+ end
90
+ end