Module: RspecSprint::Rules

Defined in:
lib/rspec_sprint/rules.rb

Overview

The judgment layer — the gem’s moat. Each rule is a plain method (design D1): it reads a normalized Snapshot and returns a Finding when its signal exceeds a threshold, or nil otherwise. Thresholds and prescription wording are the opinionated, tunable heart of the tool (premise #3).

Constant Summary collapse

FACTORY_DOMINANCE_THRESHOLD =

— Tunable thresholds (the opinionated knobs) —————————

0.30
CASCADE_RATIO =

fires when factory_time_ratio exceeds this

0.5
PATH_GROUP_SKEW_THRESHOLD =

Defaults (tune freely — these are the gem’s IP):

0.50
SLOW_EXAMPLE_TOP_N =

one path group eating MORE than this share of suite time

5
SLOW_EXAMPLE_CONCENTRATION_THRESHOLD =

“a few” = this many slowest examples

0.30
SLOW_EXAMPLE_MIN_EXAMPLES =

top-N eating more than this share of suite time

10

Class Method Summary collapse

Class Method Details

.factory_dominance(snapshot) ⇒ Object

Rule ①: factory build time dominates the suite.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/rspec_sprint/rules.rb', line 24

def factory_dominance(snapshot)
  ratio = snapshot.factory_time_ratio
  return nil if ratio < FACTORY_DOMINANCE_THRESHOLD

  pct = (ratio * 100).round
  top = (snapshot.factories || []).max_by(&:total_count)
  cascade = top && top.top_level_ratio < CASCADE_RATIO

  Finding.new(
    rule_id: :factory_dominance,
    score: ratio,
    headline: headline_for(pct, top, cascade),
    prescriptions: [
      "不要な association をデフォルト factory から外し trait へ退避",
      "永続化が不要な spec は build_stubbed / build に置換",
      "全 spec で使う共有データは let_it_be / FactoryDefault 化"
    ],
    expected_saving: "factory time の一部(目安 suite の #{(pct * 0.3).round}#{pct}%、local実測)",
    doc_url: "https://test-prof.evilmartians.io/guide/profilers/factory_prof"
  )
end

.headline_for(pct, top, cascade) ⇒ Object



126
127
128
129
130
131
132
133
134
135
# File 'lib/rspec_sprint/rules.rb', line 126

def headline_for(pct, top, cascade)
  base = "factory が suite の #{pct}% (local実測)"
  return base unless top

  if cascade
    "#{base}。最上位は :#{top.name}#{top.total_count}回, うち直接生成 #{top.top_level_count}回 = カスケード)"
  else
    "#{base}。最上位は :#{top.name}#{top.total_count}回)"
  end
end

.path_group_skew(snapshot) ⇒ Object

Rule ②: one “path group” (spec/system, spec/requests, …) dominates the suite’s wall time. NOTE: call it a path group, not RSpec ‘type:` — they can diverge (design D10⑦). Do NOT assert “split your system specs”; offer it as a candidate.

Data available on ‘snapshot`:

snapshot.path_group_durations  # => { "system" => 1.5, "models" => 0.3, ... } seconds
snapshot.suite_duration        # => total example-run wall time (the denominator)

Return a Finding when the biggest group’s share exceeds PATH_GROUP_SKEW_THRESHOLD, else nil. Score = that share (0.0..1.0).



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/rspec_sprint/rules.rb', line 58

def path_group_skew(snapshot)
  total = snapshot.suite_duration.to_f
  return nil if total.zero?

  # Drop "(root)" — specs directly under spec/ aren't a coherent group you'd
  # split into a CI job (dogfooding finding; cf. Codex #8 path-group risk).
  candidates = (snapshot.path_group_durations || {}).reject { |g, _| g == "(root)" }
  group, time = candidates.max_by { |_, t| t }
  return nil if group.nil?

  share = time / total
  return nil unless share > PATH_GROUP_SKEW_THRESHOLD

  pct = (share * 100).round
  Finding.new(
    rule_id: :path_group_skew,
    score: share,
    headline: "path group spec/#{group} が suite 時間の #{pct}% (local実測)",
    prescriptions: [
      "spec/#{group} を別CIジョブに分離し fast suite から切り出す(候補)",
      "ロジックを下位レイヤ(model/request/service)へ寄せ #{group} spec 本数を減らせるか検討",
      "runtime ログで #{group} を shard 分割"
    ],
    expected_saving: "spec/#{group} を分離すれば fast suite から最大 #{pct}% 相当を外せる(local実測)",
    doc_url: "https://test-prof.evilmartians.io/guide/profilers/tag_prof"
  )
end

.slow_examples_concentration(snapshot) ⇒ Object

Rule ③: a few slow examples concentrate most of the suite time.

Data available on ‘snapshot`:

snapshot.slow_examples   # => [Example(full_description, file_path, run_time, status)] slow-first
snapshot.suite_duration  # => denominator

Return a Finding when the top-10 examples’ summed run_time / suite_duration exceeds SLOW_EXAMPLE_CONCENTRATION_THRESHOLD, naming the worst few. Score = that concentration.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/rspec_sprint/rules.rb', line 96

def slow_examples_concentration(snapshot)
  total = snapshot.suite_duration.to_f
  return nil if total.zero?

  examples = snapshot.slow_examples || []
  count = snapshot.example_count || examples.size
  return nil if count < SLOW_EXAMPLE_MIN_EXAMPLES # tiny suite: top-N isn't a minority (D8)

  top = examples.first(SLOW_EXAMPLE_TOP_N)
  return nil if top.empty?

  concentration = top.sum(&:run_time) / total
  return nil unless concentration > SLOW_EXAMPLE_CONCENTRATION_THRESHOLD

  pct = (concentration * 100).round
  worst = top.first
  Finding.new(
    rule_id: :slow_examples_concentration,
    score: concentration,
    headline: "上位#{top.size}例が suite 時間の #{pct}% (local実測)。最遅: \"#{worst.full_description}\" (#{(worst.run_time * 1000).round}ms)",
    prescriptions: [
      "最遅例に EVENT_PROF='sql.active_record' / TEST_STACK_PROF を当てて原因特定",
      "重い setup (let! / before(:each)) を let_it_be / build_stubbed へ",
      "外部I/O・sleep・実ブラウザ依存を切り出す"
    ],
    expected_saving: "上位例を半減できれば最大 #{(pct * 0.5).round}% 相当(local実測)",
    doc_url: "https://test-prof.evilmartians.io/guide/profilers/event_prof"
  )
end