rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -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 +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -9,22 +9,22 @@ require_relative "../configuration"
9
9
 
10
10
  module Rigor
11
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`.
12
+ # ADR-22 — `rigor baseline {generate,regenerate,dump,drift,
13
+ # prune}` subcommands, backed by `Rigor::Analysis::Baseline`.
18
14
  #
19
15
  # rigor baseline generate # default: rule-ID rows
20
16
  # rigor baseline generate --match-mode message
21
17
  # rigor baseline generate --force # overwrite existing
22
18
  # rigor baseline generate --output=PATH
19
+ # rigor baseline regenerate # slice 5: unconditional rewrite
20
+ # rigor baseline dump
21
+ # rigor baseline drift
22
+ # rigor baseline prune
23
23
  class BaselineCommand # rubocop:disable Metrics/ClassLength
24
24
  EXIT_USAGE = 64
25
25
  DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
26
26
 
27
- SUBCOMMANDS = %w[generate dump drift prune].freeze
27
+ SUBCOMMANDS = %w[generate regenerate dump drift prune].freeze
28
28
 
29
29
  def initialize(argv:, out: $stdout, err: $stderr)
30
30
  @argv = argv
@@ -39,6 +39,7 @@ module Rigor
39
39
  @out.puts(help)
40
40
  0
41
41
  when "generate" then run_generate
42
+ when "regenerate" then run_regenerate
42
43
  when "dump" then run_dump
43
44
  when "drift" then run_drift
44
45
  when "prune" then run_prune
@@ -59,21 +60,36 @@ module Rigor
59
60
  path = options.fetch(:output)
60
61
 
61
62
  if File.exist?(path) && !options.fetch(:force)
62
- @err.puts("rigor: #{path} already exists. Re-run with --force to overwrite.")
63
+ @err.puts("rigor: #{path} already exists. Re-run with --force to " \
64
+ "overwrite, or use `rigor baseline regenerate`.")
63
65
  return EXIT_USAGE
64
66
  end
65
67
 
68
+ write_baseline(options, verb: "wrote baseline to")
69
+ end
70
+
71
+ # ADR-22 slice 5 — `regenerate` is `generate --force`: the
72
+ # end-of-quality-improvement-session refresh after landing
73
+ # baseline-reducing fixes. It rewrites the file
74
+ # unconditionally (no existence guard, no `--force` flag),
75
+ # so `rigor baseline regenerate` reads cleanly as "make the
76
+ # baseline match reality again".
77
+ def run_regenerate
78
+ options = parse_generate_options(subcommand: "regenerate")
79
+ write_baseline(options, verb: "regenerated baseline")
80
+ end
81
+
82
+ def write_baseline(options, verb:)
83
+ path = options.fetch(:output)
66
84
  configuration = Configuration.load(options.fetch(:config))
67
85
  diagnostics = collect_diagnostics(configuration, options)
68
86
 
69
87
  baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode))
70
88
  File.write(path, baseline.to_yaml)
71
89
 
72
- bucket_count = baseline.size
73
- diagnostic_count = diagnostics.size
74
90
  @err.puts(
75
- "rigor: wrote baseline to #{path} " \
76
- "(#{bucket_count} bucket(s) covering #{diagnostic_count} diagnostic(s); " \
91
+ "rigor: #{verb} #{path} " \
92
+ "(#{baseline.size} bucket(s) covering #{diagnostics.size} diagnostic(s); " \
77
93
  "match-mode: #{options.fetch(:match_mode)})"
78
94
  )
79
95
  if configuration.baseline_path.nil?
@@ -85,7 +101,7 @@ module Rigor
85
101
  0
86
102
  end
87
103
 
88
- def parse_generate_options
104
+ def parse_generate_options(subcommand: "generate")
89
105
  options = {
90
106
  config: nil,
91
107
  output: DEFAULT_BASELINE_PATH,
@@ -93,7 +109,7 @@ module Rigor
93
109
  force: false
94
110
  }
95
111
  parser = OptionParser.new do |opts|
96
- opts.banner = "Usage: rigor baseline generate [options]"
112
+ opts.banner = "Usage: rigor baseline #{subcommand} [options]"
97
113
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
98
114
  opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
99
115
  options[:output] = v
@@ -102,7 +118,10 @@ module Rigor
102
118
  "Row form: rule (default) or message") do |v|
103
119
  options[:match_mode] = v
104
120
  end
105
- opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
121
+ # `regenerate` always overwrites no `--force` to offer.
122
+ if subcommand == "generate"
123
+ opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
124
+ end
106
125
  end
