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