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.
- checksums.yaml +4 -4
- data/README.md +40 -29
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/check_rules.rb +57 -2
- data/lib/rigor/builtins/static_return_refinements.rb +23 -1
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli.rb +70 -3
- data/lib/rigor/configuration.rb +21 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +22 -0
- data/lib/rigor/environment.rb +13 -0
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/expression_typer.rb +17 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
- data/lib/rigor/inference/method_dispatcher.rb +20 -3
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/scope_indexer.rb +53 -7
- data/lib/rigor/inference/statement_evaluator.rb +66 -5
- data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor.rbs +1 -0
- metadata +3 -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
|
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"
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -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)
|
|
35
|
-
# `:
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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 `[
|
|
76
|
-
#
|
|
77
|
-
#
|
|
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
|