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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc37bb933dee52c1c7afe48cabacb970bf1f6188e2587ac032001185a77755ed
4
- data.tar.gz: 3b440d548798f9ce169bcb44f65760ea86858cf554d5359a0166d074ca3a54d2
3
+ metadata.gz: e5f37a1b9a05bd46d0463b1d169c0a5ec0b5a6cac40e55c4c099c68145ce24e3
4
+ data.tar.gz: d667edc83a97950ac00ab8d5745fd2a0119259f6b909e1cb9f023e36a03399be
5
5
  SHA512:
6
- metadata.gz: 1b895e63670890d0717b8e663eba88a9c5494ebc266869570f2d2d34a925ea25a82a469279d426ff6a89d1d0a608a586490577b22eee79567fb2c8a03f79e09d
7
- data.tar.gz: '0868189200f1c5aa80d3d0b50aca73c4dcf1146ea769f1f1044199872ce02aaf943282a28cbc382f615f2b1a2484c5cde05da1331181dbe7968bf27558925bad'
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
- factory suite 47% (local実測)。最上位は :user(420回, うち直接生成は 30回 = カスケード)。
10
- → 不要 association を trait へ退避 / build_stubbed / let_it_be / FactoryDefault
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
- To target a subset of specs (faster feedback during investigation):
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
- Snapshot comparison (coming in v0.2.0):
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 (e.g. "retire unused associations to a trait, switch to `build_stubbed`") rather than leaving interpretation to you.
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
- v0.1.0 — install-day tested, dogfood confirmed.
109
+ **v1.0.0**API stable. Dogfood confirmed on 3 Rails apps.
81
110
 
82
- **Dogfood result (SonicGarden/aegis, 2026-06-18):**
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
- Both findings are repo-specific with concrete numbers not generic advice.
119
+ **Snapshot schema v1 is frozen** — field additions are OK, deletions and type changes are not.
95
120
 
96
121
  ## License
97
122
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RspecSprint
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
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.0.0
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-18 00:00:00.000000000 Z
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