rspec-sprint 1.0.0 → 1.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 +4 -4
- data/README.md +44 -19
- data/lib/rspec_sprint/cli.rb +38 -0
- data/lib/rspec_sprint/fix.rb +154 -0
- data/lib/rspec_sprint/fixers/let_it_be/apply_formatter.rb +61 -0
- data/lib/rspec_sprint/fixers/let_it_be/detector.rb +90 -0
- data/lib/rspec_sprint/fixers/let_it_be/formatter.rb +58 -0
- data/lib/rspec_sprint/fixers/let_it_be/report.rb +27 -0
- data/lib/rspec_sprint/fixers/let_it_be/rewriter.rb +39 -0
- data/lib/rspec_sprint/fixers/let_it_be/verifier.rb +122 -0
- data/lib/rspec_sprint/normalizer.rb +3 -2
- data/lib/rspec_sprint/version.rb +1 -1
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5f37a1b9a05bd46d0463b1d169c0a5ec0b5a6cac40e55c4c099c68145ce24e3
|
|
4
|
+
data.tar.gz: d667edc83a97950ac00ab8d5745fd2a0119259f6b909e1cb9f023e36a03399be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6bf1ffd9151437bd41de0f718a2957b6449325d52870ae4e46b8ba8c9f850a418a793b630f1f9eae5b2c3605fe2d034ec7adae1d390e81c5c01a89b5a51fcee0
|
|
7
|
+
data.tar.gz: fd968c8e59d68dbb47b82640777f71b4a2915678719d897d054196725088cdee71839aacb9e54a4aa7482e0fca3719a586963785916debf0a61965bdf5d09e98
|
data/README.md
CHANGED
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ bundle exec rspec-sprint doctor
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
rspec-sprint doctor — 上位2件 (信号があった項目のみ):
|
|
10
|
+
|
|
11
|
+
1. factory が suite の 44% (local実測)。最上位は :tenant(689回, うち直接生成は 30回)
|
|
12
|
+
→ 不要 association を trait へ退避 / build_stubbed / let_it_be / FactoryDefault
|
|
13
|
+
想定削減: factory 比率を半減できれば最大 22% 相当(local実測)
|
|
14
|
+
|
|
15
|
+
2. 上位5例が suite 時間の 84%。最遅: "ExportedDatum zip生成" (193034ms)
|
|
16
|
+
→ 外部I/O・sleep・実ブラウザ依存を切り出す
|
|
17
|
+
想定削減: 上位例を半減できれば最大 42% 相当(local実測)
|
|
12
18
|
```
|
|
13
19
|
|
|
14
20
|
## Installation
|
|
@@ -24,32 +30,55 @@ end
|
|
|
24
30
|
|
|
25
31
|
Then run `bundle install`.
|
|
26
32
|
|
|
27
|
-
**Prerequisite:** Add to your `spec/spec_helper.rb
|
|
33
|
+
**Prerequisite:** Add to your `spec/spec_helper.rb` (or `rails_helper.rb`):
|
|
28
34
|
|
|
29
35
|
```ruby
|
|
30
36
|
require "test_prof"
|
|
31
37
|
```
|
|
32
38
|
|
|
39
|
+
This enables FactoryProf for Rule① (factory_dominance). Without it, the other rules still run.
|
|
40
|
+
|
|
33
41
|
## Usage
|
|
34
42
|
|
|
43
|
+
### Diagnose
|
|
44
|
+
|
|
35
45
|
Run the doctor command to diagnose your suite and get prescriptions ranked by ROI:
|
|
36
46
|
|
|
37
47
|
```
|
|
38
48
|
$ bundle exec rspec-sprint doctor
|
|
39
49
|
```
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
Target a subset of specs for faster feedback:
|
|
42
52
|
|
|
43
53
|
```
|
|
44
54
|
$ bundle exec rspec-sprint doctor -- spec/models
|
|
45
55
|
```
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
Skip saving a snapshot:
|
|
48
58
|
|
|
49
59
|
```
|
|
50
60
|
$ bundle exec rspec-sprint doctor --no-snapshot
|
|
51
61
|
```
|
|
52
62
|
|
|
63
|
+
### Compare against previous run
|
|
64
|
+
|
|
65
|
+
Show delta vs the most recent snapshot (requires at least one prior `doctor` run):
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
$ bundle exec rspec-sprint doctor --compare-last
|
|
69
|
+
前回比 +6.9s (+15%) factory time 40% → 48% [2026-06-18 09:00]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Save a long-term baseline
|
|
73
|
+
|
|
74
|
+
Record the current performance as a named baseline for future reference:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
$ bundle exec rspec-sprint compare --save-baseline
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The baseline is saved to `.rspec-sprint/baseline.json` and is never auto-pruned, unlike the rolling snapshots used by `--compare-last`.
|
|
81
|
+
|
|
53
82
|
The command exits 0 whether or not findings are reported, so it is safe to run in CI as an advisory step.
|
|
54
83
|
|
|
55
84
|
## Why not just test-prof?
|
|
@@ -62,7 +91,7 @@ Concretely, rspec-sprint applies three opinionated heuristics to your numbers:
|
|
|
62
91
|
- **path_group_skew** — one spec directory accounts for a disproportionate share of slowness
|
|
63
92
|
- **slow_examples_concentration** — a handful of examples dominate the tail
|
|
64
93
|
|
|
65
|
-
For each finding it emits a ranked prescription
|
|
94
|
+
For each finding it emits a ranked prescription rather than leaving interpretation to you.
|
|
66
95
|
|
|
67
96
|
Even when test-prof is not installed, FactoryBot is absent, or the suite fails partway through, `rspec-sprint doctor` degrades gracefully and tells you what it could and could not measure.
|
|
68
97
|
|
|
@@ -77,21 +106,17 @@ Even when test-prof is not installed, FactoryBot is absent, or the suite fails p
|
|
|
77
106
|
|
|
78
107
|
## Status
|
|
79
108
|
|
|
80
|
-
|
|
109
|
+
**v1.0.0** — API stable. Dogfood confirmed on 3 Rails apps.
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
| App | Bottleneck found | Prescription |
|
|
112
|
+
|-----|-----------------|--------------|
|
|
113
|
+
| SonicGarden/aegis | factory_dominance — :tenant factory 689 calls (44%) | build_stubbed / let_it_be |
|
|
114
|
+
| SonicGarden/carecollabo | slow_examples_concentration — zip export test 193s | stub external I/O |
|
|
115
|
+
| SonicGarden/testra | slow_examples_concentration — SSRF guard test 3.5s | stub network calls |
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
$ bundle exec rspec-sprint doctor -- spec/models spec/policies
|
|
86
|
-
|
|
87
|
-
1. path group spec/models が suite 時間の 92% (local実測)
|
|
88
|
-
→ spec/models を別CIジョブに分離し fast suite から切り出す(候補)
|
|
89
|
-
|
|
90
|
-
2. factory が suite の 44%。最上位は :tenant(689回)
|
|
91
|
-
→ 不要な association を trait へ退避 / build_stubbed / let_it_be
|
|
92
|
-
```
|
|
117
|
+
All three findings were repo-specific with concrete numbers and actionable prescriptions.
|
|
93
118
|
|
|
94
|
-
|
|
119
|
+
**Snapshot schema v1 is frozen** — field additions are OK, deletions and type changes are not.
|
|
95
120
|
|
|
96
121
|
## License
|
|
97
122
|
|
data/lib/rspec_sprint/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require_relative "collector"
|
|
5
5
|
require_relative "doctor"
|
|
6
|
+
require_relative "fix"
|
|
6
7
|
|
|
7
8
|
module RspecSprint
|
|
8
9
|
class CLI < Thor
|
|
@@ -52,6 +53,43 @@ module RspecSprint
|
|
|
52
53
|
save_baseline: options[:save_baseline])
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
desc "fix [TARGET]", "確定的な処方箋を自動適用する(verify-and-revert)"
|
|
57
|
+
long_desc <<~DESC
|
|
58
|
+
ホットな factory に紐づく let/let!{create} を let_it_be 化します。
|
|
59
|
+
|
|
60
|
+
適用モード(既定): suite を 1 回実行してプロファイルを取り、ROI 上位ファイルの
|
|
61
|
+
候補を適用→再実行→緑 & 速くなれば採用、そうでなければ自動リバートします。
|
|
62
|
+
採用分は working tree に未コミットで残ります(git diff で確認して commit)。
|
|
63
|
+
|
|
64
|
+
bundle exec rspec-sprint fix let-it-be
|
|
65
|
+
bundle exec rspec-sprint fix let-it-be --limit 5
|
|
66
|
+
bundle exec rspec-sprint fix let-it-be --all
|
|
67
|
+
|
|
68
|
+
dry-run モード: 検出・提案のみ(適用しない)
|
|
69
|
+
|
|
70
|
+
bundle exec rspec-sprint fix let-it-be --dry-run
|
|
71
|
+
|
|
72
|
+
TARGET は現在 let-it-be のみ対応。
|
|
73
|
+
DESC
|
|
74
|
+
option :dry_run, type: :boolean, default: false,
|
|
75
|
+
desc: "検出と提案のみ(適用しない)"
|
|
76
|
+
option :limit, type: :numeric, default: 10,
|
|
77
|
+
desc: "適用するファイル上限(ROI 降順)。--all で全ファイル"
|
|
78
|
+
option :all, type: :boolean, default: false,
|
|
79
|
+
desc: "全ファイルを処理する(--limit を無視)"
|
|
80
|
+
def fix(target = "let-it-be")
|
|
81
|
+
unless target == "let-it-be"
|
|
82
|
+
puts "未対応の TARGET です。現在は let-it-be のみ対応しています。"
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
result = Collector.new(rspec_args: []).run
|
|
86
|
+
if options[:dry_run]
|
|
87
|
+
puts Fix.dry_run(result, dir: ".")
|
|
88
|
+
else
|
|
89
|
+
puts Fix.apply(result, dir: ".", limit: options[:limit], all: options[:all])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
55
93
|
default_command :doctor
|
|
56
94
|
end
|
|
57
95
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "digest"
|
|
7
|
+
require_relative "normalizer"
|
|
8
|
+
require_relative "doctor"
|
|
9
|
+
require_relative "fixers/let_it_be/detector"
|
|
10
|
+
require_relative "fixers/let_it_be/report"
|
|
11
|
+
require_relative "fixers/let_it_be/formatter"
|
|
12
|
+
require_relative "fixers/let_it_be/rewriter"
|
|
13
|
+
require_relative "fixers/let_it_be/verifier"
|
|
14
|
+
require_relative "fixers/let_it_be/apply_formatter"
|
|
15
|
+
|
|
16
|
+
module RspecSprint
|
|
17
|
+
# `fix --dry-run let-it-be` の orchestrator。suite は CLI 側で 1 回走らせ済みで、
|
|
18
|
+
# その Result(rspec JSON + FactoryProf JSON のパス)を受け取る。FactoryProf は
|
|
19
|
+
# hard precondition(設計): 無ければ診断不能としてヒントを返す。
|
|
20
|
+
module Fix
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def dry_run(result, dir: ".")
|
|
24
|
+
return Doctor.factory_prof_missing_hint unless result.factory_prof?
|
|
25
|
+
|
|
26
|
+
snapshot = Normalizer.new(
|
|
27
|
+
rspec_json: result.rspec_json_path,
|
|
28
|
+
factory_prof_json: result.factory_prof_path
|
|
29
|
+
).call
|
|
30
|
+
|
|
31
|
+
candidates = []
|
|
32
|
+
let_bang_count = 0
|
|
33
|
+
spec_files(dir).each do |path|
|
|
34
|
+
source = File.read(path)
|
|
35
|
+
candidates.concat(Fixers::LetItBe::Detector.scan(source, file_path: relative(path, dir)))
|
|
36
|
+
let_bang_count += Fixers::LetItBe::Detector.count_let_bang_create(source)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
report = Fixers::LetItBe::Report.build(
|
|
40
|
+
candidates: candidates,
|
|
41
|
+
factories: snapshot.factories,
|
|
42
|
+
let_bang_count: let_bang_count
|
|
43
|
+
)
|
|
44
|
+
Fixers::LetItBe::Formatter.format(report)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# let/let! 候補を verify-and-revert で適用する。
|
|
48
|
+
# limit: 処理するファイル上限(ROI 降順)。all: true なら全ファイルを処理。
|
|
49
|
+
def apply(result, dir: ".", limit: 10, all: false)
|
|
50
|
+
return Doctor.factory_prof_missing_hint unless result.factory_prof?
|
|
51
|
+
return git_not_repo_hint unless git_repo?(dir)
|
|
52
|
+
return recipe_not_loaded_hint(dir) unless let_it_be_recipe_loaded?(dir)
|
|
53
|
+
|
|
54
|
+
snapshot = Normalizer.new(
|
|
55
|
+
rspec_json: result.rspec_json_path,
|
|
56
|
+
factory_prof_json: result.factory_prof_path
|
|
57
|
+
).call
|
|
58
|
+
|
|
59
|
+
factory_time = snapshot.factories.each_with_object({}) do |f, h|
|
|
60
|
+
h[f.name.to_s] = f.top_level_time.to_f
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
files_with_candidates = {}
|
|
64
|
+
spec_files(dir).each do |abs_path|
|
|
65
|
+
rel_path = relative(abs_path, dir)
|
|
66
|
+
source = File.read(abs_path)
|
|
67
|
+
cands = Fixers::LetItBe::Detector.scan_all(source, file_path: rel_path)
|
|
68
|
+
files_with_candidates[abs_path] = cands unless cands.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return "変換可能な候補が見つかりませんでした(let/let! の単一 create 本体が対象)。" \
|
|
72
|
+
" まず `rspec-sprint fix let-it-be --dry-run` で確認してください。" if files_with_candidates.empty?
|
|
73
|
+
|
|
74
|
+
sorted = files_with_candidates.sort_by do |_path, cands|
|
|
75
|
+
-cands.map { |c| factory_time[c.factory_name.to_s].to_f }.sum
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
target_files = all ? sorted : sorted.first(limit)
|
|
79
|
+
|
|
80
|
+
verifier = Fixers::LetItBe::Verifier.new(runner: rspec_runner(dir), dir: dir)
|
|
81
|
+
file_results = target_files.map do |abs_path, cands|
|
|
82
|
+
verifier.verify_file(abs_path, cands)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Fixers::LetItBe::ApplyFormatter.format(file_results)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def spec_files(dir)
|
|
89
|
+
Dir.glob(File.join(dir, "spec/**/*_spec.rb")).sort
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def relative(path, dir)
|
|
93
|
+
base = File.expand_path(dir)
|
|
94
|
+
File.expand_path(path).delete_prefix("#{base}/")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def git_repo?(dir)
|
|
98
|
+
_, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--git-dir")
|
|
99
|
+
status.success?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def git_not_repo_hint
|
|
103
|
+
"エラー: git リポジトリが見つかりません。`fix` 適用は git が必要です(復元保証のため)。"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def let_it_be_recipe_loaded?(dir)
|
|
107
|
+
helper_files = Dir.glob(File.join(dir, "spec/{spec_helper,rails_helper}.rb"))
|
|
108
|
+
helper_files.any? do |f|
|
|
109
|
+
content = File.read(f)
|
|
110
|
+
content.include?("test_prof/recipes/rspec/let_it_be") ||
|
|
111
|
+
content.include?("RSpecLetItBe")
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def recipe_not_loaded_hint(dir)
|
|
118
|
+
<<~MSG
|
|
119
|
+
エラー: `let_it_be` recipe がロードされていません。
|
|
120
|
+
|
|
121
|
+
spec/spec_helper.rb または spec/rails_helper.rb に以下を追加してください:
|
|
122
|
+
|
|
123
|
+
require "test_prof/recipes/rspec/let_it_be"
|
|
124
|
+
|
|
125
|
+
追加後、もう一度 `rspec-sprint fix let-it-be` を実行してください。
|
|
126
|
+
MSG
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rspec_runner(dir)
|
|
130
|
+
out_dir = File.join(dir, "tmp", "rspec_sprint", "verify")
|
|
131
|
+
FileUtils.mkdir_p(out_dir)
|
|
132
|
+
->(path) {
|
|
133
|
+
out_path = File.join(out_dir, "#{Digest::MD5.hexdigest(path)[0..7]}_verify.json")
|
|
134
|
+
_, status = Open3.capture2e(
|
|
135
|
+
"bundle", "exec", "rspec", path,
|
|
136
|
+
"--format", "json", "--out", out_path,
|
|
137
|
+
chdir: dir
|
|
138
|
+
)
|
|
139
|
+
green = status.exitstatus&.zero? || false
|
|
140
|
+
duration = parse_verify_duration(out_path)
|
|
141
|
+
{green: green, duration: duration}
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_verify_duration(out_path)
|
|
146
|
+
return 0.0 unless File.exist?(out_path) && !File.zero?(out_path)
|
|
147
|
+
|
|
148
|
+
data = JSON.parse(File.read(out_path))
|
|
149
|
+
data.dig("summary", "duration")&.to_f || 0.0
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
0.0
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RspecSprint
|
|
4
|
+
module Fixers
|
|
5
|
+
module LetItBe
|
|
6
|
+
module ApplyFormatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def format(results)
|
|
10
|
+
lines = ["rspec-sprint fix let-it-be", ""]
|
|
11
|
+
|
|
12
|
+
results.each do |r|
|
|
13
|
+
lines << file_line(r)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
lines << ""
|
|
17
|
+
lines << summary_line(results)
|
|
18
|
+
lines << ""
|
|
19
|
+
lines << "採用分は working tree に未コミットです。`git diff` で確認して commit してください。"
|
|
20
|
+
lines.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def file_line(r)
|
|
24
|
+
path = r.file.to_s
|
|
25
|
+
case r.status
|
|
26
|
+
when :accepted
|
|
27
|
+
"#{path}: 採用 #{r.applied.size} 件 #{time_range(r.baseline_s, r.after_s)}"
|
|
28
|
+
when :bisect_partial
|
|
29
|
+
"#{path}: 採用 #{r.applied.size} 件 [bisect]"
|
|
30
|
+
when :reverted_slow
|
|
31
|
+
"#{path}: リバート(速度改善不足)#{r.reverted.size} 件 #{time_range(r.baseline_s, r.after_s)}"
|
|
32
|
+
when :reverted_red
|
|
33
|
+
"#{path}: リバート(赤)#{r.reverted.size} 件"
|
|
34
|
+
when :skipped_dirty
|
|
35
|
+
"#{path}: スキップ(git-dirty)#{r.skipped.size} 件"
|
|
36
|
+
when :skipped_unstable
|
|
37
|
+
"#{path}: スキップ(baseline 不安定)#{r.skipped.size} 件"
|
|
38
|
+
else
|
|
39
|
+
"#{path}: 不明なステータス #{r.status}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def time_range(baseline_s, after_s)
|
|
44
|
+
return "" unless baseline_s && after_s
|
|
45
|
+
|
|
46
|
+
pct = baseline_s > 0 ? ((baseline_s - after_s) / baseline_s * 100).round : 0
|
|
47
|
+
"(#{baseline_s.round(1)}s → #{after_s.round(1)}s, -#{pct}%)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summary_line(results)
|
|
51
|
+
total_applied = results.sum { |r| r.applied.size }
|
|
52
|
+
accepted_files = results.count { |r| r.status == :accepted || r.status == :bisect_partial }
|
|
53
|
+
baseline_total = results.filter_map(&:baseline_s).sum
|
|
54
|
+
after_total = results.filter_map(&:after_s).sum
|
|
55
|
+
time_part = baseline_total > 0 && after_total > 0 ? " #{time_range(baseline_total, after_total)}" : ""
|
|
56
|
+
"合計: 採用 #{total_applied} 件 / #{accepted_files} ファイル#{time_part}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop-ast"
|
|
4
|
+
|
|
5
|
+
module RspecSprint
|
|
6
|
+
module Fixers
|
|
7
|
+
module LetItBe
|
|
8
|
+
# 1 件の autocorrect 候補。let_name / factory_name は Symbol。kind は :let or :let_bang。
|
|
9
|
+
Candidate = Struct.new(:let_name, :factory_name, :line, :file_path, :kind,
|
|
10
|
+
keyword_init: true) do
|
|
11
|
+
def initialize(let_name:, factory_name:, line:, file_path:, kind: :let)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# spec ソースを AST で走査し、本体が「単一の create(:factory) 呼び出し」だけの
|
|
17
|
+
# `let(:x) { ... }` を候補として返す純粋関数。挙動を変える変換なので、安全性は
|
|
18
|
+
# 後段(verify/revert, 本プラン対象外)に委ね、ここでは確定パターンだけ拾う。
|
|
19
|
+
class Detector
|
|
20
|
+
# block:
|
|
21
|
+
# レシーバ無し let に sym 引数 1 つ($ で let 名を捕捉)
|
|
22
|
+
# 引数なしブロック (args)
|
|
23
|
+
# 本体は レシーバが無 or FactoryBot の create、第1引数は sym($ で factory 名を捕捉)
|
|
24
|
+
PATTERN = ::RuboCop::AST::NodePattern.new(<<~PAT)
|
|
25
|
+
(block
|
|
26
|
+
(send nil? :let (sym $_))
|
|
27
|
+
(args)
|
|
28
|
+
(send {nil? (const nil? :FactoryBot)} :create (sym $_) ...))
|
|
29
|
+
PAT
|
|
30
|
+
|
|
31
|
+
# 同形だが `let!`。eager → once でセマンティクスが変わるため安全変換の対象外。
|
|
32
|
+
# 「対象外だが隣接する let!{create} がどれだけあるか」を提示するために数えるだけ。
|
|
33
|
+
LET_BANG_PATTERN = ::RuboCop::AST::NodePattern.new(<<~PAT)
|
|
34
|
+
(block
|
|
35
|
+
(send nil? :let! (sym $_))
|
|
36
|
+
(args)
|
|
37
|
+
(send {nil? (const nil? :FactoryBot)} :create (sym $_) ...))
|
|
38
|
+
PAT
|
|
39
|
+
|
|
40
|
+
def self.scan(source, file_path: nil)
|
|
41
|
+
each_match(source, PATTERN).map do |let_name, factory_name, node|
|
|
42
|
+
Candidate.new(
|
|
43
|
+
let_name: let_name,
|
|
44
|
+
factory_name: factory_name,
|
|
45
|
+
line: node.loc.line,
|
|
46
|
+
file_path: file_path,
|
|
47
|
+
kind: :let
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# let と let! の両方を候補として返す(apply モード用)。line 昇順。
|
|
53
|
+
def self.scan_all(source, file_path: nil)
|
|
54
|
+
let_cands = each_match(source, PATTERN).map do |let_name, factory_name, node|
|
|
55
|
+
Candidate.new(let_name: let_name, factory_name: factory_name,
|
|
56
|
+
line: node.loc.line, file_path: file_path, kind: :let)
|
|
57
|
+
end
|
|
58
|
+
let_bang_cands = each_match(source, LET_BANG_PATTERN).map do |let_name, factory_name, node|
|
|
59
|
+
Candidate.new(let_name: let_name, factory_name: factory_name,
|
|
60
|
+
line: node.loc.line, file_path: file_path, kind: :let_bang)
|
|
61
|
+
end
|
|
62
|
+
(let_cands + let_bang_cands).sort_by(&:line)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 安全変換できない let!{single create} の件数(lazy/let! 比を示すため)。
|
|
66
|
+
def self.count_let_bang_create(source)
|
|
67
|
+
each_match(source, LET_BANG_PATTERN).size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# AST を1回パースし、pattern に一致する block を [let_name, factory_name, node] で返す。
|
|
71
|
+
def self.each_match(source, pattern)
|
|
72
|
+
processed = ::RuboCop::AST::ProcessedSource.new(source.to_s, RUBY_VERSION.to_f)
|
|
73
|
+
ast = processed.ast
|
|
74
|
+
return [] if ast.nil?
|
|
75
|
+
|
|
76
|
+
matches = []
|
|
77
|
+
ast.each_node(:block) do |node|
|
|
78
|
+
captures = pattern.match(node)
|
|
79
|
+
next unless captures
|
|
80
|
+
|
|
81
|
+
let_name, factory_name = captures
|
|
82
|
+
matches << [let_name, factory_name, node]
|
|
83
|
+
end
|
|
84
|
+
matches
|
|
85
|
+
end
|
|
86
|
+
private_class_method :each_match
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RspecSprint
|
|
4
|
+
module Fixers
|
|
5
|
+
module LetItBe
|
|
6
|
+
# dry-run レポートを端末向けに整形する。
|
|
7
|
+
#
|
|
8
|
+
# 見出しは「変換可能候補の件数」と「対象外の let!{create} 件数(lazy/let! 比)」。
|
|
9
|
+
# 上限 share は実収量と乖離しうる(候補が参照する factory のコストの大半が let! や
|
|
10
|
+
# 直接 create 由来のことがある)ため、誤誘導する「推定削減」ではなく
|
|
11
|
+
# 「上限値・実収量ではない」と明示して補足に降格する。
|
|
12
|
+
module Formatter
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def format(report)
|
|
16
|
+
return none(report.let_bang_count) if report.candidates.empty?
|
|
17
|
+
|
|
18
|
+
lines = [
|
|
19
|
+
"rspec-sprint fix --dry-run (let_it_be)",
|
|
20
|
+
"変換可能候補(lazy let{create}): #{report.candidates.size} 件",
|
|
21
|
+
let_bang_line(report.let_bang_count),
|
|
22
|
+
""
|
|
23
|
+
].compact
|
|
24
|
+
report.candidates.each do |c|
|
|
25
|
+
lines << "#{c.file_path}:#{c.line}"
|
|
26
|
+
lines << " - let(:#{c.let_name}) { create(:#{c.factory_name}) }"
|
|
27
|
+
lines << " + let_it_be(:#{c.let_name}) { create(:#{c.factory_name}) }"
|
|
28
|
+
lines << ""
|
|
29
|
+
end
|
|
30
|
+
lines << "参考(上限値・実収量ではない): 候補が参照する factory は factory time の最大 #{pct(report)}%。" \
|
|
31
|
+
"実際の削減は適用+再実行で測定(未実施)。"
|
|
32
|
+
lines << "注意: これは dry-run です。適用はまだ行いません(緑のまま速くなるか実測する verify は次段)。"
|
|
33
|
+
lines.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def none(let_bang_count)
|
|
37
|
+
if let_bang_count.to_i.positive?
|
|
38
|
+
"変換可能な lazy let{create} 候補は 0 件。" \
|
|
39
|
+
"ただし let!{create} が #{let_bang_count} 件あります" \
|
|
40
|
+
"(eager→once でセマンティクスが変わるため安全変換不可。apply モードでは verify 付きで対応:`rspec-sprint fix let-it-be`)。"
|
|
41
|
+
else
|
|
42
|
+
"該当する let{create} 候補は見つかりませんでした(単一 create 本体の let のみ対象)。"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def let_bang_line(count)
|
|
47
|
+
return nil unless count.to_i.positive?
|
|
48
|
+
|
|
49
|
+
"対象外: let!{create} #{count} 件(eager→once でセマンティクスが変わるため安全変換不可。apply モードでは verify 付きで対応:`rspec-sprint fix let-it-be`)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pct(report)
|
|
53
|
+
(report.upper_bound_share * 100).round
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RspecSprint
|
|
4
|
+
module Fixers
|
|
5
|
+
module LetItBe
|
|
6
|
+
# 候補と factory プロファイルから「factory time 削減の上限割合」を出す。
|
|
7
|
+
# 上限値: 候補が参照する factory の top_level_time を「全てこの候補で消える」と
|
|
8
|
+
# 仮定した最大値。実際にどの call site がホットかは集計データから特定できないため
|
|
9
|
+
# (設計 Open Question 1)、これは upper bound であって実収量ではない。
|
|
10
|
+
module Report
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
Result = Struct.new(:candidates, :upper_bound_share, :let_bang_count, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def build(candidates:, factories:, let_bang_count: 0)
|
|
16
|
+
denominator = factories.sum { |f| f.top_level_time.to_f }
|
|
17
|
+
referenced = candidates.map { |c| c.factory_name.to_s }.uniq
|
|
18
|
+
numerator = factories
|
|
19
|
+
.select { |f| referenced.include?(f.name.to_s) }
|
|
20
|
+
.sum { |f| f.top_level_time.to_f }
|
|
21
|
+
share = denominator.zero? ? 0.0 : numerator / denominator
|
|
22
|
+
Result.new(candidates: candidates, upper_bound_share: share, let_bang_count: let_bang_count)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop-ast"
|
|
4
|
+
require "parser/source/tree_rewriter"
|
|
5
|
+
|
|
6
|
+
module RspecSprint
|
|
7
|
+
module Fixers
|
|
8
|
+
module LetItBe
|
|
9
|
+
module Rewriter
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# source(String)と candidates(Array[Candidate])を受け取り、
|
|
13
|
+
# 各候補行の let(/let!( を let_it_be( に書き換えた source を返す。
|
|
14
|
+
# ファイル I/O を持たない純粋関数。パース不能時は source をそのまま返す。
|
|
15
|
+
def rewrite(source, candidates)
|
|
16
|
+
return source if candidates.empty?
|
|
17
|
+
|
|
18
|
+
processed = ::RuboCop::AST::ProcessedSource.new(source.to_s, RUBY_VERSION.to_f)
|
|
19
|
+
return source if processed.ast.nil?
|
|
20
|
+
|
|
21
|
+
target_lines = candidates.map(&:line).to_set
|
|
22
|
+
tree_rewriter = ::Parser::Source::TreeRewriter.new(processed.buffer)
|
|
23
|
+
|
|
24
|
+
processed.ast.each_node(:block) do |node|
|
|
25
|
+
next unless target_lines.include?(node.loc.line)
|
|
26
|
+
|
|
27
|
+
send_node = node.children[0]
|
|
28
|
+
next unless send_node&.send_type?
|
|
29
|
+
next unless %i[let let!].include?(send_node.method_name)
|
|
30
|
+
|
|
31
|
+
tree_rewriter.replace(send_node.loc.selector, "let_it_be")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
tree_rewriter.process
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "rewriter"
|
|
5
|
+
|
|
6
|
+
module RspecSprint
|
|
7
|
+
module Fixers
|
|
8
|
+
module LetItBe
|
|
9
|
+
# verify_file の戻り値。
|
|
10
|
+
FileResult = Struct.new(
|
|
11
|
+
:file, # String — 絶対パス
|
|
12
|
+
:applied, # Array[Candidate] — 採用(working tree に残す)
|
|
13
|
+
:reverted, # Array[Candidate] — 試したが戻した
|
|
14
|
+
:skipped, # Array[Candidate] — 試さなかった(dirty/unstable)
|
|
15
|
+
:baseline_s, # Float|nil — baseline の平均(秒)
|
|
16
|
+
:after_s, # Float|nil — after の平均(秒)
|
|
17
|
+
:status, # :accepted | :reverted_slow | :reverted_red |
|
|
18
|
+
# :bisect_partial | :skipped_dirty | :skipped_unstable
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# runner: call(path) -> {green: Boolean, duration: Float}
|
|
23
|
+
# git_clean: call(path) -> Boolean(省略時は git status --porcelain を実行)
|
|
24
|
+
class Verifier
|
|
25
|
+
def initialize(runner:, dir: ".", git_clean: nil)
|
|
26
|
+
@runner = runner
|
|
27
|
+
@dir = dir
|
|
28
|
+
@git_clean_fn = git_clean
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify_file(path, candidates)
|
|
32
|
+
unless file_clean?(path)
|
|
33
|
+
return FileResult.new(file: path, applied: [], reverted: [], skipped: candidates,
|
|
34
|
+
baseline_s: nil, after_s: nil, status: :skipped_dirty)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
source_backup = File.read(path)
|
|
38
|
+
|
|
39
|
+
b1 = @runner.call(path)
|
|
40
|
+
b2 = @runner.call(path)
|
|
41
|
+
|
|
42
|
+
unless b1[:green] && b2[:green]
|
|
43
|
+
return FileResult.new(file: path, applied: [], reverted: [], skipped: candidates,
|
|
44
|
+
baseline_s: nil, after_s: nil, status: :skipped_unstable)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
baseline_s = (b1[:duration] + b2[:duration]) / 2.0
|
|
48
|
+
|
|
49
|
+
rewritten = Rewriter.rewrite(source_backup, candidates)
|
|
50
|
+
File.write(path, rewritten)
|
|
51
|
+
|
|
52
|
+
r1 = @runner.call(path)
|
|
53
|
+
r2 = @runner.call(path)
|
|
54
|
+
|
|
55
|
+
if r1[:green] && r2[:green]
|
|
56
|
+
after_s = (r1[:duration] + r2[:duration]) / 2.0
|
|
57
|
+
if improvement_sufficient?(baseline_s, after_s)
|
|
58
|
+
return FileResult.new(file: path, applied: candidates, reverted: [], skipped: [],
|
|
59
|
+
baseline_s: baseline_s, after_s: after_s, status: :accepted)
|
|
60
|
+
else
|
|
61
|
+
File.write(path, source_backup)
|
|
62
|
+
return FileResult.new(file: path, applied: [], reverted: candidates, skipped: [],
|
|
63
|
+
baseline_s: baseline_s, after_s: after_s, status: :reverted_slow)
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
File.write(path, source_backup)
|
|
67
|
+
bisect(path, candidates, source_backup, baseline_s)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def improvement_sufficient?(baseline_s, after_s)
|
|
74
|
+
delta = baseline_s - after_s
|
|
75
|
+
delta >= 0.5 || (baseline_s > 0 && delta / baseline_s >= 0.05)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def bisect(path, candidates, source_backup, baseline_s)
|
|
79
|
+
safe = []
|
|
80
|
+
candidates.each do |candidate|
|
|
81
|
+
trial = Rewriter.rewrite(source_backup, [candidate])
|
|
82
|
+
File.write(path, trial)
|
|
83
|
+
r = @runner.call(path)
|
|
84
|
+
safe << candidate if r[:green]
|
|
85
|
+
File.write(path, source_backup)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if safe.empty?
|
|
89
|
+
return FileResult.new(file: path, applied: [], reverted: candidates, skipped: [],
|
|
90
|
+
baseline_s: baseline_s, after_s: nil, status: :reverted_red)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
final = Rewriter.rewrite(source_backup, safe)
|
|
94
|
+
File.write(path, final)
|
|
95
|
+
|
|
96
|
+
r = @runner.call(path)
|
|
97
|
+
unless r[:green]
|
|
98
|
+
File.write(path, source_backup)
|
|
99
|
+
return FileResult.new(file: path, applied: [], reverted: candidates, skipped: [],
|
|
100
|
+
baseline_s: baseline_s, after_s: nil, status: :reverted_red)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
FileResult.new(file: path, applied: safe, reverted: candidates - safe, skipped: [],
|
|
104
|
+
baseline_s: baseline_s, after_s: nil, status: :bisect_partial)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def file_clean?(path)
|
|
108
|
+
if @git_clean_fn
|
|
109
|
+
@git_clean_fn.call(path)
|
|
110
|
+
else
|
|
111
|
+
default_git_clean(path)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def default_git_clean(path)
|
|
116
|
+
out, status = Open3.capture2e("git", "status", "--porcelain", path, chdir: @dir)
|
|
117
|
+
status.success? && out.strip.empty?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -10,7 +10,7 @@ module RspecSprint
|
|
|
10
10
|
class Normalizer
|
|
11
11
|
# One factory's profile. top_level_ratio is the cascade signal: low ratio
|
|
12
12
|
# means the factory is built mostly via associations, not directly.
|
|
13
|
-
Factory = Struct.new(:name, :total_count, :top_level_count, :total_time, keyword_init: true) do
|
|
13
|
+
Factory = Struct.new(:name, :total_count, :top_level_count, :total_time, :top_level_time, keyword_init: true) do
|
|
14
14
|
def top_level_ratio
|
|
15
15
|
return 0.0 if total_count.nil? || total_count.zero?
|
|
16
16
|
|
|
@@ -98,7 +98,8 @@ module RspecSprint
|
|
|
98
98
|
name: s.fetch("name"),
|
|
99
99
|
total_count: s.fetch("total_count", 0),
|
|
100
100
|
top_level_count: s.fetch("top_level_count", 0),
|
|
101
|
-
total_time: s.fetch("total_time", 0.0)
|
|
101
|
+
total_time: s.fetch("total_time", 0.0),
|
|
102
|
+
top_level_time: s.fetch("top_level_time", 0.0)
|
|
102
103
|
)
|
|
103
104
|
end
|
|
104
105
|
end
|
data/lib/rspec_sprint/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-sprint
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- yasu551
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: test-prof
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rubocop-ast
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.30'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.30'
|
|
41
55
|
description: |
|
|
42
56
|
rspec-sprint doctor runs your suite once (via test-prof's FactoryProf + rspec's
|
|
43
57
|
JSON formatter), interprets the output against opinionated heuristics, and asserts
|
|
@@ -59,6 +73,13 @@ files:
|
|
|
59
73
|
- lib/rspec_sprint/diagnosis.rb
|
|
60
74
|
- lib/rspec_sprint/doctor.rb
|
|
61
75
|
- lib/rspec_sprint/finding.rb
|
|
76
|
+
- lib/rspec_sprint/fix.rb
|
|
77
|
+
- lib/rspec_sprint/fixers/let_it_be/apply_formatter.rb
|
|
78
|
+
- lib/rspec_sprint/fixers/let_it_be/detector.rb
|
|
79
|
+
- lib/rspec_sprint/fixers/let_it_be/formatter.rb
|
|
80
|
+
- lib/rspec_sprint/fixers/let_it_be/report.rb
|
|
81
|
+
- lib/rspec_sprint/fixers/let_it_be/rewriter.rb
|
|
82
|
+
- lib/rspec_sprint/fixers/let_it_be/verifier.rb
|
|
62
83
|
- lib/rspec_sprint/formatter.rb
|
|
63
84
|
- lib/rspec_sprint/normalizer.rb
|
|
64
85
|
- lib/rspec_sprint/ranker.rb
|