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.
- checksums.yaml +7 -0
- data/.rubocop.yml +119 -0
- data/CHANGELOG.md +86 -0
- data/CODE_OF_CONDUCT.md +38 -0
- data/CONTRIBUTING.md +72 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/Rakefile +49 -0
- data/SECURITY.md +39 -0
- data/assets/heapscope-logo.png +0 -0
- data/assets/logo.svg +24 -0
- data/assets/wordmark.svg +18 -0
- data/docs/README.md +20 -0
- data/docs/ROADMAP.md +29 -0
- data/docs/adr/0001-evidence-over-certainty.md +24 -0
- data/docs/adr/0002-local-only-privacy.md +19 -0
- data/docs/adr/0003-runtime-adapters.md +25 -0
- data/docs/adr/README.md +7 -0
- data/docs/allocators.md +13 -0
- data/docs/api/overview.md +70 -0
- data/docs/changelogs/0.1.0.md +22 -0
- data/docs/changelogs/0.2.0.md +49 -0
- data/docs/changelogs/0.3.0.md +38 -0
- data/docs/changelogs/0.4.0.md +32 -0
- data/docs/changelogs/0.5.0.md +18 -0
- data/docs/changelogs/0.6.0.md +23 -0
- data/docs/cli.md +98 -0
- data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
- data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
- data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
- data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
- data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
- data/docs/diagnostics/HS006_closure_retention.md +15 -0
- data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
- data/docs/diagnostics/HS008_baseline_regression.md +15 -0
- data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
- data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
- data/docs/guides/ci-budgets.md +43 -0
- data/docs/guides/first-retention-experiment.md +25 -0
- data/docs/guides/production-safe.md +17 -0
- data/docs/index.md +38 -0
- data/examples/cache_vs_leak.rb +30 -0
- data/examples/closure_capture.rb +20 -0
- data/examples/healthy_churn.rb +16 -0
- data/examples/heapscope.yml +12 -0
- data/examples/import_leak.rb +19 -0
- data/examples/probe_and_session.rb +22 -0
- data/examples/thread_local_leak.rb +31 -0
- data/exe/heapscope +7 -0
- data/heapscope.gemspec +73 -0
- data/lib/heapscope/aging.rb +114 -0
- data/lib/heapscope/analyzer.rb +333 -0
- data/lib/heapscope/baseline.rb +77 -0
- data/lib/heapscope/branding.rb +98 -0
- data/lib/heapscope/budget.rb +108 -0
- data/lib/heapscope/capabilities.rb +42 -0
- data/lib/heapscope/catalog.rb +71 -0
- data/lib/heapscope/cli/color.rb +67 -0
- data/lib/heapscope/cli/commands/capture.rb +289 -0
- data/lib/heapscope/cli/commands/diffing.rb +157 -0
- data/lib/heapscope/cli/commands/meta.rb +244 -0
- data/lib/heapscope/cli/commands/reporting.rb +228 -0
- data/lib/heapscope/cli/completion.rb +72 -0
- data/lib/heapscope/cli/help.rb +226 -0
- data/lib/heapscope/cli/support.rb +126 -0
- data/lib/heapscope/cli.rb +165 -0
- data/lib/heapscope/closures.rb +102 -0
- data/lib/heapscope/collector.rb +167 -0
- data/lib/heapscope/config.rb +196 -0
- data/lib/heapscope/detectors.rb +131 -0
- data/lib/heapscope/diff.rb +130 -0
- data/lib/heapscope/dominators.rb +87 -0
- data/lib/heapscope/errors.rb +13 -0
- data/lib/heapscope/extrapolation.rb +51 -0
- data/lib/heapscope/findings.rb +149 -0
- data/lib/heapscope/globals.rb +108 -0
- data/lib/heapscope/graph.rb +218 -0
- data/lib/heapscope/growth.rb +108 -0
- data/lib/heapscope/middleware.rb +35 -0
- data/lib/heapscope/minitest.rb +47 -0
- data/lib/heapscope/monitor.rb +160 -0
- data/lib/heapscope/noise.rb +48 -0
- data/lib/heapscope/notifications.rb +79 -0
- data/lib/heapscope/pack.rb +55 -0
- data/lib/heapscope/paths.rb +64 -0
- data/lib/heapscope/rails.rb +71 -0
- data/lib/heapscope/report/html.rb +283 -0
- data/lib/heapscope/report/markdown.rb +78 -0
- data/lib/heapscope/report/text.rb +204 -0
- data/lib/heapscope/report.rb +308 -0
- data/lib/heapscope/retention.rb +91 -0
- data/lib/heapscope/rspec.rb +118 -0
- data/lib/heapscope/runtime/base.rb +130 -0
- data/lib/heapscope/runtime/jruby.rb +28 -0
- data/lib/heapscope/runtime/mri.rb +86 -0
- data/lib/heapscope/runtime/truffleruby.rb +40 -0
- data/lib/heapscope/runtime/windows_rss.rb +75 -0
- data/lib/heapscope/runtime.rb +33 -0
- data/lib/heapscope/schema.rb +28 -0
- data/lib/heapscope/scorecard.rb +85 -0
- data/lib/heapscope/session.rb +90 -0
- data/lib/heapscope/sidekiq_middleware.rb +36 -0
- data/lib/heapscope/snapshot.rb +165 -0
- data/lib/heapscope/suggest.rb +99 -0
- data/lib/heapscope/tables.rb +58 -0
- data/lib/heapscope/trend_store.rb +41 -0
- data/lib/heapscope/version.rb +6 -0
- data/lib/heapscope.rb +307 -0
- 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
|