rigortype 0.1.8 → 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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/cli/annotate_command.rb +224 -0
  5. data/lib/rigor/cli/baseline_command.rb +36 -16
  6. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  7. data/lib/rigor/cli.rb +62 -4
  8. data/lib/rigor/environment.rb +9 -1
  9. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  10. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  11. data/lib/rigor/inference/expression_typer.rb +165 -6
  12. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  13. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  14. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  15. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  16. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  17. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  18. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  19. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  20. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  21. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  22. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  23. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  24. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  25. data/lib/rigor/inference/narrowing.rb +29 -10
  26. data/lib/rigor/inference/statement_evaluator.rb +3 -1
  27. data/lib/rigor/plugin/base.rb +39 -0
  28. data/lib/rigor/plugin/loader.rb +22 -1
  29. data/lib/rigor/plugin/manifest.rb +73 -10
  30. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  31. data/lib/rigor/plugin/registry.rb +66 -0
  32. data/lib/rigor/triage/catalogue.rb +2 -2
  33. data/lib/rigor/type/constant.rb +29 -2
  34. data/lib/rigor/version.rb +1 -1
  35. metadata +11 -1
@@ -355,6 +355,15 @@ module Rigor
355
355
  class_name = concrete_class_name(receiver_type)
356
356
  return nil if class_name.nil?
357
357
 
358
+ # ADR-26 — a plugin may declare a class "open": one
359
+ # known to respond beyond its RBS-declared method
360
+ # surface (e.g. `ActiveRecord::Relation`, which
361
+ # delegates an unbounded set of user-defined scopes to
362
+ # its model). Flagging an undefined method on a class
363
+ # with an open dynamic surface is unsound, so the rule
364
+ # skips it.
365
+ return nil if open_receiver?(class_name, scope)
366
+
358
367
  # Slice 7 phase 12 — suppress when the user has
359
368
  # declared the method in source (instance `def`,
360
369
  # `def self.foo`, or recognised `define_method`).
@@ -424,6 +433,17 @@ module Rigor
424
433
  nil
425
434
  end
426
435
 
436
+ # ADR-26 — whether `class_name` is declared "open" by a
437
+ # loaded plugin (manifest `open_receivers:`). An open
438
+ # class responds beyond its RBS surface, so the
439
+ # `call.undefined-method` rule must not fire for it.
440
+ def open_receiver?(class_name, scope)
441
+ registry = scope.environment&.plugin_registry
442
+ return false if registry.nil?
443
+
444
+ registry.open_receiver?(class_name)
445
+ end
446
+
427
447
  def definition_available?(receiver_type, class_name, scope)
428
448
  if receiver_type.is_a?(Type::Singleton)
429
449
  !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+ require "prism"