107
126
  parser.parse!(@argv)
108
127
  options
@@ -290,7 +309,7 @@ module Rigor
290
309
  case status
291
310
  when :over then "## Over threshold (#{count}) — bucket exceeded; check the regular diagnostic output."
292
311
  when :cleared then "## Cleared (#{count}) — `rigor baseline prune` can drop these."
293
- when :reducible then "## Reducible (#{count}) — tightening opportunity; consider `regenerate` (slice 5)."
312
+ when :reducible then "## Reducible (#{count}) — tightening opportunity; run `rigor baseline regenerate`."
294
313
  when :within then "## Within threshold (#{count})"
295
314
  end
296
315
  end
@@ -365,6 +384,7 @@ module Rigor
365
384
 
366
385
  Subcommands:
367
386
  generate Write a fresh baseline file from a `rigor check` run.
387
+ regenerate Rewrite the baseline unconditionally (post-fix refresh).
368
388
  dump Print the contents of an existing baseline.
369
389
  drift Compare baseline vs current diagnostics (reduction / regression hints).
370
390
  prune Drop cleared buckets (`actual == 0`) from the baseline.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Re-lexes Ruby source with Prism and wraps each token in an
8
+ # ANSI colour escape, producing IRB-style syntax highlighting.
9
+ #
10
+ # Rigor ships no runtime dependencies (ADR-0), so the `irb`
11
+ # gem's `IRB::Color` is not available; this module reproduces
12
+ # the same effect from Prism's own token stream. `rigor
13
+ # annotate` re-parses its annotated output through here before
14
+ # printing.
15
+ module PrismColorizer
16
+ module_function
17
+
18
+ RESET = "\e[0m"
19
+
20
+ # token category => ANSI SGR parameters.
21
+ CATEGORY_SGR = {
22
+ comment: "90", # bright black (faint grey)
23
+ keyword: "33", # yellow
24
+ literal_kw: "36", # cyan — nil / true / false / self
25
+ number: "34", # blue
26
+ string: "32", # green
27
+ symbol: "36", # cyan
28
+ constant: "1;34", # bold blue
29
+ variable: "34", # blue — @ivar / @@cvar / $gvar
30
+ default: nil
31
+ }.freeze
32
+
33
+ LITERAL_KEYWORDS = %i[
34
+ KEYWORD_NIL KEYWORD_TRUE KEYWORD_FALSE KEYWORD_SELF
35
+ KEYWORD___FILE__ KEYWORD___LINE__ KEYWORD___ENCODING__
36
+ ].freeze
37
+
38
+ VARIABLE_TOKENS = %i[INSTANCE_VARIABLE CLASS_VARIABLE GLOBAL_VARIABLE].freeze
39
+
40
+ # @param source [String] Ruby source.
41
+ # @return [String] the source with ANSI colour escapes, or
42
+ # the input unchanged when lexing surfaces an error.
43
+ def colorize(source)
44
+ result = Prism.lex(source)
45
+ return source unless result.errors.empty?
46
+
47
+ render(source, result.value)
48
+ end
49
+
50
+ def render(source, lexed)
51
+ out = +""
52
+ offset = 0
53
+ previous_type = nil
54
+ lexed.each do |entry|
55
+ token = entry.first
56
+ location = token.location
57
+ out << source[offset...location.start_offset]
58
+ break if token.type == :EOF
59
+
60
+ text = source[location.start_offset...location.end_offset]
61
+ out << paint(text, effective_category(token.type, previous_type))
62
+ offset = location.end_offset
63
+ previous_type = token.type
64
+ end
65
+ out << (source[offset..] || "")
66
+ out
67
+ end
68
+
69
+ # The token after a `SYMBOL_BEGIN` (`:`) carries the symbol
70
+ # name — and Prism lexes `:then` / `:class` etc. as a
71
+ # keyword token — so it is painted with the symbol colour
72
+ # regardless of its own token type.
73
+ def effective_category(token_type, previous_type)
74
+ return :symbol if previous_type == :SYMBOL_BEGIN
75
+
76
+ category(token_type)
77
+ end
78
+
79
+ def paint(text, category)
80
+ sgr = CATEGORY_SGR.fetch(category)
81
+ return text if sgr.nil? || text.empty?
82
+
83
+ # Keep a trailing newline outside the colour span so the
84
+ # reset sits on the token's own line (comments include it).
85
+ trailing = text[/\s*\z/] || ""
86
+ body = trailing.empty? ? text : text[0...-trailing.length]
87
+ return text if body.empty?
88
+
89
+ "\e[#{sgr}m#{body}#{RESET}#{trailing}"
90
+ end
91
+
92
+ def category(token_type)
93
+ name = token_type.to_s
94
+ return :comment if token_type == :COMMENT
95
+ return :literal_kw if LITERAL_KEYWORDS.include?(token_type)
96
+ return :keyword if name.start_with?("KEYWORD_")
97
+ return :number if number_token?(name)
98
+ return :string if name.start_with?("STRING_", "HEREDOC_") || token_type == :STRING_CONTENT
99
+ return :symbol if name.start_with?("SYMBOL_")
100
+ return :constant if token_type == :CONSTANT
101
+ return :variable if VARIABLE_TOKENS.include?(token_type)
102
+
103
+ :default
104
+ end
105
+
106
+ def number_token?(name)
107
+ name.start_with?("INTEGER", "FLOAT", "RATIONAL", "IMAGINARY")
108
+ end
109
+ end
110
+ end
111
+ 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
@@ -21,13 +21,15 @@ module Rigor
21
21
  HANDLERS = {
22
22
  "check" => :run_check,
23
23
  "init" => :run_init,
24
+ "annotate" => :run_annotate,
24
25
  "type-of" => :run_type_of,
25
26
  "type-scan" => :run_type_scan,
26
27
  "explain" => :run_explain,
27
28
  "diff" => :run_diff,
28
29
  "sig-gen" => :run_sig_gen,
29
30
  "lsp" => :run_lsp,
30
- "baseline" => :run_baseline
31
+ "baseline" => :run_baseline,
32
+ "triage" => :run_triage
31
33
  }.freeze
