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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HeapScope
6
+ class Report
7
+ # Static HTML report — no backend required. Charts use inline SVG sparkline bars.
8
+ module HTML
9
+ module_function
10
+
11
+ def render(report)
12
+ data = report.to_h
13
+ <<~HTML
14
+ <!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>HeapScope Report — #{escape(report.summary[:healthy] ? "HEALTHY" : "ATTENTION")}</title>
20
+ <style>
21
+ :root {
22
+ --bg0: #f4f7f6;
23
+ --bg1: #e8eef0;
24
+ --ink: #14201c;
25
+ --muted: #5b6b66;
26
+ --accent: #0f766e;
27
+ --accent2: #334155;
28
+ --card: #ffffffcc;
29
+ --high: #b91c1c;
30
+ --med: #c2410c;
31
+ --low: #57534e;
32
+ --ok: #047857;
33
+ }
34
+ * { box-sizing: border-box; }
35
+ body {
36
+ margin: 0;
37
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
38
+ color: var(--ink);
39
+ background:
40
+ radial-gradient(1200px 600px at 10% -10%, #ccfbf1 0%, transparent 55%),
41
+ radial-gradient(900px 500px at 100% 0%, #e2e8f0 0%, transparent 50%),
42
+ linear-gradient(165deg, var(--bg0), var(--bg1));
43
+ min-height: 100vh;
44
+ }
45
+ header.hero {
46
+ padding: 2.5rem 1.25rem 1.5rem;
47
+ max-width: 1040px;
48
+ margin: 0 auto;
49
+ display: grid;
50
+ grid-template-columns: 72px 1fr;
51
+ gap: 1.25rem;
52
+ align-items: center;
53
+ }
54
+ header.hero svg { width: 72px; height: 72px; }
55
+ h1 {
56
+ font-family: "IBM Plex Serif", Georgia, serif;
57
+ font-weight: 650;
58
+ letter-spacing: -0.03em;
59
+ font-size: clamp(2rem, 4vw, 2.75rem);
60
+ margin: 0;
61
+ }
62
+ .tagline { color: var(--muted); margin: 0.35rem 0 0; }
63
+ main { max-width: 1040px; margin: 0 auto; padding: 0 1.25rem 4rem; }
64
+ .banner {
65
+ padding: 1rem 1.25rem;
66
+ background: var(--card);
67
+ backdrop-filter: blur(8px);
68
+ border-left: 5px solid var(--accent);
69
+ border-radius: 0 12px 12px 0;
70
+ margin: 1rem 0 1.5rem;
71
+ }
72
+ .banner.ok { border-left-color: var(--ok); }
73
+ .banner.warn { border-left-color: var(--med); }
74
+ h2 {
75
+ margin-top: 2.25rem;
76
+ font-size: 0.85rem;
77
+ color: var(--accent);
78
+ text-transform: uppercase;
79
+ letter-spacing: 0.08em;
80
+ }
81
+ .grid {
82
+ display: grid;
83
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
84
+ gap: 0.75rem;
85
+ }
86
+ .stat {
87
+ background: var(--card);
88
+ border-radius: 12px;
89
+ padding: 1rem;
90
+ border: 1px solid #d6e0dc;
91
+ }
92
+ .stat .label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
93
+ .stat .value { font-size: 1.35rem; font-weight: 600; margin-top: 0.25rem; }
94
+ table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; }
95
+ th, td { text-align: left; padding: 0.55rem 0.7rem; border-bottom: 1px solid #e4ebe8; font-size: 0.92rem; }
96
+ th { background: #f0f5f3; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
97
+ .sev-high { color: var(--high); font-weight: 650; }
98
+ .sev-medium { color: var(--med); font-weight: 600; }
99
+ .sev-low { color: var(--low); }
100
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 48px; margin: 0.5rem 0 1rem; }
101
+ .bars span {
102
+ display: block;
103
+ width: 10px;
104
+ background: linear-gradient(180deg, #2dd4bf, #0f766e);
105
+ border-radius: 3px 3px 0 0;
106
+ min-height: 2px;
107
+ }
108
+ pre {
109
+ background: #0f172a;
110
+ color: #e2e8f0;
111
+ padding: 1rem;
112
+ overflow: auto;
113
+ font-size: 0.78rem;
114
+ border-radius: 12px;
115
+ max-height: 420px;
116
+ }
117
+ .note { font-size: 0.85rem; color: var(--muted); }
118
+ footer { margin-top: 3rem; color: var(--muted); font-size: 0.85rem; }
119
+ @media (max-width: 640px) {
120
+ header.hero { grid-template-columns: 1fr; }
121
+ }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <header class="hero">
126
+ #{logo_svg}
127
+ <div>
128
+ <h1>HeapScope</h1>
129
+ <p class="tagline">Retention-first diagnostics · v#{escape(report.heapscope_version)} · Ruby #{escape(report.runtime_info[:ruby])} · schema #{report.schema_version}</p>
130
+ </div>
131
+ </header>
132
+ <main>
133
+ <div class="banner #{report.summary[:healthy] ? "ok" : "warn"}">
134
+ <strong>#{report.summary[:healthy] ? "HEALTHY" : "ATTENTION"}</strong>
135
+ <div class="note">#{escape(Scorecard.from_report(report).to_text.lines[2..5]&.join(' ') || 'Estimates are approximate.')}</div>
136
+ <div class="note">Object values are not included by default. Facts ≠ hypotheses.</div>
137
+ </div>
138
+
139
+ <h2>Memory overview</h2>
140
+ <div class="grid">
141
+ <div class="stat"><div class="label">RSS before</div><div class="value">#{bytes(report.before&.rss_bytes)}</div></div>
142
+ <div class="stat"><div class="label">RSS after</div><div class="value">#{bytes(report.after&.rss_bytes)}</div></div>
143
+ <div class="stat"><div class="label">RSS Δ</div><div class="value">#{bytes(report.diff&.rss_delta)}</div></div>
144
+ <div class="stat"><div class="label">Live slots Δ</div><div class="value">#{report.diff&.heap_live_delta || "n/a"}</div></div>
145
+ <div class="stat"><div class="label">Retention</div><div class="value">#{ratio(report.diff&.retention_ratio)}</div></div>
146
+ <div class="stat"><div class="label">Findings</div><div class="value">#{report.findings.size}</div></div>
147
+ </div>
148
+
149
+ #{sparkline_section(report)}
150
+
151
+ <h2>Top class growth</h2>
152
+ <table>
153
+ <tr><th>Class</th><th>Before</th><th>After</th><th>Delta</th><th>Bytes Δ</th></tr>
154
+ #{class_rows(report)}
155
+ </table>
156
+
157
+ <h2>Top suspects</h2>
158
+ #{suspect_blocks(report)}
159
+
160
+ <h2>Findings</h2>
161
+ #{finding_blocks(report)}
162
+
163
+ <h2>Next steps</h2>
164
+ #{next_step_blocks(report)}
165
+
166
+ <h2>Limitations</h2>
167
+ <p class="note">#{report.limitations.empty? ? "None recorded." : escape(report.limitations.join(", "))}</p>
168
+
169
+ <h2>Raw JSON</h2>
170
+ <pre>#{escape(JSON.pretty_generate(data))}</pre>
171
+
172
+ <footer>
173
+ #{Branding.html_footer}
174
+ </footer>
175
+ </main>
176
+ </body>
177
+ </html>
178
+ HTML
179
+ end
180
+
181
+ def logo_svg
182
+ inline = Branding.logo_svg_inline
183
+ return inline if inline && !inline.strip.empty?
184
+
185
+ <<~SVG
186
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true">
187
+ <defs>
188
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
189
+ <stop offset="0%" stop-color="#0f766e"/><stop offset="100%" stop-color="#334155"/>
190
+ </linearGradient>
191
+ </defs>
192
+ <rect width="256" height="256" rx="48" fill="url(#bg)"/>
193
+ <rect x="52" y="150" width="36" height="36" rx="4" fill="#5eead4"/>
194
+ <rect x="94" y="132" width="36" height="54" rx="4" fill="#99f6e4"/>
195
+ <rect x="136" y="114" width="36" height="72" rx="4" fill="#ccfbf1"/>
196
+ <circle cx="156" cy="96" r="42" fill="none" stroke="#ecfdf5" stroke-width="14"/>
197
+ <line x1="186" y1="126" x2="214" y2="154" stroke="#ecfdf5" stroke-width="14" stroke-linecap="round"/>
198
+ </svg>
199
+ SVG
200
+ end
201
+
202
+ def next_step_blocks(report)
203
+ steps = Suggest.next_steps(report)
204
+ return "<p class='note'>No prioritized next steps.</p>" if steps.empty?
205
+
206
+ items = steps.map.with_index do |step, i|
207
+ tag = step[:code] ? escape(step[:code]) : "hint"
208
+ "<li><strong>#{i + 1}. [#{tag}]</strong> #{escape(step[:action])}</li>"
209
+ end.join
210
+ "<ol>#{items}</ol>"
211
+ end
212
+
213
+ def sparkline_section(report)
214
+ series = report.retention && report.retention[:class_series]
215
+ return "" unless series && !series.empty?
216
+
217
+ top = series.first
218
+ vals = Array(top[:series]).map(&:to_f)
219
+ return "" if vals.empty?
220
+
221
+ max = vals.max.nonzero? || 1
222
+ bars = vals.map { |v| "<span style='height:#{[(v / max * 48).round, 2].max}px'></span>" }.join
223
+ <<~HTML
224
+ <h2>Retention sparkline — #{escape(top[:name])}</h2>
225
+ <div class="bars">#{bars}</div>
226
+ <p class="note">Sample series: #{escape(vals.map(&:to_i).inspect)} · pattern #{escape(top.dig(:trend, :pattern).to_s)}</p>
227
+ HTML
228
+ end
229
+
230
+ def class_rows(report)
231
+ rows = report.diff ? report.diff.growing_classes(20) : []
232
+ return "<tr><td colspan='5'>No growth data</td></tr>" if rows.empty?
233
+
234
+ rows.map do |c|
235
+ "<tr><td>#{escape(c[:name])}</td><td>#{c[:before_count]}</td><td>#{c[:after_count]}</td>" \
236
+ "<td>#{c[:delta_count]}</td><td>#{bytes(c[:delta_bytes])}</td></tr>"
237
+ end.join("\n")
238
+ end
239
+
240
+ def suspect_blocks(report)
241
+ return "<p class='note'>No ranked suspects.</p>" if report.suspects.empty?
242
+
243
+ rows = report.suspects.first(10).map.with_index do |s, i|
244
+ "<tr><td>#{i + 1}</td><td>#{escape(s[:name])}</td>" \
245
+ "<td class='sev-#{s[:severity]}'>#{s[:severity].to_s.upcase}</td>" \
246
+ "<td>+#{s[:delta_count]}</td><td>#{escape(s[:classification].to_s)}</td></tr>"
247
+ end.join
248
+ "<table><tr><th>#</th><th>Class</th><th>Severity</th><th>Delta</th><th>Class</th></tr>#{rows}</table>"
249
+ end
250
+
251
+ def finding_blocks(report)
252
+ return "<p class='note'>No findings.</p>" if report.findings.empty?
253
+
254
+ report.findings.map do |f|
255
+ <<~BLOCK
256
+ <div class="banner">
257
+ <div class="sev-#{f.severity}">#{f.code} — #{escape(f.title)}</div>
258
+ <ul>#{f.facts.map { |x| "<li>#{escape(x)}</li>" }.join}</ul>
259
+ #{f.hypothesis ? "<p>#{escape(f.hypothesis)}</p>" : ""}
260
+ #{f.suspected_cause ? "<p class='note'>Suspected cause: #{escape(f.suspected_cause)}</p>" : ""}
261
+ </div>
262
+ BLOCK
263
+ end.join
264
+ end
265
+
266
+ def bytes(n)
267
+ return "n/a" if n.nil?
268
+
269
+ format("%.2f MB", n.to_f / (1024 * 1024))
270
+ end
271
+
272
+ def ratio(n)
273
+ return "n/a" if n.nil?
274
+
275
+ format("%.2f%%", n * 100.0)
276
+ end
277
+
278
+ def escape(str)
279
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class Report
5
+ module Markdown
6
+ module_function
7
+
8
+ def render(report)
9
+ lines = []
10
+ lines << "# HeapScope Report"
11
+ lines << ""
12
+ lines << "- **Version:** #{report.heapscope_version}"
13
+ lines << "- **Ruby:** #{report.runtime_info[:ruby]} (#{report.runtime_info[:engine]})"
14
+ lines << "- **Result:** #{report.summary[:healthy] ? "HEALTHY" : "ATTENTION"}"
15
+ lines << ""
16
+ if report.before && report.after
17
+ lines << "## Memory"
18
+ lines << ""
19
+ lines << "| Metric | Before | After | Delta |"
20
+ lines << "|--------|--------|-------|-------|"
21
+ lines << "| RSS | #{mb(report.before.rss_bytes)} | #{mb(report.after.rss_bytes)} | #{mb(report.diff&.rss_delta)} |"
22
+ lines << "| Live slots | #{report.before.heap_live_slots} | #{report.after.heap_live_slots} | #{report.diff&.heap_live_delta} |"
23
+ lines << ""
24
+ end
25
+ if report.diff
26
+ lines << "## Top class growth"
27
+ lines << ""
28
+ lines << "| Class | Before | After | Delta | Bytes Δ |"
29
+ lines << "|-------|--------|-------|-------|---------|"
30
+ report.diff.growing_classes(15).each do |c|
31
+ lines << "| `#{c[:name]}` | #{c[:before_count]} | #{c[:after_count]} | #{c[:delta_count]} | #{mb(c[:delta_bytes])} |"
32
+ end
33
+ lines << ""
34
+ end
35
+ unless report.findings.empty?
36
+ lines << "## Findings"
37
+ lines << ""
38
+ report.findings.each do |f|
39
+ lines << "### #{f.code} — #{f.title} (`#{f.severity}`)"
40
+ f.facts.each { |fact| lines << "- Fact: #{fact}" }
41
+ f.derived.each { |d| lines << "- Derived: #{d}" }
42
+ lines << "- Hypothesis: #{f.hypothesis}" if f.hypothesis
43
+ lines << "- Suspected cause: #{f.suspected_cause}" if f.suspected_cause
44
+ lines << ""
45
+ end
46
+ end
47
+ if report.reproduction
48
+ lines << "## Reproduction"
49
+ lines << ""
50
+ lines << "```bash"
51
+ lines << report.reproduction[:command].to_s
52
+ lines << "```"
53
+ lines << ""
54
+ end
55
+ steps = Suggest.next_steps(report)
56
+ unless steps.empty?
57
+ lines << "## Next steps"
58
+ lines << ""
59
+ steps.each_with_index do |step, i|
60
+ tag = step[:code] || "hint"
61
+ lines << "#{i + 1}. **[#{tag}]** #{step[:action]}"
62
+ end
63
+ lines << ""
64
+ end
65
+ lines << "_Privacy: object values are not serialized by default._"
66
+ lines << ""
67
+ lines << "_#{Branding.report_footer}_"
68
+ lines.join("\n")
69
+ end
70
+
71
+ def mb(n)
72
+ return "n/a" if n.nil?
73
+
74
+ format("%.2f MB", n.to_f / (1024 * 1024))
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class Report
5
+ module Text
6
+ module_function
7
+
8
+ def render(report)
9
+ lines = []
10
+ lines << "╔══════════════════════════════════════════╗"
11
+ lines << "║ HEAPSCOPE REPORT ║"
12
+ lines << "╚══════════════════════════════════════════╝"
13
+ lines << "Ruby: #{report.runtime_info[:ruby]} (#{report.runtime_info[:engine]})"
14
+ lines << "HeapScope: #{report.heapscope_version}"
15
+ lines << ""
16
+
17
+ if report.summary[:healthy]
18
+ lines << "Result: HEALTHY"
19
+ lines << "Peak object growth occurred during workload may have been reclaimed."
20
+ lines << "Persistent class growth: none significant." if report.findings.none? { |f| f.code == "HS001" }
21
+ else
22
+ lines << "Result: ATTENTION"
23
+ end
24
+ lines << ""
25
+
26
+ if report.before && report.after
27
+ lines << "RSS"
28
+ lines << " Before: #{mb(report.before.rss_bytes)}"
29
+ lines << " After: #{mb(report.after.rss_bytes)}"
30
+ lines << " Delta: #{mb_delta(report.diff&.rss_delta)}"
31
+ lines << ""
32
+ lines << "Ruby Heap (live slots)"
33
+ lines << " Before: #{fmt(report.before.heap_live_slots)}"
34
+ lines << " After: #{fmt(report.after.heap_live_slots)}"
35
+ lines << " Delta: #{signed(report.diff&.heap_live_delta)}"
36
+ lines << ""
37
+ end
38
+
39
+ if report.diff
40
+ ratio = report.diff.retention_ratio
41
+ lines << "Allocated Δ: #{fmt(report.diff.allocated_delta)}"
42
+ lines << "Freed Δ: #{fmt(report.diff.freed_delta)}"
43
+ lines << "Surviving ≈: #{fmt(report.diff.surviving_estimate)}"
44
+ lines << "Retention: #{ratio ? format('%.2f%%', ratio * 100) : 'n/a'}"
45
+ lines << ""
46
+ lines << "TOP CLASS GROWTH"
47
+ lines << "CLASS BEFORE AFTER DELTA BYTES Δ"
48
+ report.diff.growing_classes(15).each do |c|
49
+ lines << format("%-28s %10s %10s %10s %12s",
50
+ truncate(c[:name], 28),
51
+ fmt(c[:before_count]),
52
+ fmt(c[:after_count]),
53
+ signed(c[:delta_count]),
54
+ mb_delta(c[:delta_bytes]))
55
+ end
56
+ lines << ""
57
+ end
58
+
59
+ if report.retention
60
+ lines << "RETENTION SESSION"
61
+ lines << " Samples: #{report.retention[:samples]}"
62
+ lines << " Force GC: #{report.retention[:force_gc]}"
63
+ Array(report.retention[:persistent]).first(10).each do |entry|
64
+ lines << " #{entry[:name]}: #{entry[:series].inspect} (#{entry[:trend][:pattern]}, slope #{entry[:trend][:slope]})"
65
+ end
66
+ lines << ""
67
+ end
68
+
69
+ unless report.suspects.empty?
70
+ lines << "TOP SUSPECTS"
71
+ report.suspects.first(8).each_with_index do |s, i|
72
+ lines << " #{i + 1}. #{s[:name]} #{s[:severity].to_s.upcase}"
73
+ lines << " +#{s[:delta_count]} retained class=#{s[:classification]}"
74
+ end
75
+ lines << ""
76
+ end
77
+
78
+ unless report.findings.empty?
79
+ lines << "FINDINGS"
80
+ report.findings.each do |f|
81
+ lines << " #{f.code} — #{f.title} [#{f.severity.to_s.upcase}]"
82
+ f.facts.each { |fact| lines << " Fact: #{fact}" }
83
+ f.derived.each { |d| lines << " Derived: #{d}" }
84
+ lines << " Hypothesis: #{f.hypothesis}" if f.hypothesis
85
+ lines << " Suspected cause: #{f.suspected_cause}" if f.suspected_cause
86
+ f.suggestions.each { |s| lines << " → #{s}" }
87
+ lines << ""
88
+ end
89
+ end
90
+
91
+ unless report.limitations.empty?
92
+ lines << "LIMITATIONS"
93
+ report.limitations.each { |l| lines << " - #{l}" }
94
+ lines << ""
95
+ end
96
+
97
+ if report.budget_result
98
+ lines << "BUDGET: #{report.budget_result[:passed] ? 'PASS' : 'FAIL'}"
99
+ Array(report.budget_result[:violations]).each { |v| lines << " - #{v}" }
100
+ lines << ""
101
+ end
102
+
103
+ if report.fragmentation
104
+ lines << "FRAGMENTATION INDICATOR"
105
+ lines << " Status: #{report.fragmentation[:status]}"
106
+ lines << " RSS/heap ratio ≈ #{report.fragmentation[:rss_to_heap_ratio]}"
107
+ lines << " #{report.fragmentation[:note]}"
108
+ lines << ""
109
+ end
110
+
111
+ if report.aging && !report.aging.empty?
112
+ lines << "OBJECT AGING (coarse)"
113
+ report.aging.first(8).each do |row|
114
+ lines << " #{row[:class]} bucket=#{row[:bucket]} survived=#{row[:survived_ratio]} series=#{row[:series].inspect}"
115
+ end
116
+ lines << " Note: coarse multi-cycle survival buckets — not exact ages."
117
+ lines << ""
118
+ end
119
+
120
+ if report.retention_paths && !report.retention_paths.empty?
121
+ lines << "RETENTION PATHS / RETAINERS"
122
+ report.retention_paths.each do |block|
123
+ lines << " #{block[:summary]}"
124
+ Array(block[:retainers]).each do |r|
125
+ lines << " - #{r[:class]} approx_retained=#{r[:approx_retained]} (#{r[:note]})"
126
+ end
127
+ end
128
+ lines << ""
129
+ end
130
+
131
+ if report.dominators && !report.dominators.empty?
132
+ lines << "APPROXIMATE TOP RETAINERS"
133
+ report.dominators.first(5).each do |d|
134
+ name = d.respond_to?(:class_name) ? d.class_name : d[:class_name]
135
+ retained = d.respond_to?(:approx_retained) ? d.approx_retained : d[:approx_retained]
136
+ lines << " #{name}: approx retained #{retained}"
137
+ end
138
+ lines << ""
139
+ end
140
+
141
+ if report.reproduction
142
+ lines << "REPRODUCTION"
143
+ lines << " #{report.reproduction[:command]}"
144
+ lines << ""
145
+ end
146
+
147
+ steps = Suggest.next_steps(report)
148
+ unless steps.empty?
149
+ lines << "NEXT STEPS"
150
+ steps.each_with_index do |step, i|
151
+ tag = step[:code] ? "[#{step[:code]}]" : "[hint]"
152
+ lines << " #{i + 1}. #{tag} #{step[:action]}"
153
+ end
154
+ lines << ""
155
+ end
156
+
157
+ alerts = report.metadata[:alerts] || report.metadata["alerts"]
158
+ if alerts && !alerts.empty?
159
+ lines << "MONITOR ALERTS"
160
+ Array(alerts).each do |a|
161
+ kind = a[:kind] || a["kind"]
162
+ msg = a[:message] || a["message"]
163
+ lines << " - #{kind}: #{msg}"
164
+ end
165
+ lines << ""
166
+ end
167
+
168
+ lines << "Privacy: object values are not serialized by default."
169
+ Branding.funding_lines.each { |l| lines << l }
170
+ lines << Branding.report_footer
171
+ lines.join("\n")
172
+ end
173
+
174
+ def mb(bytes)
175
+ return "n/a" if bytes.nil?
176
+
177
+ format("%.2f MB", bytes.to_f / (1024 * 1024))
178
+ end
179
+
180
+ def mb_delta(bytes)
181
+ return "n/a" if bytes.nil?
182
+
183
+ sign = bytes.positive? ? "+" : ""
184
+ format("%s%.2f MB", sign, bytes.to_f / (1024 * 1024))
185
+ end
186
+
187
+ def fmt(n)
188
+ return "n/a" if n.nil?
189
+
190
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
191
+ end
192
+
193
+ def signed(n)
194
+ return "n/a" if n.nil?
195
+
196
+ n.positive? ? "+#{fmt(n)}" : fmt(n)
197
+ end
198
+
199
+ def truncate(str, len)
200
+ str.length > len ? "#{str[0, len - 1]}…" : str
201
+ end
202
+ end
203
+ end
204
+ end