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