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