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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ module Commands
6
+ # Diff / compare / baseline / trends.
7
+ module Diffing
8
+ private
9
+
10
+ def cmd_diff(argv)
11
+ return help_exit("diff") if command_wants_help?(argv)
12
+
13
+ options = { output: nil, html: nil, top: 15 }
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: heapscope diff BEFORE.json AFTER.json [options]"
16
+ opts.on("-o", "--output PATH", "Write report JSON") { |p| options[:output] = p }
17
+ opts.on("--html PATH", "Write HTML report") { |p| options[:html] = p }
18
+ opts.on("--markdown", "Markdown report body") { options[:markdown] = true }
19
+ opts.on("--markdown-out PATH", "Write Markdown file") { |p| options[:markdown_out] = p }
20
+ opts.on("--top N", Integer) { |n| options[:top] = n }
21
+ opts.on("--scorecard-only") { options[:scorecard_only] = true }
22
+ opts.on("--json") { options[:json] = true }
23
+ parse_fail_on!(options, opts)
24
+ # Back-compat alias already covered by --fail-on-high via parse_fail_on!
25
+ opts.on("-h", "--help") { options[:help] = true }
26
+ end.parse!(argv)
27
+ return help_exit("diff") if options[:help]
28
+
29
+ before_path, after_path = argv.shift(2)
30
+ raise ArgumentError, "diff requires BEFORE.json AFTER.json" unless before_path && after_path
31
+
32
+ report = HeapScope.compare(before_path, after_path)
33
+ if options[:top] && report.diff
34
+ options[:table_text] = Tables.class_growth(report.diff, limit: options[:top],
35
+ format: options[:markdown] ? :markdown : :ascii)
36
+ end
37
+
38
+ if json_mode? || options[:json]
39
+ emit_json(report.to_h)
40
+ elsif options[:scorecard_only]
41
+ say report.scorecard.to_text
42
+ else
43
+ unless quiet?
44
+ say report.scorecard.to_text
45
+ say
46
+ say(options[:table_text] || report.table)
47
+ say
48
+ puts(options[:markdown] ? report.to_markdown : report.to_text)
49
+ end
50
+ end
51
+
52
+ write_outputs(report, options)
53
+ exit_for_findings(report, fail_on: options[:fail_on])
54
+ end
55
+
56
+ def cmd_baseline(argv)
57
+ return help_exit("baseline") if command_wants_help?(argv)
58
+
59
+ sub = argv.shift
60
+ case sub
61
+ when "create"
62
+ options = { output: "baseline.json" }
63
+ OptionParser.new do |opts|
64
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
65
+ opts.on("-h", "--help") { options[:help] = true }
66
+ end.parse!(argv)
67
+ return help_exit("baseline") if options[:help]
68
+
69
+ input = argv.shift or raise ArgumentError, "baseline create requires REPORT.json"
70
+ Baseline.create(input, options[:output])
71
+ say "Created baseline #{options[:output]}"
72
+ EXIT_OK
73
+ when nil, "-h", "--help"
74
+ help_exit("baseline")
75
+ else
76
+ say_err "Usage: heapscope baseline create REPORT.json -o baseline.json"
77
+ EXIT_INVALID
78
+ end
79
+ end
80
+
81
+ def cmd_compare(argv)
82
+ return help_exit("compare") if command_wants_help?(argv)
83
+
84
+ options = { threshold: 0.5 }
85
+ OptionParser.new do |opts|
86
+ opts.banner = "Usage: heapscope compare BASELINE.json CURRENT.json"
87
+ opts.on("--threshold F", Float) { |f| options[:threshold] = f }
88
+ opts.on("--json") { options[:json] = true }
89
+ opts.on("-h", "--help") { options[:help] = true }
90
+ end.parse!(argv)
91
+ return help_exit("compare") if options[:help]
92
+
93
+ baseline, current = argv.shift(2)
94
+ raise ArgumentError, "compare requires BASELINE.json CURRENT.json" unless baseline && current
95
+
96
+ result = Baseline.compare(baseline, current, threshold: options[:threshold])
97
+ if json_mode? || options[:json]
98
+ emit_json(
99
+ result.merge(
100
+ findings: result[:findings].map { |f| f.respond_to?(:to_h) ? f.to_h : f }
101
+ )
102
+ )
103
+ else
104
+ puts Color.bold("MEMORY COMPARISON", enabled: color?)
105
+ puts "Retained objects: baseline=#{result[:baseline_retained_objects]} current=#{result[:current_retained_objects]}"
106
+ puts "Change: #{(result[:object_change_ratio] * 100).round(1)}%"
107
+ puts "Retained bytes: baseline=#{result[:baseline_retained_bytes]} current=#{result[:current_retained_bytes]}"
108
+ label = result[:result]
109
+ colored =
110
+ if result[:regression]
111
+ Color.err(label, enabled: color?)
112
+ else
113
+ Color.ok(label, enabled: color?)
114
+ end
115
+ puts "Result: #{colored}"
116
+ result[:findings].each { |f| puts "#{f.code}: #{f.title}" }
117
+ end
118
+ result[:regression] ? EXIT_REGRESSION : EXIT_OK
119
+ end
120
+
121
+ def cmd_trends(argv)
122
+ return help_exit("trends") if command_wants_help?(argv)
123
+
124
+ options = {}
125
+ OptionParser.new do |opts|
126
+ opts.on("--json") { options[:json] = true }
127
+ opts.on("-h", "--help") { options[:help] = true }
128
+ end.parse!(argv)
129
+ return help_exit("trends") if options[:help]
130
+
131
+ path = argv.shift or raise ArgumentError, "trends REPORT.json required"
132
+ report = load_report!(path)
133
+ timeline = report.metadata[:timeline] || report.metadata["timeline"]
134
+ if timeline.nil? || timeline.empty?
135
+ say_err "No timeline in report. Run: heapscope monitor ..."
136
+ return EXIT_INVALID
137
+ end
138
+ if json_mode? || options[:json]
139
+ emit_json(timeline: timeline)
140
+ else
141
+ puts "TIME RSS LIVE TOP"
142
+ timeline.each do |row|
143
+ puts format(
144
+ "%-20s %-12s %-12s %s",
145
+ row[:time] || row["time"],
146
+ row[:rss_bytes] || row["rss_bytes"],
147
+ row[:live_slots] || row["live_slots"],
148
+ row[:top_class] || row["top_class"]
149
+ )
150
+ end
151
+ end
152
+ EXIT_OK
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ module Commands
6
+ # Doctor, about, env, codes, explain, completion, config, sessions, etc.
7
+ module Meta
8
+ private
9
+
10
+ def cmd_capabilities(argv)
11
+ options = {}
12
+ OptionParser.new do |opts|
13
+ opts.on("--json") { options[:json] = true }
14
+ opts.on("-h", "--help") { options[:help] = true }
15
+ end.parse!(argv)
16
+ return help_exit("capabilities") if options[:help]
17
+
18
+ caps = HeapScope.capabilities
19
+ if json_mode? || options[:json]
20
+ emit_json(caps.to_h)
21
+ else
22
+ puts caps
23
+ end
24
+ EXIT_OK
25
+ end
26
+
27
+ def cmd_doctor(argv)
28
+ return help_exit("doctor") if command_wants_help?(argv)
29
+
30
+ options = { fix_path: "heapscope.yml" }
31
+ OptionParser.new do |opts|
32
+ opts.on("--json") { options[:json] = true }
33
+ opts.on("--fix", "Write a starter heapscope.yml (local only)") { options[:fix] = true }
34
+ opts.on("--config-out PATH", "Output path for --fix (default heapscope.yml)") { |p| options[:fix_path] = p }
35
+ opts.on("--force", "Overwrite existing config when using --fix") { options[:force] = true }
36
+ opts.on("-h", "--help") { options[:help] = true }
37
+ end.parse!(argv)
38
+ return help_exit("doctor") if options[:help]
39
+
40
+ if options[:fix]
41
+ path = ConfigLoader.write_starter!(options[:fix_path], force: options[:force])
42
+ say "Wrote starter config to #{path}"
43
+ HeapScope.load_config!(path)
44
+ end
45
+
46
+ if json_mode? || options[:json]
47
+ emit_json(HeapScope.doctor.merge(fragmentation: doctor_fragmentation, config_path: options[:fix] ? options[:fix_path] : nil))
48
+ return EXIT_OK
49
+ end
50
+
51
+ puts Color.bold(Branding.compact_banner, enabled: color?)
52
+ puts "HeapScope doctor"
53
+ caps = HeapScope.capabilities
54
+ puts "Ruby #{RUBY_VERSION} (#{RUBY_ENGINE}) on #{RUBY_PLATFORM}"
55
+ puts
56
+ puts caps
57
+ puts
58
+ puts "Config mode: #{HeapScope.config.mode}"
59
+ puts "Noise patterns: #{HeapScope.config.ignore_patterns.size}"
60
+ frag = doctor_fragmentation
61
+ puts "Fragmentation indicator: #{frag[:status]} (#{frag[:note]})"
62
+ puts "RSS tracking: #{caps.rss_tracking}"
63
+ puts "Allocation tracing: #{caps.allocation_tracing}"
64
+ puts "Reachable objects: #{caps.reachable_objects}"
65
+ puts "Budget presets: #{Budget.presets.join(', ')}"
66
+ puts
67
+ Branding.funding_lines.each { |line| puts line }
68
+ puts
69
+ puts "Privacy: local-only, no telemetry, no value dumps by default."
70
+ puts "Tip: heapscope doctor --fix # write starter heapscope.yml"
71
+ EXIT_OK
72
+ end
73
+
74
+ def doctor_fragmentation
75
+ Fragmentation.assess(HeapScope.snapshot(mode: :lightweight))
76
+ rescue StandardError => e
77
+ { status: :error, note: e.message }
78
+ end
79
+
80
+ def cmd_overhead(argv)
81
+ return help_exit("overhead") if command_wants_help?(argv)
82
+
83
+ options = { mode: :lightweight, runs: 3 }
84
+ OptionParser.new do |opts|
85
+ opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
86
+ opts.on("--runs N", Integer) { |n| options[:runs] = n }
87
+ opts.on("--json") { options[:json] = true }
88
+ opts.on("-h", "--help") { options[:help] = true }
89
+ end.parse!(argv)
90
+ return help_exit("overhead") if options[:help]
91
+
92
+ result = Overhead.measure_snapshot(mode: options[:mode], runs: options[:runs])
93
+ if json_mode? || options[:json]
94
+ emit_json(result)
95
+ else
96
+ puts "OVERHEAD mode=#{result[:mode]} avg=#{result[:avg_seconds]}s"
97
+ result[:runs].each_with_index do |run, i|
98
+ puts " run #{i + 1}: #{run[:seconds].round(4)}s (#{run[:classes]} classes)"
99
+ end
100
+ puts result[:note]
101
+ end
102
+ EXIT_OK
103
+ end
104
+
105
+ def cmd_config(argv)
106
+ return help_exit("config") if command_wants_help?(argv)
107
+
108
+ path = argv.shift or raise ArgumentError, "config PATH required"
109
+ HeapScope.load_config!(path)
110
+ say "Loaded config from #{path}"
111
+ say "mode=#{HeapScope.config.mode} track_allocations=#{HeapScope.config.track_allocations}"
112
+ EXIT_OK
113
+ end
114
+
115
+ def cmd_codes(_argv)
116
+ puts Catalog.to_text
117
+ EXIT_OK
118
+ end
119
+
120
+ def cmd_explain(argv)
121
+ return help_exit("explain") if command_wants_help?(argv)
122
+
123
+ code = argv.shift or raise ArgumentError, "explain CODE required (e.g. HS001)"
124
+ puts Catalog.explain(code)
125
+ EXIT_OK
126
+ end
127
+
128
+ def cmd_sessions(_argv)
129
+ sessions = Session.list
130
+ if sessions.empty?
131
+ say "No sessions in .heapscope/sessions"
132
+ return EXIT_OK
133
+ end
134
+ sessions.each do |s|
135
+ puts "#{s[:name]} id=#{s[:id]} reports=#{Array(s[:reports]).size} created=#{s[:created_at]}"
136
+ end
137
+ EXIT_OK
138
+ end
139
+
140
+ def cmd_history(argv)
141
+ return help_exit("history") if command_wants_help?(argv)
142
+
143
+ options = { limit: 10 }
144
+ OptionParser.new do |opts|
145
+ opts.on("--limit N", Integer) { |n| options[:limit] = n }
146
+ opts.on("-h", "--help") { options[:help] = true }
147
+ end.parse!(argv)
148
+ return help_exit("history") if options[:help]
149
+
150
+ sessions = Session.list
151
+ entries = sessions.flat_map { |s| Array(s[:reports]).map { |r| r.merge(session: s[:name]) } }
152
+ .sort_by { |e| e[:at].to_s }
153
+ .reverse
154
+ .first(options[:limit])
155
+ if entries.empty?
156
+ say "No history yet. Use HeapScope.session('name') in Ruby."
157
+ return EXIT_OK
158
+ end
159
+ entries.each do |e|
160
+ puts "#{e[:at]} #{e[:session]}/#{e[:label]} healthy=#{e[:healthy]} #{e[:path]}"
161
+ end
162
+ EXIT_OK
163
+ end
164
+
165
+ def cmd_about(argv)
166
+ options = {}
167
+ OptionParser.new do |opts|
168
+ opts.on("--json") { options[:json] = true }
169
+ opts.on("-h", "--help") { options[:help] = true }
170
+ end.parse!(argv)
171
+ return help_exit("about") if options[:help]
172
+
173
+ if json_mode? || options[:json]
174
+ emit_json(Branding.to_h)
175
+ else
176
+ puts Branding.about_text
177
+ end
178
+ EXIT_OK
179
+ end
180
+
181
+ def cmd_man(_argv)
182
+ puts Branding.banner
183
+ puts
184
+ puts Branding.about_text
185
+ puts
186
+ puts "Examples:"
187
+ puts " heapscope doctor"
188
+ puts " heapscope snapshot --mode standard -o before.json"
189
+ puts " heapscope diff before.json after.json --html out.html --fail-on-medium"
190
+ puts " heapscope probe --eval '1000.times { Object.new }'"
191
+ puts " heapscope explain HS001"
192
+ puts " heapscope self-test"
193
+ puts " heapscope pack report.json -o ./pack"
194
+ puts
195
+ puts "Exit codes: 0 ok · 1 regression · 2 invalid · 3 capability"
196
+ puts "Privacy: local-only — no telemetry, no network from the gem."
197
+ EXIT_OK
198
+ end
199
+
200
+ def cmd_env(argv)
201
+ options = {}
202
+ OptionParser.new do |opts|
203
+ opts.on("--json") { options[:json] = true }
204
+ opts.on("-h", "--help") { options[:help] = true }
205
+ end.parse!(argv)
206
+ return help_exit("env") if options[:help]
207
+
208
+ keys = %w[
209
+ RUBY_VERSION RUBYOPT RUBYLIB GEM_HOME GEM_PATH BUNDLE_GEMFILE
210
+ HEAPSCOPE_CONFIG HEAPSCOPE_MODE HEAPSCOPE_QUIET
211
+ NO_COLOR TERM FORCE_COLOR
212
+ ]
213
+ data = {
214
+ ruby_version: RUBY_VERSION,
215
+ ruby_engine: RUBY_ENGINE,
216
+ ruby_platform: RUBY_PLATFORM,
217
+ heapscope_version: VERSION,
218
+ env: keys.to_h { |k| [k, ENV.fetch(k, nil)] }
219
+ }
220
+ if json_mode? || options[:json]
221
+ emit_json(data)
222
+ else
223
+ puts Color.bold(Branding.compact_banner, enabled: color?)
224
+ puts "RUBY_VERSION=#{RUBY_VERSION} ENGINE=#{RUBY_ENGINE} PLATFORM=#{RUBY_PLATFORM}"
225
+ puts "HeapScope=#{VERSION}"
226
+ puts
227
+ data[:env].each do |k, v|
228
+ puts format("%-18s %s", k, v.nil? ? "(unset)" : v)
229
+ end
230
+ end
231
+ EXIT_OK
232
+ end
233
+
234
+ def cmd_completion(argv)
235
+ return help_exit("completion") if command_wants_help?(argv)
236
+
237
+ shell = argv.shift or raise ArgumentError, "completion requires bash|zsh|powershell"
238
+ puts Completion.generate(shell)
239
+ EXIT_OK
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ module Commands
6
+ # Report rendering, pack/export, findings, scorecard, table, open/html.
7
+ module Reporting
8
+ private
9
+
10
+ def cmd_report(argv)
11
+ return help_exit("report") if command_wants_help?(argv)
12
+
13
+ options = { format: "text", output: nil }
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: heapscope report REPORT.json [options]"
16
+ opts.on("--format FMT", %w[text html json markdown md]) { |f| options[:format] = f }
17
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
18
+ opts.on("-h", "--help") { options[:help] = true }
19
+ end.parse!(argv)
20
+ return help_exit("report") if options[:help]
21
+
22
+ path = argv.shift or raise ArgumentError, "report path required"
23
+ report = load_report!(path)
24
+ format = json_mode? ? "json" : options[:format]
25
+ content =
26
+ case format
27
+ when "html" then Report::HTML.render(report)
28
+ when "json" then report.to_json
29
+ when "markdown", "md" then report.to_markdown
30
+ else report.to_text
31
+ end
32
+ if options[:output]
33
+ File.write(options[:output], content)
34
+ say "Wrote #{options[:output]}"
35
+ else
36
+ puts content
37
+ end
38
+ EXIT_OK
39
+ end
40
+
41
+ def cmd_pack(argv)
42
+ return help_exit("pack") if command_wants_help?(argv)
43
+
44
+ options = { output: "heapscope-pack", label: "heapscope-report" }
45
+ OptionParser.new do |opts|
46
+ opts.banner = "Usage: heapscope pack REPORT.json [options]"
47
+ opts.on("-o", "--output DIR", "Output directory") { |p| options[:output] = p }
48
+ opts.on("--label NAME", "File basename") { |n| options[:label] = n }
49
+ opts.on("--json") { options[:json] = true }
50
+ opts.on("-h", "--help") { options[:help] = true }
51
+ end.parse!(argv)
52
+ return help_exit("pack") if options[:help]
53
+
54
+ path = argv.shift or raise ArgumentError, "pack REPORT.json required"
55
+ report = load_report!(path)
56
+ result = Pack.export(report, options[:output], label: options[:label])
57
+ if json_mode? || options[:json]
58
+ emit_json(result)
59
+ else
60
+ say "Wrote local pack to #{result[:dir]}"
61
+ say " #{result[:json]}"
62
+ say " #{result[:html]}"
63
+ say " #{result[:markdown]}"
64
+ end
65
+ EXIT_OK
66
+ end
67
+
68
+ def cmd_export(argv)
69
+ cmd_pack(argv)
70
+ end
71
+
72
+ def cmd_suggest(argv)
73
+ return help_exit("suggest") if command_wants_help?(argv)
74
+
75
+ options = {}
76
+ OptionParser.new do |opts|
77
+ opts.on("--json") { options[:json] = true }
78
+ opts.on("--ignores-only") { options[:ignores_only] = true }
79
+ opts.on("-h", "--help") { options[:help] = true }
80
+ end.parse!(argv)
81
+ return help_exit("suggest") if options[:help]
82
+
83
+ path = argv.shift or raise ArgumentError, "suggest REPORT.json required"
84
+ report = load_report!(path)
85
+ if json_mode? || options[:json]
86
+ payload = {
87
+ next_steps: Suggest.next_steps(report),
88
+ ignore_patterns: Suggest.ignore_patterns(report)
89
+ }
90
+ payload.delete(:next_steps) if options[:ignores_only]
91
+ emit_json(payload)
92
+ elsif options[:ignores_only]
93
+ patterns = Suggest.ignore_patterns(report)
94
+ if patterns.empty?
95
+ say "No ignore suggestions."
96
+ else
97
+ patterns.each { |p| puts p }
98
+ end
99
+ else
100
+ puts Suggest.report_text(report)
101
+ end
102
+ EXIT_OK
103
+ end
104
+
105
+ def cmd_ignore_suggest(argv)
106
+ cmd_suggest(argv)
107
+ end
108
+
109
+ def cmd_findings(argv)
110
+ return help_exit("findings") if command_wants_help?(argv)
111
+
112
+ options = {}
113
+ OptionParser.new do |opts|
114
+ opts.on("--severity LEVEL", %w[low medium high]) { |s| options[:severity] = s.to_sym }
115
+ opts.on("--code CODE") { |c| options[:code] = Catalog.normalize_code(c) }
116
+ opts.on("--json") { options[:json] = true }
117
+ opts.on("-h", "--help") { options[:help] = true }
118
+ end.parse!(argv)
119
+ return help_exit("findings") if options[:help]
120
+
121
+ path = argv.shift or raise ArgumentError, "findings REPORT.json required"
122
+ report = load_report!(path)
123
+ list = Findings.rank_and_dedupe(report.findings)
124
+ list = list.select { |f| f.severity.to_sym == options[:severity] } if options[:severity]
125
+ list = list.select { |f| f.code == options[:code] } if options[:code]
126
+
127
+ if json_mode? || options[:json]
128
+ emit_json(findings: list.map(&:to_h), count: list.size)
129
+ elsif list.empty?
130
+ say "No findings matched."
131
+ else
132
+ puts Tables.findings(
133
+ Report.new(findings: list, summary: report.summary, metadata: report.metadata),
134
+ format: :ascii
135
+ )
136
+ list.each do |f|
137
+ sev = Color.severity(f.severity, enabled: color?)
138
+ say "#{f.code} [#{sev}] #{f.title}#{f.subject ? " (#{f.subject})" : ""}"
139
+ end
140
+ end
141
+ EXIT_OK
142
+ end
143
+
144
+ def cmd_scorecard(argv)
145
+ return help_exit("scorecard") if command_wants_help?(argv)
146
+
147
+ options = { title: "HeapScope" }
148
+ OptionParser.new do |opts|
149
+ opts.on("--title NAME") { |t| options[:title] = t }
150
+ opts.on("--json") { options[:json] = true }
151
+ opts.on("-h", "--help") { options[:help] = true }
152
+ end.parse!(argv)
153
+ return help_exit("scorecard") if options[:help]
154
+
155
+ path = argv.shift or raise ArgumentError, "scorecard REPORT.json required"
156
+ report = load_report!(path)
157
+ card = report.scorecard(title: options[:title])
158
+ if json_mode? || options[:json]
159
+ emit_json(card.to_h)
160
+ else
161
+ puts card.to_text
162
+ end
163
+ EXIT_OK
164
+ end
165
+
166
+ def cmd_table(argv)
167
+ return help_exit("table") if command_wants_help?(argv)
168
+
169
+ options = { top: 15 }
170
+ OptionParser.new do |opts|
171
+ opts.on("--markdown") { options[:markdown] = true }
172
+ opts.on("--top N", Integer) { |n| options[:top] = n }
173
+ opts.on("-h", "--help") { options[:help] = true }
174
+ end.parse!(argv)
175
+ return help_exit("table") if options[:help]
176
+
177
+ path = argv.shift or raise ArgumentError, "table REPORT.json required"
178
+ report = load_report!(path)
179
+ raise ArgumentError, "report has no diff/table data" unless report.diff
180
+
181
+ puts Tables.class_growth(
182
+ report.diff,
183
+ limit: options[:top],
184
+ format: options[:markdown] ? :markdown : :ascii
185
+ )
186
+ EXIT_OK
187
+ end
188
+
189
+ def cmd_open(argv)
190
+ return help_exit("open") if command_wants_help?(argv)
191
+
192
+ options = {}
193
+ OptionParser.new do |opts|
194
+ opts.on("-o", "--output PATH") { |p| options[:output] = p }
195
+ opts.on("-h", "--help") { options[:help] = true }
196
+ end.parse!(argv)
197
+ return help_exit("open") if options[:help]
198
+
199
+ path = argv.shift or raise ArgumentError, "open REPORT.json required"
200
+ report = load_report!(path)
201
+ out = options[:output] || "#{path.sub(/\.json\z/i, "")}.html"
202
+ out = "#{path}.html" if out == path
203
+ report.save_html(out)
204
+ if json_mode?
205
+ emit_json(html: out)
206
+ else
207
+ puts out
208
+ end
209
+ EXIT_OK
210
+ end
211
+
212
+ def cmd_html(argv)
213
+ cmd_open(argv)
214
+ end
215
+
216
+ def cmd_validate(argv)
217
+ return help_exit("validate") if command_wants_help?(argv)
218
+
219
+ path = argv.shift or raise ArgumentError, "validate REPORT.json required"
220
+ data = load_json_path!(path)
221
+ Schema.validate!(data)
222
+ say "OK schema_version=#{data[:schema_version]} heapscope=#{data[:heapscope_version]}"
223
+ EXIT_OK
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class CLI
5
+ # Shell completion script generators (local; no network).
6
+ module Completion
7
+ NAMES = Help::COMMANDS.keys.freeze
8
+
9
+ module_function
10
+
11
+ def generate(shell)
12
+ case shell.to_s.downcase
13
+ when "bash" then bash
14
+ when "zsh" then zsh
15
+ when "powershell", "pwsh" then powershell
16
+ else
17
+ raise ArgumentError, "unsupported shell: #{shell} (bash|zsh|powershell)"
18
+ end
19
+ end
20
+
21
+ def bash
22
+ list = NAMES.join(" ")
23
+ <<~BASH
24
+ # heapscope bash completion — eval "$(heapscope completion bash)"
25
+ _heapscope() {
26
+ local cur="${COMP_WORDS[COMP_CWORD]}"
27
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
28
+ COMPREPLY=( $(compgen -W "#{list}" -- "$cur") )
29
+ else
30
+ COMPREPLY=( $(compgen -f -- "$cur") )
31
+ fi
32
+ }
33
+ complete -F _heapscope heapscope
34
+ BASH
35
+ end
36
+
37
+ def zsh
38
+ descriptions = NAMES.map do |n|
39
+ desc = Help::COMMANDS[n].to_s.tr("'", " ")
40
+ "'#{n}:#{desc}'"
41
+ end.join(" ")
42
+ <<~ZSH
43
+ # heapscope zsh completion — eval "$(heapscope completion zsh)"
44
+ _heapscope() {
45
+ local -a cmds
46
+ cmds=(#{descriptions})
47
+ if (( CURRENT == 2 )); then
48
+ _describe 'command' cmds
49
+ else
50
+ _files
51
+ fi
52
+ }
53
+ compdef _heapscope heapscope
54
+ ZSH
55
+ end
56
+
57
+ def powershell
58
+ list = NAMES.map { |n| "'#{n}'" }.join(", ")
59
+ <<~PS
60
+ # heapscope PowerShell completion — heapscope completion powershell | Out-String | Invoke-Expression
61
+ Register-ArgumentCompleter -CommandName heapscope -ScriptBlock {
62
+ param($wordToComplete, $commandAst, $cursorPosition)
63
+ $commands = @(#{list})
64
+ $commands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
65
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
66
+ }
67
+ }
68
+ PS
69
+ end
70
+ end
71
+ end
72
+ end