5
+
6
+ require_relative "../configuration"
7
+ require_relative "../environment"
8
+ require_relative "../scope"
9
+ require_relative "../inference/scope_indexer"
10
+ require_relative "prism_colorizer"
11
+
12
+ module Rigor
13
+ class CLI
14
+ # Executes `rigor annotate FILE`.
15
+ #
16
+ # For every source line the command finds the expression the
17
+ # line evaluates to — the last statement that ends on the line
18
+ # (so `1; 2; 3` reports `3`), or, for a line that no statement
19
+ # closes, the widest expression ending there (so the `if nil`
20
+ # header reports its condition). It infers that expression's
21
+ # type and appends a `#=> dump_type: <type>` comment.
22
+ #
23
+ # The annotated source is re-parsed with Prism — a sanity gate,
24
+ # since the appended text is always a comment — and printed to
25
+ # stdout with IRB-style syntax highlighting via
26
+ # {PrismColorizer}.
27
+ class AnnotateCommand
28
+ USAGE = "Usage: rigor annotate [options] FILE"
29
+
30
+ # Appended ` #=> dump_type: <type>` suffix. Matched and
31
+ # stripped before re-annotating so re-running is idempotent.
32
+ ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
33
+
34
+ def initialize(argv:, out:, err:)
35
+ @argv = argv
36
+ @out = out
37
+ @err = err
38
+ end
39
+
40
+ # @return [Integer] CLI exit status.
41
+ def run
42
+ options = parse_options
43
+ file = @argv.shift
44
+ if file.nil?
45
+ @err.puts(USAGE)
46
+ return CLI::EXIT_USAGE
47
+ end
48
+ unless File.file?(file)
49
+ @err.puts("annotate: file not found: #{file}")
50
+ return 1
51
+ end
52
+
53
+ execute(file, options)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_options
59
+ # Default: colour a tty, unless `NO_COLOR` opts out. An
60
+ # explicit `--color` / `--no-color` overrides both.
61
+ options = { config: nil, color: @out.tty? && !no_color_env? }
62
+
63
+ parser = OptionParser.new do |opts|
64
+ opts.banner = USAGE
65
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
66
+ opts.on("--[no-]color",
67
+ "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
68
+ options[:color] = value
69
+ end
70
+ end
71
+ parser.parse!(@argv)
72
+
73
+ options
74
+ end
75
+
76
+ # https://no-color.org — colour output is suppressed by
77
+ # default when `NO_COLOR` is present and not an empty string,
78
+ # regardless of its value.
79
+ def no_color_env?
80
+ value = ENV.fetch("NO_COLOR", nil)
81
+ !value.nil? && !value.empty?
82
+ end
83
+
84
+ def execute(file, options)
85
+ configuration = Configuration.load(options.fetch(:config))
86
+ source = File.read(file)
87
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
88
+ return 1 if parse_errors?(parse_result, file)
89
+
90
+ scope_index = Inference::ScopeIndexer.index(
91
+ parse_result.value, default_scope: base_scope(configuration)
92
+ )
93
+ line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
94
+
95
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
96
+ 0
97
+ end
98
+
99
+ def base_scope(configuration)
100
+ Scope.empty(
101
+ environment: Environment.for_project(
102
+ libraries: configuration.libraries,
103
+ signature_paths: configuration.signature_paths
104
+ )
105
+ )
106
+ end
107
+
108
+ def parse_errors?(parse_result, file)
109
+ return false if parse_result.success?
110
+
111
+ parse_result.errors.each do |error|
112
+ @err.puts("#{file}:#{error.location.start_line}: #{error.message}")
113
+ end
114
+ true
115
+ end
116
+
117
+ # Appends ` #=> dump_type: <type>` to every line a type was
118
+ # inferred for, aligning the comment column.
119
+ def annotate(source, line_types)
120
+ lines = source.lines
121
+ column = annotation_column(lines, line_types)
122
+
123
+ lines.each_with_index.map do |line, index|
124
+ type = line_types[index + 1]
125
+ eol = line.end_with?("\n") ? "\n" : ""
126
+ code = line.chomp.sub(ANNOTATION_PATTERN, "")
127
+ next "#{code}#{eol}" if type.nil?
128
+
129
+ "#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
130
+ end.join
131
+ end
132
+
133
+ def annotation_column(lines, line_types)
134
+ widths = lines.each_index.filter_map do |index|
135
+ next unless line_types.key?(index + 1)
136
+
137
+ lines[index].chomp.sub(ANNOTATION_PATTERN, "").length
138
+ end
139
+ widths.max || 0
140
+ end
141
+
142
+ def render(annotated, color:)
143
+ return annotated unless color
144
+ return annotated unless Prism.parse(annotated).success?
145
+
146
+ PrismColorizer.colorize(annotated)
147
+ end
148
+ end
149
+
150
+ # Walks a parsed program and resolves, per source line, the
151
+ # type of the expression the line evaluates to. Used only by
152
+ # {AnnotateCommand}.
153
+ class LineTypeCollector
154
+ def initialize(scope_index)
155
+ @scope_index = scope_index
156
+ end
157
+
158
+ # @param program [Prism::ProgramNode]
159
+ # @return [Hash{Integer => Rigor::Type}] 1-indexed line => type.
160
+ def collect(program)
161
+ by_line = {}
162
+ each_statement(program) do |statement|
163
+ type = type_of(statement)
164
+ by_line[statement.location.end_line] = type unless type.nil?
165
+ end
166
+ fill_uncovered_lines(program, by_line)
167
+ by_line
168
+ end
169
+
170
+ private
171
+
172
+ # Yields each statement node (a child of any `StatementsNode`
173
+ # anywhere in the tree) in source order, so a later statement
174
+ # ending on a line overwrites an earlier one — `1; 2; 3`
175
+ # resolves to `3`.
176
+ def each_statement(node, &)
177
+ return if node.nil?
178
+
179
+ node.body.each(&) if node.is_a?(Prism::StatementsNode)
180
+ node.compact_child_nodes.each { |child| each_statement(child, &) }
181
+ end
182
+
183
+ # For a line no statement closes (the `if` / block header
184
+ # lines), fall back to the widest expression ending there.
185
+ def fill_uncovered_lines(program, by_line)
186
+ widest_per_line(program).each do |line, node|
187
+ next if by_line.key?(line)
188
+
189
+ type = type_of(node)
190
+ by_line[line] = type unless type.nil?
191
+ end
192
+ end
193
+
194
+ def widest_per_line(program)
195
+ widest = {}
196
+ walk(program) do |node|
197
+ next if node.is_a?(Prism::ProgramNode) || node.is_a?(Prism::StatementsNode)
198
+
199
+ line = node.location.end_line
200
+ current = widest[line]
201
+ widest[line] = node if current.nil? || span(node) > span(current)
202
+ end
203
+ widest
204
+ end
205
+
206
+ def span(node)
207
+ node.location.end_offset - node.location.start_offset
208
+ end
209
+
210
+ def walk(node, &block)
211
+ return if node.nil?
212
+
213
+ block.call(node)
214
+ node.compact_child_nodes.each { |child| walk(child, &block) }
215
+ end
216
+
217
+ def type_of(node)
218
+ @scope_index[node].type_of(node)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ end
223
+ end
224
+ end
@@ -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
data/lib/rigor/cli.rb CHANGED
@@ -21,6 +21,7 @@ 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,
@@ -88,13 +89,56 @@ module Rigor
88
89
  configuration: configuration, options: options,
89
90
  buffer: buffer, cache_root: cache_root
90
91
  )
