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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Approximate object aging across forced GC cycles.
5
+ # Does not invent ages the runtime cannot support — labels are coarse.
6
+ class Aging
7
+ BUCKETS = %i[young middle_aged long_lived unknown].freeze
8
+
9
+ Sample = Struct.new(:cycle_index, :class_counts, :generation_hint, keyword_init: true)
10
+
11
+ def initialize(runtime: Runtime.current)
12
+ @runtime = runtime
13
+ @samples = []
14
+ end
15
+
16
+ def sample!(cycle:)
17
+ counts = Hash.new(0)
18
+ if @runtime.each_object?
19
+ @runtime.each_object do |obj|
20
+ name = begin
21
+ obj.class.name
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ next unless name
26
+
27
+ counts[name] += 1
28
+ end
29
+ end
30
+ push_counts(cycle: cycle, counts: counts, generation_hint: (GC.stat[:count] if GC.respond_to?(:stat)))
31
+ end
32
+
33
+ def push_counts(cycle:, counts:, generation_hint: nil)
34
+ @samples << Sample.new(cycle_index: cycle, class_counts: counts, generation_hint: generation_hint)
35
+ self
36
+ end
37
+
38
+ def classify(class_name)
39
+ series = @samples.map { |s| s.class_counts[class_name].to_i }
40
+ return { bucket: :unknown, series: series } if series.size < 2
41
+
42
+ first = series.first
43
+ last = series.last
44
+ survived_ratio = first.positive? ? last.to_f / first : 0.0
45
+ bucket =
46
+ if survived_ratio < 0.2
47
+ :young
48
+ elsif survived_ratio < 0.7
49
+ :middle_aged
50
+ else
51
+ :long_lived
52
+ end
53
+
54
+ {
55
+ class: class_name,
56
+ bucket: bucket,
57
+ series: series,
58
+ survived_ratio: survived_ratio.round(4),
59
+ note: "Coarse age bucket from multi-cycle survival — not exact object age."
60
+ }
61
+ end
62
+
63
+ def report(top: 20)
64
+ names = @samples.flat_map { |s| s.class_counts.keys }.uniq
65
+ names.map { |n| classify(n) }
66
+ .select { |r| r[:series].last.to_i > 10 }
67
+ .sort_by { |r| -r[:survived_ratio] }
68
+ .first(top)
69
+ end
70
+ end
71
+
72
+ # Tracks GC stats around workloads for recovery correlation.
73
+ class GCTracker
74
+ def initialize
75
+ @points = []
76
+ end
77
+
78
+ def mark(label)
79
+ @points << {
80
+ label: label,
81
+ at: Time.now.utc,
82
+ gc_stat: Runtime.current.gc_stat.dup,
83
+ rss_bytes: begin
84
+ Runtime.current.rss_bytes
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ }
89
+ self
90
+ end
91
+
92
+ def correlation
93
+ return {} if @points.size < 2
94
+
95
+ first = @points.first
96
+ last = @points.last
97
+ {
98
+ points: @points,
99
+ major_gc_delta: delta(last[:gc_stat][:major_gc_count], first[:gc_stat][:major_gc_count]),
100
+ minor_gc_delta: delta(last[:gc_stat][:minor_gc_count], first[:gc_stat][:minor_gc_count]),
101
+ live_slots_delta: delta(last[:gc_stat][:heap_live_slots], first[:gc_stat][:heap_live_slots]),
102
+ rss_delta: delta(last[:rss_bytes], first[:rss_bytes])
103
+ }
104
+ end
105
+
106
+ private
107
+
108
+ def delta(a, b)
109
+ return nil if a.nil? || b.nil?
110
+
111
+ a - b
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Evidence-based analyzer producing findings and suspect rankings.
5
+ class Analyzer
6
+ CHURN_THRESHOLD = 0.05
7
+ STICKY_RETENTION = 0.30
8
+
9
+ def initialize(config: HeapScope.config)
10
+ @config = config
11
+ end
12
+
13
+ def analyze_diff(diff, context: {})
14
+ findings = []
15
+ suspects = []
16
+
17
+ diff.growing_classes(30).each do |delta|
18
+ next if @config.ignore_class?(delta[:name])
19
+ next if delta[:delta_count] < 50 && delta[:delta_bytes] < 100_000
20
+
21
+ classification = classify_population(delta, diff)
22
+ severity = suspicion_for(delta, classification, context)
23
+
24
+ suspects << {
25
+ name: delta[:name],
26
+ severity: severity,
27
+ delta_count: delta[:delta_count],
28
+ delta_bytes: delta[:delta_bytes],
29
+ classification: classification,
30
+ retention_ratio: context.dig(:retention_by_class, delta[:name])
31
+ }
32
+
33
+ next unless delta[:delta_count] >= 100
34
+ next if classification == :high_churn_low_retention
35
+ next if classification == :normal && severity == :low
36
+
37
+ findings << Finding.new(
38
+ code: "HS001",
39
+ severity: severity,
40
+ subject: delta[:name],
41
+ facts: [
42
+ "Observed fact: #{delta[:name]} count #{delta[:before_count]} → #{delta[:after_count]} (Δ #{signed(delta[:delta_count])})."
43
+ ],
44
+ derived: [
45
+ "Derived: shallow bytes Δ #{format_bytes(delta[:delta_bytes])}."
46
+ ],
47
+ hypothesis: "This population appears to be growing across the measured window.",
48
+ suspected_cause: nil,
49
+ evidence: [delta],
50
+ suggestions: [
51
+ "Compare against a post-GC snapshot",
52
+ "Run HeapScope.retention_test to check multi-cycle survival",
53
+ "Inspect allocation sites for #{delta[:name]}"
54
+ ]
55
+ )
56
+ end
57
+
58
+ if diff.retention_ratio && diff.retention_ratio > 0.2 && diff.allocated_delta.to_i > 1000
59
+ findings << Finding.new(
60
+ code: "HS002",
61
+ severity: :high,
62
+ facts: [
63
+ "Observed fact: allocated Δ #{diff.allocated_delta}, freed Δ #{diff.freed_delta}."
64
+ ],
65
+ derived: [
66
+ "Derived: retention ratio #{pct(diff.retention_ratio)} (surviving estimate #{diff.surviving_estimate})."
67
+ ],
68
+ hypothesis: "A meaningful fraction of allocations remained reachable after the workload.",
69
+ suggestions: [
70
+ "Force GC between snapshots if not already doing so",
71
+ "Identify top retained classes and their owners"
72
+ ]
73
+ )
74
+ end
75
+
76
+ if diff.native_memory_mismatch?
77
+ findings << Finding.new(
78
+ code: "HS010",
79
+ severity: :medium,
80
+ facts: [
81
+ "Observed fact: RSS Δ #{format_bytes(diff.rss_delta)}; heap live slots Δ #{diff.heap_live_delta}."
82
+ ],
83
+ derived: [
84
+ "Derived: Ruby heap growth does not explain RSS growth."
85
+ ],
86
+ hypothesis: "Native allocations, allocator fragmentation, mmap, or extension buffers may be involved.",
87
+ suggestions: [
88
+ "Do not assume an object leak from RSS alone",
89
+ "Inspect native extensions and allocator (jemalloc/glibc/macOS)"
90
+ ]
91
+ )
92
+ end
93
+
94
+ if high_churn?(diff)
95
+ findings << Finding.new(
96
+ code: "HS009",
97
+ severity: :low,
98
+ facts: [
99
+ "Observed fact: allocated Δ #{diff.allocated_delta}, freed Δ #{diff.freed_delta}."
100
+ ],
101
+ derived: ["Derived: high allocation pressure with substantial reclamation."],
102
+ hypothesis: "Workload exhibits object churn rather than persistent retention.",
103
+ suggestions: ["Optimize hot allocation sites if CPU/GC time is a concern"]
104
+ )
105
+ end
106
+
107
+ ranked = Findings.rank_and_dedupe(findings)
108
+ {
109
+ findings: ranked,
110
+ suspects: suspects.sort_by { |s| [-severity_rank(s[:severity]), -s[:delta_count]] },
111
+ summary: build_summary(diff, ranked, suspects)
112
+ }
113
+ end
114
+
115
+ def analyze_retention(session)
116
+ findings = []
117
+ persistent = session.persistent_classes
118
+ persistent.first(10).each do |entry|
119
+ next if @config.ignore_class?(entry[:name])
120
+
121
+ findings << Finding.new(
122
+ code: "HS001",
123
+ severity: entry[:delta] > 500 ? :high : :medium,
124
+ subject: entry[:name],
125
+ facts: [
126
+ "Observed fact: #{entry[:name]} series #{entry[:series].inspect}."
127
+ ],
128
+ derived: [
129
+ "Derived: pattern=#{entry[:trend][:pattern]}, slope=#{entry[:trend][:slope]} objects/sample."
130
+ ],
131
+ hypothesis: "Persistent growth across GC-sampled cycles is consistent with retention.",
132
+ suggestions: [
133
+ "Locate allocation site for #{entry[:name]}",
134
+ "Inspect thread-locals, globals, and registries for owners"
135
+ ]
136
+ )
137
+ end
138
+
139
+ if session.force_gc && persistent.any?
140
+ findings << Finding.new(
141
+ code: "HS007",
142
+ severity: :medium,
143
+ facts: ["Observed fact: forced GC between samples; persistent classes still grew."],
144
+ derived: ["Derived: recovery after GC appears poor for top populations."],
145
+ hypothesis: "Growth is not explained by delayed GC alone."
146
+ )
147
+ end
148
+
149
+ series = session.class_series
150
+ findings.concat(Detectors.collection_findings(series))
151
+
152
+ thread_local = Detectors.analyze_thread_locals
153
+ findings.concat(thread_local[:findings])
154
+
155
+ findings = Findings.rank_and_dedupe(findings)
156
+ {
157
+ findings: findings,
158
+ persistent: persistent,
159
+ class_series: series,
160
+ thread_locals: thread_local[:inventory],
161
+ collections: series.values.map { |e|
162
+ Detectors.classify_collection_growth(name: e[:name], sizes: e[:series])
163
+ }.select { |c| %i[likely_cache unbounded_candidate].include?(c[:kind]) },
164
+ summary: {
165
+ samples: session.samples.size,
166
+ persistent_count: persistent.size,
167
+ top: persistent.first(5).map { |e| { name: e[:name], delta: e[:delta], pattern: e[:trend][:pattern] } }
168
+ }
169
+ }
170
+ end
171
+
172
+ # Extra deep enrichment for retention sessions (globals, fibers, aging, etc.).
173
+ def enrich_session(session, last_snapshot)
174
+ findings = []
175
+ limitations = []
176
+ cfg = @config
177
+
178
+ Noise.apply_defaults! if cfg.apply_noise_defaults
179
+
180
+ fibers = cfg.detect_fibers ? Fibers.inventory : []
181
+ globals = cfg.detect_globals ? Globals.inventory(max_entries: 100) : []
182
+ closures =
183
+ if cfg.detect_closures
184
+ Closures.inventory(limit: 50)
185
+ else
186
+ limitations << "closure_detection_disabled"
187
+ []
188
+ end
189
+ findings.concat(Closures.findings(closures)) if cfg.detect_closures
190
+
191
+ dominators =
192
+ begin
193
+ Dominators.new.from_thread_locals(limit: 5)
194
+ rescue StandardError => e
195
+ limitations << "dominators_unavailable:#{e.class}"
196
+ []
197
+ end
198
+
199
+ fragmentation = last_snapshot ? Fragmentation.assess(last_snapshot) : nil
200
+ if fragmentation && fragmentation[:status] == :potential_fragmentation
201
+ findings << Finding.new(
202
+ code: "HS010",
203
+ severity: :low,
204
+ facts: ["Observed fact: RSS/heap ratio indicator=#{fragmentation[:rss_to_heap_ratio]}."],
205
+ derived: ["Derived: status=#{fragmentation[:status]}."],
206
+ hypothesis: fragmentation[:note],
207
+ suggestions: ["Investigate allocator / native extensions before blaming Ruby objects"]
208
+ )
209
+ end
210
+
211
+ aging = Aging.new
212
+ session.samples.each_with_index do |sample, idx|
213
+ counts = sample.snapshot.class_stats.transform_values { |s| s[:count].to_i }
214
+ aging.push_counts(cycle: idx, counts: counts, generation_hint: sample.snapshot.gc_generation)
215
+ end
216
+
217
+ gc_correlation = {
218
+ samples: session.samples.size,
219
+ first_live: session.samples.first&.snapshot&.heap_live_slots,
220
+ last_live: session.samples.last&.snapshot&.heap_live_slots,
221
+ first_rss: session.samples.first&.snapshot&.rss_bytes,
222
+ last_rss: session.samples.last&.snapshot&.rss_bytes
223
+ }
224
+
225
+ retention_paths = []
226
+ if dominators.any?
227
+ retention_paths << {
228
+ summary: "Top approximate retainers from thread-locals",
229
+ retainers: dominators.map { |d| { class: d.class_name, approx_retained: d.approx_retained, note: d.note } }
230
+ }
231
+ end
232
+
233
+ {
234
+ findings: findings,
235
+ fibers: fibers,
236
+ globals: globals,
237
+ closures: closures.first(20),
238
+ dominators: dominators,
239
+ fragmentation: fragmentation,
240
+ aging: aging.report(top: 15),
241
+ gc_correlation: gc_correlation,
242
+ retention_paths: retention_paths,
243
+ limitations: limitations
244
+ }
245
+ end
246
+
247
+ def classify_population(delta, diff)
248
+ allocated = diff.allocated_delta.to_i
249
+ retained = delta[:delta_count]
250
+ ratio = allocated.positive? ? retained.to_f / allocated : nil
251
+
252
+ if ratio && ratio < CHURN_THRESHOLD && allocated > 10_000
253
+ :high_churn_low_retention
254
+ elsif ratio && ratio > STICKY_RETENTION
255
+ :sticky
256
+ elsif retained > 1000 && allocated > 10_000 && ratio && ratio > 0.1
257
+ :severe
258
+ else
259
+ :normal
260
+ end
261
+ end
262
+
263
+ def suspicion_for(delta, classification, context)
264
+ return :low if classification == :high_churn_low_retention
265
+
266
+ score = 0
267
+ score += 2 if delta[:delta_count] > 500
268
+ score += 2 if %i[sticky severe].include?(classification)
269
+ score += 1 if context[:force_gc]
270
+ score += 1 if context.dig(:survival_cycles, delta[:name]).to_i >= 3
271
+ score -= 2 if context.dig(:likely_cache, delta[:name])
272
+ score -= 1 if @config.ignore_class?(delta[:name])
273
+
274
+ case score
275
+ when ...1 then :low
276
+ when 1..3 then :medium
277
+ else :high
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ def high_churn?(diff)
284
+ allocated = diff.allocated_delta.to_i
285
+ freed = diff.freed_delta.to_i
286
+ allocated > 50_000 && freed > allocated * 0.9
287
+ end
288
+
289
+ def build_summary(diff, findings, suspects)
290
+ actionable = findings.reject { |f| f.code == "HS009" }
291
+ healthy = actionable.none? { |f| %i[high medium].include?(f.severity) } &&
292
+ suspects.none? { |s| s[:severity] == :high }
293
+ {
294
+ healthy: healthy,
295
+ rss_delta: diff.rss_delta,
296
+ heap_live_delta: diff.heap_live_delta,
297
+ retention_ratio: diff.retention_ratio,
298
+ top_suspects: suspects.first(5),
299
+ finding_codes: findings.map(&:code)
300
+ }
301
+ end
302
+
303
+ def severity_rank(sev)
304
+ { high: 3, medium: 2, low: 1 }[sev] || 0
305
+ end
306
+
307
+ def signed(n)
308
+ n.positive? ? "+#{n}" : n.to_s
309
+ end
310
+
311
+ def pct(ratio)
312
+ format("%.2f%%", ratio * 100.0)
313
+ end
314
+
315
+ def format_bytes(bytes)
316
+ return "n/a" if bytes.nil?
317
+
318
+ abs = bytes.abs.to_f
319
+ sign = if bytes.negative?
320
+ "-"
321
+ else
322
+ (bytes.positive? ? "+" : "")
323
+ end
324
+ if abs >= 1024 * 1024
325
+ format("%s%.2f MB", sign, abs / (1024 * 1024))
326
+ elsif abs >= 1024
327
+ format("%s%.1f KB", sign, abs / 1024)
328
+ else
329
+ format("%s%d B", sign, bytes)
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HeapScope
6
+ module Baseline
7
+ module_function
8
+
9
+ def create(report_or_path, output_path)
10
+ report = report_or_path.is_a?(Report) ? report_or_path : Report.load(report_or_path)
11
+ payload = {
12
+ schema_version: SCHEMA_VERSION,
13
+ kind: "baseline",
14
+ created_at: Time.now.utc.iso8601,
15
+ heapscope_version: VERSION,
16
+ summary: report.summary,
17
+ classes: top_classes(report),
18
+ rss_bytes: report.after&.rss_bytes || report.before&.rss_bytes,
19
+ heap_live_slots: report.after&.heap_live_slots || report.before&.heap_live_slots,
20
+ retained_objects_estimate: report.diff&.surviving_estimate,
21
+ retained_bytes_estimate: report.diff&.heap_bytes_estimate_delta
22
+ }
23
+ File.write(output_path, JSON.pretty_generate(payload))
24
+ output_path
25
+ end
26
+
27
+ def compare(baseline_path, current_path_or_report, threshold: 0.5)
28
+ baseline = JSON.parse(File.read(baseline_path), symbolize_names: true)
29
+ current = current_path_or_report.is_a?(Report) ? current_path_or_report : Report.load(current_path_or_report)
30
+
31
+ base_retained = baseline[:retained_objects_estimate].to_i
32
+ curr_retained = current.diff&.surviving_estimate.to_i
33
+ change = if base_retained.zero?
34
+ curr_retained.positive? ? 1.0 : 0.0
35
+ else
36
+ (curr_retained - base_retained).to_f / base_retained
37
+ end
38
+
39
+ base_bytes = baseline[:retained_bytes_estimate].to_i
40
+ curr_bytes = current.diff&.heap_bytes_estimate_delta.to_i
41
+ bytes_change = base_bytes.zero? ? 0.0 : (curr_bytes - base_bytes).to_f / base_bytes
42
+
43
+ regression = change > threshold || bytes_change > threshold
44
+ findings = []
45
+ if regression
46
+ findings << Finding.new(
47
+ code: "HS008",
48
+ severity: :high,
49
+ facts: [
50
+ "Observed fact: retained objects baseline=#{base_retained} current=#{curr_retained}.",
51
+ "Observed fact: retained bytes baseline=#{base_bytes} current=#{curr_bytes}."
52
+ ],
53
+ derived: [
54
+ "Derived: object change #{(change * 100).round(1)}%, bytes change #{(bytes_change * 100).round(1)}%."
55
+ ],
56
+ hypothesis: "Current run exceeds baseline retention beyond threshold #{threshold}."
57
+ )
58
+ end
59
+
60
+ {
61
+ regression: regression,
62
+ baseline_retained_objects: base_retained,
63
+ current_retained_objects: curr_retained,
64
+ object_change_ratio: change,
65
+ baseline_retained_bytes: base_bytes,
66
+ current_retained_bytes: curr_bytes,
67
+ bytes_change_ratio: bytes_change,
68
+ findings: findings,
69
+ result: regression ? "REGRESSION" : "OK"
70
+ }
71
+ end
72
+
73
+ def top_classes(report)
74
+ (report.classes || []).first(50)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Central brand + funding metadata for CLI, reports, and docs generation.
5
+ module Branding
6
+ NAME = "HeapScope"
7
+ TAGLINE = "Ruby retention & heap growth diagnostics"
8
+ GITHUB_USER = "theworker02"
9
+ GITHUB_REPO = "https://github.com/theworker02/heapscope"
10
+ PAGES_URL = "https://theworker02.github.io/heapscope/"
11
+ SPONSORS_URL = "https://github.com/sponsors/theworker02"
12
+ THANKS_DEV_URL = "https://thanks.dev/u/gh/theworker02"
13
+ THANKS_DEV_PATH = "u/gh/theworker02"
14
+ LOGO_SVG = "assets/logo.svg"
15
+ WORDMARK_SVG = "assets/wordmark.svg"
16
+ LOGO_PNG = "assets/heapscope-logo.png"
17
+
18
+ module_function
19
+
20
+ def banner
21
+ <<~BANNER
22
+ ██╗ ██╗███████╗ █████╗ ██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
23
+ ██║ ██║██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝
24
+ ███████║█████╗ ███████║██████╔╝███████╗██║ ██║ ██║██████╔╝█████╗
25
+ ██╔══██║██╔══╝ ██╔══██║██╔═══╝ ╚════██║██║ ██║ ██║██╔═══╝ ██╔══╝
26
+ ██║ ██║███████╗██║ ██║██║ ███████║╚██████╗╚██████╔╝██║ ███████╗
27
+ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝
28
+ #{TAGLINE} v#{VERSION}
29
+ BANNER
30
+ end
31
+
32
+ def compact_banner
33
+ "#{NAME} v#{VERSION} — #{TAGLINE}"
34
+ end
35
+
36
+ def funding_lines
37
+ [
38
+ "Sponsor: #{SPONSORS_URL}",
39
+ "thanks.dev: #{THANKS_DEV_URL}",
40
+ "Docs site: #{PAGES_URL}"
41
+ ]
42
+ end
43
+
44
+ def about_text
45
+ lines = []
46
+ lines << compact_banner
47
+ lines << ""
48
+ lines << "Repository: #{GITHUB_REPO}"
49
+ lines << "Website: #{PAGES_URL}"
50
+ funding_lines.each { |l| lines << l }
51
+ lines << ""
52
+ lines << "Privacy: local-only diagnostics — no telemetry, no object-value dumps by default."
53
+ lines.join("\n")
54
+ end
55
+
56
+ def report_footer
57
+ "Privacy: object values are not serialized by default. · #{THANKS_DEV_URL} · #{PAGES_URL}"
58
+ end
59
+
60
+ def html_footer
61
+ "Generated locally by #{NAME} · no network · no telemetry · " \
62
+ "<a href=\"#{THANKS_DEV_URL}\">thanks.dev</a> · " \
63
+ "<a href=\"#{PAGES_URL}\">docs</a>"
64
+ end
65
+
66
+ def gem_root
67
+ File.expand_path("../..", __dir__)
68
+ end
69
+
70
+ def asset_path(relative)
71
+ File.join(gem_root, relative)
72
+ end
73
+
74
+ def logo_svg_inline
75
+ path = asset_path(LOGO_SVG)
76
+ return nil unless File.file?(path)
77
+
78
+ File.read(path)
79
+ end
80
+
81
+ def to_h
82
+ {
83
+ name: NAME,
84
+ tagline: TAGLINE,
85
+ version: VERSION,
86
+ github_user: GITHUB_USER,
87
+ github_repo: GITHUB_REPO,
88
+ pages_url: PAGES_URL,
89
+ sponsors_url: SPONSORS_URL,
90
+ thanks_dev_url: THANKS_DEV_URL,
91
+ thanks_dev_path: THANKS_DEV_PATH,
92
+ logo_svg: LOGO_SVG,
93
+ wordmark_svg: WORDMARK_SVG,
94
+ logo_png: LOGO_PNG
95
+ }
96
+ end
97
+ end
98
+ end