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,197 @@
|
|
|
1
|
+
# Runs every sequence definition in SEQUENCES_OUT, each in its own process
|
|
2
|
+
# on its own numbered test database, until the seeds are clean or one fails,
|
|
3
|
+
# and prints one table. The hunt stage runs it as a script:
|
|
4
|
+
#
|
|
5
|
+
# ruby run.rb [-j 4] [--cases 300] [--seeds 1,2,3] [--timeout S] [--json FILE] [FILE...]
|
|
6
|
+
require "json"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "etc"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require "fileutils"
|
|
11
|
+
|
|
12
|
+
$stdout.sync = true
|
|
13
|
+
# Logs hold UTF-8 (zone names, refusals); a caller with no LANG must not make them US-ASCII.
|
|
14
|
+
Encoding.default_external = Encoding::UTF_8
|
|
15
|
+
|
|
16
|
+
LIB = __dir__
|
|
17
|
+
|
|
18
|
+
args = ARGV.dup
|
|
19
|
+
take = ->(flag, default) { (i = args.index(flag)) ? args.slice!(i, 2).last : default }
|
|
20
|
+
jobs = Integer(take.("-j", [Etc.nprocessors, 4].min))
|
|
21
|
+
cases = Integer(take.("--cases", 300))
|
|
22
|
+
seeds = take.("--seeds", Array.new(3) { rand(1..2**31 - 1) }.join(","))
|
|
23
|
+
limit = Integer(take.("--timeout", 3600))
|
|
24
|
+
json_out = take.("--json", nil)
|
|
25
|
+
defs = ENV.fetch("SEQUENCES_OUT", "spec/sequences")
|
|
26
|
+
files = args.empty? ? Dir["#{defs}/*.rb"].reject { File.basename(it) == "invariants.rb" }.sort : args
|
|
27
|
+
abort "no definitions in #{defs}: holdfast run drafts them" if files.empty?
|
|
28
|
+
|
|
29
|
+
out = File.expand_path(ENV.fetch("SEQUENCES_WORK", "tmp/sequences"))
|
|
30
|
+
FileUtils.mkdir_p(out)
|
|
31
|
+
jobs = [jobs, files.size].min
|
|
32
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
|
|
34
|
+
# One database per worker, so two areas never truncate each other's rows.
|
|
35
|
+
# SEQUENCES_DATABASE_URL names a database prefix of the caller's, so two
|
|
36
|
+
# apps on one server never share app_test1.
|
|
37
|
+
worker_env = lambda do |i|
|
|
38
|
+
url = ENV["SEQUENCES_DATABASE_URL"]
|
|
39
|
+
{ "TEST_ENV_NUMBER" => (i + 1).to_s }.merge(url ? { "DATABASE_URL" => "#{url}_seq#{i + 1}" } : {})
|
|
40
|
+
end
|
|
41
|
+
puts "preparing #{jobs} test databases (TEST_ENV_NUMBER=1..#{jobs})"
|
|
42
|
+
jobs.times.map do |i|
|
|
43
|
+
Thread.new do
|
|
44
|
+
log, status = Open3.capture2e(worker_env.(i).merge("RAILS_ENV" => "test"),
|
|
45
|
+
"bin/rails", "db:create", "db:schema:load")
|
|
46
|
+
abort "database #{i + 1} failed:\n#{log}" unless status.success?
|
|
47
|
+
end
|
|
48
|
+
end.each(&:join)
|
|
49
|
+
puts "seeds #{seeds}, #{cases} cases each, #{files.size} areas, #{jobs} at a time"
|
|
50
|
+
|
|
51
|
+
queue = Queue.new
|
|
52
|
+
files.each { queue << it }
|
|
53
|
+
results = {}
|
|
54
|
+
running = {}
|
|
55
|
+
lock = Mutex.new
|
|
56
|
+
|
|
57
|
+
# Each refusal names what to change: a param the text mentions, or the kind
|
|
58
|
+
# of input a refusal like it needs.
|
|
59
|
+
def hint(text, params)
|
|
60
|
+
low = text.to_s.downcase
|
|
61
|
+
named = params.select do |param|
|
|
62
|
+
words = param.to_s.sub(/_(id|at|on|from)\z/, "").split("_")
|
|
63
|
+
low.include?(param.to_s) || words.all? { low.include?(it) }
|
|
64
|
+
end
|
|
65
|
+
return "change #{named.join(", ")}" if named.any?
|
|
66
|
+
|
|
67
|
+
case low
|
|
68
|
+
when /only one door picks|one door only|one door makes a new/
|
|
69
|
+
"the two doors cannot take the same inputs; drop the twins line, or give both doors the same params"
|
|
70
|
+
when /ends? before|duration|longest|shortest|minutes/
|
|
71
|
+
"draw the end from the start: #{params.grep(/end|until|minutes/).first || "ends_at"}: after(:starts_at, minutes: [...])"
|
|
72
|
+
when /missing param (\w+)/ then "add the param #{$1} to the action body"
|
|
73
|
+
when /param is missing|is missing|is invalid/ then "send the param the message names"
|
|
74
|
+
when /unauthori[sz]ed|not recogni[sz]ed|pin|token|unknown kiosk|forbidden/ then "a credential: the caller pool, auth_for, or the setup"
|
|
75
|
+
when /not found|couldn't find/ then "the record it looks up is not there for this caller: narrow the pool or make it in setup"
|
|
76
|
+
when /closed|not open|hours|weekday/ then "a time param inside opening hours (BOUNDARY_TIMES, clock start)"
|
|
77
|
+
when /passed|ahead|too early|too late|closed at|has closed/ then "a time param or the clock: how far ahead it is drawn"
|
|
78
|
+
else "make the state it needs in setup, or narrow a pool to records it accepts"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clean_refusal(text) = text.to_s.sub(/\A(HTTP \d+ )?refused: /, "").gsub(/\s+/, " ")[0, 70]
|
|
83
|
+
|
|
84
|
+
# An action reached its target when one run ended other than refused; one
|
|
85
|
+
# that was only refused, never drawn, or left as TODO checked nothing.
|
|
86
|
+
def scope(report)
|
|
87
|
+
reached = report["reached"]
|
|
88
|
+
missing = report["actions"].filter_map do |action|
|
|
89
|
+
kinds = reached[action] || {}
|
|
90
|
+
params = report.dig("params", action.sub(/ \[other offset\]\z/, "")) || []
|
|
91
|
+
if kinds.empty? then "#{action} (never drawn)"
|
|
92
|
+
elsif kinds.keys.all? { it.include?("refused") }
|
|
93
|
+
texts = report.dig("refused_with", action) || []
|
|
94
|
+
text = texts.first
|
|
95
|
+
also = texts.drop(1).map { clean_refusal(it) }.uniq - [clean_refusal(text)]
|
|
96
|
+
"#{action} (only refused: #{clean_refusal(text)}#{"; also: #{also.join("; ")}" unless also.empty?}; hint: #{hint(text, params)})"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
done = report["actions"].size - missing.size
|
|
100
|
+
missing += report["skipped"].map { |action, why| "#{action} (#{why})" }
|
|
101
|
+
[done, report["actions"].size + report["skipped"].size, missing]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
RANK = { "FAILURE" => 0, "unchecked" => 1, "partly checked" => 2, "clean" => 3 }.freeze
|
|
105
|
+
|
|
106
|
+
# A verdict of clean means every action, offset replay and pair of doors
|
|
107
|
+
# reached its target at least once; anything less is partly checked.
|
|
108
|
+
def verdict(r, limit)
|
|
109
|
+
rep = r[:report]
|
|
110
|
+
if rep.nil?
|
|
111
|
+
tail = File.readlines(r[:log]).grep(/Error|error:|uninitialized|undefined/).first.to_s.strip
|
|
112
|
+
return ["unchecked", "-", "unchecked: #{r[:status] == :timeout ? "timed out after #{limit} s" : "no report (#{tail[0, 100]})"}"]
|
|
113
|
+
end
|
|
114
|
+
done, total, missing = scope(rep)
|
|
115
|
+
# The time limit stopped the area between cases with no failure found:
|
|
116
|
+
# what ran still counts, and the row says it was cut short.
|
|
117
|
+
if rep["verdict"].nil?
|
|
118
|
+
ran = File.read(r[:log])[/: (\d+) runs in/, 1]
|
|
119
|
+
rep = rep.merge("verdict" => "clean", "seeds" => [nil])
|
|
120
|
+
cut = "cut by the time limit after #{ran || "some"} of #{rep["cases"]} cases"
|
|
121
|
+
end
|
|
122
|
+
case rep["verdict"]
|
|
123
|
+
when "clean"
|
|
124
|
+
runs = cut || "#{rep["seeds"].size} seeds x #{rep["cases"]} cases"
|
|
125
|
+
return ["clean", "#{done}/#{total}", "clean: #{runs}"] if missing.empty?
|
|
126
|
+
|
|
127
|
+
["partly checked", "#{done}/#{total}", "partly checked: #{runs}; not covered:\n#{missing.map { " - #{it}" }.join("\n")}"]
|
|
128
|
+
when "failure" then ["FAILURE", "#{done}/#{total}", "FAILURE: #{rep["reason"].to_s.lines.first.to_s.strip}"]
|
|
129
|
+
else ["unchecked", "#{done}/#{total}", "unchecked: #{rep["reason"].to_s.lines.first.to_s.strip}"]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
134
|
+
estimated = false
|
|
135
|
+
heartbeat = Thread.new do
|
|
136
|
+
loop do
|
|
137
|
+
sleep 20
|
|
138
|
+
lock.synchronize do
|
|
139
|
+
busy = running.map { |name, t0| "#{name} #{(now.() - t0).round} s" }.join(", ")
|
|
140
|
+
puts " ... #{(now.() - started).round} s: #{results.size} of #{files.size} done; running #{busy}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
jobs.times.map do |i|
|
|
146
|
+
Thread.new do
|
|
147
|
+
while (file = (queue.pop(true) rescue nil))
|
|
148
|
+
name = File.basename(file, ".rb")
|
|
149
|
+
report = File.join(out, "#{name}.json")
|
|
150
|
+
log = File.join(out, "#{name}.log")
|
|
151
|
+
File.delete(report) if File.exist?(report)
|
|
152
|
+
env = { **worker_env.(i), "SEQ_CASES" => cases.to_s, "SEQ_SEEDS" => seeds,
|
|
153
|
+
"SEQ_REPORT" => report, "SEQUENCES_LIB" => File.join(LIB, "sequences.rb") }
|
|
154
|
+
t0 = now.()
|
|
155
|
+
lock.synchronize { running[name] = t0 }
|
|
156
|
+
pid = Process.spawn(env, "bundle", "exec", "rspec", file, out: log, err: [:child, :out], pgroup: true)
|
|
157
|
+
status = begin
|
|
158
|
+
Timeout.timeout(limit) { Process.wait2(pid).last }
|
|
159
|
+
rescue Timeout::Error
|
|
160
|
+
Process.kill("TERM", -pid)
|
|
161
|
+
Process.wait(pid)
|
|
162
|
+
:timeout
|
|
163
|
+
end
|
|
164
|
+
seconds = (now.() - t0).round
|
|
165
|
+
lock.synchronize do
|
|
166
|
+
running.delete(name)
|
|
167
|
+
results[name] = { file:, log:, seconds:, status:, report: (JSON.parse(File.read(report)) if File.exist?(report)) }
|
|
168
|
+
kind, reached, = verdict(results[name], limit)
|
|
169
|
+
puts " #{name}: #{kind} (#{reached} reached) in #{seconds} s"
|
|
170
|
+
unless estimated
|
|
171
|
+
estimated = true
|
|
172
|
+
rounds = (files.size.to_f / jobs).ceil
|
|
173
|
+
puts " estimate: about #{(rounds * seconds / 60.0).ceil} min for all #{files.size} areas (#{rounds} rounds of #{jobs}, the first area took #{seconds} s)"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end.each(&:join)
|
|
179
|
+
heartbeat.kill
|
|
180
|
+
|
|
181
|
+
rows = results.map { |name, r| [name, *verdict(r, limit), r] }.sort_by { |name, kind, *| [RANK.fetch(kind), name] }
|
|
182
|
+
width = rows.map { it[0].size }.max
|
|
183
|
+
puts format("%-#{width}s %-7s %s", "area", "reached", "verdict")
|
|
184
|
+
rows.each do |name, kind, reached, text, r|
|
|
185
|
+
first, *rest = text.lines
|
|
186
|
+
puts format("%-#{width}s %-7s %s", name, reached, first.chomp)
|
|
187
|
+
rest.each { puts "#{" " * (width + 9)}#{it}" }
|
|
188
|
+
puts format("%-#{width}s %-7s log: %s (%d s)", "", "", r[:log].sub("#{Dir.pwd}/", ""), r[:seconds]) unless kind == "clean"
|
|
189
|
+
end
|
|
190
|
+
puts "total #{(now.() - started).round} s, seeds #{seeds}"
|
|
191
|
+
if json_out
|
|
192
|
+
File.write(json_out, JSON.generate(rows.map do |name, kind, reached, text, r|
|
|
193
|
+
{ "area" => name, "verdict" => kind, "reached" => reached, "text" => text, "log" => r[:log], "seconds" => r[:seconds],
|
|
194
|
+
"file" => r[:file], "cases" => cases, "seeds" => seeds }
|
|
195
|
+
end))
|
|
196
|
+
end
|
|
197
|
+
exit(rows.any? { %w[FAILURE unchecked].include?(it[1]) } ? 1 : 0)
|