rigortype 0.1.6 → 0.1.7

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.
@@ -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
data/lib/rigor/cli.rb CHANGED
@@ -26,7 +26,8 @@ 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
30
31
  }.freeze
31
32
 
32
33
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -71,6 +72,7 @@ module Rigor
71
72
  def run_check
72
73
  require_relative "analysis/runner"
73
74
  require_relative "analysis/buffer_binding"
75
+ require_relative "analysis/baseline"
74
76
  require_relative "cache/store"
75
77
 
76
78
  options = parse_check_options
@@ -86,6 +88,7 @@ module Rigor
86
88
  buffer: buffer, cache_root: cache_root
87
89
  )
88
90
  result = runner.run(@argv.empty? ? configuration.paths : @argv)
91
+ result = apply_baseline_filter(result, configuration, options)
89
92
 
90
93
  write_result(result, options.fetch(:format))
91
94
  write_run_stats(result.stats) if result.stats
@@ -93,6 +96,51 @@ module Rigor
93
96
  result.success? ? 0 : 1
94
97
  end
95
98
 
99
+ # ADR-22 — apply the baseline filter as the LAST step of
100
+ # the diagnostic pipeline (after `# rigor:disable`,
101
+ # `severity_profile`, etc. — WD6). Resolution order
102
+ # follows WD2 (b):
103
+ #
104
+ # 1. --no-baseline on the CLI → no baseline.
105
+ # 2. --baseline=PATH on the CLI → load that path.
106
+ # 3. .rigor.yml's `baseline: <path>` → load that path.
107
+ # 4. otherwise → no baseline.
108
+ #
109
+ # When the path resolves and loads successfully, the filter
110
+ # replaces `result.diagnostics` with the surfaced set and
111
+ # writes a one-line summary to stderr (WD7) when any
112
+ # diagnostics were silenced. Load failures emit a warning
113
+ # to stderr and fall through to "no baseline" (graceful
114
+ # degradation).
115
+ def apply_baseline_filter(result, configuration, options)
116
+ path = resolve_baseline_path(configuration, options)
117
+ return result if path.nil?
118
+
119
+ baseline = Analysis::Baseline.load(path)
120
+ return result if baseline.nil?
121
+
122
+ surfaced, silenced_count = baseline.filter(result.diagnostics)
123
+ report_baseline_summary(silenced_count, path) if silenced_count.positive?
124
+ Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
125
+ rescue Analysis::Baseline::LoadError => e
126
+ @err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
127
+ result
128
+ end
129
+
130
+ # WD2 (b) — resolve effective baseline path.
131
+ def resolve_baseline_path(configuration, options)
132
+ cli_value = options.fetch(:baseline)
133
+ case cli_value
134
+ when false then nil # --no-baseline
135
+ when :unset then configuration.baseline_path # fall through to config
136
+ else cli_value # --baseline=PATH
137
+ end
138
+ end
139
+
140
+ def report_baseline_summary(silenced_count, baseline_path)
141
+ @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
142
+ end
143
+
96
144
  def build_check_runner(configuration:, options:, buffer:, cache_root:)
97
145
  cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
98
146
  Analysis::Runner.new(
@@ -180,9 +228,14 @@ module Rigor
180
228
  # Both must appear together; the runner uses the pair
181
229
  # to bind an in-flight buffer file to its logical path.
182
230
  tmp_file: nil,
183
- instead_of: nil
231
+ instead_of: nil,
232
+ # ADR-22 — baseline filter. `:unset` means "fall through
233
+ # to `.rigor.yml`'s `baseline:` key"; a String overrides
234
+ # the config; `false` (from `--no-baseline`) suppresses
235
+ # any baseline that the config might name.
236
+ baseline: :unset
184
237
  }
185
- parser = OptionParser.new do |opts|
238
+ parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
186
239
  opts.banner = "Usage: rigor check [options] [paths]"
187
240
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
188
241
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
@@ -206,6 +259,14 @@ module Rigor
206
259
  "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
207
260
  options[:instead_of] = value
208
261
  end