91
- result = runner.run(@argv.empty? ? configuration.paths : @argv)
92
- 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)
93
94
 
94
95
  write_result(result, options.fetch(:format))
95
96
  write_run_stats(result.stats) if result.stats
96
97
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
97
- 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.")
98
142
  end
99
143
 
100
144
  # ADR-22 — apply the baseline filter as the LAST step of
@@ -234,7 +278,10 @@ module Rigor
234
278
  # to `.rigor.yml`'s `baseline:` key"; a String overrides
235
279
  # the config; `false` (from `--no-baseline`) suppresses
236
280
  # any baseline that the config might name.
237
- 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
238
285
  }
239
286
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
240
287
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -268,6 +315,10 @@ module Rigor
268
315
  "ADR-22: ignore any configured baseline for this run") do
269
316
  options[:baseline] = false
270
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
271
322
  end
272
323
  parser.parse!(@argv)
273
324
  options
@@ -423,6 +474,12 @@ module Rigor
423
474
  YAML
424
475
  end
425
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
+
426
483
  def run_type_of
427
484
  require_relative "cli/type_of_command"
428
485
 
@@ -506,6 +563,7 @@ module Rigor
506
563
  Commands:
507
564
  check Analyze Ruby source files
508
565
  init Create a starter .rigor.yml
566
+ annotate Print FILE with each line's last-expression type
509
567
  type-of Print the inferred type at FILE:LINE:COL
510
568
  type-scan Report Scope#type_of coverage across PATHs
511
569
  explain Print the description of one or all CheckRules
@@ -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
  )