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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f19b183542b1eb5619c796e4ed02859386dcf21cc0b381e3b45b84df9eb20c46
4
+ data.tar.gz: f1f2a7d651cc193820c8a8edaa049568c81842e2b5b1af0b3366526e12d61c7a
5
+ SHA512:
6
+ metadata.gz: a9071a68c3d371c161db59608ffcc3cfef4462b314a86870747fa9604b89a716c7666777017ca637ef0e8353085a78eabbca80413cc4ba077d122d91548f83ca
7
+ data.tar.gz: e9f9728bc94ee0bb2bffd4177d218f6ff64b7d53f5f07446711cdc71deeba7701fac230879b5933cc8725b5d1ac0734bfba43b66be9f87245ac584165837fdbb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meganemura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.ja.md ADDED
@@ -0,0 +1,235 @@
1
+ # holdfast
2
+
3
+ holdfast は、Rails アプリについて疑ったことを、読んだだけで終わらせずに実際に走らせます。
4
+ 疑い(バグ、二つのアクションの競合、別の領域への影響)は、それぞれ数行の YAML で書きます。
5
+ `holdfast check` はアプリ自身のファクトリで状況を作り、base と head でアクションを走らせます。
6
+ 答えは三つです。
7
+ `confirmed` は残すべき失敗するスペックを伴い、`refuted` は観測した値を伴い、`could_not_run` は正確なエラーを伴います。
8
+ `holdfast run` は変更を base と比べて確かめ、何も変わっていないときはアプリの入口をランダムな順序で走らせてバグを探します。
9
+
10
+ holdfast は、コードを読んで見つけたことを報告するコーディングエージェントのための道具です。
11
+ エージェントがたどるループは [`skills/holdfast/`](skills/holdfast/SKILL.md) のスキルにあります。
12
+
13
+ ## Rails アプリでのクイックスタート
14
+
15
+ Ruby 3.4 以降、git、そして RSpec と FactoryBot でテスト用データベースに対してスペックが走るアプリが必要です。
16
+ 各ステップはアプリのルートで実行します。
17
+
18
+ ### 1. gem を追加する
19
+
20
+ ```ruby
21
+ group :test do
22
+ gem "holdfast", require: false
23
+ end
24
+ ```
25
+
26
+ 続けて `bundle install` を実行します。
27
+ holdfast は hegeltest に依存していて、探索(hunt)がそれを使います。
28
+
29
+ ### 2. 読んで、疑いを書く
30
+
31
+ 以下の例は [`test/fixture/clinic`](test/fixture/clinic) のクリニックアプリで走らせたものです。
32
+ このアプリには予約、来なかった予約を no-show にして料金を請求するジョブ、そして受付のチェックインがあります。
33
+ ジョブを読むと、怪しい点が二つ見つかります。
34
+ 確定していない仮予約(tentative)まで対象にしていることと、行を読み込んでから一行ずつロックするので、その間のチェックインが上書きされうることです。
35
+ 両方を書き出します。
36
+
37
+ ```yaml
38
+ - says: a passed tentative appointment is charged a no-show fee
39
+ given: {appointment: {status: tentative, starts_at: -1h}}
40
+ do: NoShowSweepJob.perform_now
41
+ expect: no PaymentGateway charge
42
+ - says: check-in between the sweep's load and its lock becomes a no-show
43
+ given: {appointment: {status: booked, starts_at: -20m}}
44
+ race: [NoShowSweepJob.perform_now, "CheckIn.new(appointment).call"]
45
+ expect: appointment.status == "checked_in"
46
+ ```
47
+
48
+ `given` はキーごとに、同じ名前のファクトリからレコードを一つ作ります。
49
+ `do` は Ruby です。
50
+ `race` は、一つ目のアクションを一時停止点で止めて二つ目を走らせ、次にその逆を行い、さらに二つの直列の順序も走らせます。
51
+ `expect` は、走らせた後に成り立つべきことです。
52
+ 書き方のすべては [スキル](skills/holdfast/SKILL.md) にあります。
53
+
54
+ ### 3. 確かめる
55
+
56
+ ```sh
57
+ bundle exec holdfast check suspicions.yml
58
+ ```
59
+
60
+ 初回の実行で、アプリがテストをどう走らせるかを学び、自分専用のテスト用データベースを作ります。
61
+ クリニックアプリでの出力の全体は次のとおりです。
62
+
63
+ ```text
64
+ # holdfast
65
+
66
+ check: 2 suspicion(s); no change: checks run on the current tree only (af23e82); app, lib, config, db and the Gemfile match main (a17770f)
67
+ kept specs: spec/holdfast/main/ (branch main); rows: .holdfast/check/main/check.jsonl
68
+ seconds: record 9.6, run 5.5
69
+
70
+ confirmed=2 could_not_run=0 refuted=0
71
+
72
+ ## Confirmed (the kept spec fails)
73
+
74
+ - `a passed tentative appointment is charged a no-show fee`: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
75
+ - ran: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]
76
+ - no PaymentGateway charge fails ([[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
77
+ - spec output: suspicion confirmed: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
78
+ - repro: `DATABASE_URL=postgres://postgres@127.0.0.1:56111/hf_clinic_9f0097_c2 TEST_ENV_NUMBER=c2 bundle exec rspec spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb`
79
+ - next: keep spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb; it fails until the code is fixed
80
+ - `check-in between the sweep's load and its lock becomes a no-show`: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [[Appointment#1(status: "no_show")], Appointment#1(status: "checked_in"); appointment.status == "checked_in" fails ("no_show")], an end no serial order reaches (first then second ends [[Appointment#1(status: "no_show")], raised CheckIn::Refused; appointment.status == "checked_in" fails ("no_show")]; second then first ends [[], Appointment#1(status: "checked_in"); appointment.status == "checked_in" holds ("checked_in")]); serial order "first then second" also fails expect, so this end is the evidence
81
+ - pause points tried: batch_load: Appointment load (1 rows)
82
+ - first held at batch_load, second runs: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); held at Appointment load (1 rows); appointment.status == "checked_in" fails ("no_show")
83
+ - first then second: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; CheckIn.new(appointment).call raised CheckIn::Refused: appointment is no_show; appointment.status == "checked_in" fails ("no_show")
84
+ - second then first: CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); NoShowSweepJob.perform_now returned []; appointment.status == "checked_in" holds ("checked_in")
85
+ - second held at batch_load, first runs: CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; held at Appointment load (1 rows); the second waited on a lock; appointment.status == "checked_in" fails ("no_show")
86
+ - spec output: suspicion confirmed: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [[Appointment#1(status: "no_show")], Appointment#1(status: "checked_in"); appointment.status == "checked_in" fails ("no_show")], an end no serial order reaches (first then second ends [[Appointment#1(status: "no_show")], raised CheckIn::Refused; appointment.status == "checked_in" fails ("no_show")]; second then first ends [[], Appointment#1(status: "checked_in"); appointment.status == "checked_in" holds ("checked_in")]); serial order "first then second" also fails expect, so this end is the evidence
87
+ - repro: `DATABASE_URL=postgres://postgres@127.0.0.1:56111/hf_clinic_9f0097_c2 TEST_ENV_NUMBER=c2 bundle exec rspec spec/holdfast/main/s2_check_in_between_the_sweep_s_load_and_its_lock_b_spec.rb`
88
+ - next: keep spec/holdfast/main/s2_check_in_between_the_sweep_s_load_and_its_lock_b_spec.rb; it fails until the code is fixed
89
+ rows: .holdfast/check/main/check.jsonl; kept specs: spec/holdfast/main/
90
+ ```
91
+
92
+ 競合が confirmed になるのは、止めた順序が、どの直列の順序でも到達しない状態で終わったからです。
93
+ ある直列の順序でも同じ状態に到達する競合は refuted になります。
94
+
95
+ ### 4. 残す
96
+
97
+ confirmed の行はそれぞれ `spec/holdfast/<branch>/` の下のスペックを示し、そのスペックはコードを直すまで失敗します。
98
+ コードを直し、スペックを走らせ、残します。
99
+ ジョブが確定済みの予約(booked)だけを選ぶように直すと、一つ目のスペックは通ります。
100
+
101
+ ```sh
102
+ bundle exec rspec spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb
103
+ ```
104
+
105
+ ```text
106
+ 1 example, 0 failures
107
+ ```
108
+
109
+ 終了ステータスは、confirmed の行があれば 2、使い方の誤りなら 1、何も confirmed でなければ 0 です。
110
+
111
+ ## 変更を確かめる
112
+
113
+ base のブランチと違うブランチの上では(`--base REF` で別の base を指定できます)、疑いはそれぞれ base と作業ツリーの両方で、どちらも head のファクトリで走ります。
114
+ 行には、その変更が持ち込んだものかどうかが書かれます。
115
+ ここではブランチ `skip-tentative` がジョブを確定済みの予約だけを選ぶように変え、同じファイルをもう一度走らせています。
116
+
117
+ ```text
118
+ # holdfast
119
+
120
+ check: 2 suspicion(s); change: each runs on base main (a17770f) and on the working tree (a0a7f53); 1 file(s) differ: app/jobs/no_show_sweep_job.rb
121
+ kept specs: spec/holdfast/skip-tentative/ (branch skip-tentative); rows: .holdfast/check/skip-tentative/check.jsonl
122
+ seconds: record 0.0, run 5.6
123
+
124
+ confirmed=1 could_not_run=0 refuted=1
125
+
126
+ ## Confirmed (the kept spec fails)
127
+
128
+ - `check-in between the sweep's load and its lock becomes a no-show`: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [...]
129
+ - base a17770f: confirmed: [...]; it was there before the change
130
+ [...]
131
+
132
+ ## Refuted (what was observed)
133
+
134
+ - `a passed tentative appointment is charged a no-show fee`: no PaymentGateway charge holds (observed [])
135
+ - base a17770f: confirmed: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]]); the change removes it
136
+ - ran: NoShowSweepJob.perform_now returned []
137
+ - no PaymentGateway charge holds ([])
138
+ rows: .holdfast/check/skip-tentative/check.jsonl; kept specs: spec/holdfast/skip-tentative/
139
+ ```
140
+
141
+ `[...]` は、上の行の繰り返しになる競合の行から切り取った部分です。
142
+ base にないメソッド、カラム、ルートを呼ぶ疑いは head でだけ confirmed になり、行には base では走らせられないと書かれます。
143
+
144
+ `holdfast run` は、疑いを書かずに変更を確かめます。
145
+ 変更された各メソッドに一行を与え、変更された定数やスコープの呼び出し元にも行を与えます。
146
+ 各対象の名前を含むテストを base と head で走らせ、各メソッドの引数からピンの下書きを作ります。
147
+ 行は `unintended_change`(base で通り head で失敗する)、`intended_change`(意図したと印を付けた違い、または変更で足したテストだけが通る新しい振る舞い)、`unchecked`(誰も引いていない値を示すので、それを疑いとして書く)、`clean`(示した証拠だけを保証する)のいずれかです。
148
+ この部分は [jitcatch](https://github.com/meganemura/jitcatch) と [branchout](https://github.com/meganemura/branchout) を使います。
149
+ どちらも holdfast といっしょに入ります。
150
+
151
+ ## 何も変わっていないときの探索
152
+
153
+ 変更がないときの `holdfast run` は、アプリの書き込み領域(ルート、Grape のエンドポイント、ジョブ)ごとにシーケンスの定義を一つ `.holdfast/sequences/` に下書きします。
154
+ 次に、それらのアクションをランダムな順序でテスト用データベースに対して走らせます。
155
+ その際、不変条件、別のタイムゾーン、外部呼び出しでの二つのアクションの割り込み、最初のバッチ読み込みの後で止めたジョブ、受け付けられたリクエストをもう一度送ることを試します。
156
+ 失敗は最短のシーケンスまで縮められます(縮める時間は最大 60 秒です)。
157
+ `--budget SECONDS` で実行時間の上限を決めます(既定は 600)。
158
+ クリニックアプリで、上限を 120 秒にした出力です。
159
+
160
+ ```text
161
+ # holdfast
162
+
163
+ nothing in the app changed against main (27257f9)
164
+ note: hunt: unreached: 1 of 2 actions (refused every time or never drawn); 0 of 1 areas reached under half
165
+ seconds: record 10.1, hunt 16.8
166
+
167
+ unintended_change=0 bug_suspect=1 unchecked=1 intended_change=0 clean=0
168
+
169
+ ## Suspected bugs
170
+
171
+ - `sequences: check_ins`: no_show_sweep_job loaded Appointment#1 with status "booked"; check_ins_create set it to "checked_in" while no_show_sweep_job held it; no_show_sweep_job then wrote "no_show", though its own query does not select a row whose status is "checked_in"
172
+ - failure: no_show_sweep_job loaded Appointment#1 with status "booked"; check_ins_create set it to "checked_in" while no_show_sweep_job held it; no_show_sweep_job then wrote "no_show", though its own query does not select a row whose status is "checked_in"
173
+ - log: .holdfast/runs/sequences/check_ins.log (the shortest sequence and an `it` block that replays it)
174
+ - next: paste the `it` block from the log into .holdfast/sequences/check_ins.rb, see it fail, fix the code, run again
175
+
176
+ ## Not checked
177
+
178
+ - `(hunt)`: unreached: 1 of 2 actions (refused every time or never drawn); 0 of 1 areas reached under half
179
+ - next: read each area's row below for the refusals, fix the setup or the sign-in in its file, then holdfast run; until then the hunt says little about these areas
180
+ rows: .holdfast/verdicts.jsonl
181
+ ```
182
+
183
+ 探索は、疑いを書かなくても、二つ目の疑いと同じ競合を見つけました。
184
+ 探索の行は疑いにつながります。
185
+ それを書き出し、`holdfast check` で確かめます。
186
+ `holdfast run --sequences` は変更が触れる領域も探索し、`--observe` は各領域のアクションの順序を base と head で走らせて、違ったステップを一つずつ報告します。
187
+
188
+ ## 使い方
189
+
190
+ ```text
191
+ holdfast check [FILE]... [--suspect YAML]... [--base REF] [--here] [--repo DIR]
192
+ holdfast run [--base REF] [--target NAME]... [--only NAME]... [--intent TEXT] [--learn] [--sequences] [--observe] [--budget SECONDS] [--repo DIR]
193
+ holdfast show
194
+ ```
195
+
196
+ - `--suspect YAML` は疑いを一つ YAML のフローマッピングとして渡し、繰り返して使えます。
197
+ - `--here` は、変更の上でも、作業ツリーで一度だけ確かめます。
198
+ - `--learn` は、失敗したセットアップを直した後に、アプリがテストをどう走らせるかを学び直します。
199
+ - `show` は、最後の `holdfast run` の要約をもう一度表示します。
200
+
201
+ holdfast はアプリの `.holdfast/` と `spec/holdfast/` の下に書き込みます。
202
+ `.holdfast/` はアプリの `.gitignore` に加えます。
203
+ 残すスペックはコミットします。
204
+
205
+ 各アプリは、そのディレクトリから作った自分専用のテスト用データベース名を `DATABASE_URL` で受け取ります。
206
+ そのため、base と head、また一つのサーバー上の二つのアプリが、同じデータベースを共有しません。
207
+ セットアップは `bin/rails db:create db:schema:load` を実行します。
208
+
209
+ ## 制限
210
+
211
+ - 入力がレコードを一つも作らないピンの下書きは何も確かめませんが、そのピンが変更された行を読み込むと、`holdfast run` はその対象を `clean` と示すことがあります。
212
+ `clean` の行を信頼する前に、その証拠を読んでください。
213
+ - 競合は、一時停止点 `batch_load`、`outside_call`、`row_lock`、`first_write`、または指定したメソッドで試します。
214
+ それ以外の割り込み方は試しません。
215
+ - 探索が到達するのは、下書きされたセットアップとサインインが働くアクションだけです。
216
+ 到達しなかった数は最初の行に出ます。
217
+
218
+ ## 開発
219
+
220
+ ```sh
221
+ bundle install
222
+ bundle install --gemfile test/fixture/clinic/Gemfile
223
+ DB_HOST=localhost DB_PORT=5432 ruby -Ilib test/run.rb
224
+ ```
225
+
226
+ `DB_HOST` がなければ、クリニックアプリを走らせるテストはスキップされます。
227
+ 詳しくは [AGENTS.md](AGENTS.md) にあります。
228
+
229
+ ## ライセンス
230
+
231
+ MIT
232
+
233
+ ---
234
+
235
+ [English](README.md)
data/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # holdfast
2
+
3
+ holdfast runs what you suspect about a Rails app instead of leaving it read but not run. You write each suspicion (a bug, a race between two actions, an effect on another area) as a few lines of YAML. `holdfast check` builds the world with the app's own factories, runs the action on base and head, and answers `confirmed` with a failing spec to keep, `refuted` with what it observed, or `could_not_run` with the exact error. `holdfast run` checks a change against its base, and hunts with random sequences of the app's entry points when nothing changed.
4
+
5
+ It is made for a coding agent that reads code and has to say what it found. The skill in [`skills/holdfast/`](skills/holdfast/SKILL.md) is the loop an agent follows.
6
+
7
+ ## Quick start in your Rails app
8
+
9
+ You need Ruby 3.4 or newer, git, and an app whose specs run with RSpec and FactoryBot against a test database. Run each step from the app's root.
10
+
11
+ ### 1. Add the gem
12
+
13
+ ```ruby
14
+ group :test do
15
+ gem "holdfast", require: false
16
+ end
17
+ ```
18
+
19
+ Then run `bundle install`. holdfast depends on hegeltest, which the hunt uses.
20
+
21
+ ### 2. Read, and write what you suspect
22
+
23
+ The examples below run against the clinic app in [`test/fixture/clinic`](test/fixture/clinic): appointments, a job that marks missed appointments as no-shows and charges a fee, and a desk check-in. Reading the job, two things look wrong. It also sweeps tentative appointments, which were never confirmed, and it loads its rows before it locks each one, so a check-in in between may be overwritten. Write both down:
24
+
25
+ ```yaml
26
+ - says: a passed tentative appointment is charged a no-show fee
27
+ given: {appointment: {status: tentative, starts_at: -1h}}
28
+ do: NoShowSweepJob.perform_now
29
+ expect: no PaymentGateway charge
30
+ - says: check-in between the sweep's load and its lock becomes a no-show
31
+ given: {appointment: {status: booked, starts_at: -20m}}
32
+ race: [NoShowSweepJob.perform_now, "CheckIn.new(appointment).call"]
33
+ expect: appointment.status == "checked_in"
34
+ ```
35
+
36
+ `given` makes one record per key from the factory of that name. `do` is Ruby. `race` holds the first action at a pause point while the second runs, then the reverse, and also runs both serial orders. `expect` is what must be true afterwards. The [skill](skills/holdfast/SKILL.md) lists every form.
37
+
38
+ ### 3. Check
39
+
40
+ ```sh
41
+ bundle exec holdfast check suspicions.yml
42
+ ```
43
+
44
+ The first run learns how the app runs its tests and makes test databases of its own. This is the whole output on the clinic app:
45
+
46
+ ```text
47
+ # holdfast
48
+
49
+ check: 2 suspicion(s); no change: checks run on the current tree only (af23e82); app, lib, config, db and the Gemfile match main (a17770f)
50
+ kept specs: spec/holdfast/main/ (branch main); rows: .holdfast/check/main/check.jsonl
51
+ seconds: record 9.6, run 5.5
52
+
53
+ confirmed=2 could_not_run=0 refuted=0
54
+
55
+ ## Confirmed (the kept spec fails)
56
+
57
+ - `a passed tentative appointment is charged a no-show fee`: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
58
+ - ran: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]
59
+ - no PaymentGateway charge fails ([[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
60
+ - spec output: suspicion confirmed: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]])
61
+ - repro: `DATABASE_URL=postgres://postgres@127.0.0.1:56111/hf_clinic_9f0097_c2 TEST_ENV_NUMBER=c2 bundle exec rspec spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb`
62
+ - next: keep spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb; it fails until the code is fixed
63
+ - `check-in between the sweep's load and its lock becomes a no-show`: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [[Appointment#1(status: "no_show")], Appointment#1(status: "checked_in"); appointment.status == "checked_in" fails ("no_show")], an end no serial order reaches (first then second ends [[Appointment#1(status: "no_show")], raised CheckIn::Refused; appointment.status == "checked_in" fails ("no_show")]; second then first ends [[], Appointment#1(status: "checked_in"); appointment.status == "checked_in" holds ("checked_in")]); serial order "first then second" also fails expect, so this end is the evidence
64
+ - pause points tried: batch_load: Appointment load (1 rows)
65
+ - first held at batch_load, second runs: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); held at Appointment load (1 rows); appointment.status == "checked_in" fails ("no_show")
66
+ - first then second: NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; CheckIn.new(appointment).call raised CheckIn::Refused: appointment is no_show; appointment.status == "checked_in" fails ("no_show")
67
+ - second then first: CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); NoShowSweepJob.perform_now returned []; appointment.status == "checked_in" holds ("checked_in")
68
+ - second held at batch_load, first runs: CheckIn.new(appointment).call returned Appointment#1(status: "checked_in"); NoShowSweepJob.perform_now returned [Appointment#1(status: "no_show")]; held at Appointment load (1 rows); the second waited on a lock; appointment.status == "checked_in" fails ("no_show")
69
+ - spec output: suspicion confirmed: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [[Appointment#1(status: "no_show")], Appointment#1(status: "checked_in"); appointment.status == "checked_in" fails ("no_show")], an end no serial order reaches (first then second ends [[Appointment#1(status: "no_show")], raised CheckIn::Refused; appointment.status == "checked_in" fails ("no_show")]; second then first ends [[], Appointment#1(status: "checked_in"); appointment.status == "checked_in" holds ("checked_in")]); serial order "first then second" also fails expect, so this end is the evidence
70
+ - repro: `DATABASE_URL=postgres://postgres@127.0.0.1:56111/hf_clinic_9f0097_c2 TEST_ENV_NUMBER=c2 bundle exec rspec spec/holdfast/main/s2_check_in_between_the_sweep_s_load_and_its_lock_b_spec.rb`
71
+ - next: keep spec/holdfast/main/s2_check_in_between_the_sweep_s_load_and_its_lock_b_spec.rb; it fails until the code is fixed
72
+ rows: .holdfast/check/main/check.jsonl; kept specs: spec/holdfast/main/
73
+ ```
74
+
75
+ The race is confirmed only because the held order ends in a state that no serial order reaches. A race whose end some serial order also reaches is refuted.
76
+
77
+ ### 4. Keep
78
+
79
+ Each confirmed row names a spec under `spec/holdfast/<branch>/` that fails until the code is fixed. Fix the code, run the spec, and keep it. After the sweep selects only booked appointments, the first spec passes:
80
+
81
+ ```sh
82
+ bundle exec rspec spec/holdfast/main/s1_a_passed_tentative_appointment_is_charged_a_no_s_spec.rb
83
+ ```
84
+
85
+ ```text
86
+ 1 example, 0 failures
87
+ ```
88
+
89
+ Exit status 2 means a row was confirmed, 1 a usage error, and 0 that nothing was confirmed.
90
+
91
+ ## Check a change
92
+
93
+ On a branch that differs from the base branch (`--base REF` names another), each suspicion runs on base and on the working tree, both with head's factories, and the row says whether the change brought it in. Here the branch `skip-tentative` changes the sweep to select only booked appointments, and the same file runs again:
94
+
95
+ ```text
96
+ # holdfast
97
+
98
+ check: 2 suspicion(s); change: each runs on base main (a17770f) and on the working tree (a0a7f53); 1 file(s) differ: app/jobs/no_show_sweep_job.rb
99
+ kept specs: spec/holdfast/skip-tentative/ (branch skip-tentative); rows: .holdfast/check/skip-tentative/check.jsonl
100
+ seconds: record 0.0, run 5.6
101
+
102
+ confirmed=1 could_not_run=0 refuted=1
103
+
104
+ ## Confirmed (the kept spec fails)
105
+
106
+ - `check-in between the sweep's load and its lock becomes a no-show`: first held at batch_load, second runs (held at Appointment load (1 rows)) ends [...]
107
+ - base a17770f: confirmed: [...]; it was there before the change
108
+ [...]
109
+
110
+ ## Refuted (what was observed)
111
+
112
+ - `a passed tentative appointment is charged a no-show fee`: no PaymentGateway charge holds (observed [])
113
+ - base a17770f: confirmed: no PaymentGateway charge does not hold (observed [[{customer: "pat_1", amount_cents: 2500, reference: "no-show-1"}]]); the change removes it
114
+ - ran: NoShowSweepJob.perform_now returned []
115
+ - no PaymentGateway charge holds ([])
116
+ rows: .holdfast/check/skip-tentative/check.jsonl; kept specs: spec/holdfast/skip-tentative/
117
+ ```
118
+
119
+ `[...]` marks text cut from the race row, which repeats the row above. A suspicion that calls a method, a column or a route that base does not have is confirmed on head only, and the row says that base cannot run it.
120
+
121
+ `holdfast run` checks a change without suspicions. It gives each changed method a row, and the callers of a changed constant or scope too, runs the tests that name each target on base and head, and drafts pins from each method's parameters. A row is `unintended_change` (passes on base, fails on head), `intended_change` (a difference you marked as meant, or new behavior that only the change's new specs run), `unchecked` (with the value nobody drew, to write as a suspicion), or `clean` (covering only the evidence it lists). This part runs on [jitcatch](https://github.com/meganemura/jitcatch) and [branchout](https://github.com/meganemura/branchout), which come with holdfast.
122
+
123
+ ## Hunt when nothing changed
124
+
125
+ `holdfast run` with no change drafts one sequence definition per write area of the app (routes, Grape endpoints, jobs) into `.holdfast/sequences/`. It then runs random orders of their actions against the test database, under invariants, other time zones, two actions interleaved at an outside call, a job held after its first batch load, and an accepted request sent once more. A failure is shrunk to the shortest sequence, for at most 60 seconds. `--budget SECONDS` caps the run (default 600). On the clinic app, with a budget of 120 seconds:
126
+
127
+ ```text
128
+ # holdfast
129
+
130
+ nothing in the app changed against main (27257f9)
131
+ note: hunt: unreached: 1 of 2 actions (refused every time or never drawn); 0 of 1 areas reached under half
132
+ seconds: record 10.1, hunt 16.8
133
+
134
+ unintended_change=0 bug_suspect=1 unchecked=1 intended_change=0 clean=0
135
+
136
+ ## Suspected bugs
137
+
138
+ - `sequences: check_ins`: no_show_sweep_job loaded Appointment#1 with status "booked"; check_ins_create set it to "checked_in" while no_show_sweep_job held it; no_show_sweep_job then wrote "no_show", though its own query does not select a row whose status is "checked_in"
139
+ - failure: no_show_sweep_job loaded Appointment#1 with status "booked"; check_ins_create set it to "checked_in" while no_show_sweep_job held it; no_show_sweep_job then wrote "no_show", though its own query does not select a row whose status is "checked_in"
140
+ - log: .holdfast/runs/sequences/check_ins.log (the shortest sequence and an `it` block that replays it)
141
+ - next: paste the `it` block from the log into .holdfast/sequences/check_ins.rb, see it fail, fix the code, run again
142
+
143
+ ## Not checked
144
+
145
+ - `(hunt)`: unreached: 1 of 2 actions (refused every time or never drawn); 0 of 1 areas reached under half
146
+ - next: read each area's row below for the refusals, fix the setup or the sign-in in its file, then holdfast run; until then the hunt says little about these areas
147
+ rows: .holdfast/verdicts.jsonl
148
+ ```
149
+
150
+ The hunt found the same race as the second suspicion, with no suspicion written. A hunt row leads to a suspicion: write it down and confirm it with `holdfast check`. `holdfast run --sequences` also hunts over the areas a change touches, and `--observe` runs each area's action orders on base and head and reports each step that differs.
151
+
152
+ ## Usage
153
+
154
+ ```text
155
+ holdfast check [FILE]... [--suspect YAML]... [--base REF] [--here] [--repo DIR]
156
+ holdfast run [--base REF] [--target NAME]... [--only NAME]... [--intent TEXT] [--learn] [--sequences] [--observe] [--budget SECONDS] [--repo DIR]
157
+ holdfast show
158
+ ```
159
+
160
+ - `--suspect YAML` passes one suspicion as a YAML flow mapping, and repeats.
161
+ - `--here` runs a check once on the working tree, even in a change.
162
+ - `--learn` learns again how the app runs its tests, after you fix a failed setup.
163
+ - `show` prints the last summary of `holdfast run` again.
164
+
165
+ holdfast writes under `.holdfast/` and `spec/holdfast/` in the app. Add `.holdfast/` to the app's `.gitignore`. Commit the specs you keep.
166
+
167
+ Each app gets its own test database names through `DATABASE_URL`, made from its directory, so base and head, and two apps on one server, do not share one. The setup runs `bin/rails db:create db:schema:load`.
168
+
169
+ ## Limits
170
+
171
+ - A drafted pin whose inputs build no record checks nothing, and `holdfast run` can still list its target as `clean` when the pin loads the changed line. Read the evidence of a `clean` row before you trust it.
172
+ - A race is tried at the pause points `batch_load`, `outside_call`, `row_lock`, `first_write`, or a method you name. Other interleavings are not tried.
173
+ - The hunt reaches only the actions whose drafted setup and sign-in work. The first line says how many it did not reach.
174
+
175
+ ## Development
176
+
177
+ ```sh
178
+ bundle install
179
+ bundle install --gemfile test/fixture/clinic/Gemfile
180
+ DB_HOST=localhost DB_PORT=5432 ruby -Ilib test/run.rb
181
+ ```
182
+
183
+ Without `DB_HOST`, the tests that run the clinic app skip. [AGENTS.md](AGENTS.md) has the details.
184
+
185
+ ## License
186
+
187
+ MIT
188
+
189
+ ---
190
+
191
+ [Japanese](README.ja.md)
data/bin/holdfast ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ Encoding.default_external = Encoding::UTF_8
3
+ require_relative "../lib/holdfast"
4
+ exit Holdfast::CLI.start(ARGV)
@@ -0,0 +1,124 @@
1
+ module Holdfast
2
+ # The unchanged code that reads a changed value through something else: a
3
+ # scope or a method whose own lines did not change but that reads a
4
+ # constant the change edits, and the methods that call that scope or
5
+ # method, one or two calls up. A reader of the diff stops at the diff, so
6
+ # this is where a break hides; each caller becomes a target with a drafted
7
+ # pin and ranks first.
8
+ #
9
+ # jitcatch's brief names the affected methods and their direct and
10
+ # dynamic callers. The second hop comes from jitcatch's call graph, which
11
+ # it does not publish, so this reads it through the brief's own instance.
12
+ module Callers
13
+ CAP = 8
14
+ Value = Data.define(:method, :constant, :model, :column, :values, :outside)
15
+
16
+ def self.targets(jc, brief, change)
17
+ values = brief["methods"].filter_map { value_of(_1, brief, change) }
18
+ return [] if values.empty?
19
+
20
+ head_idx = jc.send(:index_ref, change.head, "head")
21
+ base_idx = jc.send(:index_ref, change.base, "base")
22
+ edges = jc.send(:edges_for, head_idx)
23
+ known = brief["methods"].map { _1["name"] }
24
+ found = {}
25
+ values.each do |v|
26
+ m = v.method
27
+ hop1 = (Array(m["callsites"]).map { _1["enclosing"] } + Array(m["dynamic_callers"]).map { _1["from"] } +
28
+ Hash(m["caller_depths"]).select { _2 == 1 }.keys).uniq
29
+ hop1.each { found[_1] ||= [v, m["name"]] unless skip?(_1, known) }
30
+ hop1.each do |c|
31
+ jc.send(:callers_by_depth, c, edges, 1).each_key { found[_1] ||= [v, c] unless skip?(_1, known) }
32
+ end
33
+ end
34
+ found.first(CAP).filter_map { |name, (v, through)| target(name, v, through, head_idx, base_idx, change) }
35
+ end
36
+
37
+ # A value this module walks from: a method the brief marks as affected by
38
+ # a changed constant, or a changed scope.
39
+ def self.value_of(m, brief, change)
40
+ scope = m["new_source"].to_s.lstrip.start_with?("scope ")
41
+ return nil unless m["change"] == "affected" || (m["change"] == "modified" && scope)
42
+
43
+ const = brief["constants"].find { _1["id"] == m["via_constant"] }
44
+ old_values = strings(const && const["old_source"])
45
+ new_values = strings(const && const["new_source"])
46
+ owner = m["name"].split(/[#.]/).first
47
+ column = scope && const ? where_column(m["new_source"], const["name"].split("::").last) : nil
48
+ model_file = m["file"] if column
49
+ outside = model_file ? (other_strings(change, model_file) - old_values - new_values).first : nil
50
+ Value.new(method: m, constant: const && const["name"], model: (owner if column), column:,
51
+ values: (old_values | new_values), outside:)
52
+ end
53
+
54
+ def self.skip?(name, known)
55
+ known.include?(name) || !name.match?(/\A[A-Z][\w:]*[#.][a-z_]\w*[?!=]?\z/) ||
56
+ name.split(/[#.]/).first.end_with?("Controller") || name.end_with?("#initialize")
57
+ end
58
+
59
+ def self.target(name, v, through, head_idx, base_idx, change)
60
+ node = head_idx["methods"].find { _1.qualified == name } or return nil
61
+ old = base_idx["methods"].find { _1.qualified == name }
62
+ via = v.constant ? "#{v.method["name"]}, which reads #{v.constant}" : v.method["name"]
63
+ why = through == v.method["name"] ? "calls #{via}" : "calls #{through}, which calls #{via}"
64
+ reads = { "via" => v.method["name"], "constant" => v.constant, "model" => v.model, "column" => v.column,
65
+ "values" => v.values, "outside" => v.outside }.compact
66
+ Target.new(name:, kind: "method", file: node.file, line: node.start_line, why: ["caller", why],
67
+ tests: tests_for(change, node), source: node.source, reader: nil, reads:,
68
+ span: { "old_start" => old&.start_line, "old_end" => old&.end_line, "new_start" => node.start_line, "new_end" => node.end_line })
69
+ end
70
+
71
+ # Test files at base that name the caller's method, or its class and a
72
+ # request into it; coverage in the run decides which ones reached it.
73
+ def self.tests_for(change, node)
74
+ bare = node.bare_name
75
+ owner = node.qualified.split(/[#.]/).first.split("::").last
76
+ out, = Open3.capture2("git", "grep", "-l", "-w", "-e", bare, change.base, "--", "spec", "test", chdir: change.root)
77
+ named = out.lines.map { _1.strip.sub(/\A#{change.base}:/, "") }
78
+ out2, = Open3.capture2("git", "grep", "-l", "-w", "-e", owner, change.base, "--", "spec", "test", chdir: change.root)
79
+ (named & out2.lines.map { _1.strip.sub(/\A#{change.base}:/, "") }).first(6)
80
+ end
81
+
82
+ # The string elements of the first array literal in a source line.
83
+ def self.strings(source)
84
+ return [] unless source
85
+
86
+ found = []
87
+ walk(Prism.parse(source).value) do |n|
88
+ next unless n.is_a?(Prism::ArrayNode) && found.empty?
89
+
90
+ found = n.elements.filter_map { |e| e.unescaped if e.is_a?(Prism::StringNode) || e.is_a?(Prism::SymbolNode) }
91
+ end
92
+ found
93
+ end
94
+
95
+ # Every string listed in the model file's array constants (STATUSES), so
96
+ # a draw also holds a value neither side's list holds.
97
+ def self.other_strings(change, file)
98
+ text = Open3.capture2("git", "show", "#{change.head}:#{file}", chdir: change.root).first
99
+ text.scan(/^\s*[A-Z][A-Z0-9_]*\s*=\s*(%w\[[^\]]*\]|\[[^\]]*\])/).flat_map { strings(_1.first) }.uniq
100
+ end
101
+
102
+ # The column a scope compares with the constant: `where(status: OPEN)`.
103
+ def self.where_column(source, const)
104
+ col = nil
105
+ walk(Prism.parse(source.to_s).value) do |n|
106
+ next unless n.is_a?(Prism::CallNode) && %i[where not].include?(n.name) && n.arguments
107
+
108
+ n.arguments.arguments.grep(Prism::KeywordHashNode).flat_map(&:elements).each do |pair|
109
+ val = pair.value
110
+ name = val.is_a?(Prism::ConstantReadNode) ? val.name.to_s : val.is_a?(Prism::ConstantPathNode) ? val.name.to_s : nil
111
+ col ||= pair.key.unescaped if name == const && pair.key.is_a?(Prism::SymbolNode)
112
+ end
113
+ end
114
+ col
115
+ end
116
+
117
+ def self.walk(node, &block)
118
+ return unless node
119
+
120
+ yield node
121
+ node.compact_child_nodes.each { walk(_1, &block) }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,59 @@
1
+ module Holdfast
2
+ # What one run looks at: the base whose behavior must survive, the head
3
+ # (the working tree as it is now, committed or not), and so which mode the
4
+ # harness runs in. The working tree is snapshotted into a commit object
5
+ # through a private index, so the caller's index and stash stay untouched.
6
+ Change = Data.define(:root, :base, :head, :base_label, :mode, :named) do
7
+ def self.read(root, base_ref:, named:)
8
+ base_sha = git(root, "merge-base", base_ref, "HEAD")
9
+ head_sha = snapshot(root)
10
+ same = git(root, "rev-parse", "#{base_sha}^{tree}") == git(root, "rev-parse", "#{head_sha}^{tree}")
11
+ # Edits only to tests, factories or docs change no behavior the app
12
+ # has, so they neither make a change run nor stop a hunt.
13
+ code = git(root, "diff", "--name-only", base_sha, head_sha, "--", *CODE).lines.map(&:strip).reject(&:empty?)
14
+ same ||= code.empty?
15
+ mode = if !same then "change"
16
+ elsif named.any? then "before"
17
+ else "hunt"
18
+ end
19
+ new(root:, base: base_sha, head: head_sha, base_label: base_ref, mode:, named:)
20
+ end
21
+
22
+ CODE = %w[app lib config db Gemfile Gemfile.lock].freeze
23
+
24
+ def self.snapshot(root)
25
+ dirty = !git(root, "status", "--porcelain", "--untracked-files=all", "--", ".", ":!.holdfast").empty?
26
+ return git(root, "rev-parse", "HEAD") unless dirty
27
+
28
+ index = File.join(Dir.mktmpdir("holdfast-index"), "index")
29
+ env = { "GIT_INDEX_FILE" => index }
30
+ git(root, "read-tree", "HEAD", env:)
31
+ git(root, "add", "-A", env:)
32
+ git(root, "rm", "-r", "--cached", "--quiet", "--ignore-unmatch", ".holdfast", env:)
33
+ tree = git(root, "write-tree", env:)
34
+ # The commit stays private to this run, so it needs no identity from the user and must not fail without one.
35
+ who = { "GIT_AUTHOR_NAME" => "holdfast", "GIT_AUTHOR_EMAIL" => "holdfast@localhost",
36
+ "GIT_COMMITTER_NAME" => "holdfast", "GIT_COMMITTER_EMAIL" => "holdfast@localhost" }
37
+ git(root, "commit-tree", tree, "-p", "HEAD", "-m", "holdfast: working tree", env: env.merge(who))
38
+ ensure
39
+ FileUtils.rm_rf(File.dirname(index)) if index
40
+ end
41
+
42
+ def self.git(root, *args, env: {})
43
+ out, err, status = Open3.capture3(env, "git", *args, chdir: root)
44
+ raise Holdfast::Error, "git #{args.join(" ")}: #{err.lines.first.to_s.strip}" unless status.success?
45
+
46
+ out.strip
47
+ end
48
+
49
+ def changed? = mode == "change"
50
+ def short(sha) = sha[0, 7]
51
+ def header
52
+ case mode
53
+ when "change" then "change: #{base_label} (#{short(base)}) .. working tree (#{short(head)})"
54
+ when "before" then "before a change: pinning #{named.join(", ")} at #{short(head)}"
55
+ else "#{Change.git(root, "diff", "--quiet", base, head).then { "" } rescue "only tests, factories or docs differ from #{base_label}; " }nothing in the app changed against #{base_label} (#{short(base)})"
56
+ end
57
+ end
58
+ end
59
+ end