hayaoki 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.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gemfile"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ # Report / RunResult は Hash のキーが String でも Symbol でも動くように、値の取り出しはこの補助を通す。
8
+ module Access
9
+ module_function
10
+
11
+ # obj が Hash なら String / Symbol 両方のキーを試す。それ以外は同名のメソッドを呼ぶ。
12
+ def get(obj, *keys)
13
+ keys.reduce(obj) do |cur, key|
14
+ return nil if cur.nil?
15
+ if cur.is_a?(Hash)
16
+ if cur.key?(key.to_s) then cur[key.to_s]
17
+ elsif cur.key?(key.to_sym) then cur[key.to_sym]
18
+ end
19
+ elsif cur.respond_to?(key)
20
+ cur.public_send(key)
21
+ end
22
+ end
23
+ end
24
+
25
+ # { "median" => x, ... } でも数値でもよい。
26
+ def med(stat)
27
+ v = stat.is_a?(Hash) ? get(stat, :median) : stat
28
+ v.is_a?(Numeric) ? v.to_f : nil
29
+ end
30
+
31
+ def round(x) = x.nil? ? nil : x.round(1)
32
+ end
33
+
34
+ # ルールが受け取る入力一式。
35
+ Context = Struct.new(:report, :app_dir, :result, :initializer_threshold_ms, :gem_threshold_ms, keyword_init: true) do
36
+ def gems = Array(Access.get(report, :gems))
37
+ def meta = Access.get(report, :meta) || {}
38
+ def env = (Access.get(meta, :env) || Access.get(result, :meta, :env)).to_s
39
+
40
+ def warnings
41
+ (Array(Access.get(report, :warnings)) + Array(Access.get(result, :meta, :warnings))).map(&:to_s)
42
+ end
43
+
44
+ def phase_ms(name) = Access.med(Access.get(report, :phases, name))
45
+ end
46
+
47
+ module Rules
48
+ # 各ルールの基底。call は Suggestion の配列を返す。
49
+ class Base
50
+ include Hayaoki::BootReport::Access
51
+
52
+ def self.rule_id = name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
53
+
54
+ def initialize(ctx)
55
+ @ctx = ctx
56
+ end
57
+
58
+ def call = raise(NotImplementedError)
59
+
60
+ private
61
+
62
+ attr_reader :ctx
63
+
64
+ def suggestion(confidence, title:, evidence:, action:, savings: nil)
65
+ Suggestion.new(rule: self.class.rule_id, confidence: confidence, title: title, evidence: evidence,
66
+ estimated_savings_ms: savings && savings.round(1), action: action)
67
+ end
68
+
69
+ # Gemfile のパス。BUNDLE_GEMFILE (絶対 / app からの相対) > <app>/Gemfile > <app>/gems.rb。無ければ nil
70
+ def gemfile_path
71
+ return @gemfile_path if defined?(@gemfile_path)
72
+ app = ctx.app_dir.to_s
73
+ env = ENV["BUNDLE_GEMFILE"].to_s
74
+ candidates = []
75
+ candidates << File.expand_path(env, app) unless env.empty?
76
+ candidates << File.join(app, "Gemfile") << File.join(app, "gems.rb")
77
+ @gemfile_path = candidates.find { File.file?(_1) }
78
+ end
79
+
80
+ # Gemfile の中身 (無ければ nil)
81
+ def gemfile
82
+ path = gemfile_path
83
+ path && File.read(path)
84
+ rescue SystemCallError
85
+ nil
86
+ end
87
+
88
+ # Gemfile を解析した { gem 名 => Gemfile::Entry } (eval_gemfile 先を含む)。Gemfile が無ければ nil。
89
+ def gemfile_entries
90
+ return @gemfile_entries if defined?(@gemfile_entries)
91
+ src = gemfile
92
+ @gemfile_entries = src && Hayaoki::BootReport::Gemfile.parse(src, dir: File.dirname(gemfile_path))
93
+ end
94
+
95
+ # 子プロセスの payload が記録した gem の full_gem_path ({ name => path })。計測対象 app の bundle を指す。
96
+ def gem_paths = @gem_paths ||= spec_field(1)
97
+
98
+ # 子プロセスが記録した gem のバージョン (アプリの bundle のもの)
99
+ def gem_versions = @gem_versions ||= spec_field(0)
100
+
101
+ def spec_field(i)
102
+ pl = get(ctx.result, :payloads)
103
+ lists = pl.is_a?(Hash) ? pl.values : []
104
+ lists.flatten.each_with_object({}) do |p, h|
105
+ specs = get(p, :specs)
106
+ next unless specs.is_a?(Hash)
107
+ specs.each { |name, info| h[name.to_s] ||= info[i] if info.is_a?(Array) && info[i] }
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # bootsnap が無い / 有効になっていない可能性。
9
+ class BootsnapIneffective < Base
10
+ MIN_DIFF_RATIO = 0.05 # cold と warm の差がこれ未満なら有効になっていないとみなす
11
+ CACHE_WARNING = /BOOTSNAP_CACHE_DIR was not used/
12
+
13
+ def call
14
+ loaded = ctx.gems.any? { |g| get(g, :name).to_s == "bootsnap" }
15
+ return [missing(loaded_in_gemfile?)] unless loaded
16
+ out = []
17
+ out << small_diff
18
+ out << cache_unused
19
+ out.compact
20
+ end
21
+
22
+ private
23
+
24
+ def loaded_in_gemfile? = !!(gemfile.to_s =~ /^\s*gem\s+["']bootsnap["']/)
25
+
26
+ def missing(in_gemfile)
27
+ if in_gemfile
28
+ suggestion(:high,
29
+ title: "bootsnap is in the Gemfile but does not seem to be loaded at boot",
30
+ evidence: [{ "bootsnap_in_gemfile" => true, "loaded_at_boot" => false }],
31
+ action: "Check that config/boot.rb contains `require \"bootsnap/setup\"` (after bundler/setup), " \
32
+ "or remove `require: false` handling that skips it.")
33
+ else
34
+ suggestion(:high,
35
+ title: "bootsnap was not loaded; adding it can speed up boot",
36
+ evidence: [{ "bootsnap_loaded" => false }],
37
+ action: "Add to the Gemfile: gem \"bootsnap\", require: false\n" \
38
+ "and to config/boot.rb (after `require \"bundler/setup\"`): require \"bootsnap/setup\"")
39
+ end
40
+ end
41
+
42
+ def small_diff
43
+ warm = boot_ms(:warm)
44
+ cold = boot_ms(:cold)
45
+ return nil unless warm && cold && cold.positive?
46
+ diff = cold - warm
47
+ return nil unless diff / cold < MIN_DIFF_RATIO
48
+ suggestion(:high,
49
+ title: "bootsnap's warm cache shows little benefit over a cold cache (#{(diff / cold * 100).round(1)}%)",
50
+ evidence: [{ "cold_ms" => round(cold), "warm_ms" => round(warm), "diff_ms" => round(diff),
51
+ "threshold_pct" => MIN_DIFF_RATIO * 100 }],
52
+ action: "Bootsnap may not be configured as expected. Check that Bootsnap.setup (or bootsnap/setup) " \
53
+ "runs before the app loads, that cache_dir is writable and persisted between boots, " \
54
+ "and that load_path_cache / compile_cache are enabled.")
55
+ end
56
+
57
+ def cache_unused
58
+ hit = ctx.warnings.grep(CACHE_WARNING)
59
+ return nil if hit.empty?
60
+ suggestion(:high,
61
+ title: "bootsnap's cache directory stayed empty during measurement",
62
+ evidence: hit.map { |w| { "warning" => w } },
63
+ action: "The app may call Bootsnap.setup with its own cache_dir, or bootsnap is not active. " \
64
+ "Make sure the cache is actually written (default tmp/cache/bootsnap) and kept between deploys/boots.")
65
+ end
66
+
67
+ # 条件ごとの boot 時間 (ms, 中央値)。payload の boot_end_us / exit_us から求める。
68
+ def boot_ms(cond)
69
+ list = payloads(cond)
70
+ vals = list.filter_map do |p|
71
+ us = get(p, :boot_end_us) || get(p, :exit_us)
72
+ us && us / 1000.0
73
+ end
74
+ return nil if vals.empty?
75
+ s = vals.sort
76
+ n = s.size
77
+ n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
78
+ end
79
+
80
+ def payloads(cond)
81
+ pl = get(ctx.result, :payloads)
82
+ return [] unless pl.is_a?(Hash)
83
+ Array(pl[cond] || pl[cond.to_s]).select { |p| get(p, :status).to_s == "ok" }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # AbstractController.eager_load! は全コントローラの action_methods を起動時に計算する。
9
+ # 遅延にすると、各コントローラの最初のリクエストで自分の分だけ計算する
10
+ # (Mastodon: 起動 -201ms、336 コントローラ合計 217ms = 1 個あたり約 0.65ms)。
11
+ class ControllerActionMethods < Base
12
+ NAME = "AbstractController.eager_load!"
13
+ MIN_MS = 100.0
14
+
15
+ def call
16
+ e = Array(get(ctx.report, :eager_namespaces)).find { |x| get(x, :name) == NAME } or return []
17
+ ms = med(get(e, :inclusive_ms))
18
+ return [] unless ms && ms >= MIN_MS
19
+ [suggestion(:low,
20
+ title: "#{NAME} computes action_methods for every controller at boot (#{round(ms)} ms)",
21
+ evidence: [{ "namespace" => NAME, "inclusive_ms" => round(ms), "threshold_ms" => MIN_MS }],
22
+ savings: ms,
23
+ action: "To compute each controller's action_methods on its first request instead, add\n" \
24
+ " # config/initializers/lazy_action_methods.rb\n" \
25
+ " Rails.application.config.eager_load_namespaces.delete(AbstractController)\n" \
26
+ "The work moves to requests, but each request pays only for its own controller " \
27
+ "(the total divided by the number of controllers). Useful for serverless and short-lived " \
28
+ "processes; for long-running servers, keeping it is usually better.")]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # 開発・テスト用の既知の gem が、計測した環境 (既定は production) で読み込まれている。
9
+ class DevGemsInProduction < Base
10
+ # 完全一致
11
+ DEV_GEMS = %w[
12
+ pry byebug debug rubocop web-console listen spring rspec factory_bot factory_bot_rails faker
13
+ better_errors binding_of_caller bullet annotate annotate_models brakeman guard minitest-reporters
14
+ capybara selenium-webdriver webdrivers rails-controller-testing shoulda-matchers simplecov
15
+ database_cleaner database_cleaner-active_record rack-mini-profiler letter_opener derailed_benchmarks
16
+ rubocop-ast
17
+ ].freeze
18
+ # 前方一致 (pry-rails, rubocop-rails, rspec-core, spring-watcher-listen ...)
19
+ DEV_GEM_PREFIXES = %w[pry- rubocop- rspec- spring- factory_bot_ capybara- guard- web-console- debug_].freeze
20
+ # 開発・テスト以外の環境ではこの rule は対象外
21
+ NON_PRODUCTION_ENVS = %w[development test].freeze
22
+
23
+ def self.dev_gem?(name)
24
+ DEV_GEMS.include?(name) || DEV_GEM_PREFIXES.any? { |p| name.start_with?(p) }
25
+ end
26
+
27
+ # テスト用データ生成系は本番で読み込まれると実害 (メモリ・起動時間) が大きいので medium に引き上げる
28
+ MEDIUM_GEMS = %w[faker factory_bot factory_bot_rails].freeze
29
+ DEV_GROUPS = %w[development test].freeze
30
+
31
+ def call
32
+ return [] if NON_PRODUCTION_ENVS.include?(ctx.env)
33
+ entries = gemfile_entries
34
+ tiers = Hash.new { |h, k| h[k] = [] }
35
+ ctx.gems.each do |g|
36
+ name = get(g, :name).to_s
37
+ next unless get(g, :category).to_s == "gem" && self.class.dev_gem?(name)
38
+ entry = entries && entries[name]
39
+ # development/test の group だけに属する gem は本番では読み込まれない
40
+ next if entry && !entry.groups.empty? && entry.groups.all? { DEV_GROUPS.include?(_1) }
41
+ ms = med(get(g, :self_ms))
42
+ row = { "name" => name, "self_ms" => round(ms || 0.0),
43
+ "gemfile" => entry ? (entry.groups.empty? ? "direct, no group" : "direct, groups: #{entry.groups.join(",")}") : (entries ? "transitive" : "unknown (no Gemfile)") }
44
+ tiers[tier(name, entry)] << row
45
+ end
46
+ %i[high medium low].filter_map { |t| tiers.key?(t) && build(t, tiers[t]) }
47
+ end
48
+
49
+ private
50
+
51
+ # Gemfile に直接書かれ group 無し (= production で読み込まれる) なら high、
52
+ # 間接依存や Gemfile 不明は確認できないので low。
53
+ def tier(name, entry)
54
+ return :low unless entry
55
+ MEDIUM_GEMS.include?(name) ? :medium : :high
56
+ end
57
+
58
+ def build(tier, found)
59
+ total = found.sum { |f| f["self_ms"] }
60
+ names = found.map { |f| f["name"] }
61
+ env = ctx.env.empty? ? "the measured environment" : ctx.env
62
+ title = if tier == :low
63
+ "Development/test gems may be loaded in #{env} via other dependencies (#{names.first(3).join(", ")}#{names.size > 3 ? ", ..." : ""})"
64
+ else
65
+ "Development/test gems are loaded in #{env} (#{names.first(3).join(", ")}#{names.size > 3 ? ", ..." : ""})"
66
+ end
67
+ action = if tier == :low
68
+ "These were not found as direct Gemfile entries (transitive dependency or Gemfile not found). " \
69
+ "Check which gem depends on them (`bundle viz` / `bundle why`) before acting."
70
+ else
71
+ "Move them to the development/test groups in the Gemfile, e.g.:\n" \
72
+ " group :development, :test do\n #{names.first(3).map { |n| %(gem "#{n}") }.join("\n ")}\n end\n" \
73
+ "Check first that nothing in production code depends on them."
74
+ end
75
+ suggestion(tier, title: title, evidence: found.sort_by { |f| -f["self_ms"] }, savings: total, action: action)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # Devise は before_eager_load で app.reload_routes! を呼ぶ (Devise.reload_routes = true が初期値)。
9
+ # production では set_routes_reloader_hook でもルーティングを読むので、起動中に 2 回読むことになる。
10
+ # Mastodon で 194ms 短くなった (bench/boot_variants.rb)。Devise 5.0 からは Devise.mappings を参照したときに
11
+ # 必要ならルーティングを読むので、false にしても mappings は使える。4.x にはこの処理が無い。
12
+ class DeviseReloadRoutes < Base
13
+ SOURCE = "/lib/devise/rails.rb"
14
+ MIN_MS = 50.0
15
+
16
+ def call
17
+ hook = Array(get(ctx.report, :on_loads)).find do |h|
18
+ get(h, :name).to_s == "before_eager_load" && get(h, :source).to_s.include?(SOURCE)
19
+ end or return []
20
+ ms = med(get(hook, :inclusive_ms))
21
+ return [] unless ms && ms >= MIN_MS
22
+ version = gem_versions["devise"]
23
+ modern = version && Gem::Version.new(version) >= Gem::Version.new("5.0.0.rc")
24
+ caveat = if modern
25
+ "Devise #{version} loads the routes on demand when Devise.mappings is first used, so this is safe " \
26
+ "unless your own code reads Devise.mappings during eager loading."
27
+ else
28
+ "Devise #{version || "(version unknown)"} builds Devise.mappings only when the routes are loaded. " \
29
+ "Routes are still loaded later in boot (set_routes_reloader_hook), but code that reads " \
30
+ "Devise.mappings during eager loading would see none. Devise 5.0+ removes this risk."
31
+ end
32
+ [suggestion(modern ? :medium : :low,
33
+ title: "Devise reloads the routes before eager loading (#{round(ms)} ms); they are loaded again later in boot",
34
+ evidence: [{ "hook" => "before_eager_load", "source" => "devise/rails.rb", "inclusive_ms" => round(ms),
35
+ "devise" => version, "threshold_ms" => MIN_MS }],
36
+ savings: ms,
37
+ action: "In config/initializers/devise.rb:\n" \
38
+ " config.reload_routes = false\n" + caveat)]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # eager_load の割合が大きいときの判断材料 (情報のみ)。
9
+ class EagerLoadTradeoff < Base
10
+ MIN_RATIO = 0.15
11
+
12
+ def call
13
+ eager = ctx.phase_ms("eager_load")
14
+ total = ctx.phase_ms("boot") || ctx.phase_ms("total")
15
+ return [] unless eager && total && total.positive?
16
+ ratio = eager / total
17
+ return [] if ratio < MIN_RATIO
18
+ [suggestion(:info,
19
+ title: "eager_load accounts for #{(ratio * 100).round(1)}% of boot time",
20
+ evidence: [{ "eager_load_ms" => round(eager), "boot_ms" => round(total), "ratio" => ratio.round(3),
21
+ "threshold" => MIN_RATIO }],
22
+ savings: eager,
23
+ action: "For serverless or short-lived processes where boot latency matters, you could consider " \
24
+ "config.eager_load = false: boot gets faster, but classes are then loaded on first use, " \
25
+ "so the first requests may be slower and thread-safety/CoW benefits of eager loading are lost. " \
26
+ "For long-running servers, keeping eager_load enabled is usually preferable. " \
27
+ "The estimated saving moves work to the first request; it is not a net saving.")]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # アプリ自身の initializer (config/initializers 配下など) が重い。
9
+ class HeavyAppInitializer < Base
10
+ def call
11
+ items = candidates.select { |i| i["inclusive_ms"] > ctx.initializer_threshold_ms }
12
+ return [] if items.empty?
13
+ items.sort_by! { |i| -i["inclusive_ms"] }
14
+ [suggestion(:medium,
15
+ title: "#{items.size} app initializer(s) take more than #{fmt(ctx.initializer_threshold_ms)} ms; " \
16
+ "they may be deferrable (slowest: #{items.first["name"]})",
17
+ evidence: items,
18
+ savings: items.sum { |i| i["inclusive_ms"] },
19
+ action: "Consider (the time may include gem requires counted elsewhere):\n" \
20
+ " - run it later: Rails.application.config.after_initialize { ... }\n" \
21
+ " - require heavy libraries lazily, inside the code that uses them\n" \
22
+ " - register it with ActiveSupport.on_load(:active_record) { ... } so it runs only when needed\n" \
23
+ "Savings are an upper bound: deferred work still runs, just later.")]
24
+ end
25
+
26
+ private
27
+
28
+ def candidates
29
+ by_name = {}
30
+ # 切り詰め前の app_initializer_times があればそれを使う (無ければ表示用の上位 N 件)
31
+ full = get(ctx.report, :app_initializer_times)
32
+ inits = full ? get(full, :initializers) : get(ctx.report, :initializers)
33
+ files = full ? get(full, :files) : get(ctx.report, :files)
34
+ Array(inits).each do |i|
35
+ next unless get(i, :category).to_s == "app"
36
+ ms = med(get(i, :inclusive_ms)) or next
37
+ name = [get(i, :name), get(i, :context)].compact.join(" ")
38
+ add(by_name, name, ms, "initializer")
39
+ end
40
+ Array(files).each do |f|
41
+ path = get(f, :path).to_s
42
+ next unless get(f, :category).to_s == "app" && path.include?("/config/initializers/")
43
+ ms = med(get(f, :inclusive_ms)) or next
44
+ add(by_name, path.sub(%r{\A.*?/(config/initializers/)}, '\1'), ms, "file")
45
+ end
46
+ by_name.values
47
+ end
48
+
49
+ def add(h, name, ms, kind)
50
+ cur = h[name]
51
+ return if cur && cur["inclusive_ms"] >= ms
52
+ h[name] = { "name" => name, "kind" => kind, "inclusive_ms" => round(ms) }
53
+ end
54
+
55
+ def fmt(x) = x == x.to_i ? x.to_i.to_s : x.to_s
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Rules
8
+ # Rails は active_support.initialize_time_zone で TZInfo を eager_load_namespaces に加え、
9
+ # production の eager_load! で全タイムゾーン (約 600) を読み込む。Linux のコンテナでは 200〜330ms かかる
10
+ # (bench/tzinfo_eager_load.rb)。遅延にすると、使うゾーンだけを初回に読む (1 ゾーン 0.3ms 未満)。
11
+ class TzinfoEagerLoad < Base
12
+ NAME = "TZInfo.eager_load!"
13
+ MIN_MS = 20.0
14
+
15
+ def call
16
+ e = Array(get(ctx.report, :eager_namespaces)).find { |x| get(x, :name) == NAME } or return []
17
+ ms = med(get(e, :inclusive_ms))
18
+ return [] unless ms && ms >= MIN_MS
19
+ [suggestion(:medium,
20
+ title: "#{NAME} loads every time zone at boot (#{round(ms)} ms)",
21
+ evidence: [{ "namespace" => NAME, "inclusive_ms" => round(ms), "threshold_ms" => MIN_MS }],
22
+ savings: ms,
23
+ action: "Rails eager loads all ~600 TZInfo zones in production. To load zones on first use instead, add\n" \
24
+ " # config/initializers/tzinfo_lazy.rb\n" \
25
+ " Rails.application.config.eager_load_namespaces.delete(TZInfo)\n" \
26
+ "Each zone is then read when first used (typically well under 1 ms). TZInfo's lazy loading is thread-safe.\n" \
27
+ "Trade-off: with a forking server (Puma workers + preload_app!), zones are no longer loaded before fork, " \
28
+ "so they are not shared copy-on-write. Most useful for serverless and short-lived processes.")]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+
4
+ require_relative "base"
5
+ require_relative "dev_gems_in_production"
6
+
7
+ module Hayaoki
8
+ module BootReport
9
+ module Rules
10
+ # 重い gem の定数が app/ config/ lib/ から参照されていない (require: false の候補)。
11
+ class UnusedHeavyGem < Base
12
+ SEARCH_DIRS = %w[app config lib].freeze
13
+ SEARCH_EXTS = %w[.rb .erb .rake .haml .slim .jbuilder .ru].freeze
14
+ SEARCH_ROOT_FILES = %w[config.ru Rakefile].freeze
15
+ MAX_FILE_BYTES = 2_000_000
16
+
17
+ EXCLUDED = (%w[
18
+ rails railties activesupport activerecord activemodel actionpack actionview actionmailer actioncable
19
+ activejob activestorage actiontext actionmailbox bootsnap zeitwerk bundler rubygems rack i18n
20
+ concurrent-ruby tzinfo mutex_m bigdecimal drb base64 logger
21
+ ]).freeze
22
+ # Railtie やアダプタ経由で使われ、定数を直接書かないことが多い gem (誤検出を避けるため対象外)
23
+ IMPLICIT_USE = %w[
24
+ puma pg mysql2 sqlite3 sprockets sprockets-rails sass-rails sassc sassc-rails propshaft importmap-rails
25
+ turbo-rails stimulus-rails jbuilder tailwindcss-rails jsbundling-rails cssbundling-rails
26
+ solid_cache solid_queue solid_cable tzinfo-data kamal thruster net-imap net-smtp net-pop
27
+ ].freeze
28
+ # APM / 監視系: Railtie や Rack middleware 経由で動くため定数参照が無くても使われている
29
+ APM_GEMS = %w[newrelic_rpm ddtrace datadog scout_apm skylight honeybadger bugsnag rollbar appsignal].freeze
30
+ APM_PREFIXES = %w[sentry- opentelemetry-].freeze
31
+ # gem が提供する DSL 名 (定数でなくクラス本体のマクロとして使われるもの)。小さく明示的に保つ
32
+ DSL_NAMES = {
33
+ "paper_trail" => %w[has_paper_trail], "carrierwave" => %w[mount_uploader mount_uploaders],
34
+ "friendly_id" => %w[friendly_id], "acts-as-taggable-on" => %w[acts_as_taggable_on acts_as_taggable],
35
+ "acts_as_list" => %w[acts_as_list], "ancestry" => %w[has_ancestry], "rolify" => %w[rolify],
36
+ "searchkick" => %w[searchkick], "counter_culture" => %w[counter_culture],
37
+ "devise" => %w[devise_for devise], "audited" => %w[audited], "aasm" => %w[aasm],
38
+ "enumerize" => %w[enumerize],
39
+ }.freeze
40
+ # 既知の定数名 (gem 名から機械的に導けないもの)
41
+ KNOWN_CONSTANTS = {
42
+ "httparty" => %w[HTTParty], "aws-sdk-s3" => %w[Aws::S3 Aws], "aws-sdk" => %w[Aws],
43
+ "nokogiri" => %w[Nokogiri], "rest-client" => %w[RestClient], "activeadmin" => %w[ActiveAdmin],
44
+ "google-cloud-storage" => %w[Google::Cloud::Storage], "mini_magick" => %w[MiniMagick],
45
+ "ruby-vips" => %w[Vips], "image_processing" => %w[ImageProcessing], "rqrcode" => %w[RQRCode],
46
+ "redcarpet" => %w[Redcarpet], "kaminari" => %w[Kaminari], "elasticsearch" => %w[Elasticsearch],
47
+ "stripe" => %w[Stripe], "sentry-ruby" => %w[Sentry], "sentry-rails" => %w[Sentry],
48
+ }.freeze
49
+
50
+ def call
51
+ return [] unless gemfile_entries # Gemfile が無ければ直接依存か判定できないので提案しない
52
+ cands = candidates
53
+ return [] if cands.empty?
54
+ used = referenced(cands)
55
+ cands.reject { |c| used.include?(c[:name]) }.sort_by { |c| -c[:ms] }.map do |c|
56
+ suggestion(:low,
57
+ title: "No references found to #{c[:name]} in app code (#{round(c[:ms])} ms at boot)",
58
+ evidence: [{ "gem" => c[:name], "self_ms" => round(c[:ms]), "searched_tokens" => c[:tokens] }],
59
+ savings: c[:ms],
60
+ action: "If it is not needed at boot, consider loading it lazily in the Gemfile:\n" \
61
+ " gem \"#{c[:name]}\", require: false\n" \
62
+ "and `require \"#{c[:name]}\"` where it is first needed. This is a static text search: " \
63
+ "it can miss use via metaprogramming, other gems, rake tasks outside the searched " \
64
+ "directories, or Railtie hooks, so please verify before changing.")
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def candidates
71
+ entries = gemfile_entries
72
+ ctx.gems.filter_map do |g|
73
+ name = get(g, :name).to_s
74
+ ms = med(get(g, :self_ms))
75
+ next unless get(g, :category).to_s == "gem" && ms && ms > ctx.gem_threshold_ms
76
+ next if EXCLUDED.include?(name) || IMPLICIT_USE.include?(name) || DevGemsInProduction.dev_gem?(name)
77
+ next if apm?(name) || railtie?(g, name)
78
+ if entries
79
+ entry = entries[name]
80
+ next unless entry # 間接依存は Gemfile で require: false にできない
81
+ next if entry.require == false # 既に lazy
82
+ next if entry.require.is_a?(Array) && !entry.require.empty? # 別名で require されている
83
+ end
84
+ given = Array(get(g, :constants)).map(&:to_s).reject(&:empty?)
85
+ tokens = (given.empty? ? guess_constants(name) : given) + DSL_NAMES.fetch(name, [])
86
+ { name: name, ms: ms, tokens: tokens.uniq }
87
+ end
88
+ end
89
+
90
+ def apm?(name) = APM_GEMS.include?(name) || APM_PREFIXES.any? { name.start_with?(_1) }
91
+
92
+ # Railtie / Engine を持つ gem は初期化時に自動で組み込まれるので、定数参照が無くても使われている。
93
+ # 計測結果 (gem が initializer を定義している / on_load の時間が gem に計上されている) か、gem 内の railtie.rb / engine.rb で判定する。
94
+ def railtie?(g, name)
95
+ return true if (med(get(g, :initializer_count)) || 0) > 0 || (med(get(g, :on_load_ms)) || 0) > 0
96
+ railtie_files?(name)
97
+ end
98
+
99
+ def railtie_files?(name)
100
+ path = gem_paths[name] || Gem.loaded_specs[name]&.full_gem_path or return false # 子プロセスの記録を優先
101
+ Dir.glob(File.join(path, "lib", "**", "{railtie,engine}.rb")).any?
102
+ rescue StandardError
103
+ false
104
+ end
105
+
106
+ # gem 名 → 定数名の候補 (ゆるい方に倒す: 一つでも見つかれば「使われている」とみなす)
107
+ def guess_constants(name)
108
+ return KNOWN_CONSTANTS[name] if KNOWN_CONSTANTS.key?(name)
109
+ parts = name.split(/[-]/)
110
+ camel = ->(s) { s.split("_").map { |w| w[0].to_s.upcase + w[1..].to_s }.join }
111
+ toks = [parts.map(&camel).join("::"), parts.map(&camel).join]
112
+ # rack-attack → Rack のように先頭だけでは広すぎるので、単一セグメントの gem 名だけ
113
+ toks << camel.call(parts.first) if parts.size == 1
114
+ toks << name # require "foo-bar" の文字列
115
+ toks.uniq
116
+ end
117
+
118
+ # 各ファイルを 1 回だけ読み、全トークンをまとめた正規表現で比べる。使われていた gem 名の Set を返す。
119
+ def referenced(cands)
120
+ owner = Hash.new { |h, k| h[k] = [] }
121
+ cands.each { |c| c[:tokens].each { |t| owner[t.downcase] << c[:name] } }
122
+ re = Regexp.new("(?<![A-Za-z0-9_])(#{owner.keys.sort_by { |k| -k.size }.map { |k| Regexp.escape(k) }.join("|")})(?![A-Za-z0-9_])", Regexp::IGNORECASE)
123
+ used = Set.new
124
+ each_source_file do |path|
125
+ text = File.binread(path).force_encoding(Encoding::UTF_8).scrub
126
+ text.scan(re) { |(tok)| owner[tok.downcase].each { |n| used << n } }
127
+ return used if used.size >= cands.size
128
+ end
129
+ used
130
+ end
131
+
132
+ def each_source_file
133
+ root = ctx.app_dir.to_s
134
+ files = SEARCH_DIRS.flat_map do |d|
135
+ Dir.glob(File.join(root, d, "**", "*{#{SEARCH_EXTS.join(",")}}"), File::FNM_DOTMATCH)
136
+ end
137
+ files += SEARCH_ROOT_FILES.map { |f| File.join(root, f) }
138
+ files.uniq.each do |path|
139
+ next unless File.file?(path)
140
+ next if File.size(path) > MAX_FILE_BYTES
141
+ yield path
142
+ rescue SystemCallError
143
+ next
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end