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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -29
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/check_rules.rb +60 -3
  5. data/lib/rigor/analysis/diagnostic.rb +17 -3
  6. data/lib/rigor/analysis/runner.rb +178 -3
  7. data/lib/rigor/analysis/worker_session.rb +14 -3
  8. data/lib/rigor/builtins/static_return_refinements.rb +23 -1
  9. data/lib/rigor/cli/baseline_command.rb +377 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +78 -3
  13. data/lib/rigor/configuration.rb +21 -1
  14. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  15. data/lib/rigor/environment/rbs_loader.rb +22 -0
  16. data/lib/rigor/environment.rb +13 -0
  17. data/lib/rigor/flow_contribution/fact.rb +20 -10
  18. data/lib/rigor/inference/expression_typer.rb +152 -14
  19. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
  20. data/lib/rigor/inference/method_dispatcher.rb +50 -5
  21. data/lib/rigor/inference/narrowing.rb +103 -1
  22. data/lib/rigor/inference/scope_indexer.rb +209 -13
  23. data/lib/rigor/inference/statement_evaluator.rb +91 -10
  24. data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
  25. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  26. data/lib/rigor/scope.rb +46 -0
  27. data/lib/rigor/triage/catalogue.rb +296 -0
  28. data/lib/rigor/triage/hint.rb +27 -0
  29. data/lib/rigor/triage.rb +89 -0
  30. data/lib/rigor/version.rb +1 -1
  31. data/sig/rigor/environment.rbs +2 -0
  32. data/sig/rigor/inference.rbs +1 -0
  33. data/sig/rigor/scope.rbs +6 -0
  34. data/sig/rigor.rbs +1 -0
  35. 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