rigortype 0.1.6 → 0.1.8
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 +4 -4
- data/README.md +40 -29
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/check_rules.rb +60 -3
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/builtins/static_return_refinements.rb +23 -1
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +78 -3
- data/lib/rigor/configuration.rb +21 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +22 -0
- data/lib/rigor/environment.rb +13 -0
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/expression_typer.rb +152 -14
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
- data/lib/rigor/inference/method_dispatcher.rb +50 -5
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/scope_indexer.rb +209 -13
- data/lib/rigor/inference/statement_evaluator.rb +91 -10
- data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- data/sig/rigor.rbs +1 -0
- metadata +8 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../analysis/baseline"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../configuration"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
class CLI
|
|
12
|
+
# ADR-22 Slice 1 — `rigor baseline {generate}` subcommands.
|
|
13
|
+
# Backed by `Rigor::Analysis::Baseline`. Future slices
|
|
14
|
+
# extend the subcommand surface with `dump`, `drift`,
|
|
15
|
+
# `prune`, `regenerate`.
|
|
16
|
+
#
|
|
17
|
+
# Initial subcommand: `generate`.
|
|
18
|
+
#
|
|
19
|
+
# rigor baseline generate # default: rule-ID rows
|
|
20
|
+
# rigor baseline generate --match-mode message
|
|
21
|
+
# rigor baseline generate --force # overwrite existing
|
|
22
|
+
# rigor baseline generate --output=PATH
|
|
23
|
+
class BaselineCommand # rubocop:disable Metrics/ClassLength
|
|
24
|
+
EXIT_USAGE = 64
|
|
25
|
+
DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
|
|
26
|
+
|
|
27
|
+
SUBCOMMANDS = %w[generate dump drift prune].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(argv:, out: $stdout, err: $stderr)
|
|
30
|
+
@argv = argv
|
|
31
|
+
@out = out
|
|
32
|
+
@err = err
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
subcommand = @argv.shift
|
|
37
|
+
case subcommand
|
|
38
|
+
when nil, "help", "-h", "--help"
|
|
39
|
+
@out.puts(help)
|
|
40
|
+
0
|
|
41
|
+
when "generate" then run_generate
|
|
42
|
+
when "dump" then run_dump
|
|
43
|
+
when "drift" then run_drift
|
|
44
|
+
when "prune" then run_prune
|
|
45
|
+
else
|
|
46
|
+
@err.puts("Unknown baseline subcommand: #{subcommand.inspect}")
|
|
47
|
+
@err.puts(help)
|
|
48
|
+
EXIT_USAGE
|
|
49
|
+
end
|
|
50
|
+
rescue OptionParser::ParseError => e
|
|
51
|
+
@err.puts(e.message)
|
|
52
|
+
EXIT_USAGE
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_generate
|
|
58
|
+
options = parse_generate_options
|
|
59
|
+
path = options.fetch(:output)
|
|
60
|
+
|
|
61
|
+
if File.exist?(path) && !options.fetch(:force)
|
|
62
|
+
@err.puts("rigor: #{path} already exists. Re-run with --force to overwrite.")
|
|
63
|
+
return EXIT_USAGE
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
67
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
68
|
+
|
|
69
|
+
baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode))
|
|
70
|
+
File.write(path, baseline.to_yaml)
|
|
71
|
+
|
|
72
|
+
bucket_count = baseline.size
|
|
73
|
+
diagnostic_count = diagnostics.size
|
|
74
|
+
@err.puts(
|
|
75
|
+
"rigor: wrote baseline to #{path} " \
|
|
76
|
+
"(#{bucket_count} bucket(s) covering #{diagnostic_count} diagnostic(s); " \
|
|
77
|
+
"match-mode: #{options.fetch(:match_mode)})"
|
|
78
|
+
)
|
|
79
|
+
if configuration.baseline_path.nil?
|
|
80
|
+
@err.puts(
|
|
81
|
+
"rigor: note — `.rigor.yml` does not declare `baseline:`; " \
|
|
82
|
+
"add `baseline: #{path}` to activate the suppression."
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_generate_options
|
|
89
|
+
options = {
|
|
90
|
+
config: nil,
|
|
91
|
+
output: DEFAULT_BASELINE_PATH,
|
|
92
|
+
match_mode: :rule,
|
|
93
|
+
force: false
|
|
94
|
+
}
|
|
95
|
+
parser = OptionParser.new do |opts|
|
|
96
|
+
opts.banner = "Usage: rigor baseline generate [options]"
|
|
97
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
98
|
+
opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
99
|
+
options[:output] = v
|
|
100
|
+
end
|
|
101
|
+
opts.on("--match-mode=MODE", %i[rule message],
|
|
102
|
+
"Row form: rule (default) or message") do |v|
|
|
103
|
+
options[:match_mode] = v
|
|
104
|
+
end
|
|
105
|
+
opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
|
|
106
|
+
end
|
|
107
|
+
parser.parse!(@argv)
|
|
108
|
+
options
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def collect_diagnostics(configuration, _options)
|
|
112
|
+
cache_store = Cache::Store.new(root: configuration.cache_path)
|
|
113
|
+
# IMPORTANT: do NOT activate the existing baseline when
|
|
114
|
+
# generating a fresh one — otherwise the new file
|
|
115
|
+
# records the post-filter (silenced) diagnostic set,
|
|
116
|
+
# which is empty after a successful first run.
|
|
117
|
+
configuration_for_generation = override_configuration_baseline_off(configuration)
|
|
118
|
+
runner = Analysis::Runner.new(
|
|
119
|
+
configuration: configuration_for_generation,
|
|
120
|
+
cache_store: cache_store,
|
|
121
|
+
collect_stats: false
|
|
122
|
+
)
|
|
123
|
+
runner.run(configuration_for_generation.paths).diagnostics
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def override_configuration_baseline_off(configuration)
|
|
127
|
+
# Synthesise a new Configuration with `baseline` explicitly
|
|
128
|
+
# disabled. The original Configuration is frozen-ish so we
|
|
129
|
+
# round-trip through the constructor with an override hash.
|
|
130
|
+
defaults = Configuration::DEFAULTS.merge(
|
|
131
|
+
"paths" => configuration.paths,
|
|
132
|
+
"exclude" => configuration.exclude_patterns,
|
|
133
|
+
"plugins" => configuration.plugins.map(&:to_h),
|
|
134
|
+
"disable" => configuration.disabled_rules,
|
|
135
|
+
"libraries" => configuration.libraries,
|
|
136
|
+
"signature_paths" => configuration.signature_paths,
|
|
137
|
+
"pre_eval" => configuration.pre_eval,
|
|
138
|
+
"severity_profile" => configuration.severity_profile.to_s,
|
|
139
|
+
"severity_overrides" => configuration.severity_overrides,
|
|
140
|
+
"baseline" => false,
|
|
141
|
+
"cache" => { "path" => configuration.cache_path }
|
|
142
|
+
)
|
|
143
|
+
Configuration.new(defaults)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ---- dump --------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def run_dump
|
|
149
|
+
options = parse_dump_options
|
|
150
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
151
|
+
return EXIT_USAGE if baseline == :error
|
|
152
|
+
|
|
153
|
+
rows = filter_dump_rows(baseline.buckets, options)
|
|
154
|
+
case options.fetch(:format)
|
|
155
|
+
when :json then @out.puts(JSON.pretty_generate(dump_to_json(rows)))
|
|
156
|
+
else dump_text(rows, options)
|
|
157
|
+
end
|
|
158
|
+
0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_dump_options
|
|
162
|
+
options = { baseline: DEFAULT_BASELINE_PATH, format: :text, rule: nil, file: nil }
|
|
163
|
+
parser = OptionParser.new do |opts|
|
|
164
|
+
opts.banner = "Usage: rigor baseline dump [options]"
|
|
165
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
166
|
+
options[:baseline] = v
|
|
167
|
+
end
|
|
168
|
+
opts.on("--format=FORMAT", %i[text json], "Output format: text (default) or json") do |v|
|
|
169
|
+
options[:format] = v
|
|
170
|
+
end
|
|
171
|
+
opts.on("--rule=RULE", "Filter rows by exact rule id") { |v| options[:rule] = v }
|
|
172
|
+
opts.on("--file=GLOB", "Filter rows by File.fnmatch? glob") { |v| options[:file] = v }
|
|
173
|
+
end
|
|
174
|
+
parser.parse!(@argv)
|
|
175
|
+
options
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def filter_dump_rows(buckets, options)
|
|
179
|
+
buckets.select do |b|
|
|
180
|
+
next false if options[:rule] && b.rule != options[:rule]
|
|
181
|
+
next false if options[:file] && !File.fnmatch?(options[:file], b.file)
|
|
182
|
+
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def dump_text(rows, options)
|
|
188
|
+
if rows.empty?
|
|
189
|
+
@out.puts("(no baseline rows matching the supplied filters)")
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Group by rule for readability; rules with the most
|
|
194
|
+
# entries first.
|
|
195
|
+
by_rule = rows.group_by(&:rule).sort_by { |_rule, group| -group.size }
|
|
196
|
+
by_rule.each do |rule, group|
|
|
197
|
+
total = group.sum(&:count)
|
|
198
|
+
@out.puts("#{rule} (#{group.size} bucket(s), #{total} occurrence(s))")
|
|
199
|
+
group.sort_by { |b| [-b.count, b.file] }.each do |bucket|
|
|
200
|
+
label = if bucket.message_regex
|
|
201
|
+
" #{bucket.file}: #{bucket.count} ~/#{bucket.message_regex.source}/"
|
|
202
|
+
else
|
|
203
|
+
" #{bucket.file}: #{bucket.count}"
|
|
204
|
+
end
|
|
205
|
+
@out.puts(label)
|
|
206
|
+
end
|
|
207
|
+
@out.puts("")
|
|
208
|
+
end
|
|
209
|
+
@out.puts("Total: #{rows.size} bucket(s), #{rows.sum(&:count)} occurrence(s)")
|
|
210
|
+
_ = options # reserved for future flags
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dump_to_json(rows)
|
|
214
|
+
{
|
|
215
|
+
"version" => Analysis::Baseline::CURRENT_VERSION,
|
|
216
|
+
"ignored" => rows.map do |b|
|
|
217
|
+
row = { "file" => b.file, "rule" => b.rule, "count" => b.count }
|
|
218
|
+
row["message"] = b.message_regex.source if b.message_regex
|
|
219
|
+
row
|
|
220
|
+
end
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ---- drift --------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def run_drift
|
|
227
|
+
options = parse_drift_options
|
|
228
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
229
|
+
return EXIT_USAGE if baseline == :error
|
|
230
|
+
|
|
231
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
232
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
233
|
+
drift_rows = baseline.audit(diagnostics)
|
|
234
|
+
|
|
235
|
+
shown = if options.fetch(:only).nil?
|
|
236
|
+
drift_rows.reject { |r| r.delta.zero? }
|
|
237
|
+
else
|
|
238
|
+
drift_rows.select { |r| r.status == options.fetch(:only) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if shown.empty?
|
|
242
|
+
@out.puts("No drift detected.")
|
|
243
|
+
return 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
report_drift_rows(shown, baseline_path: options.fetch(:baseline))
|
|
247
|
+
0
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse_drift_options
|
|
251
|
+
options = {
|
|
252
|
+
config: nil,
|
|
253
|
+
baseline: DEFAULT_BASELINE_PATH,
|
|
254
|
+
only: nil
|
|
255
|
+
}
|
|
256
|
+
parser = OptionParser.new do |opts|
|
|
257
|
+
opts.banner = "Usage: rigor baseline drift [options]"
|
|
258
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
259
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
260
|
+
options[:baseline] = v
|
|
261
|
+
end
|
|
262
|
+
opts.on("--only=STATUS", %i[within over cleared reducible],
|
|
263
|
+
"Show only buckets with the given status (within|over|cleared|reducible)") do |v|
|
|
264
|
+
options[:only] = v
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
parser.parse!(@argv)
|
|
268
|
+
options
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def report_drift_rows(rows, baseline_path:) # rubocop:disable Metrics/AbcSize
|
|
272
|
+
@out.puts("Drift report against #{baseline_path}:")
|
|
273
|
+
@out.puts("")
|
|
274
|
+
groups = rows.group_by(&:status)
|
|
275
|
+
%i[over cleared reducible within].each do |status|
|
|
276
|
+
group = groups[status] || []
|
|
277
|
+
next if group.empty?
|
|
278
|
+
|
|
279
|
+
@out.puts(drift_section_header(status, group.size))
|
|
280
|
+
group.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
281
|
+
delta_str = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
|
|
282
|
+
@out.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
|
|
283
|
+
"#{row.bucket.count} → #{row.actual_count} (Δ#{delta_str})")
|
|
284
|
+
end
|
|
285
|
+
@out.puts("")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def drift_section_header(status, count)
|
|
290
|
+
case status
|
|
291
|
+
when :over then "## Over threshold (#{count}) — bucket exceeded; check the regular diagnostic output."
|
|
292
|
+
when :cleared then "## Cleared (#{count}) — `rigor baseline prune` can drop these."
|
|
293
|
+
when :reducible then "## Reducible (#{count}) — tightening opportunity; consider `regenerate` (slice 5)."
|
|
294
|
+
when :within then "## Within threshold (#{count})"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ---- prune --------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def run_prune
|
|
301
|
+
options = parse_prune_options
|
|
302
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
303
|
+
return EXIT_USAGE if baseline == :error
|
|
304
|
+
|
|
305
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
306
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
307
|
+
drift_rows = baseline.audit(diagnostics)
|
|
308
|
+
cleared = drift_rows.select { |r| r.status == :cleared }
|
|
309
|
+
|
|
310
|
+
if cleared.empty?
|
|
311
|
+
@out.puts("No cleared buckets to prune.")
|
|
312
|
+
return 0
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
announce_prune(cleared, options.fetch(:baseline))
|
|
316
|
+
return 0 if options.fetch(:dry_run)
|
|
317
|
+
|
|
318
|
+
pruned = baseline.without(cleared.map(&:bucket))
|
|
319
|
+
File.write(options.fetch(:baseline), pruned.to_yaml)
|
|
320
|
+
@err.puts("rigor: pruned #{cleared.size} bucket(s); baseline now has #{pruned.size} entries.")
|
|
321
|
+
0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def parse_prune_options
|
|
325
|
+
options = {
|
|
326
|
+
config: nil,
|
|
327
|
+
baseline: DEFAULT_BASELINE_PATH,
|
|
328
|
+
dry_run: false
|
|
329
|
+
}
|
|
330
|
+
parser = OptionParser.new do |opts|
|
|
331
|
+
opts.banner = "Usage: rigor baseline prune [options]"
|
|
332
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
333
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
334
|
+
options[:baseline] = v
|
|
335
|
+
end
|
|
336
|
+
opts.on("--dry-run", "Show what would be dropped without writing the file") { options[:dry_run] = true }
|
|
337
|
+
end
|
|
338
|
+
parser.parse!(@argv)
|
|
339
|
+
options
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def announce_prune(cleared, baseline_path)
|
|
343
|
+
@out.puts("#{cleared.size} bucket(s) to prune from #{baseline_path}:")
|
|
344
|
+
cleared.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
345
|
+
@out.puts(" - #{row.bucket.file} [#{row.bucket.rule}] (was: #{row.bucket.count})")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ---- shared helpers ----------------------------------------
|
|
350
|
+
|
|
351
|
+
def load_baseline_or_exit(path)
|
|
352
|
+
unless File.exist?(path)
|
|
353
|
+
@err.puts("rigor: baseline file not found: #{path}")
|
|
354
|
+
return :error
|
|
355
|
+
end
|
|
356
|
+
Analysis::Baseline.load(path)
|
|
357
|
+
rescue Analysis::Baseline::LoadError => e
|
|
358
|
+
@err.puts("rigor: baseline load failed: #{e.message}")
|
|
359
|
+
:error
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def help
|
|
363
|
+
<<~HELP
|
|
364
|
+
Usage: rigor baseline <subcommand> [options]
|
|
365
|
+
|
|
366
|
+
Subcommands:
|
|
367
|
+
generate Write a fresh baseline file from a `rigor check` run.
|
|
368
|
+
dump Print the contents of an existing baseline.
|
|
369
|
+
drift Compare baseline vs current diagnostics (reduction / regression hints).
|
|
370
|
+
prune Drop cleared buckets (`actual == 0`) from the baseline.
|
|
371
|
+
|
|
372
|
+
Run `rigor baseline <subcommand> --help` for subcommand options.
|
|
373
|
+
HELP
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../triage"
|
|
9
|
+
require_relative "triage_renderer"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
class CLI
|
|
13
|
+
# ADR-23 — executes `rigor triage`.
|
|
14
|
+
#
|
|
15
|
+
# Runs the same analysis as `rigor check`, then summarises the
|
|
16
|
+
# diagnostic stream (rule distribution, per-file hotspots,
|
|
17
|
+
# heuristic hints) instead of printing the raw per-line list.
|
|
18
|
+
# Read-only and advisory (WD4): never edits config, never
|
|
19
|
+
# writes a baseline. Always exits 0 — it is an inspection
|
|
20
|
+
# command, not a gate (`rigor check` remains the gate).
|
|
21
|
+
class TriageCommand
|
|
22
|
+
USAGE = "Usage: rigor triage [options] [paths]"
|
|
23
|
+
DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(argv:, out:, err:)
|
|
26
|
+
@argv = argv
|
|
27
|
+
@out = out
|
|
28
|
+
@err = err
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer] CLI exit status (always 0).
|
|
32
|
+
def run
|
|
33
|
+
options = parse_options
|
|
34
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
35
|
+
diagnostics = analyze(configuration)
|
|
36
|
+
|
|
37
|
+
report = Triage.analyze(diagnostics, top: options.fetch(:top),
|
|
38
|
+
hints: options.fetch(:sections).include?(:hints))
|
|
39
|
+
renderer = TriageRenderer.new(report, sections: options.fetch(:sections))
|
|
40
|
+
@out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_options
|
|
47
|
+
options = { config: nil, format: "text", top: 10, sections: DEFAULT_SECTIONS }
|
|
48
|
+
OptionParser.new do |opts|
|
|
49
|
+
opts.banner = USAGE
|
|
50
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
51
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
52
|
+
opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
|
|
53
|
+
opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
|
|
54
|
+
opts.on("--no-hints", "Print distribution + hotspots only") do
|
|
55
|
+
options[:sections] = %i[distribution hotspots]
|
|
56
|
+
end
|
|
57
|
+
end.parse!(@argv)
|
|
58
|
+
validate!(options)
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate!(options)
|
|
63
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
64
|
+
|
|
65
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Sequential, cache-backed, no run-stats: triage only needs
|
|
69
|
+
# the diagnostic stream, and sequential keeps the rule
|
|
70
|
+
# distribution deterministic (the fork pool's cross-file
|
|
71
|
+
# divergence would skew the histogram).
|
|
72
|
+
def analyze(configuration)
|
|
73
|
+
runner = Analysis::Runner.new(
|
|
74
|
+
configuration: configuration,
|
|
75
|
+
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
76
|
+
collect_stats: false,
|
|
77
|
+
workers: 0
|
|
78
|
+
)
|
|
79
|
+
runner.run(@argv.empty? ? configuration.paths : @argv).diagnostics
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../triage"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
class CLI
|
|
9
|
+
# ADR-23 — renders a {Rigor::Triage::Report} as the `rigor
|
|
10
|
+
# triage` text report or as `--format json`.
|
|
11
|
+
class TriageRenderer
|
|
12
|
+
BAR_WIDTH = 24
|
|
13
|
+
|
|
14
|
+
def initialize(report, sections:)
|
|
15
|
+
@report = report
|
|
16
|
+
@sections = sections # subset of %i[distribution hotspots hints]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json
|
|
20
|
+
JSON.pretty_generate(Triage.report_to_h(@report))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def text
|
|
24
|
+
blocks = []
|
|
25
|
+
blocks << distribution_block if @sections.include?(:distribution)
|
|
26
|
+
blocks << hotspots_block if @sections.include?(:hotspots)
|
|
27
|
+
blocks << hints_block if @sections.include?(:hints)
|
|
28
|
+
"#{blocks.join("\n\n")}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def distribution_block
|
|
34
|
+
s = @report.summary
|
|
35
|
+
max = @report.distribution.map(&:count).max || 1
|
|
36
|
+
lines = ["Diagnostic distribution — #{s.total} total " \
|
|
37
|
+
"(#{s.error} error / #{s.warning} warning#{" / #{s.info} info" if s.info.positive?})"]
|
|
38
|
+
@report.distribution.each do |row|
|
|
39
|
+
lines << format(" %<rule>-32s %<count>5d %<bar>s",
|
|
40
|
+
rule: row.rule, count: row.count, bar: bar(row.count, max))
|
|
41
|
+
end
|
|
42
|
+
lines.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def hotspots_block
|
|
46
|
+
return "Hotspot files\n (none)" if @report.hotspots.empty?
|
|
47
|
+
|
|
48
|
+
lines = ["Hotspot files"]
|
|
49
|
+
@report.hotspots.each do |spot|
|
|
50
|
+
by_rule = spot.by_rule.map { |rule, count| "#{rule}×#{count}" }.join(" ")
|
|
51
|
+
lines << format(" %<file>-40s %<count>4d %<rules>s",
|
|
52
|
+
file: spot.file, count: spot.count, rules: by_rule)
|
|
53
|
+
end
|
|
54
|
+
lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hints_block
|
|
58
|
+
return "Hints\n (no heuristic hints)" if @report.hints.empty?
|
|
59
|
+
|
|
60
|
+
lines = ["Hints — heuristics, verify before acting"]
|
|
61
|
+
@report.hints.each do |hint|
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << " [#{hint.confidence} #{hint.id}] #{hint.diagnostic_count} diagnostic(s)"
|
|
64
|
+
lines << " #{hint.summary}"
|
|
65
|
+
lines << " → #{hint.action}"
|
|
66
|
+
end
|
|
67
|
+
lines.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bar(count, max)
|
|
71
|
+
filled = max.zero? ? 0 : (count * BAR_WIDTH / max)
|
|
72
|
+
filled = 1 if filled.zero? && count.positive?
|
|
73
|
+
"█" * filled
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -26,7 +26,9 @@ module Rigor
|
|
|
26
26
|
"explain" => :run_explain,
|
|
27
27
|
"diff" => :run_diff,
|
|
28
28
|
"sig-gen" => :run_sig_gen,
|
|
29
|
-
"lsp" => :run_lsp
|
|
29
|
+
"lsp" => :run_lsp,
|
|
30
|
+
"baseline" => :run_baseline,
|
|
31
|
+
"triage" => :run_triage
|
|
30
32
|
}.freeze
|
|
31
33
|
|
|
32
34
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -71,6 +73,7 @@ module Rigor
|
|
|
71
73
|
def run_check
|
|
72
74
|
require_relative "analysis/runner"
|
|
73
75
|
require_relative "analysis/buffer_binding"
|
|
76
|
+
require_relative "analysis/baseline"
|
|
74
77
|
require_relative "cache/store"
|
|
75
78
|
|
|
76
79
|
options = parse_check_options
|
|
@@ -86,6 +89,7 @@ module Rigor
|
|
|
86
89
|
buffer: buffer, cache_root: cache_root
|
|
87
90
|
)
|
|
88
91
|
result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
92
|
+
result = apply_baseline_filter(result, configuration, options)
|
|
89
93
|
|
|
90
94
|
write_result(result, options.fetch(:format))
|
|
91
95
|
write_run_stats(result.stats) if result.stats
|
|
@@ -93,6 +97,51 @@ module Rigor
|
|
|
93
97
|
result.success? ? 0 : 1
|
|
94
98
|
end
|
|
95
99
|
|
|
100
|
+
# ADR-22 — apply the baseline filter as the LAST step of
|
|
101
|
+
# the diagnostic pipeline (after `# rigor:disable`,
|
|
102
|
+
# `severity_profile`, etc. — WD6). Resolution order
|
|
103
|
+
# follows WD2 (b):
|
|
104
|
+
#
|
|
105
|
+
# 1. --no-baseline on the CLI → no baseline.
|
|
106
|
+
# 2. --baseline=PATH on the CLI → load that path.
|
|
107
|
+
# 3. .rigor.yml's `baseline: <path>` → load that path.
|
|
108
|
+
# 4. otherwise → no baseline.
|
|
109
|
+
#
|
|
110
|
+
# When the path resolves and loads successfully, the filter
|
|
111
|
+
# replaces `result.diagnostics` with the surfaced set and
|
|
112
|
+
# writes a one-line summary to stderr (WD7) when any
|
|
113
|
+
# diagnostics were silenced. Load failures emit a warning
|
|
114
|
+
# to stderr and fall through to "no baseline" (graceful
|
|
115
|
+
# degradation).
|
|
116
|
+
def apply_baseline_filter(result, configuration, options)
|
|
117
|
+
path = resolve_baseline_path(configuration, options)
|
|
118
|
+
return result if path.nil?
|
|
119
|
+
|
|
120
|
+
baseline = Analysis::Baseline.load(path)
|
|
121
|
+
return result if baseline.nil?
|
|
122
|
+
|
|
123
|
+
surfaced, silenced_count = baseline.filter(result.diagnostics)
|
|
124
|
+
report_baseline_summary(silenced_count, path) if silenced_count.positive?
|
|
125
|
+
Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
|
|
126
|
+
rescue Analysis::Baseline::LoadError => e
|
|
127
|
+
@err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# WD2 (b) — resolve effective baseline path.
|
|
132
|
+
def resolve_baseline_path(configuration, options)
|
|
133
|
+
cli_value = options.fetch(:baseline)
|
|
134
|
+
case cli_value
|
|
135
|
+
when false then nil # --no-baseline
|
|
136
|
+
when :unset then configuration.baseline_path # fall through to config
|
|
137
|
+
else cli_value # --baseline=PATH
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def report_baseline_summary(silenced_count, baseline_path)
|
|
142
|
+
@err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
|
|
143
|
+
end
|
|
144
|
+
|
|
96
145
|
def build_check_runner(configuration:, options:, buffer:, cache_root:)
|
|
97
146
|
cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
|
|
98
147
|
Analysis::Runner.new(
|
|
@@ -180,9 +229,14 @@ module Rigor
|
|
|
180
229
|
# Both must appear together; the runner uses the pair
|
|
181
230
|
# to bind an in-flight buffer file to its logical path.
|
|
182
231
|
tmp_file: nil,
|
|
183
|
-
instead_of: nil
|
|
232
|
+
instead_of: nil,
|
|
233
|
+
# ADR-22 — baseline filter. `:unset` means "fall through
|
|
234
|
+
# to `.rigor.yml`'s `baseline:` key"; a String overrides
|
|
235
|
+
# the config; `false` (from `--no-baseline`) suppresses
|
|
236
|
+
# any baseline that the config might name.
|
|
237
|
+
baseline: :unset
|
|
184
238
|
}
|
|
185
|
-
parser = OptionParser.new do |opts|
|
|
239
|
+
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
186
240
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
187
241
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
188
242
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
@@ -206,6 +260,14 @@ module Rigor
|
|
|
206
260
|
"Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
|
|
207
261
|
options[:instead_of] = value
|
|
208
262
|
end
|
|
263
|
+
opts.on("--baseline=PATH",
|
|
264
|
+
"ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
|
|
265
|
+
options[:baseline] = value
|
|
266
|
+
end
|
|
267
|
+
opts.on("--no-baseline",
|
|
268
|
+
"ADR-22: ignore any configured baseline for this run") do
|
|
269
|
+
options[:baseline] = false
|
|
270
|
+
end
|
|
209
271
|
end
|
|
210
272
|
parser.parse!(@argv)
|
|
211
273
|
options
|
|
@@ -397,6 +459,18 @@ module Rigor
|
|
|
397
459
|
LspCommand.new(argv: @argv, out: @out, err: @err).run
|
|
398
460
|
end
|
|
399
461
|
|
|
462
|
+
def run_baseline
|
|
463
|
+
require_relative "cli/baseline_command"
|
|
464
|
+
|
|
465
|
+
BaselineCommand.new(argv: @argv, out: @out, err: @err).run
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def run_triage
|
|
469
|
+
require_relative "cli/triage_command"
|
|
470
|
+
|
|
471
|
+
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
472
|
+
end
|
|
473
|
+
|
|
400
474
|
def write_result(result, format)
|
|
401
475
|
case format
|
|
402
476
|
when "json"
|
|
@@ -438,6 +512,7 @@ module Rigor
|
|
|
438
512
|
diff Compare current diagnostics to a saved baseline JSON
|
|
439
513
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
440
514
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
515
|
+
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
441
516
|
version Print the Rigor version
|
|
442
517
|
help Print this help
|
|
443
518
|
HELP
|