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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Explicit memory budgets for CI and regression tests. Integers only — no AS core_ext required.
5
+ class Budget
6
+ PRESETS = {
7
+ rails_request: {
8
+ max_retained_objects: 5_000,
9
+ max_retained_bytes: 8 * 1024 * 1024,
10
+ max_retention_ratio: 0.15,
11
+ max_rss_growth: 20 * 1024 * 1024,
12
+ max_heap_live_growth: 50_000,
13
+ severity_threshold: :high
14
+ },
15
+ sidekiq_job: {
16
+ max_retained_objects: 10_000,
17
+ max_retained_bytes: 16 * 1024 * 1024,
18
+ max_retention_ratio: 0.20,
19
+ max_rss_growth: 50 * 1024 * 1024,
20
+ max_heap_live_growth: 100_000,
21
+ severity_threshold: :high
22
+ },
23
+ ci_strict: {
24
+ max_retained_objects: 500,
25
+ max_retained_bytes: 2 * 1024 * 1024,
26
+ max_retention_ratio: 0.05,
27
+ max_rss_growth: 5 * 1024 * 1024,
28
+ max_heap_live_growth: 5_000,
29
+ severity_threshold: :medium
30
+ }
31
+ }.freeze
32
+
33
+ attr_reader :max_retained_objects, :max_retained_bytes, :max_retention_ratio,
34
+ :max_rss_growth, :max_heap_live_growth, :severity_threshold, :preset_name
35
+
36
+ def self.preset(name)
37
+ key = name.to_sym
38
+ attrs = PRESETS[key]
39
+ raise ArgumentError, "Unknown budget preset: #{name} (#{PRESETS.keys.join(', ')})" unless attrs
40
+
41
+ new(**attrs, preset_name: key)
42
+ end
43
+
44
+ def self.presets
45
+ PRESETS.keys
46
+ end
47
+
48
+ def initialize(max_retained_objects: nil, max_retained_bytes: nil,
49
+ max_retention_ratio: nil, max_rss_growth: nil,
50
+ max_heap_live_growth: nil, severity_threshold: :high,
51
+ preset_name: nil)
52
+ @max_retained_objects = max_retained_objects
53
+ @max_retained_bytes = max_retained_bytes
54
+ @max_retention_ratio = max_retention_ratio
55
+ @max_rss_growth = max_rss_growth
56
+ @max_heap_live_growth = max_heap_live_growth
57
+ @severity_threshold = severity_threshold
58
+ @preset_name = preset_name
59
+ end
60
+
61
+ def evaluate(report)
62
+ violations = []
63
+ diff = report.diff
64
+
65
+ if max_retained_objects && diff
66
+ surviving = diff.surviving_estimate || report.classes.sum { |c| [c[:delta_count].to_i, 0].max }
67
+ violations << "Retained objects #{surviving} exceed budget #{max_retained_objects}" if surviving && surviving > max_retained_objects
68
+ end
69
+
70
+ if max_retained_bytes && diff
71
+ bytes = diff.heap_bytes_estimate_delta
72
+ violations << "Retained bytes #{bytes} exceed budget #{max_retained_bytes}" if bytes > max_retained_bytes
73
+ end
74
+
75
+ if max_retention_ratio && diff&.retention_ratio && (diff.retention_ratio > max_retention_ratio)
76
+ violations << "Retention ratio #{diff.retention_ratio} exceeds budget #{max_retention_ratio}"
77
+ end
78
+
79
+ violations << "RSS growth #{diff.rss_delta} exceeds budget #{max_rss_growth}" if max_rss_growth && diff&.rss_delta && diff.rss_delta > max_rss_growth
80
+
81
+ if max_heap_live_growth && diff&.heap_live_delta && diff.heap_live_delta > max_heap_live_growth
82
+ violations << "Heap live growth #{diff.heap_live_delta} exceeds budget #{max_heap_live_growth}"
83
+ end
84
+
85
+ rank = { low: 1, medium: 2, high: 3 }
86
+ threshold = rank[severity_threshold] || 3
87
+ report.findings.each do |f|
88
+ next if (rank[f.severity] || 0) < threshold
89
+
90
+ violations << "Finding #{f.code} severity #{f.severity} at/above threshold #{severity_threshold}"
91
+ end
92
+
93
+ { passed: violations.empty?, violations: violations, preset: preset_name }
94
+ end
95
+
96
+ def to_h
97
+ {
98
+ preset: preset_name,
99
+ max_retained_objects: max_retained_objects,
100
+ max_retained_bytes: max_retained_bytes,
101
+ max_retention_ratio: max_retention_ratio,
102
+ max_rss_growth: max_rss_growth,
103
+ max_heap_live_growth: max_heap_live_growth,
104
+ severity_threshold: severity_threshold
105
+ }.compact
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Runtime capability probe. Never claims unavailable features exist.
5
+ class Capabilities
6
+ ATTRS = %i[
7
+ engine
8
+ ruby_version
9
+ allocation_tracing
10
+ reachable_objects
11
+ memsize
12
+ rss_tracking
13
+ each_object
14
+ gc_stat
15
+ dump_all
16
+ object_id_stable_across_restart
17
+ ].freeze
18
+
19
+ attr_reader(*ATTRS)
20
+
21
+ def initialize(runtime)
22
+ @engine = runtime.engine
23
+ @ruby_version = RUBY_VERSION
24
+ @allocation_tracing = runtime.allocation_tracing?
25
+ @reachable_objects = runtime.reachable_objects?
26
+ @memsize = runtime.memsize?
27
+ @rss_tracking = runtime.rss_tracking?
28
+ @each_object = runtime.each_object?
29
+ @gc_stat = runtime.gc_stat?
30
+ @dump_all = false
31
+ @object_id_stable_across_restart = false
32
+ end
33
+
34
+ def to_h
35
+ ATTRS.to_h { |key| [key, public_send(key)] }
36
+ end
37
+
38
+ def to_s
39
+ to_h.map { |k, v| "#{k}: #{v}" }.join("\n")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Catalog of diagnostic codes for CLI / docs generation.
5
+ module Catalog
6
+ module_function
7
+
8
+ def codes
9
+ Diagnostics::CODES.values
10
+ end
11
+
12
+ def lookup(code)
13
+ key = normalize_code(code)
14
+ Diagnostics::CODES[key]
15
+ end
16
+
17
+ def explain(code)
18
+ meta = lookup(code)
19
+ raise ArgumentError, "Unknown diagnostic code: #{code}" unless meta
20
+
21
+ doc = encyclopedia_text(meta)
22
+ return doc if doc
23
+
24
+ fallback_text(meta)
25
+ end
26
+
27
+ def to_text
28
+ lines = [Branding.compact_banner, "", "Diagnostic codes", ""]
29
+ codes.each do |c|
30
+ lines << "#{c[:id]} #{c[:name]}"
31
+ lines << " #{c[:title]} (default severity: #{c[:default_severity]})"
32
+ lines << ""
33
+ end
34
+ lines << Branding.funding_lines.join("\n")
35
+ lines.join("\n")
36
+ end
37
+
38
+ def normalize_code(code)
39
+ raw = code.to_s.strip.upcase
40
+ return raw if Diagnostics::CODES.key?(raw)
41
+ return "HS#{raw}" if raw.match?(/\A\d{3}\z/)
42
+ return format("HS%03d", raw.to_i) if raw.match?(/\A\d+\z/)
43
+
44
+ raw
45
+ end
46
+
47
+ def encyclopedia_path(meta)
48
+ root = File.expand_path("../../..", __dir__)
49
+ File.join(root, "docs", "diagnostics", "#{meta[:id]}_#{meta[:name]}.md")
50
+ end
51
+
52
+ def encyclopedia_text(meta)
53
+ path = encyclopedia_path(meta)
54
+ return nil unless File.file?(path)
55
+
56
+ File.read(path)
57
+ end
58
+
59
+ def fallback_text(meta)
60
+ <<~TEXT
61
+ #{meta[:id]} — #{meta[:name]}
62
+ Title: #{meta[:title]}
63
+ Default severity: #{meta[:default_severity]}
64
+
65
+ See docs/diagnostics/#{meta[:id]}_#{meta[:name]}.md when packaged with the gem.
66
+ #{Branding.funding_lines.join("\n")}
67
+ TEXT
68
+ end
69
+ private_class_method :encyclopedia_path, :encyclopedia_text, :fallback_text
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ # Optional ANSI color for TTY output. Respects NO_COLOR and --no-color.
6
+ module Color
7
+ RESET = "\e[0m"
8
+ BOLD = "\e[1m"
9
+ DIM = "\e[2m"
10
+ RED = "\e[31m"
11
+ GREEN = "\e[32m"
12
+ YELLOW = "\e[33m"
13
+ CYAN = "\e[36m"
14
+ MAGENTA = "\e[35m"
15
+
16
+ module_function
17
+
18
+ def enabled?(force: nil)
19
+ return force unless force.nil?
20
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
21
+ return false if ENV["TERM"].to_s == "dumb"
22
+
23
+ $stdout.tty?
24
+ rescue StandardError
25
+ false
26
+ end
27
+
28
+ def wrap(text, code, enabled:)
29
+ return text.to_s unless enabled && code
30
+
31
+ "#{code}#{text}#{RESET}"
32
+ end
33
+
34
+ def severity(sev, enabled:)
35
+ s = sev.to_s.upcase
36
+ code =
37
+ case sev.to_s.downcase.to_sym
38
+ when :high then RED
39
+ when :medium then YELLOW
40
+ when :low then CYAN
41
+ else DIM
42
+ end
43
+ wrap(s, code, enabled: enabled)
44
+ end
45
+
46
+ def ok(text, enabled:)
47
+ wrap(text, GREEN, enabled: enabled)
48
+ end
49
+
50
+ def warn_text(text, enabled:)
51
+ wrap(text, YELLOW, enabled: enabled)
52
+ end
53
+
54
+ def err(text, enabled:)
55
+ wrap(text, RED, enabled: enabled)
56
+ end
57
+
58
+ def accent(text, enabled:)
59
+ wrap(text, CYAN, enabled: enabled)
60
+ end
61
+
62
+ def bold(text, enabled:)
63
+ wrap(text, BOLD, enabled: enabled)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ # Capture / live-process commands.
6
+ module Commands
7
+ module Capture
8
+ private
9
+
10
+ def cmd_snapshot(argv)
11
+ return help_exit("snapshot") if command_wants_help?(argv)
12
+
13
+ options = { mode: :standard, output: "heapscope-snapshot.json", slim: false }
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: heapscope snapshot [options]"
16
+ opts.on("--mode MODE", %w[lightweight standard deep], "Snapshot mode") { |m| options[:mode] = m.to_sym }
17
+ opts.on("-o", "--output PATH", "Output JSON path") { |p| options[:output] = p }
18
+ opts.on("--slim", "Write slim JSON (top classes only)") { options[:slim] = true }
19
+ opts.on("--force-gc", "Force GC before capture") { options[:force_gc] = true }
20
+ opts.on("--track-allocations", "Enable allocation tracing for this capture") { options[:track_allocations] = true }
21
+ opts.on("--verbose", "Verbose logging") { HeapScope.config.verbose = true }
22
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
23
+ end.parse!(argv)
24
+ return help_exit("snapshot") if options[:help]
25
+
26
+ HeapScope.config.track_allocations = true if options[:track_allocations]
27
+ GC.start if options[:force_gc]
28
+ snap = HeapScope.snapshot(mode: options[:mode])
29
+ snap.save(options[:output], slim: options[:slim])
30
+ if json_mode?
31
+ emit_json(snap.to_h(slim: options[:slim]).merge(saved_to: options[:output]))
32
+ else
33
+ say "Wrote snapshot #{snap.id} (mode=#{snap.mode}#{options[:slim] ? ', slim' : ''}) to #{options[:output]}"
34
+ say "Limitations: #{snap.limitations.join(', ')}" unless snap.limitations.empty?
35
+ end
36
+ EXIT_OK
37
+ end
38
+
39
+ def cmd_inspect(argv)
40
+ return help_exit("inspect") if command_wants_help?(argv)
41
+
42
+ options = { mode: :standard, top: 30 }
43
+ OptionParser.new do |opts|
44
+ opts.banner = "Usage: heapscope inspect [capture.json] [options]"
45
+ opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
46
+ opts.on("--html PATH", "Also write HTML") { |p| options[:html] = p }
47
+ opts.on("--top N", Integer, "Top class rows") { |n| options[:top] = n }
48
+ opts.on("--markdown", "Markdown output") { options[:markdown] = true }
49
+ opts.on("--json", "JSON output") { options[:json] = true }
50
+ parse_fail_on!(options, opts)
51
+ opts.on("-h", "--help") { options[:help] = true }
52
+ end.parse!(argv)
53
+ return help_exit("inspect") if options[:help]
54
+
55
+ path = argv.shift
56
+ report =
57
+ if path
58
+ raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
59
+
60
+ data = JSON.parse(File.read(path), symbolize_names: true)
61
+ if data[:schema_version] && data[:findings]
62
+ Report.from_h(data)
63
+ else
64
+ snap = Snapshot.from_h(data)
65
+ Report.new(
66
+ summary: { healthy: true },
67
+ after: snap,
68
+ classes: snap.top_classes(options[:top]),
69
+ metadata: { kind: "inspect_capture" },
70
+ limitations: snap.limitations,
71
+ runtime_info: { ruby: RUBY_VERSION, engine: RUBY_ENGINE }
72
+ )
73
+ end
74
+ else
75
+ say_err "Note: remote PID attachment is not supported; inspecting current process." unless quiet?
76
+ before = HeapScope.snapshot(mode: :lightweight)
77
+ GC.start
78
+ after = HeapScope.snapshot(mode: options[:mode])
79
+ HeapScope.compare(before, after, metadata: { kind: "inspect" })
80
+ end
81
+
82
+ print_report_views(report, options)
83
+ write_outputs(report, options)
84
+ exit_for_findings(report, fail_on: options[:fail_on])
85
+ end
86
+
87
+ def cmd_monitor(argv)
88
+ return help_exit("monitor") if command_wants_help?(argv)
89
+
90
+ options = {
91
+ interval: 10,
92
+ duration: 60,
93
+ mode: :lightweight,
94
+ output: "heapscope-monitor.json",
95
+ alert: false,
96
+ rss_alert_bytes: 25 * 1024 * 1024,
97
+ live_alert_slots: 80_000
98
+ }
99
+ OptionParser.new do |opts|
100
+ opts.banner = "Usage: heapscope monitor [options]"
101
+ opts.on("--interval SEC", Integer) { |n| options[:interval] = n }
102
+ opts.on("--duration SEC", Integer) { |n| options[:duration] = n }
103
+ opts.on("--mode MODE", %w[lightweight standard]) { |m| options[:mode] = m.to_sym }
104
+ opts.on("--alert", "Emit anomaly alerts on RSS/live-slot spikes") { options[:alert] = true }
105
+ opts.on("--rss-alert-bytes N", Integer) { |n| options[:rss_alert_bytes] = n }
106
+ opts.on("--live-alert-slots N", Integer) { |n| options[:live_alert_slots] = n }
107
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
108
+ opts.on("--html PATH") { |p| options[:html] = p }
109
+ parse_fail_on!(options, opts)
110
+ opts.on("-h", "--help") { options[:help] = true }
111
+ end.parse!(argv)
112
+ return help_exit("monitor") if options[:help]
113
+
114
+ raise ArgumentError, "interval too aggressive (min 2s)" if options[:interval] < 2
115
+
116
+ monitor = Monitor.start(
117
+ interval: options[:interval],
118
+ mode: options[:mode],
119
+ alert: options[:alert],
120
+ rss_alert_bytes: options[:rss_alert_bytes],
121
+ live_alert_slots: options[:live_alert_slots]
122
+ )
123
+ say "Monitoring for #{options[:duration]}s every #{options[:interval]}s#{options[:alert] ? ' (alerts on)' : ''}..."
124
+ sleep options[:duration]
125
+ report = monitor.stop
126
+ report.save(options[:output])
127
+ report.save_html(options[:html]) if options[:html]
128
+ if json_mode?
129
+ emit_json(report.to_h.merge(saved_to: options[:output]))
130
+ else
131
+ puts report.to_text unless quiet?
132
+ say "Wrote #{options[:output]}"
133
+ say "Alerts: #{monitor.alerts.size}" if options[:alert]
134
+ end
135
+ exit_for_findings(report, fail_on: options[:fail_on])
136
+ end
137
+
138
+ def cmd_watch(argv)
139
+ argv = ["--alert", *argv] unless argv.include?("--alert") || argv.include?("--help") || argv.include?("-h")
140
+ cmd_monitor(argv)
141
+ end
142
+
143
+ def cmd_probe(argv)
144
+ return help_exit("probe") if command_wants_help?(argv)
145
+
146
+ options = { title: "probe", mode: :lightweight, force_gc: true }
147
+ OptionParser.new do |opts|
148
+ opts.banner = "Usage: heapscope probe --file script.rb|--eval CODE [options]"
149
+ opts.on("--file PATH") { |p| options[:file] = p }
150
+ opts.on("--eval CODE") { |c| options[:eval] = c }
151
+ opts.on("--title NAME") { |t| options[:title] = t }
152
+ opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
153
+ opts.on("--force-gc") { options[:force_gc] = true }
154
+ opts.on("--no-force-gc") { options[:force_gc] = false }
155
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
156
+ opts.on("--html PATH") { |p| options[:html] = p }
157
+ opts.on("--json") { options[:json] = true }
158
+ parse_fail_on!(options, opts)
159
+ opts.on("-h", "--help") { options[:help] = true }
160
+ end.parse!(argv)
161
+ return help_exit("probe") if options[:help]
162
+
163
+ result = HeapScope.probe(
164
+ title: options[:title],
165
+ force_gc: options[:force_gc],
166
+ mode: options[:mode],
167
+ print: !(json_mode? || options[:json] || quiet?)
168
+ ) { run_file_or_eval!(options) }
169
+
170
+ report = result.report
171
+ write_outputs(report, options)
172
+ emit_json(report.scorecard(title: options[:title]).to_h.merge(report: report.to_h)) if json_mode? || options[:json]
173
+ exit_for_findings(report, fail_on: options[:fail_on])
174
+ end
175
+
176
+ def cmd_measure(argv)
177
+ return help_exit("measure") if command_wants_help?(argv)
178
+
179
+ options = { mode: :standard, force_gc: HeapScope.config.force_gc_default }
180
+ OptionParser.new do |opts|
181
+ opts.banner = "Usage: heapscope measure --file script.rb|--eval CODE [options]"
182
+ opts.on("--file PATH") { |p| options[:file] = p }
183
+ opts.on("--eval CODE") { |c| options[:eval] = c }
184
+ opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
185
+ opts.on("--force-gc") { options[:force_gc] = true }
186
+ opts.on("--no-force-gc") { options[:force_gc] = false }
187
+ opts.on("--recovery-wait SEC", Float) { |n| options[:recovery_wait] = n }
188
+ opts.on("--track-allocations") { options[:track_allocations] = true }
189
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
190
+ opts.on("--html PATH") { |p| options[:html] = p }
191
+ opts.on("--markdown") { options[:markdown] = true }
192
+ opts.on("--json") { options[:json] = true }
193
+ parse_fail_on!(options, opts)
194
+ opts.on("-h", "--help") { options[:help] = true }
195
+ end.parse!(argv)
196
+ return help_exit("measure") if options[:help]
197
+
198
+ report = HeapScope.measure(
199
+ force_gc: options[:force_gc],
200
+ mode: options[:mode],
201
+ recovery_wait: options[:recovery_wait],
202
+ track_allocations: options[:track_allocations] || HeapScope.config.track_allocations,
203
+ metadata: { kind: "cli_measure" }
204
+ ) { run_file_or_eval!(options) }
205
+
206
+ print_report_views(report, options)
207
+ write_outputs(report, options)
208
+ exit_for_findings(report, fail_on: options[:fail_on])
209
+ end
210
+
211
+ def cmd_retention(argv)
212
+ return help_exit("retention") if command_wants_help?(argv)
213
+
214
+ options = { cycles: 5, mode: :lightweight, force_gc: true }
215
+ OptionParser.new do |opts|
216
+ opts.banner = "Usage: heapscope retention --file script.rb|--eval CODE [options]"
217
+ opts.on("--cycles N", Integer) { |n| options[:cycles] = n }
218
+ opts.on("--file PATH") { |p| options[:file] = p }
219
+ opts.on("--eval CODE") { |c| options[:eval] = c }
220
+ opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
221
+ opts.on("--force-gc") { options[:force_gc] = true }
222
+ opts.on("--no-force-gc") { options[:force_gc] = false }
223
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
224
+ opts.on("--html PATH") { |p| options[:html] = p }
225
+ opts.on("--markdown") { options[:markdown] = true }
226
+ opts.on("--json") { options[:json] = true }
227
+ parse_fail_on!(options, opts)
228
+ opts.on("-h", "--help") { options[:help] = true }
229
+ end.parse!(argv)
230
+ return help_exit("retention") if options[:help]
231
+
232
+ report = HeapScope.retention_test(
233
+ cycles: options[:cycles],
234
+ force_gc: options[:force_gc],
235
+ mode: options[:mode],
236
+ metadata: { kind: "cli_retention" }
237
+ ) { run_file_or_eval!(options) }
238
+
239
+ print_report_views(report, options)
240
+ write_outputs(report, options)
241
+ exit_for_findings(report, fail_on: options[:fail_on])
242
+ end
243
+
244
+ def cmd_self_test(argv)
245
+ return help_exit("self-test") if command_wants_help?(argv)
246
+
247
+ options = {}
248
+ OptionParser.new do |opts|
249
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
250
+ opts.on("--html PATH") { |p| options[:html] = p }
251
+ opts.on("--json") { options[:json] = true }
252
+ opts.on("-h", "--help") { options[:help] = true }
253
+ end.parse!(argv)
254
+ return help_exit("self-test") if options[:help]
255
+
256
+ leak = []
257
+ result = HeapScope.probe(
258
+ title: "self-test retention fixture",
259
+ force_gc: true,
260
+ mode: :lightweight,
261
+ print: !(json_mode? || options[:json] || quiet?)
262
+ ) do
263
+ 800.times { leak << ("heapscope-self-test-" * 4) }
264
+ end
265
+
266
+ report = result.report
267
+ write_outputs(report, options)
268
+ if json_mode? || options[:json]
269
+ emit_json(
270
+ scorecard: report.scorecard(title: "self-test").to_h,
271
+ findings: report.findings.map(&:to_h),
272
+ note: "Intentional retention demo; leak array held #{leak.size} strings."
273
+ )
274
+ else
275
+ say
276
+ say Color.accent("Self-test complete (intentional retention; local-only).", enabled: color?)
277
+ say "Findings: #{report.findings.size} Suspects: #{report.suspects.size}"
278
+ report.findings.each do |f|
279
+ sev = Color.severity(f.severity, enabled: color?)
280
+ say " #{f.code} [#{sev}] #{f.title}"
281
+ end
282
+ say "Privacy: no network, no telemetry."
283
+ end
284
+ EXIT_OK
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end