heapscope 0.6.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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +119 -0
  3. data/CHANGELOG.md +86 -0
  4. data/CODE_OF_CONDUCT.md +38 -0
  5. data/CONTRIBUTING.md +72 -0
  6. data/Gemfile +11 -0
  7. data/LICENSE +21 -0
  8. data/README.md +342 -0
  9. data/Rakefile +49 -0
  10. data/SECURITY.md +39 -0
  11. data/assets/heapscope-logo.png +0 -0
  12. data/assets/logo.svg +24 -0
  13. data/assets/wordmark.svg +18 -0
  14. data/docs/README.md +20 -0
  15. data/docs/ROADMAP.md +29 -0
  16. data/docs/adr/0001-evidence-over-certainty.md +24 -0
  17. data/docs/adr/0002-local-only-privacy.md +19 -0
  18. data/docs/adr/0003-runtime-adapters.md +25 -0
  19. data/docs/adr/README.md +7 -0
  20. data/docs/allocators.md +13 -0
  21. data/docs/api/overview.md +70 -0
  22. data/docs/changelogs/0.1.0.md +22 -0
  23. data/docs/changelogs/0.2.0.md +49 -0
  24. data/docs/changelogs/0.3.0.md +38 -0
  25. data/docs/changelogs/0.4.0.md +32 -0
  26. data/docs/changelogs/0.5.0.md +18 -0
  27. data/docs/changelogs/0.6.0.md +23 -0
  28. data/docs/cli.md +98 -0
  29. data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
  30. data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
  31. data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
  32. data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
  33. data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
  34. data/docs/diagnostics/HS006_closure_retention.md +15 -0
  35. data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
  36. data/docs/diagnostics/HS008_baseline_regression.md +15 -0
  37. data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
  38. data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
  39. data/docs/guides/ci-budgets.md +43 -0
  40. data/docs/guides/first-retention-experiment.md +25 -0
  41. data/docs/guides/production-safe.md +17 -0
  42. data/docs/index.md +38 -0
  43. data/examples/cache_vs_leak.rb +30 -0
  44. data/examples/closure_capture.rb +20 -0
  45. data/examples/healthy_churn.rb +16 -0
  46. data/examples/heapscope.yml +12 -0
  47. data/examples/import_leak.rb +19 -0
  48. data/examples/probe_and_session.rb +22 -0
  49. data/examples/thread_local_leak.rb +31 -0
  50. data/exe/heapscope +7 -0
  51. data/heapscope.gemspec +73 -0
  52. data/lib/heapscope/aging.rb +114 -0
  53. data/lib/heapscope/analyzer.rb +333 -0
  54. data/lib/heapscope/baseline.rb +77 -0
  55. data/lib/heapscope/branding.rb +98 -0
  56. data/lib/heapscope/budget.rb +108 -0
  57. data/lib/heapscope/capabilities.rb +42 -0
  58. data/lib/heapscope/catalog.rb +71 -0
  59. data/lib/heapscope/cli/color.rb +67 -0
  60. data/lib/heapscope/cli/commands/capture.rb +289 -0
  61. data/lib/heapscope/cli/commands/diffing.rb +157 -0
  62. data/lib/heapscope/cli/commands/meta.rb +244 -0
  63. data/lib/heapscope/cli/commands/reporting.rb +228 -0
  64. data/lib/heapscope/cli/completion.rb +72 -0
  65. data/lib/heapscope/cli/help.rb +226 -0
  66. data/lib/heapscope/cli/support.rb +126 -0
  67. data/lib/heapscope/cli.rb +165 -0
  68. data/lib/heapscope/closures.rb +102 -0
  69. data/lib/heapscope/collector.rb +167 -0
  70. data/lib/heapscope/config.rb +196 -0
  71. data/lib/heapscope/detectors.rb +131 -0
  72. data/lib/heapscope/diff.rb +130 -0
  73. data/lib/heapscope/dominators.rb +87 -0
  74. data/lib/heapscope/errors.rb +13 -0
  75. data/lib/heapscope/extrapolation.rb +51 -0
  76. data/lib/heapscope/findings.rb +149 -0
  77. data/lib/heapscope/globals.rb +108 -0
  78. data/lib/heapscope/graph.rb +218 -0
  79. data/lib/heapscope/growth.rb +108 -0
  80. data/lib/heapscope/middleware.rb +35 -0
  81. data/lib/heapscope/minitest.rb +47 -0
  82. data/lib/heapscope/monitor.rb +160 -0
  83. data/lib/heapscope/noise.rb +48 -0
  84. data/lib/heapscope/notifications.rb +79 -0
  85. data/lib/heapscope/pack.rb +55 -0
  86. data/lib/heapscope/paths.rb +64 -0
  87. data/lib/heapscope/rails.rb +71 -0
  88. data/lib/heapscope/report/html.rb +283 -0
  89. data/lib/heapscope/report/markdown.rb +78 -0
  90. data/lib/heapscope/report/text.rb +204 -0
  91. data/lib/heapscope/report.rb +308 -0
  92. data/lib/heapscope/retention.rb +91 -0
  93. data/lib/heapscope/rspec.rb +118 -0
  94. data/lib/heapscope/runtime/base.rb +130 -0
  95. data/lib/heapscope/runtime/jruby.rb +28 -0
  96. data/lib/heapscope/runtime/mri.rb +86 -0
  97. data/lib/heapscope/runtime/truffleruby.rb +40 -0
  98. data/lib/heapscope/runtime/windows_rss.rb +75 -0
  99. data/lib/heapscope/runtime.rb +33 -0
  100. data/lib/heapscope/schema.rb +28 -0
  101. data/lib/heapscope/scorecard.rb +85 -0
  102. data/lib/heapscope/session.rb +90 -0
  103. data/lib/heapscope/sidekiq_middleware.rb +36 -0
  104. data/lib/heapscope/snapshot.rb +165 -0
  105. data/lib/heapscope/suggest.rb +99 -0
  106. data/lib/heapscope/tables.rb +58 -0
  107. data/lib/heapscope/trend_store.rb +41 -0
  108. data/lib/heapscope/version.rb +6 -0
  109. data/lib/heapscope.rb +307 -0
  110. metadata +211 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Ignore-pattern suggestions and prioritized next-step recommendations.
