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,136 @@
|
|
|
1
|
+
# Loaded by holdfast's pin tests inside the app's own suite. It calls one
|
|
2
|
+
# method on one input and turns what it saw into plain JSON: the returned
|
|
3
|
+
# value (a record by its attributes, not its class), the error it raised,
|
|
4
|
+
# and the attributes of each record the call created or changed. A pin test
|
|
5
|
+
# compares that JSON with the JSON the base side produced, and names only the
|
|
6
|
+
# paths that differ.
|
|
7
|
+
require "json"
|
|
8
|
+
require "date"
|
|
9
|
+
|
|
10
|
+
module Holdfast
|
|
11
|
+
module Observe
|
|
12
|
+
# Ids, foreign keys and timestamps differ between two runs of the same
|
|
13
|
+
# input, so they never decide a pin.
|
|
14
|
+
VOLATILE = /\A(id|created_at|updated_at)\z|_id\z/
|
|
15
|
+
RECENT = 86_400
|
|
16
|
+
|
|
17
|
+
module Track
|
|
18
|
+
def _create_record(*)
|
|
19
|
+
super.tap { (Thread.current[:holdfast_writes] ||= []) << [:created, self] if Thread.current[:holdfast_writes] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def _update_record(*)
|
|
23
|
+
super.tap { (Thread.current[:holdfast_writes] ||= []) << [:updated, self] if Thread.current[:holdfast_writes] }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
ActiveRecord::Base.prepend(Track) if defined?(ActiveRecord::Base)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def observe(context, input, target)
|
|
31
|
+
const_name, kind, name = target.match(/\A(.+?)([#.])(\w+[?!=]?)\z/).captures
|
|
32
|
+
const = Object.const_get(const_name)
|
|
33
|
+
within_savepoint do
|
|
34
|
+
run_ruby(context, input["before"]) if input["before"]
|
|
35
|
+
receiver = if input.key?("receiver") then value(context, input["receiver"])
|
|
36
|
+
elsif kind == "." then const
|
|
37
|
+
else const.new(*value(context, input.fetch("new", [])))
|
|
38
|
+
end
|
|
39
|
+
args = value(context, Array(input["args"]))
|
|
40
|
+
kwargs = value(context, input.fetch("kwargs", {})).transform_keys(&:to_sym)
|
|
41
|
+
Thread.current[:holdfast_writes] = []
|
|
42
|
+
outcome = begin
|
|
43
|
+
{ "returns" => project(receiver.__send__(name, *args, **kwargs)) }
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
{ "raises" => e.class.name, "message" => e.message.gsub(/\d{4,}/, "N").lines.first.to_s.strip }
|
|
46
|
+
end
|
|
47
|
+
writes = Thread.current[:holdfast_writes]
|
|
48
|
+
outcome["writes"] = written(writes) unless writes.empty?
|
|
49
|
+
outcome
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[:holdfast_writes] = nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Paths whose values differ, as "path: base X, head Y" lines.
|
|
56
|
+
def differences(expected, actual, path = "")
|
|
57
|
+
return [] if expected == actual
|
|
58
|
+
|
|
59
|
+
if expected.is_a?(Hash) && actual.is_a?(Hash)
|
|
60
|
+
(expected.keys | actual.keys).flat_map { differences(expected[_1], actual[_1], path.empty? ? _1.to_s : "#{path}.#{_1}") }
|
|
61
|
+
elsif expected.is_a?(Array) && actual.is_a?(Array) && expected.size == actual.size
|
|
62
|
+
expected.each_index.flat_map { differences(expected[_1], actual[_1], "#{path}[#{_1}]") }
|
|
63
|
+
else
|
|
64
|
+
["#{path.empty? ? "value" : path}: base #{short(expected)}, head #{short(actual)}"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def short(v) = (v.nil? ? "(none)" : v.is_a?(String) ? v : JSON.generate(v)).then { _1.size > 90 ? "#{_1[0, 87]}..." : _1 }
|
|
69
|
+
|
|
70
|
+
def within_savepoint
|
|
71
|
+
return yield unless defined?(ActiveRecord::Base)
|
|
72
|
+
|
|
73
|
+
result = nil
|
|
74
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
75
|
+
result = yield
|
|
76
|
+
raise ActiveRecord::Rollback
|
|
77
|
+
end
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run_ruby(context, code) = context.instance_eval(code.is_a?(Hash) ? code.fetch("ruby") : code.to_s)
|
|
82
|
+
|
|
83
|
+
def value(context, v)
|
|
84
|
+
case v
|
|
85
|
+
when Hash then v.keys == ["ruby"] ? run_ruby(context, v) : v.to_h { [_1, value(context, _2)] }
|
|
86
|
+
when Array then v.map { value(context, _1) }
|
|
87
|
+
else v
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def written(writes)
|
|
92
|
+
seen = {}
|
|
93
|
+
writes.each do |how, record|
|
|
94
|
+
key = [how, record.class.name, record.object_id]
|
|
95
|
+
if how == :created
|
|
96
|
+
seen[key] = { "created" => record.class.name, "attributes" => attributes(record) }
|
|
97
|
+
else
|
|
98
|
+
changed = record.saved_changes.keys.grep_v(VOLATILE)
|
|
99
|
+
next if changed.empty?
|
|
100
|
+
|
|
101
|
+
entry = (seen[key] ||= { "updated" => record.class.name, "changed" => {} })
|
|
102
|
+
changed.each { entry["changed"][_1] = project(record.public_send(_1)) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
seen.values.sort_by { JSON.generate(_1) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def attributes(record) = record.attributes.reject { |k, _| k.match?(VOLATILE) }.sort.to_h.transform_values { project(_1) }
|
|
109
|
+
|
|
110
|
+
def project(v, depth = 0)
|
|
111
|
+
return v.class.name if depth > 3
|
|
112
|
+
|
|
113
|
+
case v
|
|
114
|
+
when nil, true, false, Integer, String then v
|
|
115
|
+
when Float then v.round(6)
|
|
116
|
+
when Symbol then ":#{v}"
|
|
117
|
+
when Date then v.respond_to?(:hour) ? time(v) : "Date #{v.iso8601}"
|
|
118
|
+
when Time then time(v)
|
|
119
|
+
when ->(x) { defined?(ActiveSupport::TimeWithZone) && x.is_a?(ActiveSupport::TimeWithZone) } then time(v)
|
|
120
|
+
when ->(x) { defined?(BigDecimal) && x.is_a?(BigDecimal) } then v.to_s("F")
|
|
121
|
+
when ->(x) { defined?(ActiveRecord::Base) && x.is_a?(ActiveRecord::Base) }
|
|
122
|
+
{ "record" => v.class.name, "persisted" => v.persisted? }.merge(attributes(v))
|
|
123
|
+
when ->(x) { defined?(ActiveRecord::Relation) && x.is_a?(ActiveRecord::Relation) } then v.to_a.first(20).map { project(_1, depth + 1) }
|
|
124
|
+
when Array then v.first(50).map { project(_1, depth + 1) }
|
|
125
|
+
when Hash then v.to_h { [_1.is_a?(Symbol) ? ":#{_1}" : _1.to_s, project(_2, depth + 1)] }
|
|
126
|
+
when Data then { "data" => v.class.name }.merge(v.to_h.to_h { [_1.to_s, project(_2, depth + 1)] })
|
|
127
|
+
when Struct then { "struct" => v.class.name }.merge(v.to_h.to_h { [_1.to_s, project(_2, depth + 1)] })
|
|
128
|
+
else { "object" => v.class.name }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A time near now comes from the clock, not the input, so it is pinned
|
|
133
|
+
# as recent only.
|
|
134
|
+
def time(t) = (Time.now.to_f - t.to_f).abs < RECENT ? "Time (now)" : "Time #{t.utc.iso8601}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Loaded through RUBYOPT into each test process jitcatch starts, before the
|
|
2
|
+
# app: it records which lines of the app each test file ran, so a row can say
|
|
3
|
+
# whether a test reached the changed lines or only named the method. It runs
|
|
4
|
+
# with no holdfast code loaded and writes one JSON file per process.
|
|
5
|
+
require "coverage"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
# eval: true also counts code compiled from a file name, which is how
|
|
9
|
+
# ActionView runs a template. Branches tell whether the body of a
|
|
10
|
+
# `return x if y` ran, which line counts cannot.
|
|
11
|
+
Coverage.start(lines: true, branches: true, eval: true) unless Coverage.running?
|
|
12
|
+
|
|
13
|
+
# Which of the values the change added or removed (HOLDFAST_VALUES, a JSON
|
|
14
|
+
# list) an input drew: a row written with that value. Reads do not count: a
|
|
15
|
+
# scope that selects by a constant puts every member in each query.
|
|
16
|
+
HOLDFAST_WANTED = (JSON.parse(ENV.fetch("HOLDFAST_VALUES", "[]")) rescue []).map(&:to_s)
|
|
17
|
+
HOLDFAST_DREW = []
|
|
18
|
+
unless HOLDFAST_WANTED.empty?
|
|
19
|
+
attach = TracePoint.new(:end) do |tp|
|
|
20
|
+
next unless defined?(ActiveSupport::Notifications)
|
|
21
|
+
|
|
22
|
+
tp.disable
|
|
23
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
24
|
+
sql = payload[:sql].to_s
|
|
25
|
+
next unless sql.match?(/\A\s*(INSERT|UPDATE)/i)
|
|
26
|
+
|
|
27
|
+
binds = payload[:type_casted_binds]
|
|
28
|
+
binds = binds.call if binds.respond_to?(:call)
|
|
29
|
+
seen = Array(binds).map(&:to_s) + sql.scan(/'((?:[^']|'')*)'/).flatten
|
|
30
|
+
HOLDFAST_DREW.concat(HOLDFAST_WANTED & seen)
|
|
31
|
+
HOLDFAST_DREW.uniq!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
attach.enable
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# What ran since the last take, as { file => lines } and { file => arms },
|
|
38
|
+
# for the app's own files. Each take clears the counts, so the file's total
|
|
39
|
+
# is the union of every take.
|
|
40
|
+
HOLDFAST_ROOT = "#{Dir.pwd}/"
|
|
41
|
+
HOLDFAST_TOTAL = { "hits" => Hash.new { |h, k| h[k] = [] }, "arms" => Hash.new { |h, k| h[k] = [] } }
|
|
42
|
+
def holdfast_take
|
|
43
|
+
hits = {}
|
|
44
|
+
arms = {}
|
|
45
|
+
Coverage.result(stop: false, clear: true).each do |path, counts|
|
|
46
|
+
next unless path.start_with?(HOLDFAST_ROOT)
|
|
47
|
+
|
|
48
|
+
relative = path.delete_prefix(HOLDFAST_ROOT)
|
|
49
|
+
next unless relative.start_with?("app/", "lib/")
|
|
50
|
+
|
|
51
|
+
lines = counts.is_a?(Hash) ? counts[:lines] : counts
|
|
52
|
+
ran = Array(lines).each_with_index.filter_map { |count, i| i + 1 if count.to_i.positive? }
|
|
53
|
+
taken = (counts.is_a?(Hash) && counts[:branches] || {}).values.flat_map { |a| a.filter_map { |(_, _, line, column, last, last_column), n| "#{line}:#{column}-#{last}:#{last_column}" if n.positive? } }
|
|
54
|
+
hits[relative] = ran unless ran.empty?
|
|
55
|
+
arms[relative] = taken unless taken.empty?
|
|
56
|
+
end
|
|
57
|
+
hits.each { |k, v| HOLDFAST_TOTAL["hits"][k] |= v }
|
|
58
|
+
arms.each { |k, v| HOLDFAST_TOTAL["arms"][k] |= v }
|
|
59
|
+
{ "hits" => hits, "arms" => arms }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One test file holds examples that agree and examples the change added, so
|
|
63
|
+
# a file's lines cannot say which example drew a new branch. Each example's
|
|
64
|
+
# own lines are kept by the line it is declared on, the line jitcatch
|
|
65
|
+
# reports for it.
|
|
66
|
+
HOLDFAST_EXAMPLES = {}
|
|
67
|
+
module HoldfastRSpecExample
|
|
68
|
+
def run(*)
|
|
69
|
+
holdfast_take
|
|
70
|
+
super
|
|
71
|
+
ensure
|
|
72
|
+
HOLDFAST_EXAMPLES[metadata[:line_number]] = holdfast_take
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
module HoldfastMinitestTest
|
|
76
|
+
def run(*)
|
|
77
|
+
holdfast_take
|
|
78
|
+
super
|
|
79
|
+
ensure
|
|
80
|
+
line = (method(name).source_location&.last rescue nil)
|
|
81
|
+
HOLDFAST_EXAMPLES[line] = holdfast_take if line
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
holdfast_hook = TracePoint.new(:end) do |tp|
|
|
85
|
+
if defined?(RSpec::Core::Example) && !RSpec::Core::Example.include?(HoldfastRSpecExample)
|
|
86
|
+
RSpec::Core::Example.prepend(HoldfastRSpecExample)
|
|
87
|
+
tp.disable
|
|
88
|
+
elsif defined?(Minitest::Test) && !Minitest::Test.include?(HoldfastMinitestTest)
|
|
89
|
+
Minitest::Test.prepend(HoldfastMinitestTest)
|
|
90
|
+
tp.disable
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
holdfast_hook.enable
|
|
94
|
+
|
|
95
|
+
at_exit do
|
|
96
|
+
dir = ENV["HOLDFAST_REACH_DIR"]
|
|
97
|
+
test = ARGV.reverse.find { _1.include?(".jitcatch/") }
|
|
98
|
+
next unless dir && test
|
|
99
|
+
|
|
100
|
+
holdfast_take
|
|
101
|
+
record = { "side" => ENV["TEST_ENV_NUMBER"], "test" => test.sub(%r{\A.*?\.jitcatch/}, ""), "hits" => HOLDFAST_TOTAL["hits"],
|
|
102
|
+
"drew" => HOLDFAST_DREW, "arms" => HOLDFAST_TOTAL["arms"], "examples" => HOLDFAST_EXAMPLES.compact }
|
|
103
|
+
File.write(File.join(dir, "reach-#{ENV["TEST_ENV_NUMBER"]}-#{Process.pid}.json"), JSON.generate(record))
|
|
104
|
+
end
|