262
+ opts.on("--baseline=PATH",
263
+ "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
264
+ options[:baseline] = value
265
+ end
266
+ opts.on("--no-baseline",
267
+ "ADR-22: ignore any configured baseline for this run") do
268
+ options[:baseline] = false
269
+ end
209
270
  end
210
271
  parser.parse!(@argv)
211
272
  options
@@ -397,6 +458,12 @@ module Rigor
397
458
  LspCommand.new(argv: @argv, out: @out, err: @err).run
398
459
  end
399
460
 
461
+ def run_baseline
462
+ require_relative "cli/baseline_command"
463
+
464
+ BaselineCommand.new(argv: @argv, out: @out, err: @err).run
465
+ end
466
+
400
467
  def write_result(result, format)
401
468
  case format
402
469
  when "json"
@@ -59,6 +59,14 @@ module Rigor
59
59
  # the dispatcher tier consuming the registry lands in
60
60
  # slice 2.
61
61
  "pre_eval" => [],
62
+ # ADR-22 — baseline file path. nil (default) means no
63
+ # baseline is loaded; the `false` literal is treated as
64
+ # the explicit-disable form for `.rigor.yml`-side override
65
+ # of an upstream `.rigor.dist.yml` `baseline:` declaration.
66
+ # The presence of `.rigor-baseline.yml` on disk alone does
67
+ # NOT activate filtering — the path must be named here
68
+ # (WD2 (b) of ADR-22).
69
+ "baseline" => nil,
62
70
  "fold_platform_specific_paths" => false,
63
71
  "cache" => {
64
72
  "path" => ".rigor/cache"
@@ -166,7 +174,7 @@ module Rigor
166
174
  :dependencies, :parallel_workers,
167
175
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
168
176
  :rbs_collection_lockfile, :rbs_collection_auto_detect,
169
- :pre_eval
177
+ :pre_eval, :baseline_path
170
178
 
171
179
  # Loads a configuration file.
172
180
  #
@@ -321,6 +329,7 @@ module Rigor
321
329
  @pre_eval = expand_pre_eval_entries(
322
330
  Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
323
331
  )
332
+ @baseline_path = coerce_baseline_path(data.fetch("baseline", DEFAULTS.fetch("baseline")))
324
333
  @fold_platform_specific_paths = data.fetch(
325
334
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
326
335
  ) == true
@@ -488,6 +497,17 @@ module Rigor
488
497
  raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
489
498
  end
490
499
 
500
+ # ADR-22 WD2 (b) — `baseline: <path>` activates the file;
501
+ # `baseline: false` is the explicit-disable form (useful in
502
+ # `.rigor.yml` to override an upstream `.rigor.dist.yml`
503
+ # that names a baseline). `nil` (default / absent key) is
504
+ # also "no baseline".
505
+ def coerce_baseline_path(value)
506
+ return nil if value.nil? || value == false
507
+
508
+ value.to_s
509
+ end
510
+
491
511
  def coerce_network_policy(value)
492
512
  sym = value.to_sym
493
513
  unless VALID_NETWORK_POLICIES.include?(sym)
@@ -42,7 +42,7 @@ module Rigor
42
42
  # enough that hard-coding is acceptable; a directory walk
43
43
  # at every call would add stat-cost to no benefit.)
44
44
  VENDORED_GEM_NAMES = Set[
45
- "bcrypt", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis"
45
+ "bcrypt", "bundler", "cgi", "did_you_mean", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis", "rubygems"
46
46
  ].freeze
47
47
 
48
48
  # @param locked_gems [Hash{String => LockfileResolver::LockedGem}]
@@ -160,6 +160,28 @@ module Rigor
160
160
  end
161
161
  end
162
162
 
163
+ # Returns true when the named RBS declaration is a Module
164
+ # (`RBS::AST::Declarations::Module`) rather than a Class. The
165
+ # `user_class_fallback_receiver` tier consults this to route
166
+ # `Nominal[M].some_kernel_method` (where M is a module mixin
167
+ # like `PP::ObjectMixin`) through the `Nominal[Object]`
168
+ # fallback, because every concrete includer of M sees Kernel
169
+ # / Object instance methods as part of its own ancestor chain.
170
+ #
171
+ # Returns false for classes, for unknown names, and when the
172
+ # RBS environment failed to build (fail-soft).
173
+ def rbs_module?(name)
174
+ return false if env.nil?
175
+
176
+ rbs_name = parse_type_name(name)
177
+ return false if rbs_name.nil?
178
+
179
+ entry = env.class_decls[rbs_name]
180
+ entry.is_a?(::RBS::Environment::ModuleEntry)
181
+ rescue ::RBS::BaseError
182
+ false
183
+ end
184
+
163
185
  # Yields every known class / module / alias name (top-level
164
186
  # prefixed) currently loaded into the environment. The cache
165
187
  # producer that materialises the known-name set uses this so
@@ -354,6 +354,19 @@ module Rigor
354
354
  @rbs_loader&.reflection
355
355
  end
356
356
 
357
+ # Returns true when the RBS environment carries the named
358
+ # declaration as a Module (not a Class). Used by the
359
+ # `user_class_fallback_receiver` tier to detect a module-mixin
360
+ # receiver (e.g. `PP::ObjectMixin`) so the dispatcher can route
361
+ # unresolved method calls through the `Nominal[Object]`
362
+ # fallback — every concrete includer of M honours Kernel /
363
+ # Object instance methods through its own ancestor chain.
364
+ def rbs_module?(name)
365
+ return false unless rbs_loader
366
+
367
+ rbs_loader.rbs_module?(name)
368
+ end
369
+
357
370
  # Compares two class/module names using analyzer-owned class data.
358
371
  # Returns `:equal`, `:subclass`, `:superclass`, `:disjoint`, or
359
372
  # `:unknown`. The static registry handles built-ins cheaply; the RBS
@@ -31,14 +31,20 @@ module Rigor
31
31
  #
32
32
  # ## Field set
33
33
  #
34
- # - `target_kind`: `:parameter` (call-site argument) or
35
- # `:self` (receiver). Future slices may extend the set
36
- # (`:local`, `:ivar`, `:result`); the merger is agnostic
37
- # to the concrete kinds and only requires equality.
34
+ # - `target_kind`: `:parameter` (call-site argument), `:self`
35
+ # (receiver), or `:local` (a named local in the surrounding
36
+ # scope). v0.1.8 Pillar 2 Slice 1 added `:local` so plugins
37
+ # recognising bespoke call shapes (`expect(x).to be_a(T)`)
38
+ # can narrow a specific scope-bound local without routing
39
+ # through the parameter-name lookup that requires an
40
+ # authoritative RBS sig on the called method. Future slices
41
+ # may extend further (`:ivar`, `:result`). The merger is
42
+ # agnostic to the concrete kinds and only requires equality.
38
43
  # - `target_name`: a `Symbol`. For `:parameter` it's the
39
44
  # declared parameter name. For `:self` it is the literal
40
45
  # `:self` symbol so the field stays non-nil and the merge
41
- # key is well-defined.
46
+ # key is well-defined. For `:local` it's the local-variable
47
+ # name (e.g. `:x` for `expect(x).to be_a(T)`).
42
48
  # - `type`: a `Rigor::Type::*` (Nominal, Refined,
43
49
  # IntegerRange, Difference, …) the fact narrows the
44
50
  # target toward (when `negative` is false) or away from
@@ -53,7 +59,7 @@ module Rigor
53
59
  # value {Element#target} keys on, so two facts that narrow
54
60
  # the same parameter from different contribution sources
55
61
  # land in the same merge bucket.
56
- FACT_VALID_TARGET_KINDS = %i[parameter self].freeze
62
+ FACT_VALID_TARGET_KINDS = %i[parameter self local].freeze
57
63
 
58
64
  class Fact < Data.define(:target_kind, :target_name, :type, :negative)
59
65
  def initialize(target_kind:, target_name:, type:, negative: false)
@@ -72,10 +78,14 @@ module Rigor
72
78
  end
73
79
 
74
80
  # Composite target identifier the merger keys on. `:self`
75
- # for self-targeted facts; otherwise `[:parameter, name]`
76
- # so two contributions that narrow the same parameter
77
- # (regardless of source family) land in the same merge
78
- # bucket.
81
+ # for self-targeted facts; otherwise `[kind, name]` so two
82
+ # contributions that narrow the same `(kind, name)` pair —
83
+ # regardless of source family land in the same merge
84
+ # bucket. `:local` and `:parameter` facts that name the
85
+ # same symbol stay in separate buckets, which is the
86
+ # correct semantics: a `:local` fact narrows the surrounding
87
+ # scope's named local, a `:parameter` fact narrows the
88
+ # call-site argument matching the parameter declaration.
79
89
  def target
80
90
  target_kind == :self ? :self : [target_kind, target_name]
81
91
  end