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,105 @@
|
|
|
1
|
+
module Holdfast
|
|
2
|
+
# The pin inputs of one target, in branchout's format plus three keys of
|
|
3
|
+
# holdfast's own: "change": true on an input the change is meant to alter,
|
|
4
|
+
# "raises" for an intended error, and a "generate" list whose entries hegel
|
|
5
|
+
# expands into concrete inputs. The expansion is derandomized with a fixed
|
|
6
|
+
# seed, so base and head (and every rerun) see the same values.
|
|
7
|
+
#
|
|
8
|
+
# {"generate": [{"receiver": {"ruby": "create(:loan, due_on: Date.new(2026, 1, 1) - %{days})"},
|
|
9
|
+
# "args": [], "draws": {"days": {"integers": [0, 40]}},
|
|
10
|
+
# "cases": 20, "change_when": "days > 10"}]}
|
|
11
|
+
class Inputs
|
|
12
|
+
Entry = Data.define(:input, :change, :note)
|
|
13
|
+
OWN_KEYS = %w[change raises change_when draws cases].freeze
|
|
14
|
+
SEED = 20_261_107
|
|
15
|
+
|
|
16
|
+
def self.path(root, target) = File.join(Holdfast.dir(root), "evidence", "#{Holdfast.slug(target)}.inputs.json")
|
|
17
|
+
|
|
18
|
+
def self.read(root, target, boundaries: [])
|
|
19
|
+
file = path(root, target)
|
|
20
|
+
return nil unless File.file?(file)
|
|
21
|
+
|
|
22
|
+
data = JSON.parse(File.read(file))
|
|
23
|
+
new(file, data, boundaries)
|
|
24
|
+
rescue JSON::ParserError => e
|
|
25
|
+
raise Holdfast::Error, "#{file} is not JSON: #{e.message.lines.first.strip}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :file, :raw
|
|
29
|
+
|
|
30
|
+
def initialize(file, raw, boundaries)
|
|
31
|
+
@file, @raw, @boundaries = file, raw, boundaries
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def entries
|
|
35
|
+
@entries ||= Array(raw["inputs"]).map { Entry.new(input: _1.except(*OWN_KEYS), change: _1["change"] == true, note: _1["note"]) } +
|
|
36
|
+
Array(raw["generate"]).each_with_index.flat_map { |spec, i| generated(spec, i) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def declared_errors = Array(raw["inputs"]).filter_map { _1["raises"] }
|
|
40
|
+
def generated_count = entries.count { _1.note.to_s.start_with?("drawn ") }
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def generated(spec, index)
|
|
45
|
+
draws = spec.fetch("draws") { raise Holdfast::Error, "#{file} generate[#{index}] has no \"draws\"" }
|
|
46
|
+
values = drawn_values(draws, spec.fetch("cases", 20), SEED + index)
|
|
47
|
+
values.uniq.map do |names|
|
|
48
|
+
input = fill(spec.except(*OWN_KEYS), names)
|
|
49
|
+
change = spec["change_when"] ? condition_holds?(spec["change_when"], names) : spec["change"] == true
|
|
50
|
+
note = "drawn #{names.map { "#{_1}=#{_2.inspect}" }.join(", ")}"
|
|
51
|
+
Entry.new(input: input.merge("note" => note), change:, note:)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# hegel draws the values; integer ranges also get each boundary literal of
|
|
56
|
+
# the target, one below it and one above it, so every side of a
|
|
57
|
+
# comparison the method makes gets at least one input.
|
|
58
|
+
def drawn_values(draws, cases, seed)
|
|
59
|
+
finite = draws.values.map { _1["one_of"] || (_1["booleans"] && [true, false]) }
|
|
60
|
+
# A small finite space is listed whole; a random draw from it repeats.
|
|
61
|
+
if finite.all? && finite.map(&:size).inject(1, :*) <= [cases, 120].max
|
|
62
|
+
return finite.first.product(*finite.drop(1)).map { draws.keys.zip(_1).to_h }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
require "hegel"
|
|
66
|
+
found = []
|
|
67
|
+
Hegel.test(test_cases: cases, derandomize: true, seed:, verbosity: :quiet) do |tc|
|
|
68
|
+
found << draws.to_h { |name, kind| [name, tc.draw(generator(kind))] }
|
|
69
|
+
end
|
|
70
|
+
edges = draws.select { _2.key?("integers") }.flat_map do |name, kind|
|
|
71
|
+
low, high = kind["integers"]
|
|
72
|
+
@boundaries.flat_map { [_1 - 1, _1, _1 + 1] }.select { (low..high).cover?(_1) }.map { |value| [name, value] }
|
|
73
|
+
end
|
|
74
|
+
found + edges.map { |name, value| (found.first || {}).merge(name => value) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def generator(kind)
|
|
78
|
+
g = Hegel::Generators
|
|
79
|
+
case kind.keys.first
|
|
80
|
+
when "integers" then g.integers(min_value: kind["integers"][0], max_value: kind["integers"][1])
|
|
81
|
+
when "booleans" then g.booleans
|
|
82
|
+
when "one_of" then g.sampled_from(kind["one_of"])
|
|
83
|
+
else raise Holdfast::Error, "#{file}: unknown draw #{kind.keys.first}; use integers, booleans or one_of"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fill(value, names)
|
|
88
|
+
case value
|
|
89
|
+
when Hash
|
|
90
|
+
return names.fetch(value["draw"]) if value.keys == ["draw"]
|
|
91
|
+
|
|
92
|
+
value.transform_values { fill(_1, names) }
|
|
93
|
+
when Array then value.map { fill(_1, names) }
|
|
94
|
+
when String then value.gsub(/%\{(\w+)\}/) { names.fetch(Regexp.last_match(1)).inspect }
|
|
95
|
+
else value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def condition_holds?(condition, names)
|
|
100
|
+
scope = Object.new
|
|
101
|
+
names.each { |name, v| scope.define_singleton_method(name) { v } }
|
|
102
|
+
scope.instance_eval(condition) ? true : false
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Holdfast
|
|
4
|
+
# What this project needs to run a test, learned once by running it and
|
|
5
|
+
# kept in .holdfast/record.json for every stage. Each learning step keeps
|
|
6
|
+
# its command and outcome, so a failed setup is one fact the verdicts cite
|
|
7
|
+
# instead of a load error on every row.
|
|
8
|
+
class Record
|
|
9
|
+
# jitcatch numbers base 1 and head 2; a pin runs in a checkout of its own.
|
|
10
|
+
SIDES = { "base" => "1", "head" => "2", "pin" => "3" }.freeze
|
|
11
|
+
COVERAGE = %w[coverage/jitcatch-coverage.json tmp/jitcatch-coverage.json].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :data
|
|
14
|
+
|
|
15
|
+
def self.path(root) = File.join(Holdfast.dir(root), "record.json")
|
|
16
|
+
|
|
17
|
+
def self.load_or_learn(root, relearn: false)
|
|
18
|
+
return new(JSON.parse(File.read(path(root)))) if File.file?(path(root)) && !relearn
|
|
19
|
+
|
|
20
|
+
learn(root).tap { _1.write(root) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.learn(root)
|
|
24
|
+
facts = read_facts(root)
|
|
25
|
+
steps = []
|
|
26
|
+
if facts["rails"] && facts["database"]["server"]
|
|
27
|
+
SIDES.each do |side, number|
|
|
28
|
+
steps << run_step(root, "create the #{side} database (#{database_name(facts, number) || "TEST_ENV_NUMBER=#{number}"})", facts["side_setup"],
|
|
29
|
+
side_env(facts, number))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
if steps.all? { _1["ok"] } && (probe = smallest_test(root, facts["framework"]))
|
|
33
|
+
cmd = facts["framework"] == "rspec" ? "#{facts["bundle"]}rspec #{probe}" : "#{facts["bundle"]}ruby -Itest #{probe}"
|
|
34
|
+
steps << run_step(root, "boot one test", cmd, side_env(facts, SIDES["base"]))
|
|
35
|
+
end
|
|
36
|
+
failed = steps.find { !_1["ok"] }
|
|
37
|
+
new(facts.merge("learned_at" => Time.now.utc.iso8601, "steps" => steps,
|
|
38
|
+
"status" => failed ? "setup_failed" : "ok",
|
|
39
|
+
"failure" => failed && "#{failed["name"]}: `#{failed["command"]}` exited #{failed["exit"]}"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.read_facts(root)
|
|
43
|
+
lock = File.read(File.join(root, "Gemfile.lock")) rescue ""
|
|
44
|
+
framework = lock.match?(/^ rspec-core /) && File.directory?(File.join(root, "spec")) ? "rspec" : "minitest"
|
|
45
|
+
db_yml = File.read(File.join(root, "config/database.yml")) rescue ""
|
|
46
|
+
test_section = db_yml[/^test:.*\z/m].to_s
|
|
47
|
+
adapter = test_section[/adapter:\s*(\w+)/, 1] || db_yml[/adapter:\s*(\w+)/, 1]
|
|
48
|
+
server = %w[postgresql mysql2 trilogy].include?(adapter)
|
|
49
|
+
{
|
|
50
|
+
"framework" => framework,
|
|
51
|
+
"bundle" => File.file?(File.join(root, "Gemfile")) ? "bundle exec " : "",
|
|
52
|
+
"rails" => File.file?(File.join(root, "config/environment.rb")),
|
|
53
|
+
"helper" => framework == "rspec" ? (File.file?(File.join(root, "spec/rails_helper.rb")) ? "rails_helper" : "spec_helper") : "test_helper",
|
|
54
|
+
"database" => { "adapter" => adapter, "server" => server, "numbered" => test_section.include?("TEST_ENV_NUMBER"),
|
|
55
|
+
"url" => server ? database_url(root, adapter) : nil }.compact,
|
|
56
|
+
"env" => db_yml.scan(/ENV(?:\.fetch\(|\[)\s*["'](\w+)["']/).flatten.uniq - %w[TEST_ENV_NUMBER RAILS_ENV],
|
|
57
|
+
"side_setup" => server ? "bin/rails db:create db:schema:load" : nil,
|
|
58
|
+
"base_branch" => base_branch(root),
|
|
59
|
+
"coverage" => COVERAGE.find { File.file?(File.join(root, _1)) }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Two apps whose database.yml both say app_test<N> would share one
|
|
64
|
+
# database on a shared server. Each app gets its own names, from its
|
|
65
|
+
# directory and a digest of its path, through DATABASE_URL, which Rails
|
|
66
|
+
# prefers over database.yml and jitcatch numbers per side.
|
|
67
|
+
def self.database_url(root, adapter)
|
|
68
|
+
yml = YAML.safe_load(ERB.new(File.read(File.join(root, "config/database.yml"))).result, aliases: true)
|
|
69
|
+
test = yml.fetch("test", {})
|
|
70
|
+
scheme = { "postgresql" => "postgres", "mysql2" => "mysql2", "trilogy" => "trilogy" }.fetch(adapter)
|
|
71
|
+
user = [test["username"], test["password"]].compact.join(":")
|
|
72
|
+
host = test["host"] || "localhost"
|
|
73
|
+
name = "hf_#{File.basename(root).downcase.gsub(/[^a-z0-9]+/, "_")}_#{Digest::SHA1.hexdigest(root)[0, 6]}"
|
|
74
|
+
"#{scheme}://#{"#{user}@" unless user.empty?}#{host}#{":#{test["port"]}" if test["port"]}/#{name}"
|
|
75
|
+
rescue StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.side_env(facts, number)
|
|
80
|
+
url = facts.dig("database", "url")
|
|
81
|
+
{ "RAILS_ENV" => "test", "TEST_ENV_NUMBER" => number }.merge(url ? { "DATABASE_URL" => "#{url}_#{number}" } : {})
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.database_name(facts, number) = facts.dig("database", "url")&.then { "#{_1[%r{[^/]+\z}]}_#{number}" }
|
|
85
|
+
|
|
86
|
+
def self.base_branch(root)
|
|
87
|
+
remote = Open3.capture2("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: root, err: File::NULL).first.strip
|
|
88
|
+
return remote unless remote.empty?
|
|
89
|
+
|
|
90
|
+
%w[main master].find { system("git", "rev-parse", "--verify", "--quiet", _1, chdir: root, out: File::NULL) } || "HEAD"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.smallest_test(root, framework)
|
|
94
|
+
glob = framework == "rspec" ? "spec/**/*_spec.rb" : "test/**/*_test.rb"
|
|
95
|
+
Dir.glob(glob, base: root).min_by { File.size(File.join(root, _1)) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.run_step(root, name, command, extra_env)
|
|
99
|
+
env = extra_env.merge("BUNDLE_GEMFILE" => File.join(root, "Gemfile"))
|
|
100
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
+
out, status = Open3.capture2e(env, "/bin/sh", "-c", command, chdir: root)
|
|
102
|
+
{ "name" => name, "command" => command, "env" => extra_env, "ok" => status.success?, "exit" => status.exitstatus,
|
|
103
|
+
"seconds" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1),
|
|
104
|
+
"tail" => status.success? ? nil : failure_lines(out) }.compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Rake ends every failure with the same two lines; the cause is above them.
|
|
108
|
+
def self.failure_lines(out)
|
|
109
|
+
lines = out.lines.map(&:rstrip).reject { _1.strip.empty? || _1.start_with?("Tasks:", "(See full trace") }
|
|
110
|
+
(lines.grep(/error|refused|could not|failed|bad/i).first(4) + lines.last(2)).uniq
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize(data) = @data = data
|
|
114
|
+
def ok? = data["status"] == "ok"
|
|
115
|
+
def failure = data["failure"]
|
|
116
|
+
def framework = data["framework"]
|
|
117
|
+
def env_names = data["env"]
|
|
118
|
+
def side_setup = data["side_setup"]
|
|
119
|
+
def bundle = data["bundle"]
|
|
120
|
+
def helper = data["helper"]
|
|
121
|
+
def base_branch = data["base_branch"]
|
|
122
|
+
def coverage = data["coverage"]
|
|
123
|
+
def database_url = data.dig("database", "url")
|
|
124
|
+
def env_for(side) = self.class.side_env(data, SIDES.fetch(side))
|
|
125
|
+
def test_suffix = framework == "rspec" ? "_spec.rb" : "_test.rb"
|
|
126
|
+
|
|
127
|
+
def write(root)
|
|
128
|
+
FileUtils.mkdir_p(Holdfast.dir(root))
|
|
129
|
+
File.write(self.class.path(root), "#{JSON.pretty_generate(data)}\n")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|