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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ # Help text and per-command usage.
6
+ module Help
7
+ COMMANDS = {
8
+ "snapshot" => "Capture a heap snapshot to JSON",
9
+ "inspect" => "Inspect current process (embedded) or a saved capture",
10
+ "diff" => "Diff two snapshots (scorecard + table + report)",
11
+ "monitor" => "Sample the current process for a duration",
12
+ "watch" => "Monitor with anomaly alerts enabled (alias)",
13
+ "trends" => "Summarize a monitor/report timeline",
14
+ "report" => "Render a text/HTML/markdown/JSON report",
15
+ "compare" => "Compare a baseline to a current report",
16
+ "baseline" => "Create a baseline from a report",
17
+ "sessions" => "List saved .heapscope sessions",
18
+ "history" => "Show recent session reports",
19
+ "validate" => "Validate a report JSON schema",
20
+ "suggest" => "Suggest ignore_patterns from a report",
21
+ "ignore-suggest" => "Alias of suggest",
22
+ "pack" => "Export a local JSON+HTML+Markdown report bundle",
23
+ "export" => "Alias of pack",
24
+ "probe" => "Measure a Ruby file/snippet and print a scorecard",
25
+ "measure" => "CLI wrapper around HeapScope.measure",
26
+ "retention" => "CLI wrapper around HeapScope.retention_test",
27
+ "findings" => "List/filter findings from a report",
28
+ "scorecard" => "Print scorecard only from a report",
29
+ "table" => "Print growth table from a report",
30
+ "explain" => "Print diagnostic encyclopedia entry for a code",
31
+ "open" => "Write HTML for a report and print the path",
32
+ "html" => "Alias of open",
33
+ "self-test" => "Run a built-in retention demo and show findings",
34
+ "env" => "Print HeapScope-relevant environment variables",
35
+ "completion" => "Generate bash/zsh/powershell completion scripts",
36
+ "man" => "Extended about + examples",
37
+ "codes" => "List diagnostic codes (HS001–HS010)",
38
+ "doctor" => "Diagnose runtime capabilities & config",
39
+ "overhead" => "Measure HeapScope snapshot overhead",
40
+ "config" => "Load a YAML/Ruby config file",
41
+ "about" => "Branding, docs, and funding links",
42
+ "capabilities" => "Show runtime capabilities",
43
+ "version" => "Print version",
44
+ "help" => "Show help (or help <command>)"
45
+ }.freeze
46
+
47
+ module_function
48
+
49
+ def global_help
50
+ lines = []
51
+ lines << Branding.compact_banner
52
+ lines << ""
53
+ lines << "Usage:"
54
+ lines << " heapscope [global options] <command> [options]"
55
+ lines << ""
56
+ lines << "Global options:"
57
+ lines << " --verbose Verbose HeapScope logging"
58
+ lines << " --quiet Suppress non-essential output"
59
+ lines << " --config PATH Load YAML/Ruby config before command"
60
+ lines << " --json Prefer machine-readable JSON on key commands"
61
+ lines << " --no-color Disable ANSI colors"
62
+ lines << " --color Force ANSI colors"
63
+ lines << " -h, --help Show help"
64
+ lines << " -v, --version Print version"
65
+ lines << ""
66
+ lines << "Commands:"
67
+ COMMANDS.each do |name, desc|
68
+ lines << format(" %-14s %s", name, desc)
69
+ end
70
+ lines << ""
71
+ lines << "Exit codes:"
72
+ lines << " 0 success"
73
+ lines << " 1 regression / finding above threshold"
74
+ lines << " 2 invalid input/configuration"
75
+ lines << " 3 runtime capability unavailable"
76
+ lines << ""
77
+ lines << "Privacy: no network, no telemetry, no object value serialization by default."
78
+ lines << "Sponsor: #{Branding::THANKS_DEV_URL}"
79
+ lines << "Docs: #{Branding::PAGES_URL}"
80
+ lines << ""
81
+ lines << "Try: heapscope help <command> or heapscope <command> --help"
82
+ lines.join("\n")
83
+ end
84
+
85
+ def command_help(name)
86
+ key = name.to_s
87
+ desc = COMMANDS[key]
88
+ return nil unless desc
89
+
90
+ body = USAGE.fetch(key, "Usage: heapscope #{key} [options]\n\n#{desc}")
91
+ "#{Branding.compact_banner}\n\n#{body}\n"
92
+ end
93
+
94
+ USAGE = {
95
+ "snapshot" => <<~U,
96
+ Usage: heapscope snapshot [options]
97
+ --mode MODE lightweight|standard|deep
98
+ -o, --output PATH Output JSON path
99
+ --slim Slim JSON (top classes / truncated sites)
100
+ --force-gc Force GC before capture
101
+ --track-allocations Enable allocation tracing for this capture
102
+ U
103
+ "inspect" => <<~U,
104
+ Usage: heapscope inspect [capture.json] [options]
105
+ --mode MODE Snapshot mode when inspecting live process
106
+ --top N Limit class rows in derived views
107
+ --html PATH Also write HTML
108
+ --markdown Print Markdown instead of text
109
+ --json Print JSON
110
+ --fail-on-high Exit 1 on HIGH findings
111
+ U
112
+ "diff" => <<~U,
113
+ Usage: heapscope diff BEFORE.json AFTER.json [options]
114
+ -o, --output PATH Write report JSON
115
+ --html PATH Write HTML report
116
+ --markdown Print Markdown report body
117
+ --top N Limit growth table rows
118
+ --scorecard-only Print scorecard only
119
+ --fail-on-high Exit 1 if HIGH findings
120
+ --fail-on-medium Exit 1 if MEDIUM or HIGH
121
+ --json Print report JSON
122
+ U
123
+ "monitor" => <<~U,
124
+ Usage: heapscope monitor [options]
125
+ --interval SEC Sample interval (min 2)
126
+ --duration SEC Total duration
127
+ --mode MODE lightweight|standard
128
+ --alert Emit RSS / live-slot spike alerts
129
+ --rss-alert-bytes N RSS delta threshold (default 25MB)
130
+ --live-alert-slots N Live-slot delta threshold
131
+ -o, --output PATH Report JSON path
132
+ --html PATH Also write HTML
133
+ --fail-on-high Exit 1 on HIGH findings
134
+ U
135
+ "watch" => <<~U,
136
+ Usage: heapscope watch [options]
137
+ Same as `monitor`, with --alert enabled by default.
138
+ U
139
+ "probe" => <<~U,
140
+ Usage: heapscope probe --file script.rb [options]
141
+ heapscope probe --eval 'ruby code' [options]
142
+ --file PATH Load Ruby file inside measured block
143
+ --eval CODE Eval Ruby snippet inside measured block
144
+ --title NAME Scorecard title
145
+ --mode MODE Snapshot mode
146
+ --force-gc / --no-force-gc
147
+ -o, --output PATH Save report JSON
148
+ --html PATH Save HTML
149
+ --json Print JSON report
150
+ U
151
+ "measure" => <<~U,
152
+ Usage: heapscope measure --file script.rb [options]
153
+ --file PATH / --eval CODE
154
+ --mode MODE --force-gc --recovery-wait SEC
155
+ --track-allocations
156
+ -o, --output PATH --html PATH --json
157
+ --fail-on-high / --fail-on-medium
158
+ U
159
+ "retention" => <<~U,
160
+ Usage: heapscope retention --file script.rb [options]
161
+ --cycles N Retention cycles (default 5)
162
+ --file PATH / --eval CODE
163
+ --mode MODE --force-gc / --no-force-gc
164
+ -o, --output PATH --html PATH --json
165
+ --fail-on-high / --fail-on-medium
166
+ U
167
+ "findings" => <<~U,
168
+ Usage: heapscope findings REPORT.json [options]
169
+ --severity LEVEL Filter: low|medium|high
170
+ --code HS001 Filter by diagnostic code
171
+ --json Machine-readable output
172
+ U
173
+ "scorecard" => "Usage: heapscope scorecard REPORT.json [--json] [--title NAME]\n",
174
+ "table" => "Usage: heapscope table REPORT.json [--markdown] [--top N]\n",
175
+ "explain" => "Usage: heapscope explain CODE\n\nExample: heapscope explain HS001\n",
176
+ "open" => "Usage: heapscope open REPORT.json [-o out.html]\n",
177
+ "html" => "Usage: heapscope html REPORT.json [-o out.html]\n",
178
+ "self-test" => "Usage: heapscope self-test [--json] [-o report.json]\n\nRuns a built-in intentional retention demo (local only).\n",
179
+ "env" => "Usage: heapscope env [--json]\n",
180
+ "completion" => "Usage: heapscope completion bash|zsh|powershell\n",
181
+ "man" => "Usage: heapscope man\n\nExtended branding, privacy notes, and examples.\n",
182
+ "pack" => <<~U,
183
+ Usage: heapscope pack REPORT.json [options]
184
+ -o, --output DIR Output directory
185
+ --label NAME File basename
186
+ U
187
+ "export" => "Alias of pack. See: heapscope help pack\n",
188
+ "suggest" => <<~U,
189
+ Usage: heapscope suggest REPORT.json [options]
190
+ --json next_steps + ignore_patterns
191
+ --ignores-only Print ignore patterns only
192
+ U
193
+ "ignore-suggest" => "Alias of suggest. See: heapscope help suggest\n",
194
+ "report" => <<~U,
195
+ Usage: heapscope report REPORT.json [options]
196
+ --format FMT text|html|json|markdown|md
197
+ -o, --output PATH
198
+ U
199
+ "baseline" => "Usage: heapscope baseline create REPORT.json -o baseline.json\n",
200
+ "compare" => <<~U,
201
+ Usage: heapscope compare BASELINE.json CURRENT.json [options]
202
+ --threshold F Relative growth threshold (default 0.5)
203
+ --json Machine-readable result
204
+ U
205
+ "codes" => "Usage: heapscope codes\n",
206
+ "doctor" => <<~U,
207
+ Usage: heapscope doctor [options]
208
+ --json Machine-readable doctor payload
209
+ --fix Write starter heapscope.yml
210
+ --config-out PATH Path for --fix (default ./heapscope.yml)
211
+ --force Overwrite existing config with --fix
212
+ U
213
+ "overhead" => "Usage: heapscope overhead [--mode MODE] [--runs N] [--json]\n",
214
+ "config" => "Usage: heapscope config PATH\n",
215
+ "about" => "Usage: heapscope about [--json]\n",
216
+ "capabilities" => "Usage: heapscope capabilities [--json]\n",
217
+ "sessions" => "Usage: heapscope sessions\n",
218
+ "history" => "Usage: heapscope history [--limit N]\n",
219
+ "validate" => "Usage: heapscope validate REPORT.json\n",
220
+ "trends" => "Usage: heapscope trends REPORT.json\n",
221
+ "version" => "Usage: heapscope version\n",
222
+ "help" => "Usage: heapscope help [command]\n"
223
+ }.freeze
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ # Shared helpers for option parsing, output, and severity exits.
6
+ module Support
7
+ SEVERITY_RANK = { low: 1, medium: 2, high: 3 }.freeze
8
+
9
+ private
10
+
11
+ def color?
12
+ @color_enabled
13
+ end
14
+
15
+ def json_mode?
16
+ @json_mode
17
+ end
18
+
19
+ def quiet?
20
+ HeapScope.config.quiet
21
+ end
22
+
23
+ def say(msg = "")
24
+ return if quiet?
25
+
26
+ puts msg
27
+ end
28
+
29
+ def say_err(msg)
30
+ warn msg
31
+ end
32
+
33
+ def emit_json(payload)
34
+ puts JSON.pretty_generate(payload)
35
+ end
36
+
37
+ def colorize(text, code)
38
+ Color.wrap(text, code, enabled: color?)
39
+ end
40
+
41
+ def load_report!(path)
42
+ raise ArgumentError, "report path required" if path.nil? || path.empty?
43
+ raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
44
+
45
+ Report.load(path)
46
+ end
47
+
48
+ def load_json_path!(path)
49
+ raise ArgumentError, "path required" if path.nil? || path.empty?
50
+ raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
51
+
52
+ JSON.parse(File.read(path), symbolize_names: true)
53
+ end
54
+
55
+ def parse_fail_on!(options, opts)
56
+ opts.on("--fail-on-high", "Exit 1 if HIGH findings present") { options[:fail_on] = :high }
57
+ opts.on("--fail-on-medium", "Exit 1 if MEDIUM or HIGH findings present") { options[:fail_on] = :medium }
58
+ opts.on("--fail-on LEVEL", %w[low medium high], "Exit 1 if findings at/above LEVEL") do |level|
59
+ options[:fail_on] = level.to_sym
60
+ end
61
+ end
62
+
63
+ def exit_for_findings(report, fail_on: nil)
64
+ return EXIT_OK unless fail_on
65
+
66
+ threshold = SEVERITY_RANK[fail_on.to_sym]
67
+ raise ArgumentError, "invalid fail-on level: #{fail_on}" unless threshold
68
+
69
+ if report.findings.any? { |f| SEVERITY_RANK.fetch(f.severity.to_sym, 0) >= threshold }
70
+ EXIT_REGRESSION
71
+ else
72
+ EXIT_OK
73
+ end
74
+ end
75
+
76
+ def print_report_views(report, options)
77
+ if json_mode? || options[:json]
78
+ emit_json(report.to_h)
79
+ return
80
+ end
81
+
82
+ if options[:scorecard_only]
83
+ say report.scorecard(title: options[:title] || "HeapScope").to_text
84
+ return
85
+ end
86
+
87
+ return if quiet?
88
+
89
+ say report.scorecard(title: options[:title] || "HeapScope").to_text unless options[:no_scorecard]
90
+ if options[:table] != false && report.diff
91
+ say
92
+ say report.table(format: options[:markdown] ? :markdown : :ascii)
93
+ end
94
+ say
95
+ if options[:markdown]
96
+ puts report.to_markdown
97
+ else
98
+ puts report.to_text
99
+ end
100
+ end
101
+
102
+ def write_outputs(report, options)
103
+ report.save(options[:output]) if options[:output]
104
+ report.save_html(options[:html]) if options[:html]
105
+ report.save_markdown(options[:markdown_out]) if options[:markdown_out]
106
+ end
107
+
108
+ def run_file_or_eval!(options)
109
+ if options[:file]
110
+ raise ArgumentError, "File not found: #{options[:file]}" unless File.exist?(options[:file])
111
+
112
+ load(options[:file])
113
+ elsif options[:eval]
114
+ # Intentional: CLI probe/measure of user-provided snippet in local process only.
115
+ eval(options[:eval], TOPLEVEL_BINDING, "(heapscope-eval)") # rubocop:disable Security/Eval
116
+ else
117
+ raise ArgumentError, "provide --file PATH or --eval 'ruby'"
118
+ end
119
+ end
120
+
121
+ def command_wants_help?(argv)
122
+ argv.any? { |a| a == "-h" || a == "--help" }
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "fileutils"
6
+
7
+ require_relative "cli/color"
8
+ require_relative "cli/support"
9
+ require_relative "cli/help"
10
+ require_relative "cli/completion"
11
+ require_relative "cli/commands/capture"
12
+ require_relative "cli/commands/diffing"
13
+ require_relative "cli/commands/reporting"
14
+ require_relative "cli/commands/meta"
15
+
16
+ module HeapScope
17
+ # Polished, expansive CLI for HeapScope diagnostics (local-only).
18
+ class CLI
19
+ include Support
20
+ include Commands::Capture
21
+ include Commands::Diffing
22
+ include Commands::Reporting
23
+ include Commands::Meta
24
+
25
+ EXIT_OK = 0
26
+ EXIT_REGRESSION = 1
27
+ EXIT_INVALID = 2
28
+ EXIT_CAPABILITY = 3
29
+
30
+ COMMAND_MAP = {
31
+ "snapshot" => :cmd_snapshot,
32
+ "inspect" => :cmd_inspect,
33
+ "diff" => :cmd_diff,
34
+ "monitor" => :cmd_monitor,
35
+ "watch" => :cmd_watch,
36
+ "trends" => :cmd_trends,
37
+ "report" => :cmd_report,
38
+ "compare" => :cmd_compare,
39
+ "baseline" => :cmd_baseline,
40
+ "sessions" => :cmd_sessions,
41
+ "history" => :cmd_history,
42
+ "validate" => :cmd_validate,
43
+ "suggest" => :cmd_suggest,
44
+ "ignore-suggest" => :cmd_ignore_suggest,
45
+ "pack" => :cmd_pack,
46
+ "export" => :cmd_export,
47
+ "probe" => :cmd_probe,
48
+ "measure" => :cmd_measure,
49
+ "retention" => :cmd_retention,
50
+ "findings" => :cmd_findings,
51
+ "scorecard" => :cmd_scorecard,
52
+ "table" => :cmd_table,
53
+ "explain" => :cmd_explain,
54
+ "open" => :cmd_open,
55
+ "html" => :cmd_html,
56
+ "self-test" => :cmd_self_test,
57
+ "env" => :cmd_env,
58
+ "completion" => :cmd_completion,
59
+ "man" => :cmd_man,
60
+ "codes" => :cmd_codes,
61
+ "doctor" => :cmd_doctor,
62
+ "overhead" => :cmd_overhead,
63
+ "config" => :cmd_config,
64
+ "about" => :cmd_about,
65
+ "capabilities" => :cmd_capabilities
66
+ }.freeze
67
+
68
+ def self.run(argv = ARGV)
69
+ new.run(argv)
70
+ end
71
+
72
+ def initialize
73
+ @json_mode = false
74
+ @color_enabled = Color.enabled?
75
+ end
76
+
77
+ def run(argv)
78
+ argv = argv.map(&:to_s)
79
+ parse_globals!(argv)
80
+
81
+ command = argv.shift
82
+ case command
83
+ when "version", "-v", "--version"
84
+ puts "heapscope #{VERSION}"
85
+ EXIT_OK
86
+ when "help", "-h", "--help", nil
87
+ topic = argv.shift
88
+ if topic && Help::COMMANDS.key?(topic)
89
+ puts Help.command_help(topic)
90
+ EXIT_OK
91
+ else
92
+ print_help
93
+ command.nil? ? EXIT_INVALID : EXIT_OK
94
+ end
95
+ else
96
+ method = COMMAND_MAP[command]
97
+ unless method
98
+ say_err Color.err("Unknown command: #{command}", enabled: color?)
99
+ print_help
100
+ return EXIT_INVALID
101
+ end
102
+ send(method, argv)
103
+ end
104
+ rescue CapabilityError => e
105
+ say_err "Capability unavailable: #{e.message}"
106
+ EXIT_CAPABILITY
107
+ rescue InvalidReportError, SnapshotError, ConfigurationError, ArgumentError => e
108
+ say_err "Invalid input: #{e.message}"
109
+ EXIT_INVALID
110
+ rescue BudgetExceededError => e
111
+ say_err e.message
112
+ EXIT_REGRESSION
113
+ end
114
+
115
+ private
116
+
117
+ def parse_globals!(argv)
118
+ loop do
119
+ break if argv.empty?
120
+
121
+ case argv.first
122
+ when "--verbose"
123
+ argv.shift
124
+ HeapScope.config.verbose = true
125
+ when "--quiet"
126
+ argv.shift
127
+ HeapScope.config.quiet = true
128
+ when "--json"
129
+ argv.shift
130
+ @json_mode = true
131
+ when "--no-color"
132
+ argv.shift
133
+ @color_enabled = false
134
+ when "--color"
135
+ argv.shift
136
+ @color_enabled = true
137
+ when "--config"
138
+ argv.shift
139
+ path = argv.shift or raise ArgumentError, "--config requires PATH"
140
+ HeapScope.load_config!(path)
141
+ when /\A--config=(.+)\z/
142
+ argv.shift
143
+ HeapScope.load_config!(::Regexp.last_match(1))
144
+ else
145
+ break
146
+ end
147
+ end
148
+ end
149
+
150
+ def print_help
151
+ puts Help.global_help
152
+ end
153
+
154
+ def help_exit(command)
155
+ text = Help.command_help(command)
156
+ if text
157
+ puts text
158
+ EXIT_OK
159
+ else
160
+ print_help
161
+ EXIT_INVALID
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Conservative Proc/closure retention analysis.
5
+ # Ruby does not expose captured bindings cleanly — treat results as hypotheses.
6
+ module Closures
7
+ module_function
8
+
9
+ def inventory(limit: 200)
10
+ procs = []
11
+ return procs unless Runtime.current.each_object?
12
+
13
+ Runtime.current.each_object(Proc) do |proc|
14
+ break if procs.size >= limit
15
+
16
+ info = Runtime.current.allocation_info(proc)
17
+ retained = begin
18
+ Graph.new.estimate_retained_size(proc, max_objects: 500, max_depth: 4)
19
+ rescue StandardError
20
+ { retained: nil }
21
+ end
22
+ procs << {
23
+ class: "Proc",
24
+ lambda: proc.lambda?,
25
+ arity: begin
26
+ proc.arity
27
+ rescue StandardError
28
+ nil
29
+ end,
30
+ allocation: info,
31
+ shallow_bytes: Runtime.current.memsize_of(proc),
32
+ approx_retained: retained[:retained],
33
+ approximate: true,
34
+ note: "Closure captures are not fully introspectable — retained size is approximate."
35
+ }
36
+ end
37
+ procs
38
+ rescue StandardError
39
+ []
40
+ end
41
+
42
+ def findings(procs)
43
+ procs.select { |p| p[:approx_retained].to_i > 500_000 }.map do |p|
44
+ site = p.dig(:allocation, :file) && "#{p[:allocation][:file]}:#{p[:allocation][:line]}"
45
+ Finding.new(
46
+ code: "HS006",
47
+ severity: :medium,
48
+ subject: site || "Proc",
49
+ facts: [
50
+ "Observed fact: Proc approx retained #{p[:approx_retained]} bytes" \
51
+ "#{site ? " allocated at #{site}" : ""}."
52
+ ],
53
+ derived: ["Derived: bounded retained-size estimate exceeded 500KB."],
54
+ hypothesis: "A long-lived Proc may be capturing a large object graph.",
55
+ suspected_cause: site,
56
+ suggestions: [
57
+ "Avoid capturing large objects in long-lived callbacks",
58
+ "Prefer weak refs or explicit clearable context objects"
59
+ ],
60
+ evidence: [p]
61
+ )
62
+ end
63
+ end
64
+ end
65
+
66
+ # Fiber / execution-context awareness (best-effort).
67
+ module Fibers
68
+ module_function
69
+
70
+ def inventory
71
+ list = []
72
+ if defined?(Fiber) && Fiber.respond_to?(:list)
73
+ Fiber.list.each do |fiber|
74
+ list << fiber_info(fiber)
75
+ end
76
+ elsif defined?(Fiber)
77
+ list << fiber_info(Fiber.current)
78
+ end
79
+ list
80
+ rescue StandardError => e
81
+ [{ error: e.message }]
82
+ end
83
+
84
+ def fiber_info(fiber)
85
+ storage =
86
+ if fiber.respond_to?(:storage)
87
+ fiber.storage
88
+ else
89
+ {}
90
+ end
91
+ {
92
+ object_id: fiber.__id__,
93
+ alive: (fiber.alive? if fiber.respond_to?(:alive?)),
94
+ storage_keys: storage.respond_to?(:keys) ? storage.keys.map(&:inspect) : [],
95
+ storage_size: storage.respond_to?(:size) ? storage.size : nil,
96
+ note: "Fiber-local storage support varies by Ruby version."
97
+ }
98
+ rescue StandardError => e
99
+ { error: e.message }
100
+ end
101
+ end
102
+ end