32
34
 
33
35
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -87,13 +89,56 @@ module Rigor
87
89
  configuration: configuration, options: options,
88
90
  buffer: buffer, cache_root: cache_root
89
91
  )
90
- result = runner.run(@argv.empty? ? configuration.paths : @argv)
91
- result = apply_baseline_filter(result, configuration, options)
92
+ raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
93
+ result = apply_baseline_filter(raw_result, configuration, options)
92
94
 
93
95
  write_result(result, options.fetch(:format))
94
96
  write_run_stats(result.stats) if result.stats
95
97
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
96
- result.success? ? 0 : 1
98
+
99
+ exit_code = result.success? ? 0 : 1
100
+ exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
101
+ exit_code
102
+ end
103
+
104
+ # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
105
+ # flag is set, ANY baseline drift fails the run — not only
106
+ # excess drift (a bucket over threshold, which already fails
107
+ # via the surfaced diagnostics) but also DEFICIT drift
108
+ # (`actual < count`: the baseline has grown looser than the
109
+ # code and should be regenerated). A no-op, with a stderr
110
+ # note, when no baseline is active — the flag never
111
+ # implicitly loads a baseline the config did not name (WD2).
112
+ def baseline_strict_violation?(raw_diagnostics, configuration, options)
113
+ return false unless options.fetch(:baseline_strict)
114
+
115
+ path = resolve_baseline_path(configuration, options)
116
+ if path.nil?
117
+ @err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
118
+ return false
119
+ end
120
+
121
+ baseline = Analysis::Baseline.load(path)
122
+ return false if baseline.nil? || baseline.empty?
123
+
124
+ drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
125
+ return false if drifted.empty?
126
+
127
+ report_strict_drift(drifted, path)
128
+ true
129
+ rescue Analysis::Baseline::LoadError => e
130
+ @err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
131
+ false
132
+ end
133
+
134
+ def report_strict_drift(rows, path)
135
+ @err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
136
+ rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
137
+ delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
138
+ @err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
139
+ "#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
140
+ end
141
+ @err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
97
142
  end
98
143
 
99
144
  # ADR-22 — apply the baseline filter as the LAST step of
@@ -233,7 +278,10 @@ module Rigor
233
278
  # to `.rigor.yml`'s `baseline:` key"; a String overrides
234
279
  # the config; `false` (from `--no-baseline`) suppresses
235
280
  # any baseline that the config might name.
236
- baseline: :unset
281
+ baseline: :unset,
282
+ # ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
283
+ # run on any baseline drift, in either direction.
284
+ baseline_strict: false
237
285
  }
238
286
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
239
287
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -267,6 +315,10 @@ module Rigor
267
315
  "ADR-22: ignore any configured baseline for this run") do
268
316
  options[:baseline] = false
269
317
  end
318
+ opts.on("--baseline-strict",
319
+ "ADR-22: fail the run on any baseline drift (CI gate)") do
320
+ options[:baseline_strict] = true
321
+ end
270
322
  end