5
+ # Never auto-applies configuration — returns guidance only.
6
+ module Suggest
7
+ NEXT_STEP_BY_CODE = {
8
+ "HS001" => "Run HeapScope.retention_test (multi-cycle + force_gc) on the top growing class.",
9
+ "HS002" => "Force GC between snapshots and re-measure retention ratio after idle recovery.",
10
+ "HS003" => "Inventory Thread.current keys after the request/job and clear request-scoped locals.",
11
+ "HS004" => "Bound or expire the growing collection; confirm whether it is an intentional cache.",
12
+ "HS005" => "Audit callback/subscriber registries for unbounded appends across requests.",
13
+ "HS006" => "Check Proc/lambda captures for long-lived owners (controllers, jobs, threads).",
14
+ "HS007" => "Compare pre-GC vs post-GC snapshots; investigate sticky populations that survive GC.",
15
+ "HS008" => "Diff against the saved baseline and gate CI with Budget.preset(:ci_strict).",
16
+ "HS009" => "Treat as churn unless RSS/live slots also climb; optimize hot allocation sites if GC-bound.",
17
+ "HS010" => "Do not assume a Ruby object leak — check native extensions and allocator fragmentation."
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def ignore_patterns(report)
23
+ names = report.diff&.growing_classes(50)&.map { |c| c[:name] } || []
24
+ patterns = Noise.suggestion_patterns
25
+ hints = names.select { |n| patterns.any? { |re| n.match?(re) } }
26
+ stable = Array(report.suspects).select { |s| s[:severity] == :low }.map { |s| s[:name] }
27
+ (hints + stable).uniq
28
+ .reject { |n| Noise.noisy?(n) }
29
+ .map { |n| "^#{Regexp.escape(n)}" }
30
+ end
31
+
32
+ def next_steps(report, limit: 8)
33
+ steps = []
34
+ ranked = Findings.rank_and_dedupe(report.findings)
35
+ ranked.first(limit).each do |finding|
36
+ base = NEXT_STEP_BY_CODE[finding.code] || "Review finding #{finding.code} evidence and suggestions."
37
+ subject = finding.subject ? " (#{finding.subject})" : ""
38
+ steps << {
39
+ priority: Findings.priority_score(finding),
40
+ code: finding.code,
41
+ severity: finding.severity,
42
+ action: "#{base}#{subject}",
43
+ suggestions: finding.suggestions
44
+ }
45
+ end
46
+
47
+ if report.summary[:healthy] == false && steps.empty?
48
+ steps << {
49
+ priority: 40,
50
+ code: nil,
51
+ severity: :medium,
52
+ action: "Review top suspects and class growth table; run heapscope suggest on this report.",
53
+ suggestions: []
54
+ }
55
+ end
56
+
57
+ ignores = ignore_patterns(report)
58
+ if ignores.any?
59
+ steps << {
60
+ priority: 5,
61
+ code: nil,
62
+ severity: :low,
63
+ action: "Review #{ignores.size} suggested ignore_pattern(s) before applying (never auto-applied).",
64
+ suggestions: ignores.first(5)
65
+ }
66
+ end
67
+
68
+ steps.sort_by { |s| -s[:priority].to_i }.first(limit)
69
+ end
70
+
71
+ def report_text(report)
72
+ patterns = ignore_patterns(report)
73
+ steps = next_steps(report)
74
+ lines = []
75
+
76
+ lines << "Next steps (prioritized):"
77
+ if steps.empty?
78
+ lines << " (none — report looks quiet)"
79
+ else
80
+ steps.each_with_index do |step, i|
81
+ tag = step[:code] ? "[#{step[:code]}]" : "[hint]"
82
+ lines << " #{i + 1}. #{tag} #{step[:action]}"
83
+ end
84
+ end
85
+
86
+ lines << ""
87
+ if patterns.empty?
88
+ lines << "No ignore suggestions."
89
+ else
90
+ lines << "Suggested ignore_patterns (review before applying):"
91
+ patterns.each { |p| lines << " - #{p}" }
92
+ lines << ""
93
+ lines << "HeapScope.configure { |c| c.ignore_patterns << /.../ }"
94
+ end
95
+
96
+ lines.join("\n")
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # ASCII / Markdown tables for diffs and class growth.
5
+ module Tables
6
+ module_function
7
+
8
+ def class_growth(diff, limit: 15, format: :ascii)
9
+ rows = diff.growing_classes(limit).map do |c|
10
+ [
11
+ c[:name].to_s,
12
+ c[:before_count].to_s,
13
+ c[:after_count].to_s,
14
+ signed(c[:delta_count]),
15
+ bytes(c[:delta_bytes])
16
+ ]
17
+ end
18
+ headers = %w[CLASS BEFORE AFTER DELTA BYTES]
19
+ format == :markdown ? markdown(headers, rows) : ascii(headers, rows)
20
+ end
21
+
22
+ def findings(report, format: :ascii)
23
+ rows = report.findings.map { |f| [f.code, f.severity.to_s.upcase, f.title.to_s, f.subject.to_s] }
24
+ headers = %w[CODE SEV TITLE SUBJECT]
25
+ format == :markdown ? markdown(headers, rows) : ascii(headers, rows)
26
+ end
27
+
28
+ def ascii(headers, rows)
29
+ widths = headers.each_index.map do |i|
30
+ ([headers[i].length] + rows.map { |r| r[i].to_s.length }).max
31
+ end
32
+ sep = "+#{widths.map { |w| '-' * (w + 2) }.join('+')}+"
33
+ line = lambda do |cols|
34
+ cells = cols.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" | ")
35
+ "| #{cells} |"
36
+ end
37
+ ([sep, line.call(headers), sep] + rows.map { |r| line.call(r) } + [sep]).join("\n")
38
+ end
39
+
40
+ def markdown(headers, rows)
41
+ lines = []
42
+ lines << "| #{headers.join(' | ')} |"
43
+ lines << "| #{headers.map { '---' }.join(' | ')} |"
44
+ rows.each { |r| lines << "| #{r.join(' | ')} |" }
45
+ lines.join("\n")
46
+ end
47
+
48
+ def signed(n)
49
+ n.positive? ? "+#{n}" : n.to_s
50
+ end
51
+
52
+ def bytes(n)
53
+ return "n/a" if n.nil?
54
+
55
+ format("%+.2fMB", n.to_f / (1024 * 1024))
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HeapScope
6
+ # Persists monitor samples for historical trend reports.
7
+ class TrendStore
8
+ def initialize(path)
9
+ @path = path
10
+ end
11
+
12
+ def append(sample_hash)
13
+ rows = load
14
+ rows << sample_hash
15
+ File.write(@path, JSON.pretty_generate(rows))
16
+ rows.size
17
+ end
18
+
19
+ def load
20
+ return [] unless File.exist?(@path)
21
+
22
+ JSON.parse(File.read(@path), symbolize_names: true)
23
+ rescue StandardError
24
+ []
25
+ end
26
+
27
+ def summary
28
+ rows = load
29
+ return { samples: 0 } if rows.empty?
30
+
31
+ rss = rows.map { |r| r[:rss_bytes].to_i }
32
+ live = rows.map { |r| r[:live_slots].to_i }
33
+ {
34
+ samples: rows.size,
35
+ rss: { first: rss.first, last: rss.last, delta: rss.last - rss.first, max: rss.max },
36
+ live_slots: { first: live.first, last: live.last, delta: live.last - live.first, max: live.max },
37
+ top_classes: rows.map { |r| r[:top_class] }.compact.tally.sort_by { |_, v| -v }.first(5)
38
+ }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ VERSION = "0.6.0"
5
+ SCHEMA_VERSION = 1
6
+ end
data/lib/heapscope.rb ADDED
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "heapscope/version"
4
+ require_relative "heapscope/errors"
5
+ require_relative "heapscope/config"
6
+ require_relative "heapscope/capabilities"
7
+ require_relative "heapscope/runtime"
8
+ require_relative "heapscope/snapshot"
9
+ require_relative "heapscope/collector"
10
+ require_relative "heapscope/diff"
11
+ require_relative "heapscope/growth"
12
+ require_relative "heapscope/findings"
13
+ require_relative "heapscope/analyzer"
14
+ require_relative "heapscope/retention"
15
+ require_relative "heapscope/graph"
16
+ require_relative "heapscope/report"
17
+ require_relative "heapscope/budget"
18
+ require_relative "heapscope/baseline"
19
+ require_relative "heapscope/monitor"
20
+ require_relative "heapscope/detectors"
21
+ require_relative "heapscope/extrapolation"
22
+ require_relative "heapscope/aging"
23
+ require_relative "heapscope/globals"
24
+ require_relative "heapscope/closures"
25
+ require_relative "heapscope/dominators"
26
+ require_relative "heapscope/noise"
27
+ require_relative "heapscope/notifications"
28
+ require_relative "heapscope/paths"
29
+ require_relative "heapscope/trend_store"
30
+ require_relative "heapscope/scorecard"
31
+ require_relative "heapscope/session"
32
+ require_relative "heapscope/tables"
33
+ require_relative "heapscope/schema"
34
+ require_relative "heapscope/branding"
35
+ require_relative "heapscope/suggest"
36
+ require_relative "heapscope/catalog"
37
+ require_relative "heapscope/pack"
38
+
39
+ # HeapScope — Ruby object retention, heap growth, and memory leak diagnostics.
40
+ #
41
+ # Distinguishes allocation pressure from retention, and intentional retention
42
+ # from suspicious patterns. Prefers evidence over certainty.
43
+ module HeapScope
44
+ class << self
45
+ def snapshot(mode: nil, **opts)
46
+ Collector.new.capture(mode: mode, **opts)
47
+ end
48
+
49
+ def compare(before, after, metadata: {})
50
+ before = Snapshot.load(before) if before.is_a?(String)
51
+ after = Snapshot.load(after) if after.is_a?(String)
52
+ diff = Diff.new(before, after)
53
+ analysis = Analyzer.new.analyze_diff(diff, context: metadata)
54
+ Report.from_diff(diff, analysis: analysis, metadata: metadata)
55
+ end
56
+
57
+ def measure(force_gc: config.force_gc_default, mode: nil, recovery_wait: nil,
58
+ track_allocations: config.track_allocations, metadata: {})
59
+ raise ArgumentError, "block required" unless block_given?
60
+
61
+ tracer = AllocationTracer.new
62
+ tracing = track_allocations && tracer.available?
63
+ tracer.start! if tracing
64
+
65
+ GC.start if force_gc
66
+ before = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "before"))
67
+
68
+ allocated_before = Runtime.current.gc_stat[:total_allocated_objects]
69
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ result = yield
71
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
72
+ allocated_after = Runtime.current.gc_stat[:total_allocated_objects]
73
+
74
+ immediately_after = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "immediately_after"))
75
+
76
+ GC.start if force_gc
77
+ after_gc = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "after_gc"))
78
+
79
+ after_idle = nil
80
+ if recovery_wait&.positive?
81
+ sleep recovery_wait
82
+ GC.start if force_gc
83
+ after_idle = snapshot(mode: mode || :lightweight, metadata: metadata.merge(phase: "after_idle"))
84
+ end
85
+
86
+ final = after_idle || after_gc
87
+ diff = Diff.new(before, final)
88
+ analysis = Analyzer.new.analyze_diff(
89
+ diff,
90
+ context: metadata.merge(force_gc: force_gc)
91
+ )
92
+
93
+ allocated = (allocated_after || 0) - (allocated_before || 0)
94
+ rate = elapsed.positive? ? (allocated / elapsed) : nil
95
+
96
+ report = Report.from_diff(
97
+ diff,
98
+ analysis: analysis,
99
+ metadata: metadata.merge(
100
+ kind: "measure",
101
+ force_gc: force_gc,
102
+ elapsed_seconds: elapsed.round(4),
103
+ objects_allocated_during: allocated,
104
+ allocation_rate_per_sec: rate&.round(1),
105
+ phases: {
106
+ before: before.id,
107
+ immediately_after: immediately_after.id,
108
+ after_gc: after_gc.id,
109
+ after_idle: after_idle&.id
110
+ }.compact,
111
+ recovery: recovery_stats(before, immediately_after, after_gc, after_idle),
112
+ block_result_class: result.class.name
113
+ )
114
+ )
115
+ report
116
+ ensure
117
+ tracer&.stop! if tracing
118
+ end
119
+
120
+ def retention_test(cycles: 5, force_gc: true, mode: :lightweight, metadata: {})
121
+ raise ArgumentError, "block required" unless block_given?
122
+
123
+ session = RetentionSession.new(force_gc: force_gc, mode: mode, metadata: metadata)
124
+ session.sample(label: "baseline")
125
+ cycles.times do |i|
126
+ yield
127
+ session.sample(label: "cycle_#{i + 1}")
128
+ end
129
+ report = session.finish
130
+ # Enrich metadata
131
+ report.metadata[:kind] = "retention_test"
132
+ report.metadata[:cycles] = cycles
133
+ report
134
+ end
135
+
136
+ def experiment(runs: 10, force_gc: true, mode: :lightweight, metadata: {}, &block)
137
+ raise ArgumentError, "block required" unless block_given?
138
+
139
+ results = runs.times.map do |i|
140
+ measure(force_gc: force_gc, mode: mode, metadata: metadata.merge(run: i), &block)
141
+ end
142
+
143
+ retained = results.map { |r| r.diff&.surviving_estimate.to_i }
144
+ rss = results.map { |r| r.diff&.rss_delta.to_i }
145
+ stats = lambda do |arr|
146
+ sorted = arr.sort
147
+ {
148
+ min: sorted.first,
149
+ max: sorted.last,
150
+ median: percentile(sorted, 50),
151
+ p95: percentile(sorted, 95),
152
+ mean: (arr.sum.to_f / arr.size).round(2),
153
+ variance: Growth.sample_variance(arr.map(&:to_f)).round(2)
154
+ }
155
+ end
156
+
157
+ Report.new(
158
+ runtime_info: { ruby: RUBY_VERSION, engine: RUBY_ENGINE },
159
+ summary: {
160
+ healthy: results.all?(&:healthy?),
161
+ runs: runs,
162
+ retained_objects: stats.call(retained),
163
+ rss_delta: stats.call(rss)
164
+ },
165
+ findings: results.flat_map(&:findings).uniq { |f| [f.code, f.subject] },
166
+ suspects: results.flat_map(&:suspects).group_by { |s| s[:name] }.map do |_name, list|
167
+ list.max_by { |s| s[:delta_count] }.merge(runs_seen: list.size)
168
+ end,
169
+ metadata: metadata.merge(kind: "experiment", runs: runs),
170
+ before: results.first&.before,
171
+ after: results.last&.after,
172
+ diff: results.last&.diff,
173
+ classes: results.last&.classes || []
174
+ )
175
+ end
176
+
177
+ def repeat(times, force_gc: true, mode: :lightweight, &block)
178
+ retention_test(cycles: times, force_gc: force_gc, mode: mode, &block)
179
+ end
180
+
181
+ # Soft check — always returns a report with budget_result.
182
+ def check_budget(budget:, force_gc: true, mode: :standard, metadata: {}, &block)
183
+ raise ArgumentError, "block required" unless block
184
+
185
+ report = measure(force_gc: force_gc, mode: mode, metadata: metadata, &block)
186
+ result = budget.evaluate(report)
187
+ Report.new(
188
+ schema_version: report.schema_version,
189
+ heapscope_version: report.heapscope_version,
190
+ runtime_info: report.runtime_info,
191
+ summary: report.summary.merge(budget_passed: result[:passed]),
192
+ before: report.before,
193
+ after: report.after,
194
+ diff: report.diff,
195
+ findings: report.findings,
196
+ suspects: report.suspects,
197
+ classes: report.classes,
198
+ allocation_sites: report.allocation_sites,
199
+ retention: report.retention,
200
+ metadata: report.metadata,
201
+ limitations: report.limitations,
202
+ budget_result: result,
203
+ reproduction: report.reproduction
204
+ )
205
+ end
206
+
207
+ # Like check_budget, but raises BudgetExceededError on failure.
208
+ def check(budget:, **opts, &block)
209
+ report = check_budget(budget: budget, **opts, &block)
210
+ return report if report.passed_budget?
211
+
212
+ raise BudgetExceededError, report.budget_result[:violations].join("; ")
213
+ end
214
+
215
+ def capabilities
216
+ Capabilities.new(Runtime.current)
217
+ end
218
+
219
+ def runtime
220
+ Runtime.current
221
+ end
222
+
223
+ def doctor
224
+ {
225
+ version: VERSION,
226
+ ruby: RUBY_VERSION,
227
+ engine: RUBY_ENGINE,
228
+ platform: RUBY_PLATFORM,
229
+ capabilities: capabilities.to_h,
230
+ config: {
231
+ mode: config.mode,
232
+ ignore_patterns: config.ignore_patterns.map(&:inspect)
233
+ }
234
+ }
235
+ end
236
+
237
+ def overhead(mode: :lightweight, runs: 3)
238
+ Overhead.measure_snapshot(mode: mode, runs: runs)
239
+ end
240
+
241
+ def probe(title: "probe", **opts, &block)
242
+ Probe.run(title: title, **opts, &block)
243
+ end
244
+
245
+ def session(name, root: Dir.pwd)
246
+ Session.open(name, root: root)
247
+ end
248
+
249
+ def scorecard(report, title: "HeapScope")
250
+ Scorecard.from_report(report, title: title)
251
+ end
252
+
253
+ def codes
254
+ Catalog.codes
255
+ end
256
+
257
+ def about
258
+ Branding.about_text
259
+ end
260
+
261
+ def branding
262
+ Branding.to_h
263
+ end
264
+
265
+ def suggest_ignores(report)
266
+ Suggest.ignore_patterns(report)
267
+ end
268
+
269
+ def next_steps(report, limit: 8)
270
+ Suggest.next_steps(report, limit: limit)
271
+ end
272
+
273
+ def budget_preset(name)
274
+ Budget.preset(name)
275
+ end
276
+
277
+ def pack(report, dir, label: "heapscope-report")
278
+ Pack.export(report, dir, label: label)
279
+ end
280
+
281
+ def write_config!(path = "heapscope.yml", force: false)
282
+ ConfigLoader.write_starter!(path, force: force)
283
+ end
284
+
285
+ private
286
+
287
+ def recovery_stats(before, immediate, after_gc, after_idle)
288
+ {
289
+ baseline_rss: before.rss_bytes,
290
+ peak_rss: immediate.rss_bytes,
291
+ after_gc_rss: after_gc.rss_bytes,
292
+ after_idle_rss: after_idle&.rss_bytes,
293
+ baseline_live: before.heap_live_slots,
294
+ peak_live: immediate.heap_live_slots,
295
+ after_gc_live: after_gc.heap_live_slots,
296
+ after_idle_live: after_idle&.heap_live_slots
297
+ }
298
+ end
299
+
300
+ def percentile(sorted, pct)
301
+ return nil if sorted.empty?
302
+
303
+ k = ((pct / 100.0) * (sorted.size - 1)).round
304
+ sorted[k]
305
+ end
306
+ end
307
+ end