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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../version"
5
+ require_relative "../advisor"
6
+
7
+ module Hayaoki
8
+ module BootReport
9
+ module Formatters
10
+ class Json
11
+ SCHEMA_VERSION = 1
12
+
13
+ def initialize(pretty: true)
14
+ @pretty = pretty
15
+ end
16
+
17
+ attr_accessor :suggestions, :suggestion_warnings
18
+
19
+ def with_suggestions(list, warnings = [])
20
+ @suggestions = list
21
+ @suggestion_warnings = warnings
22
+ self
23
+ end
24
+
25
+ # reports: { "warm" => Report, ... } または Report 単体
26
+ def format(reports)
27
+ reports = { reports.condition.to_s => reports } unless reports.is_a?(Hash)
28
+ reports = reports.transform_values { |r| scrub(r.to_h) }
29
+ reports = reports.transform_values { |r| scrub(r.to_h) }
30
+ doc = { "schema_version" => SCHEMA_VERSION, "hayaoki_version" => Hayaoki::VERSION,
31
+ "reports" => reports }
32
+ if @suggestions
33
+ doc["suggestions"] = @suggestions.map { |s| s.respond_to?(:to_h) ? s.to_h : s }
34
+ # 非加算の上限 (findings は重複しうる)
35
+ doc["savings_upper_bound_ms"] = Suggestion.savings_upper_bound(@suggestions)
36
+ doc["suggestion_warnings"] = Array(@suggestion_warnings)
37
+ end
38
+ (@pretty ? ::JSON.pretty_generate(doc) : ::JSON.generate(doc)) + "\n"
39
+ end
40
+
41
+ private
42
+
43
+ # 失敗 run の stderr_tail は秘密を含みうるので出力しない (message / exit_status / error_class は残す)
44
+ def scrub(h)
45
+ meta = h["meta"]
46
+ return h unless meta.is_a?(Hash) && meta["failed_runs"].is_a?(Array)
47
+ h.merge("meta" => meta.merge("failed_runs" => meta["failed_runs"].map { |f| f.reject { |k, _| k == "stderr_tail" } }))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hayaoki
4
+ module BootReport
5
+ module Formatters
6
+ # 出力 (JSON / speedscope の文字列) から個人を特定できる絶対パスを消す。
7
+ # gem のパスは <gem:name>/、アプリのルートは <app>、ホームは ~ に置き換える。
8
+ class Redact
9
+ GEM_PATH = %r{(?:/[^\s"/\\]+)+/gems/([^\s"/\\]+?)-(?:\d|[0-9a-f]{7,})[^\s"/\\]*/}
10
+
11
+ def initialize(app_dir: nil, home: Dir.home)
12
+ @apps = []
13
+ if app_dir
14
+ exp = File.expand_path(app_dir)
15
+ real = (File.realpath(exp) rescue exp) # macOS: /var と /private/var
16
+ @apps = [exp, real].uniq.sort_by { |a| -a.size }
17
+ end
18
+ @home = home
19
+ end
20
+
21
+ def apply(text)
22
+ out = text.gsub(GEM_PATH) { "<gem:#{Regexp.last_match(1)}>/" }
23
+ @apps.each do |a|
24
+ out = out.gsub(/#{Regexp.escape(a)}(?=[\/"\s\\]|\z)/, "<app>") unless a == "/"
25
+ end
26
+ out = out.gsub(/#{Regexp.escape(@home)}(?=[\/"\s\\]|\z)/, "~") if @home && @home != "/"
27
+ out
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../version"
5
+
6
+ module Hayaoki
7
+ module BootReport
8
+ module Formatters
9
+ # payload (probe の出力) 1 回分を speedscope の evented 形式にする。メインスレッド (tid 0) のみ。
10
+ class Speedscope
11
+ SCHEMA = "https://www.speedscope.app/file-format-schema.json"
12
+ KINDS = %w[require require_relative load initializer on_load hooks eager_load phase].freeze
13
+
14
+ # payloads: 配列。boot の total に一番近い (=中央値に近い) run を選ぶ。
15
+ def self.pick_median(payloads)
16
+ ok = payloads.select { |p| p["status"] == "ok" }
17
+ ok = payloads if ok.empty?
18
+ sorted = ok.sort_by { |p| p["boot_end_us"] || p["exit_us"] || 0 }
19
+ sorted[sorted.size / 2]
20
+ end
21
+
22
+ def initialize(name: "hayaoki boot")
23
+ @name = name
24
+ end
25
+
26
+ # RunResult の payloads (Hash) / 配列 / 単一 payload を受け付ける。
27
+ def format(input, condition: nil)
28
+ picks = pick_all(input, condition)
29
+ docs = picks.map { |name, payload| build(payload, name: name) }
30
+ doc = docs.first
31
+ if docs.size > 1
32
+ # 条件ごとに profile を持つ 1 ファイルにまとめる (frame は条件ごとの添字がずれるので付け替える)
33
+ frames = []
34
+ profiles = docs.each_with_index.map do |d, di|
35
+ map = d["shared"]["frames"].map { |f| frames << f; frames.size - 1 }
36
+ prof = d["profiles"].first.dup
37
+ prof["events"] = prof["events"].map { |e| e.merge("frame" => map[e["frame"]]) }
38
+ prof
39
+ end
40
+ doc = doc.merge("shared" => { "frames" => frames }, "profiles" => profiles)
41
+ end
42
+ ::JSON.generate(doc) + "\n"
43
+ end
44
+
45
+ def build(payload, name: @name)
46
+ w = payload["width"] || 8
47
+ ev = payload["events"] || []
48
+ exit_us = payload["exit_us"] || ev.each_slice(w).filter_map { |e| e[4] }.max || 0
49
+ spans = ev.each_slice(w).with_index.map do |(kind, n, par, t0, t1, _res, tid), i|
50
+ { idx: i * w, kind: kind, name: n, parent: par, t0: t0, t1: t1 || [exit_us, t0].max, tid: tid.to_i, kids: [] }
51
+ end
52
+ main = spans.select { |s| s[:tid].zero? }
53
+ by = main.to_h { |s| [s[:idx], s] }
54
+ roots = []
55
+ main.each { |s| (by[s[:parent]] ? by[s[:parent]][:kids] : roots) << s }
56
+ origin = main.map { |s| s[:t0] }.min || 0
57
+ frames = []
58
+ fidx = {}
59
+ events = []
60
+ emit = lambda do |s, lo, hi|
61
+ a = [[s[:t0], lo].max, hi].min
62
+ b = [[s[:t1], a].max, hi].min
63
+ key = [s[:kind], s[:name]]
64
+ f = (fidx[key] ||= (frames << { "name" => "#{KINDS[s[:kind]] || s[:kind]}: #{s[:name]}" }; frames.size - 1))
65
+ events << { "type" => "O", "frame" => f, "at" => a - origin }
66
+ cursor = a
67
+ s[:kids].sort_by { |k| k[:t0] }.each do |k|
68
+ next if k[:t0] < cursor && k[:t1] <= cursor
69
+ emit.call(k, cursor, b)
70
+ cursor = [cursor, [k[:t1], b].min].max
71
+ end
72
+ events << { "type" => "C", "frame" => f, "at" => b - origin }
73
+ end
74
+ cursor = origin
75
+ roots.sort_by { |s| s[:t0] }.each do |s|
76
+ next if s[:t1] <= cursor && s[:t0] < cursor
77
+ emit.call(s, cursor, [s[:t1], cursor].max)
78
+ cursor = [cursor, s[:t1]].max
79
+ end
80
+ end_value = [events.map { |e| e["at"] }.max || 0, 0].max
81
+ {
82
+ "$schema" => SCHEMA,
83
+ "shared" => { "frames" => frames },
84
+ "profiles" => [{ "type" => "evented", "name" => name, "unit" => "microseconds",
85
+ "startValue" => 0, "endValue" => end_value, "events" => events }],
86
+ "name" => @name, "activeProfileIndex" => 0, "exporter" => "hayaoki #{Hayaoki::VERSION}"
87
+ }
88
+ end
89
+
90
+ private
91
+
92
+ # [[profile 名, payload], ...]。condition 指定が無く複数条件 (bootsnap: both) なら条件ごとに出す。
93
+ def pick_all(input, condition)
94
+ if !condition && !input.is_a?(Array) && !(input.is_a?(Hash) && input.key?("events"))
95
+ map = input.is_a?(Hash) ? input : input.payloads
96
+ if map.size > 1
97
+ return map.map { |c, list| ["#{@name} [#{c}]", self.class.pick_median(list)] }
98
+ end
99
+ end
100
+ [[@name, pick(input, condition)]]
101
+ end
102
+
103
+ def pick(input, condition)
104
+ case input
105
+ when Hash
106
+ if input.key?("events") then input
107
+ else self.class.pick_median(condition ? input[condition.to_s] : input.values.first)
108
+ end
109
+ when Array then self.class.pick_median(input)
110
+ else
111
+ list = condition ? input.payloads[condition.to_s] : input.payloads.values.first
112
+ self.class.pick_median(list)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../advisor"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ module Formatters
8
+ # 端末向けテキスト。reports は { "warm" => Report, "cold" => Report } (aggregate_result の戻り値)。
9
+ class Text
10
+ WIDTH = 80
11
+ BAR = "█"
12
+ BAR_WIDTH = 28
13
+ PHASE_LABELS = { "vm_boot" => "vm boot", "bundler_require" => "bundler require", "initialize" => "initialize",
14
+ "eager_load" => "eager load", "boot" => "boot" }.freeze
15
+
16
+ # redact: 文字列を受け取って伏せた文字列を返すもの (--redact)。切り詰める前に適用しないとパスが一致しない。
17
+ def initialize(top: 10, redact: nil)
18
+ @top = top
19
+ @redact = redact
20
+ end
21
+
22
+ # Advisor の結果 (Suggestion の配列 / 警告の配列)。nil なら Suggestions の節を出さない。
23
+ attr_accessor :suggestions, :suggestion_warnings
24
+
25
+ def with_suggestions(list, warnings = [])
26
+ @suggestions = list
27
+ @suggestion_warnings = warnings
28
+ self
29
+ end
30
+
31
+ def format(reports)
32
+ reports = { reports.condition.to_s => reports } unless reports.is_a?(Hash)
33
+ @conds = reports.keys
34
+ @reports = reports
35
+ out = []
36
+ header(out)
37
+ phases(out)
38
+ categories(out)
39
+ ranked(out, "Top gems (self time)", :gems, ->(r) { r.gems }, ->(e) { e["name"] }, "self_ms", breakdown: { "req" => "require_ms", "onload" => "on_load_ms", "eager" => "eager_ms" })
40
+ ranked(out, "Top initializers (inclusive)", :inits, ->(r) { r.initializers }, ->(e) { [e["name"], e["context"]].compact.join(" ") }, "inclusive_ms",
41
+ breakdown: { "init body (own time, not blamed on gem)" => "init_body_ms" })
42
+ ranked(out, "Top on_load hooks (inclusive)", :on_loads, ->(r) { r.on_loads }, ->(e) { [e["name"], e["source"]].compact.join(" ") }, "inclusive_ms")
43
+ if @reports.values.any? { |r| Array(r.eager_namespaces).any? }
44
+ ranked(out, "Top eager_load namespaces (inclusive)", :eager_namespaces, ->(r) { r.eager_namespaces }, ->(e) { e["name"] }, "inclusive_ms")
45
+ end
46
+ false_requires(out)
47
+ overhead(out)
48
+ warnings(out)
49
+ suggestions_section(out)
50
+ out.map { |l| clip(l) }.join("\n") + "\n"
51
+ end
52
+
53
+ private
54
+
55
+ def header(out)
56
+ m = (@reports.values.first.meta || {})
57
+ out << "hayaoki boot-report"
58
+ out << " app: #{m["app_dir"]}" if m["app_dir"]
59
+ out << " runs=#{m["runs"] || @reports.values.first.runs} env=#{m["env"]} bootsnap=#{m["bootsnap"] || @conds.join(",")}"
60
+ @reports.each do |c, r|
61
+ failed = r.failed.to_i.positive? ? " (#{r.failed} failed)" : ""
62
+ out << " total[#{c}]: #{stat_s(r.phases["total"])}#{failed}"
63
+ end
64
+ out << " diff (#{@conds.last} - #{@conds.first}): #{diff_s(med(first_total), med(last_total))}" if @conds.size > 1
65
+ out << ""
66
+ end
67
+
68
+ def first_total = @reports[@conds.first].phases["total"]
69
+ def last_total = @reports[@conds.last].phases["total"]
70
+
71
+ def phases(out)
72
+ out << "Phases (median)"
73
+ keys = PHASE_LABELS.keys.select { |k| @reports.values.any? { |r| r.phases[k] } }
74
+ max = keys.flat_map { |k| @reports.values.map { |r| med(r.phases[k]) } }.compact.max.to_f
75
+ keys.each do |k|
76
+ @reports.each do |c, r|
77
+ s = r.phases[k]
78
+ next unless s
79
+ label = @conds.size > 1 ? "#{PHASE_LABELS[k]} [#{c}]" : PHASE_LABELS[k]
80
+ label = " └ #{label}" if k == "eager_load" # initialize の内側 (足し算しない)
81
+ n = max.positive? ? (med(s) / max * BAR_WIDTH).round : 0
82
+ n = 1 if n.zero? && med(s).to_f.positive?
83
+ out << Kernel.format(" %-22s %-#{BAR_WIDTH}s %s", label, BAR * n, stat_s(s, range: @conds.size == 1))
84
+ end
85
+ end
86
+ out << " (└ eager load runs within initialize; do not add them)" if keys.include?("eager_load")
87
+ out << ""
88
+ end
89
+
90
+ def categories(out)
91
+ return if @reports.values.all? { |r| (r.categories || {}).empty? }
92
+ out << "Self time by category (median; boot - attributed = unattributed)"
93
+ @reports.each do |c, r|
94
+ cats = r.categories || {}
95
+ next if cats.empty?
96
+ tag = @conds.size > 1 ? " [#{c}]" : ""
97
+ cats.each { |k, v| out << Kernel.format(" %-22s %10s", "#{k}#{tag}", "#{fmt(med(v["self_ms"]))}") }
98
+ end
99
+ out << ""
100
+ end
101
+
102
+ def ranked(out, title, _key, getter, namer, metric, breakdown: nil)
103
+ out << "#{title} (top #{@top})"
104
+ lists = @reports.transform_values { |r| Array(getter.call(r)) }
105
+ names = lists.values.first.first(@top).map(&namer)
106
+ if @conds.size > 1
107
+ names |= lists.values.last.first(@top).map(&namer)
108
+ names = names.first(@top * 2)
109
+ end
110
+ if names.empty?
111
+ out << " (none)"
112
+ out << ""
113
+ return
114
+ end
115
+ idx = lists.transform_values { |l| l.to_h { |e| [namer.call(e), e] } }
116
+ nw = [WIDTH - 2 - 12 * @conds.size - (@conds.size > 1 ? 10 : 22), 20].max
117
+ names.each do |n|
118
+ vals = @conds.map { |c| idx[c][n] && idx[c][n][metric] }
119
+ line = Kernel.format(" %-#{nw}s", trunc(n.to_s, nw))
120
+ vals.each { |v| line += Kernel.format(" %10s", v ? "#{fmt(med(v))}" : "-") }
121
+ if @conds.size > 1
122
+ line += Kernel.format(" %9s", vals.all? ? signed(med(vals.last) - med(vals.first)) : "-")
123
+ else
124
+ v = vals.first
125
+ line += Kernel.format(" %-20s", v ? "[#{fmt(v["min"])}-#{fmt(v["max"])}]" : "")
126
+ e = idx[@conds.first][n]
127
+ if breakdown && e
128
+ out << line.rstrip
129
+ parts = breakdown.filter_map do |l, kk|
130
+ x = e[kk] && med(e[kk])
131
+ "#{l} #{fmt(x)}" if x && x >= 0.05
132
+ end
133
+ next if parts.empty?
134
+ cat = e["category"] ? "#{e["category"]}: " : ""
135
+ out << " #{cat}#{parts.join(", ")}".rstrip
136
+ next
137
+ end
138
+ end
139
+ out << line.rstrip
140
+ end
141
+ out << " (ms, median)" if @conds.size > 1
142
+ out << ""
143
+ end
144
+
145
+ def false_requires(out)
146
+ out << "require that returned false"
147
+ @reports.each do |c, r|
148
+ f = r.false_require || {}
149
+ tag = @conds.size > 1 ? " [#{c}]" : ""
150
+ out << " count#{tag}: #{f["count"] ? fmt(med(f["count"]), 0) : "-"}, time: #{stat_s(f["time_ms"])}"
151
+ end
152
+ out << ""
153
+ end
154
+
155
+ def overhead(out)
156
+ out << "Measurement overhead"
157
+ ov = (@reports.values.first.meta || {})["overhead"] || {}
158
+ @conds.each do |c|
159
+ o = ov[c]
160
+ next out << " #{c}: n/a" unless o
161
+ ratio = o["ratio"] ? Kernel.format(", x%.3f", o["ratio"]) : ""
162
+ noise = overhead_noise(o)
163
+ diff = if noise && o["diff_ms"].abs < noise
164
+ "diff within noise (±#{fmt(noise)} ms) (#{signed(o["diff_ms"])} ms#{ratio})"
165
+ else
166
+ "diff #{signed(o["diff_ms"])} ms#{ratio.empty? ? "" : " (#{ratio[2..]})"}"
167
+ end
168
+ out << " #{c}: baseline #{fmt(o["baseline_ms"])} ms, with probe #{fmt(o["probe_ms"])} ms"
169
+ out << " #{diff}"
170
+ end
171
+ out << ""
172
+ end
173
+
174
+ # 基準側と probe 側の run ごとのばらつき (min〜max の半幅) の大きい方。サンプルが 2 件未満なら nil。
175
+ def overhead_noise(o)
176
+ halves = %w[baseline_samples_ms probe_samples_ms].filter_map do |k|
177
+ v = Array(o[k]).compact
178
+ (v.max - v.min) / 2.0 if v.size >= 2
179
+ end
180
+ halves.max
181
+ end
182
+
183
+ def warnings(out)
184
+ w = @reports.values.flat_map { |r| Array(r.warnings) }.uniq
185
+ return if w.empty?
186
+ out << "Warnings"
187
+ w.each { |x| out << " ! #{x}" }
188
+ end
189
+
190
+ SUGGESTION_ORDER = %w[high medium low info].freeze
191
+
192
+ def suggestions_section(out)
193
+ return unless @suggestions
194
+ out << ""
195
+ out << "Suggestions"
196
+ if @suggestions.empty?
197
+ out << " (none)"
198
+ end
199
+ sorted = @suggestions.each_with_index.sort_by { |s, i| [SUGGESTION_ORDER.index(s.confidence.to_s) || 9, i] }.map(&:first)
200
+ sorted.each do |s|
201
+ wrap("[#{s.confidence}] #{s.title}", 2, 9).each { |l| out << l }
202
+ wrap("id: #{s.id}", 9, 11).each { |l| out << l } if s.id
203
+ ev = Array(s.evidence)
204
+ ev.first(5).each { |e| wrap("- #{evidence_s(e)}", 9, 11).each { |l| out << l } }
205
+ out << " - (+#{ev.size - 5} more)" if ev.size > 5
206
+ out << " estimated savings: up to #{fmt(s.estimated_savings_ms)} ms" if s.estimated_savings_ms
207
+ wrap("overlaps with: #{s.overlaps.join(", ")} (savings not additive)", 9, 11).each { |l| out << l } unless Array(s.overlaps).empty?
208
+ out << " action:"
209
+ s.action.to_s.split("\n").each do |line|
210
+ lead = line[/\A */].size
211
+ wrap(line.strip, 11 + lead, 13 + lead).each { |l| out << l }
212
+ end
213
+ end
214
+ ub = Suggestion.savings_upper_bound(@suggestions)
215
+ if ub && @suggestions.count(&:estimated_savings_ms) > 1
216
+ wrap("total savings: at most #{fmt(ub)} ms (upper bound; findings overlap, so do not treat as additive)", 2, 4).each { |l| out << l }
217
+ end
218
+ Array(@suggestion_warnings).each { |w| wrap("! #{w}", 2, 4).each { |l| out << l } }
219
+ end
220
+
221
+ def evidence_s(e)
222
+ e.is_a?(Hash) ? e.map { |k, v| "#{k}=#{v.is_a?(Array) ? v.join("/") : v}" }.join(" ") : e.to_s
223
+ end
224
+
225
+ # 80 桁に収まるように単語で折り返す。1 行目は indent、続きは cont 桁で字下げする。
226
+ def wrap(text, indent, cont)
227
+ lines = []
228
+ cur = " " * indent
229
+ fresh = true
230
+ text.to_s.split(/\s+/).reject(&:empty?).each do |w|
231
+ w = w[0, WIDTH - cont - 2] + "…" if w.length > WIDTH - cont - 1
232
+ if !fresh && cur.length + 1 + w.length > WIDTH - 1
233
+ lines << cur
234
+ cur = " " * cont
235
+ fresh = true
236
+ end
237
+ cur += (fresh ? "" : " ") + w
238
+ fresh = false
239
+ end
240
+ lines << cur
241
+ end
242
+
243
+ def med(s) = s.is_a?(Hash) ? s["median"] : s
244
+
245
+ def fmt(x, digits = 1)
246
+ x.nil? ? "-" : Kernel.format("%.#{digits}f", x)
247
+ end
248
+
249
+ def signed(x) = Kernel.format("%+.1f", x)
250
+
251
+ def diff_s(a, b)
252
+ return "-" unless a && b
253
+ pct = a.positive? ? Kernel.format(" (%+.1f%%)", (b - a) / a * 100) : ""
254
+ "#{signed(b - a)} ms#{pct}"
255
+ end
256
+
257
+ def stat_s(s, range: true)
258
+ return "-" unless s
259
+ base = "#{fmt(s["median"])} ms"
260
+ range ? "#{base} [#{fmt(s["min"])}-#{fmt(s["max"])}]" : base
261
+ end
262
+
263
+ def trunc(s, w)
264
+ s = @redact.call(s) if @redact
265
+ s.length > w ? "…#{s[-(w - 1)..]}" : s
266
+ end
267
+
268
+ def clip(l)
269
+ l = @redact.call(l) if @redact
270
+ l.length > WIDTH ? l[0, WIDTH - 1] + "…" : l
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ # Gemfile の行ベースの簡易パーサ (eval しない)。
8
+ # group ブロック / インラインの group: groups: / require: / eval_gemfile を拾う。
9
+ module Gemfile
10
+ # groups: 空配列 = どの group にも属さない (production で読み込まれる)
11
+ # require: nil (指定なし) / false / 文字列の配列 (別名で require される)
12
+ Entry = Struct.new(:name, :groups, :require, keyword_init: true)
13
+
14
+ GEM_RE = /\A\s*gem\s*\(?\s*["']([^"']+)["']/
15
+ GROUP_BLOCK_RE = /\A\s*group\s*\(?\s*([^#]*?)\)?\s+do\b/
16
+ GROUP_ONELINE_RE = /\A\s*group\s*\(?\s*([^#]*?)\)?\s+do\b(.*)\bend\s*\z/
17
+ DO_BLOCK_RE = /\bdo\s*(\|[^|]*\|)?\s*\z/
18
+ END_RE = /\A\s*end\b/
19
+ # 文のかたち (modifier の `gem "x" if foo` は行頭が gem なのでここには来ない)
20
+ STATEMENT_RE = /\A\s*(?:if|unless|case|while|until|begin|def|class|module|for)\b/
21
+ EVAL_RE = /\beval_gemfile\s*\(?\s*(?:["']([^"']+)["']|\w+)/
22
+ GLOB_RE = /Dir(?:\.glob\s*\(?|\s*\[)\s*(?:[\w.:]*expand_path\s*\(\s*)?["']([^"']+)["']/
23
+ PERCENT_RE = /%[iIwW][\[(]([^\])]*)[\])]/
24
+ MAX_DEPTH = 8
25
+
26
+ module_function
27
+
28
+ # dir: eval_gemfile の相対パスの基準 (Gemfile のあるディレクトリ)。nil なら eval_gemfile は無視する。
29
+ # => { "gem-name" => Entry }
30
+ def parse(src, dir: nil)
31
+ entries = {}
32
+ walk(src, dir, entries, [], Set.new, 0)
33
+ entries
34
+ end
35
+
36
+ def walk(src, dir, entries, base_groups, seen, depth)
37
+ stack = [base_groups] # 各要素: group 名の配列 (group ブロック) か nil (その他のブロック)
38
+ glob = nil
39
+ logical_lines(src).each do |line|
40
+ if (m = line.match(GROUP_ONELINE_RE))
41
+ add_gem(entries, m[2], stack.compact.flatten + symbols(m[1]))
42
+ elsif (m = line.match(GROUP_BLOCK_RE))
43
+ stack.push(symbols(m[1]))
44
+ elsif line.match?(END_RE)
45
+ stack.pop if stack.size > 1
46
+ elsif line.match?(GEM_RE)
47
+ add_gem(entries, line, stack.compact.flatten)
48
+ elsif line.match?(EVAL_RE)
49
+ glob = line[GLOB_RE, 1] || glob
50
+ eval_files(line, glob, dir).each do |path|
51
+ next if depth >= MAX_DEPTH || !seen.add?(path)
52
+ inner = read_file(path)
53
+ walk(inner, File.dirname(path), entries, stack.compact.flatten, seen, depth + 1) if inner
54
+ seen.delete(path)
55
+ end
56
+ elsif line.match?(GLOB_RE)
57
+ glob = line[GLOB_RE, 1]
58
+ push_do_or_statement(stack, line)
59
+ else
60
+ push_do_or_statement(stack, line)
61
+ end
62
+ end
63
+ end
64
+
65
+ def read_file(path)
66
+ File.read(path)
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ def push_do_or_statement(stack, line)
72
+ if line.match?(STATEMENT_RE)
73
+ stack.push(nil) unless line.match?(/\bend\s*\z/)
74
+ elsif line.match?(DO_BLOCK_RE)
75
+ stack.push(nil)
76
+ end
77
+ end
78
+
79
+ # 行内 eval_gemfile "path" / Dir.glob(...) 経由の eval_gemfile f を実ファイルに解決する
80
+ def eval_files(line, glob, dir)
81
+ return [] unless dir
82
+ m = line.match(EVAL_RE)
83
+ pattern = m[1] || glob
84
+ return [] unless pattern
85
+ File.expand_path(pattern, dir).then { |p| Dir.glob(p).sort }.select { File.file?(_1) }.map { File.realpath(_1) }
86
+ end
87
+
88
+ # コメントを除き、行末が , か \ の行は次の行と連結する
89
+ def logical_lines(src)
90
+ out = []
91
+ buf = nil
92
+ src.each_line do |raw|
93
+ line = raw.sub(/(?<!["'\w])#.*/, "").rstrip
94
+ if buf
95
+ buf = "#{buf} #{line.strip}"
96
+ else
97
+ buf = line
98
+ end
99
+ if buf.end_with?(",") || buf.end_with?("\\")
100
+ buf = buf.chomp("\\")
101
+ next
102
+ end
103
+ out << buf
104
+ buf = nil
105
+ end
106
+ out << buf if buf
107
+ out
108
+ end
109
+
110
+ def add_gem(entries, line, block_groups)
111
+ m = line.match(GEM_RE) or return
112
+ name = m[1]
113
+ groups = (block_groups + inline_groups(line)).uniq
114
+ req = require_opt(line)
115
+ if (old = entries[name])
116
+ # if/else などで複数宣言されたら、全ての宣言が group 付きのときだけ group 付きとみなす
117
+ groups = (old.groups.empty? || groups.empty?) ? [] : (old.groups | groups)
118
+ req = nil unless old.require == req
119
+ end
120
+ entries[name] = Entry.new(name: name, groups: groups, require: req)
121
+ end
122
+
123
+ def symbols(str)
124
+ found = []
125
+ str.scan(PERCENT_RE) { |(body)| found.concat(body.split) }
126
+ rest = str.gsub(PERCENT_RE, " ")
127
+ found + rest.scan(/:(\w+)|["'](\w+)["']/).map { |a, b| a || b }
128
+ end
129
+
130
+ def inline_groups(line)
131
+ m = line.match(/(?:\bgroups?:|:groups?\s*=>)\s*(\[[^\]]*\]|%[iIwW][\[(][^\])]*[\])]|:\w+|["']\w+["'])/)
132
+ m ? symbols(m[1]) : []
133
+ end
134
+
135
+ def require_opt(line)
136
+ m = line.match(/(?:\brequire:|:require\s*=>)\s*(false|nil|true|\[[^\]]*\]|%[iIwW][\[(][^\])]*[\])]|"[^"]*"|'[^']*')/)
137
+ return nil unless m
138
+ v = m[1]
139
+ return false if v == "false"
140
+ return nil if v == "nil" || v == "true"
141
+ return v[PERCENT_RE, 1].split if v.start_with?("%")
142
+ v.scan(/["']([^"']*)["']/).flatten
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hayaoki
4
+ module BootReport
5
+ # 集計結果。to_h は JSON.generate に渡せる値 (String キーの Hash / Array / 数値 / nil) だけで作る。
6
+ class Report
7
+ ATTRS = %i[condition runs failed phases categories gems files initializers app_initializer_times on_loads eager_namespaces false_require threads
8
+ ruby features meta warnings].freeze
9
+ attr_reader(*ATTRS)
10
+
11
+ def initialize(**kw)
12
+ unknown = kw.keys - ATTRS
13
+ raise ArgumentError, "unknown keys: #{unknown.inspect}" unless unknown.empty?
14
+ ATTRS.each { |a| instance_variable_set(:"@#{a}", kw[a]) }
15
+ end
16
+
17
+ def to_h
18
+ ATTRS.to_h { |a| [a.to_s, public_send(a)] }
19
+ end
20
+ end
21
+ end
22
+ end