271
323
  parser.parse!(@argv)
272
324
  options
@@ -422,6 +474,12 @@ module Rigor
422
474
  YAML
423
475
  end
424
476
 
477
+ def run_annotate
478
+ require_relative "cli/annotate_command"
479
+
480
+ AnnotateCommand.new(argv: @argv, out: @out, err: @err).run
481
+ end
482
+
425
483
  def run_type_of
426
484
  require_relative "cli/type_of_command"
427
485
 
@@ -464,6 +522,12 @@ module Rigor
464
522
  BaselineCommand.new(argv: @argv, out: @out, err: @err).run
465
523
  end
466
524
 
525
+ def run_triage
526
+ require_relative "cli/triage_command"
527
+
528
+ CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
529
+ end
530
+
467
531
  def write_result(result, format)
468
532
  case format
469
533
  when "json"
@@ -499,12 +563,14 @@ module Rigor
499
563
  Commands:
500
564
  check Analyze Ruby source files
501
565
  init Create a starter .rigor.yml
566
+ annotate Print FILE with each line's last-expression type
502
567
  type-of Print the inferred type at FILE:LINE:COL
503
568
  type-scan Report Scope#type_of coverage across PATHs
504
569
  explain Print the description of one or all CheckRules
505
570
  diff Compare current diagnostics to a saved baseline JSON
506
571
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
507
572
  lsp Run the Rigor Language Server (LSP) over stdio
573
+ triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
508
574
  version Print the Rigor version
509
575
  help Print this help
510
576
  HELP
@@ -252,7 +252,15 @@ module Rigor
252
252
  project_root: root,
253
253
  auto_detect: rbs_collection_auto_detect
254
254
  ).map(&:to_s)
255
- loader_signature_paths = resolved_paths + gem_sig_paths + collection_paths
255
+ # ADR-25 RBS signature directories contributed by loaded
256
+ # plugins via their manifest `signature_paths:`. Resolved
257
+ # to absolute dirs by `Plugin::Base#signature_paths`;
258
+ # additive, ranked below the user's explicit
259
+ # `signature_paths:` and above the opportunistic bundle /
260
+ # collection discovery. A duplicate-declaration conflict
261
+ # degrades through the same O7 failure-memo path.
262
+ plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
263
+ loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
256
264
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
257
265
  loader = RbsLoader.new(
258
266
  libraries: merged_libraries,
@@ -57,7 +57,8 @@ module Rigor
57
57
  return nil unless klass
58
58
 
59
59
  bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
60
- klass.dig(bucket_key, selector.to_s)
60
+ klass.dig(bucket_key, selector.to_s) ||
61
+ resolve_alias_entry(klass, selector, bucket_key)
61
62
  end
62
63
 
63
64
  def reset!
@@ -66,6 +67,21 @@ module Rigor
66
67
 
67
68
  private
68
69
 
70
+ def resolve_alias_entry(klass, selector, bucket_key)
71
+ return nil unless bucket_key == "instance_methods"
72
+
73
+ aliases = klass["aliases"]
74
+ return nil unless aliases
75
+
76
+ alias_entry = aliases[selector.to_s]
77
+ return nil unless alias_entry
78
+
79
+ target = alias_entry["old"]
80
+ return nil unless target
81
+
82
+ klass.dig(bucket_key, target)
83
+ end
84
+
69
85
  def blocked?(class_name, selector)
70
86
  # Bang-suffixed selectors are mutating by Ruby convention
71
87
  # (`upcase!`, `concat`, etc. are listed explicitly below;
@@ -55,7 +55,16 @@ module Rigor
55
55
  # as `time_localtime`: `time_modify(time)` then a
56
56
  # `time_set_vtm` write and `TZMODE_SET_UTC`. Both
57
57
  # selectors share the cfunc, so both must be blocked.
58
- :gmtime, :utc
58
+ :gmtime, :utc,
59
+ # `getlocal` is not a mutator — it returns a fresh Time —
60
+ # but the fresh Time is pinned to the *analysis machine's*
61
+ # timezone. Folding it through a `Constant[Time]` carrier
62
+ # (which only ever holds a UTC literal from `Time.utc`)
63
+ # would bake a host-dependent wall clock / `utc_offset`
64
+ # into the inferred type. Blocked so the fold stays
65
+ # machine-independent; the RBS tier answers `Nominal[Time]`.
66
+ # `getutc` / `getgm` stay foldable — their result is UTC.
67
+ :getlocal
59
68
  ]
60
69
  }
61
70
  )