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,33 @@
1
+ module Holdfast
2
+ # The stages of `holdfast run` load jitcatch and branchout here, so a
3
+ # bundle that left them out gets a message instead of a LoadError.
4
+ module Tools
5
+ def self.missing(name, use)
6
+ "#{use} needs the #{name} gem, which holdfast depends on: run `bundle install` " \
7
+ "with holdfast in the app's Gemfile, then run holdfast with bundle exec"
8
+ end
9
+
10
+ def self.jitcatch!
11
+ require "jitcatch"
12
+ rescue LoadError
13
+ raise Holdfast::Error, missing("jitcatch", "holdfast run on a change")
14
+ end
15
+
16
+ # The rescue list of the CLI: JitCatch::Error exists only once jitcatch
17
+ # is loaded.
18
+ def self.errors = defined?(JitCatch::Error) ? [JitCatch::Error] : []
19
+
20
+ # branchout runs inside the app's test process, which has the app's
21
+ # bundle and not necessarily branchout, so it is put on the load path by
22
+ # directory. Under bundle exec, Gem::Specification sees only the bundle,
23
+ # so the installed gems are searched by path too.
24
+ def self.branchout_lib
25
+ feature = $LOAD_PATH.resolve_feature_path("branchout") rescue nil
26
+ return File.dirname(feature[1]) if feature
27
+
28
+ dirs = Gem.path.flat_map { Dir.glob(File.join(_1, "gems", "branchout-*", "lib")) }
29
+ found = dirs.max_by { Gem::Version.new(_1[%r{branchout-([\d.]+)/lib\z}, 1] || "0") }
30
+ found or raise Holdfast::Error, missing("branchout", "pinning a target")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,69 @@
1
+ module Holdfast
2
+ # The one row every stage returns: one per changed method, per changed
3
+ # value, or per hunted area. +evidence+ is the list of what was checked
4
+ # under it (tests that reached the changed lines, pins, readers), so a row
5
+ # never reads as more than it covered. +next_step+ is the one thing to do
6
+ # after reading it; every row has one.
7
+ Verdict = Data.define(:target, :stage, :kind, :reason, :scope, :evidence, :repro, :next_step, :shown) do
8
+ def self.of(target:, stage:, kind:, reason:, next_step:, scope: nil, evidence: nil, repro: nil, shown: true)
9
+ raise ArgumentError, "unknown verdict kind #{kind}" unless KINDS.include?(kind)
10
+ raise ArgumentError, "a verdict needs a next step" if next_step.to_s.empty?
11
+
12
+ new(target:, stage:, kind:, reason:, scope: scope.to_s.empty? ? nil : scope, evidence: Array(evidence), repro:, next_step:, shown:)
13
+ end
14
+
15
+ def to_json_line
16
+ JSON.generate({ "target" => target, "stage" => stage, "kind" => kind, "reason" => reason, "scope" => scope,
17
+ "evidence" => (evidence unless evidence.empty?), "repro" => repro, "next" => next_step, "shown" => (false unless shown) }.compact)
18
+ end
19
+ end
20
+
21
+ # The order a person reads in: what needs a decision first.
22
+ KINDS = %w[confirmed unintended_change bug_suspect could_not_run unchecked intended_change refuted clean].freeze
23
+
24
+ # One screen: a row that needs nothing is one line; a row that needs a
25
+ # decision shows its evidence, its repro and its next step. Everything
26
+ # else is in verdicts.jsonl.
27
+ module Summary
28
+ HEADINGS = {
29
+ "confirmed" => "Confirmed (the kept spec fails)",
30
+ "unintended_change" => "Changed, and the intent did not say so",
31
+ "bug_suspect" => "Suspected bugs",
32
+ "could_not_run" => "Could not run",
33
+ "unchecked" => "Not checked",
34
+ "intended_change" => "Changed as intended (confirm against the intent)",
35
+ "refuted" => "Refuted (what was observed)",
36
+ "clean" => "Compared on both sides and unchanged"
37
+ }.freeze
38
+ SHOWN = { "confirmed" => 8, "could_not_run" => 3, "refuted" => 3, "unintended_change" => 4, "bug_suspect" => 4, "unchecked" => 2, "intended_change" => 0, "clean" => 0 }.freeze
39
+ QUIET = %w[intended_change refuted clean].freeze
40
+
41
+ def self.markdown(header, verdicts, mode: "change", rows: "verdicts.jsonl")
42
+ shown_kinds = verdicts.any? { _1.stage == "check" } ? %w[confirmed could_not_run refuted] : KINDS - %w[confirmed could_not_run refuted]
43
+ counts = (shown_kinds | verdicts.map(&:kind).uniq).map { |kind| "#{kind}=#{verdicts.count { _1.kind == kind }}" }.join(" ")
44
+ lines = ["# holdfast", "", *header, "", counts, ""]
45
+ hidden = verdicts.reject(&:shown)
46
+ verdicts = verdicts.select(&:shown)
47
+ KINDS.each do |kind|
48
+ group = verdicts.select { _1.kind == kind }
49
+ next if group.empty?
50
+
51
+ lines << "## #{mode == "before" && kind == "clean" ? "Pinned before the change" : HEADINGS.fetch(kind)}" << ""
52
+ lines << "Read each test the change wrote and confirm its head value is what the intent wants; repro lines are in verdicts.jsonl." << "" if kind == "intended_change"
53
+ group.each do |v|
54
+ lines << "- `#{v.target}`: #{v.reason}"
55
+ shown = mode == "before" ? 3 : SHOWN[kind]
56
+ v.evidence.first(shown).each { lines << " - #{_1}" }
57
+ lines << " - (#{v.evidence.size - shown} more in #{rows})" if shown.positive? && v.evidence.size > shown
58
+ next if QUIET.include?(kind)
59
+
60
+ lines << " - repro: `#{v.repro}`" if v.repro
61
+ lines << " - next: #{v.next_step}"
62
+ end
63
+ lines << ""
64
+ end
65
+ lines << "#{hidden.size} more row(s) the change cannot reach (tests that reached no changed line and did not break, new specs with no base side) are in verdicts.jsonl only." << "" if hidden.any?
66
+ lines.join("\n")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module Holdfast
2
+ VERSION = "0.1.0"
3
+ # The hegeltest the gem depends on, and the one the hunt adds to an app bundle that lacks it.
4
+ HEGELTEST_VERSION = "0.5.1"
5
+ end
data/lib/holdfast.rb ADDED
@@ -0,0 +1,44 @@
1
+ # holdfast checks a Rails app by running it. `holdfast check` turns what an
2
+ # agent suspects into specs and runs them on base and head. `holdfast run`
3
+ # looks at the working tree against a base and decides what runs: it selects
4
+ # the targets and their tests, drafts and fills missing pins, compares base
5
+ # and head, and hunts when nothing changed. Every tool result becomes
6
+ # evidence under one row per target; no tool's own output reaches the caller.
7
+ require "json"
8
+ require "fileutils"
9
+ require "open3"
10
+ require "tmpdir"
11
+ require "erb"
12
+ require "yaml"
13
+ require "prism"
14
+
15
+ module Holdfast
16
+ Error = Class.new(StandardError)
17
+
18
+ # Loaded into the app's own test processes by path, never required here.
19
+ RUNTIME = File.expand_path("holdfast/runtime", __dir__)
20
+ SEQUENCES = File.expand_path("holdfast/sequences", __dir__)
21
+
22
+ def self.dir(root) = File.join(root, ".holdfast")
23
+ def self.slug(name) = name.gsub(/[^\w]+/, "-").gsub(/\A-|-\z/, "").downcase
24
+ def self.snake(name) = name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
25
+ end
26
+
27
+ require_relative "holdfast/version"
28
+ require_relative "holdfast/tools"
29
+ require_relative "holdfast/verdict"
30
+ require_relative "holdfast/record"
31
+ require_relative "holdfast/change"
32
+ require_relative "holdfast/inputs"
33
+ require_relative "holdfast/draft"
34
+ require_relative "holdfast/callers"
35
+ require_relative "holdfast/drawn"
36
+ require_relative "holdfast/stages/select"
37
+ require_relative "holdfast/stages/fill"
38
+ require_relative "holdfast/stages/diff"
39
+ require_relative "holdfast/stages/fold"
40
+ require_relative "holdfast/stages/hunt"
41
+ require_relative "holdfast/stages/observe"
42
+ require_relative "holdfast/harness"
43
+ require_relative "holdfast/check"
44
+ require_relative "holdfast/cli"
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: holdfast
3
+ description: Run what you suspect instead of leaving it "read only". In a Ruby or Rails app, write each suspicion (a bug, a race, an effect on another area) as a few lines of YAML; `holdfast check` builds the world with the app's factories, runs it on base and head, and answers confirmed (with a failing spec to keep), refuted (with what it saw), or could_not_run (with the exact error). `holdfast run` checks a change against base, and hunts when nothing changed. Use after reading code, before calling a change safe, or when asked to find bugs.
4
+ ---
5
+
6
+ # holdfast
7
+
8
+ ## Setup
9
+
10
+ Add `gem "holdfast", require: false` to the app's `:test` group and run `bundle install`; run every command below as `bundle exec holdfast ...` from the app root. jitcatch and branchout, which `holdfast run` uses on a change, come with it. The app needs RSpec, FactoryBot and a test database.
11
+
12
+ ## The loop: read, suspect, check, keep
13
+
14
+ 1. Read the code. Write each thing you suspect in a file, one line of `says` and its `given`, `do` (or `race`) and `expect`. A race or a cross-area effect costs the same few lines; do not leave one "read, not run".
15
+ 2. `holdfast check suspicions.yml` (or `--suspect '<one YAML mapping>'`, repeatable). Run it from the app root.
16
+ 3. Keep each confirmed spec, `spec/holdfast/<branch>/<id>_spec.rb` (the header names the folder): it fails until the code is fixed. Report confirmed rows with their spec and refuted rows with what was observed. Fix each could_not_run suspicion and run again.
17
+
18
+ ```yaml
19
+ - says: a passed tentative appointment is charged a no-show fee
20
+ given: {appointment: {status: tentative, starts_at: -1h}}
21
+ do: NoShowSweepJob.perform_now
22
+ expect: no PaymentGateway charge
23
+ - says: check-in between the sweep's load and its lock becomes a no-show
24
+ given: {appointment: {status: booked, starts_at: -20m}}
25
+ race: [NoShowSweepJob.perform_now, "CheckIn.new(appointment).call"]
26
+ expect: appointment.status == "checked_in"
27
+ - says: two desks checking in one appointment both succeed
28
+ given: {appointment: {ruby: "create(:appointment, starts_at: 5.minutes.from_now)"}}
29
+ race: ["CheckIn.new(appointment).call", "CheckIn.new(appointment).call"]
30
+ expect: errors.compact.size == 1 && appointment.status == "checked_in"
31
+ - says: a check-in hands back the appointment, checked in
32
+ given: {appointment: {status: booked, starts_at: +5m}}
33
+ do: checked = CheckIn.new(appointment).call
34
+ expect: no error && checked.status == "checked_in"
35
+ ```
36
+
37
+ - `given`: one record per key, from the factory of that name (`factory:`, `traits:`). A value is a literal, a relative time (`-1h`, `+2d`, `-20m`), an earlier key, or `{ruby: "..."}`. A string, or `{ruby: "..."}`, in place of the attributes is Ruby that returns the record or any value. An earlier record fills a required `belongs_to` you leave out, and so does what it belongs to.
38
+ - `do`: Ruby, with each record by its name. A local or `@name` that `do` sets is what `expect` reads by that name (a record read again). Request helpers work: `post "/api/v1/..."`, then `response`. A 404 the app answers is an outcome; a path no route matches is could_not_run.
39
+ - `race`: two actions. The first is held at `pause` while the second runs, then the reverse, and both serial orders. Confirmed only when a held order fails `expect` and ends in a way no serial order ends (ids of records the actions made and times do not count). When a serial order fails `expect` too, the row says so and is refuted as a race, unless the held order's end is new; then the row names that end and each serial end. `pause` is `batch_load` (the first query that returns rows), `outside_call` (the first call to a client such as `PaymentGateway.client`), `row_lock` (before the first `with_lock` or `lock!`), `first_write`, or `Class#method`. With no `pause`, or a `batch_load` nobody reaches, each is tried in that order; the row says which one held.
40
+ - `expect`: Ruby that is true afterwards (records read again; `result`, `error`; in a race `results[0]`, `results[1]`, `errors[i]` by the order `race` lists them; `calls(X, :verb)`, the arguments of each call to `X.client`), or `no X verb`, `X verb [once|N times]`, `raises X`, `no error`; these combine with `&&` and `||`. A list must all hold. When `do` raised, a nil `response` or `result` in `expect` is that raise: the line fails and names it. A failed `a && b` names the part that failed and its value.
41
+
42
+ What to suspect for a race: a lock taken on a different row than the one written (two members booking one room each lock their own member row); a read before the lock (a status or a total read, then `with_lock`); an absolute write after a slow call (`attempts = notice.attempts + 1`, a courier call, then `update!(attempts:)`); check-then-act with no unique index under it (`exists?` then `create!`). Write the two actions and the end no serial order allows; leave `pause` out.
43
+
44
+ In a change (the tree differs from the base branch, or `--base REF`), each suspicion runs on base and on head with head's factories, and the row says whether the change brought it in. A base that has no such method, constant, column or route says "base cannot run it (feature absent: no column archived_at on Fine)", and the row is confirmed on head only. With no change, or `--here`, it runs once on the tree, and the header says so. Exit 2 when a row is confirmed.
45
+
46
+ | row | means | do |
47
+ |---|---|---|
48
+ | `confirmed` | `expect` failed; the spec fails, its output is in the row | Keep the spec. Fix the code. |
49
+ | `refuted` | `expect` held; each observed value is listed | Nothing to fix for this suspicion. |
50
+ | `could_not_run` | Nothing was checked: the row names the `given` key and its error, the `do` line that raised, a request with no route or one turned away (401, 403), or that no pause point was reached (with the app methods the action called, to name as `pause`) | Fix the suspicion, run again. |
51
+
52
+ An error that app code raised is how the action ended, and `expect` is checked against it. An error from your own line (a misspelt name, a wrong argument list) is could_not_run. Each branch keeps its own specs and rows: `.holdfast/check/<branch>/check.md` is the summary, `check.jsonl` beside it has every row.
53
+
54
+ ## Check a change: holdfast run
55
+
56
+ ```sh
57
+ holdfast run [--base REF] [--intent TEXT] [--target NAME]... [--only NAME]... [--sequences] [--observe] [--budget SECONDS]
58
+ ```
59
+
60
+ It compares the working tree with the merge base of REF. Each changed method gets a row; so do the callers of a changed constant or scope, one and two calls up, ranked first. On base and head it runs the tests that name each target, the test files the change edited, your evidence tests, and pins it drafts from the method's parameters. A test counts only when its own example reached the changed lines, not when another example in its file did.
61
+
62
+ A row is `clean` only when, besides reaching the lines, an input drew each value the change added or removed (a member of a changed list or table constant the target reads, a literal a changed line compares with) on both sides, and a test ran each branch the change added (for `return x if y`, the body itself, not only the line). Otherwise it is `unchecked` and names the value nobody drew: write that as a suspicion and `holdfast check` it.
63
+
64
+ | kind | do |
65
+ |---|---|
66
+ | `unintended_change` | Passes on base, fails on head, not marked. Fix the code, or mark it meant (below). |
67
+ | `bug_suspect` | A hunt broke a rule or raised. Fix the code, or declare the error. |
68
+ | `unchecked` | Do what `next` says. Never report it as safe. |
69
+ | `intended_change` | A difference you marked as meant, or new behavior only new specs run (an added method, or an added branch the agreeing tests never took). Confirm the head value is what the intent wants. |
70
+ | `clean` | Covers only the evidence it lists. A drafted pin whose inputs built no record (`returns: []` in `.holdfast/runs/<slug>/observed.json`) checked nothing: write the case as a suspicion. |
71
+
72
+ Evidence goes under `.holdfast/evidence/<slug>` (the row's `next` names the path): `<slug>/*_spec.rb` tests (under `change/` when meant to fail on base), or `<slug>.inputs.json` pin inputs (copy the draft from `.holdfast/drafts/`, fill each `{"fill": ...}`, mark `"change": true` where the intent changes the result). `--target NAME` with nothing changed pins NAME before you edit it.
73
+
74
+ ## Hunt: nothing changed
75
+
76
+ `holdfast run` with no change drafts one sequences definition per write area into `.holdfast/sequences/` and runs random orders, interleavings, time zones and second doors under invariants (`--budget`, default 600 s). The first line says how much ran: `unreached: N of M actions`. An area that reached under half its actions is never clean; fix its setup or sign-in, then run again. Hunt rows lead you to suspicions: confirm each with `holdfast check`.
77
+
78
+ Each run tries random orders of an area's actions from a world of drawn size, two actions interleaved at a call into an outside client, a job held after its first batch load while another action changes a row it loaded, a clock at day edges in other time zones, the same instant in another UTC offset, a request the app accepted sent once more with the same inputs (a double click or a retry, shown as `again, as sent before:`), and a desk route and an API endpoint about one table from the same state. Any HTTP 5xx, and any exception the area does not list as a refusal, fails.
79
+
80
+ The first hunt writes `.holdfast/sequences/invariants.rb`. Its drafted rules (money a record took, the order a waiting status is left in, one row for each `has_one` no unique index holds, and the bounds a column name states: `*_left` and `*_remaining` never below zero, `*_count` never past its `max_*`, a percentage within 0..100) run as written: delete one only when the app means otherwise. Add the rules only you know in the same shape; each returns true when it holds, or a message that names the rows that break it:
81
+
82
+ ```ruby
83
+ Sequences.everywhere do
84
+ invariant(:a_checked_in_appointment_is_never_charged) do
85
+ charged = PaymentGateway.client.charges.map(&:reference)
86
+ bad = Appointment.where(status: "checked_in").select { charged.include?("no-show-#{it.id}") }
87
+ bad.empty? || "checked-in appointments #{bad.map(&:id)} were charged a no-show fee"
88
+ end
89
+ end
90
+ ```
91
+
92
+ A `bug_suspect` row names the broken rule, and its log holds the shortest sequence as a numbered narrative and an `it` block that replays it. Shrinking stops 60 s after the first failure (`SEQ_SHRINK_SECONDS`), and the row then says `shrinking stopped`; when the area's time limit cut it first, the row says `not shrunk`. Either way the log holds the shortest sequence found so far. A twins pair the two doors cannot feed the same inputs (one picks a record the other takes from the sign-in, or one makes a new unique value) is listed as not reached; drop the `twins` line or give both doors the same params. A partly checked area lists each action that was only refused (with up to three refusals, most common first, and the param to change) or never drawn: make the record in the area's `setup`, narrow a `pool`, or change the draws, then run again. An area the time limit stopped between cases says `cut by the time limit after N of M cases`; what ran still counts.
93
+
94
+ ## When setup fails
95
+
96
+ The first run learns `.holdfast/record.json` (test framework, databases: each app gets its own names through `DATABASE_URL`). A failed step is kept with its command and output, and every row says so. Fix the environment, then `holdfast run --learn`.
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: holdfast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - meganemura
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hegeltest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.5.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.5.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: jitcatch
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: branchout
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
54
+ description: holdfast turns each suspicion an agent writes about a Rails app (a bug,
55
+ a race, an effect on another area) into a spec built with the app's factories, runs
56
+ it on base and head, and answers confirmed, refuted, or could_not_run. It also checks
57
+ a change against its base and hunts with random sequences of the app's entry points
58
+ when nothing changed.
59
+ email:
60
+ - meganemura@users.noreply.github.com
61
+ executables:
62
+ - holdfast
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - LICENSE
67
+ - README.ja.md
68
+ - README.md
69
+ - bin/holdfast
70
+ - lib/holdfast.rb
71
+ - lib/holdfast/callers.rb
72
+ - lib/holdfast/change.rb
73
+ - lib/holdfast/check.rb
74
+ - lib/holdfast/cli.rb
75
+ - lib/holdfast/draft.rb
76
+ - lib/holdfast/drawn.rb
77
+ - lib/holdfast/harness.rb
78
+ - lib/holdfast/inputs.rb
79
+ - lib/holdfast/record.rb
80
+ - lib/holdfast/runtime/check_support.rb
81
+ - lib/holdfast/runtime/observe.rb
82
+ - lib/holdfast/runtime/reach.rb
83
+ - lib/holdfast/sequences/propose.rb
84
+ - lib/holdfast/sequences/run.rb
85
+ - lib/holdfast/sequences/sequences.rb
86
+ - lib/holdfast/stages/diff.rb
87
+ - lib/holdfast/stages/fill.rb
88
+ - lib/holdfast/stages/fold.rb
89
+ - lib/holdfast/stages/hunt.rb
90
+ - lib/holdfast/stages/observe.rb
91
+ - lib/holdfast/stages/select.rb
92
+ - lib/holdfast/tools.rb
93
+ - lib/holdfast/verdict.rb
94
+ - lib/holdfast/version.rb
95
+ - skills/holdfast/SKILL.md
96
+ homepage: https://github.com/meganemura/holdfast
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ source_code_uri: https://github.com/meganemura/holdfast
101
+ changelog_uri: https://github.com/meganemura/holdfast/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/meganemura/holdfast/issues
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.4'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 4.0.16
119
+ specification_version: 4
120
+ summary: Run what you suspect in a Rails app on base and head, and keep the spec that
121
+ proves it.
122
+ test_files: []