rigortype 0.1.1 → 0.1.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/data/builtins/ruby_core/range.yml +6 -4
  4. data/data/builtins/ruby_core/string.yml +15 -10
  5. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  6. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  7. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  8. data/lib/rigor/analysis/check_rules.rb +346 -18
  9. data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
  10. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  13. data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  15. data/lib/rigor/analysis/runner.rb +96 -6
  16. data/lib/rigor/cache/descriptor.rb +58 -5
  17. data/lib/rigor/cli/diff_command.rb +169 -0
  18. data/lib/rigor/cli/explain_command.rb +129 -0
  19. data/lib/rigor/cli.rb +18 -1
  20. data/lib/rigor/configuration/dependencies.rb +235 -0
  21. data/lib/rigor/configuration/severity_profile.rb +18 -3
  22. data/lib/rigor/configuration.rb +53 -13
  23. data/lib/rigor/environment.rb +16 -4
  24. data/lib/rigor/flow_contribution/merger.rb +4 -0
  25. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  26. data/lib/rigor/inference/method_dispatcher.rb +87 -0
  27. data/lib/rigor/inference/scope_indexer.rb +171 -2
  28. data/lib/rigor/inference/statement_evaluator.rb +65 -1
  29. data/lib/rigor/plugin/io_boundary.rb +92 -19
  30. data/lib/rigor/plugin/manifest.rb +26 -5
  31. data/lib/rigor/plugin/trust_policy.rb +30 -7
  32. data/lib/rigor/scope.rb +30 -5
  33. data/lib/rigor/version.rb +1 -1
  34. data/sig/rigor/environment.rbs +3 -2
  35. data/sig/rigor/scope.rbs +3 -0
  36. metadata +13 -1
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Executes `rigor diff <baseline.json> [paths...]`. Compares
9
+ # the current `rigor check` diagnostics against a saved
10
+ # baseline JSON (the output of a previous `rigor check
11
+ # --format=json` run) and prints the delta:
12
+ #
13
+ # - **new** — diagnostics in the current run that were not
14
+ # in the baseline (typically a regression introduced in
15
+ # this PR).
16
+ # - **fixed** — diagnostics in the baseline that no longer
17
+ # appear in the current run (typically progress).
18
+ #
19
+ # Identity for matching is the tuple
20
+ # `(path, line, column, rule, source_family, message)`.
21
+ # An edit that moves a diagnostic to a new line surfaces as
22
+ # one fixed + one new pair, which lines up with the user's
23
+ # mental model of "you changed the line, the analyzer's
24
+ # position changed too."
25
+ #
26
+ # CI usage: commit a `rigor.baseline.json` produced once
27
+ # with `rigor check --format=json > rigor.baseline.json`,
28
+ # then run `rigor diff rigor.baseline.json` in CI. Exit code
29
+ # is `1` when any new diagnostic appears, `0` otherwise —
30
+ # so adding new errors fails CI but legacy errors recorded
31
+ # in the baseline don't.
32
+ class DiffCommand # rubocop:disable Metrics/ClassLength
33
+ USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
34
+
35
+ def initialize(argv:, out:, err:)
36
+ @argv = argv
37
+ @out = out
38
+ @err = err
39
+ end
40
+
41
+ # @return [Integer] CLI exit status.
42
+ def run
43
+ options = parse_options
44
+
45
+ baseline_path = @argv.shift
46
+ if baseline_path.nil?
47
+ @err.puts(USAGE)
48
+ return CLI::EXIT_USAGE
49
+ end
50
+
51
+ baseline = load_diagnostics(baseline_path)
52
+ current = options.fetch(:current_path) ? load_diagnostics(options.fetch(:current_path)) : run_current(options)
53
+ return CLI::EXIT_USAGE if baseline.nil? || current.nil?
54
+
55
+ diff = compute_diff(baseline, current)
56
+ write_diff(
57
+ diff, options.fetch(:format),
58
+ baseline_path: baseline_path,
59
+ baseline_count: baseline.size,
60
+ current_count: current.size
61
+ )
62
+ diff[:new].any? ? 1 : 0
63
+ end
64
+
65
+ private
66
+
67
+ def parse_options
68
+ options = { format: "text", current_path: nil, config: nil }
69
+ OptionParser.new do |opt|
70
+ opt.banner = USAGE
71
+ opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
72
+ options[:format] = fmt
73
+ end
74
+ opt.on("--current=PATH", "Compare to the saved current JSON instead of running `rigor check`.") do |path|
75
+ options[:current_path] = path
76
+ end
77
+ opt.on("--config=PATH", "Path to .rigor.yml. Forwarded to the implicit `rigor check` run.") do |path|
78
+ options[:config] = path
79
+ end
80
+ end.parse!(@argv)
81
+ options
82
+ end
83
+
84
+ # Runs `rigor check` against the remaining argv (or the
85
+ # configured paths) and returns the diagnostics array.
86
+ # Reuses the analyzer + configuration plumbing the
87
+ # check-command path uses.
88
+ def run_current(options)
89
+ require_relative "../analysis/runner"
90
+ require_relative "../configuration"
91
+
92
+ configuration = Configuration.load(options.fetch(:config))
93
+ paths = @argv.empty? ? configuration.paths : @argv
94
+ result = Analysis::Runner.new(configuration: configuration).run(paths)
95
+ result.diagnostics.map(&:to_h)
96
+ end
97
+
98
+ def load_diagnostics(path)
99
+ unless File.file?(path)
100
+ @err.puts("Baseline file not found: #{path}")
101
+ return nil
102
+ end
103
+
104
+ payload = JSON.parse(File.read(path))
105
+ payload.is_a?(Hash) ? Array(payload["diagnostics"]) : Array(payload)
106
+ rescue JSON::ParserError => e
107
+ @err.puts("Invalid JSON in #{path}: #{e.message}")
108
+ nil
109
+ end
110
+
111
+ KEY_FIELDS = %w[path line column rule source_family message].freeze
112
+ private_constant :KEY_FIELDS
113
+
114
+ def compute_diff(baseline, current)
115
+ baseline_keys = baseline.to_set { |d| identity_for(d) }
116
+ current_keys = current.to_set { |d| identity_for(d) }
117
+
118
+ new_diags = current.reject { |d| baseline_keys.include?(identity_for(d)) }
119
+ fixed = baseline.reject { |d| current_keys.include?(identity_for(d)) }
120
+ { new: new_diags, fixed: fixed }
121
+ end
122
+
123
+ def identity_for(diagnostic)
124
+ KEY_FIELDS.map { |k| diagnostic[k] }
125
+ end
126
+
127
+ def write_diff(diff, format, baseline_path:, baseline_count:, current_count:)
128
+ case format
129
+ when "json"
130
+ write_diff_json(diff, baseline_path, baseline_count, current_count)
131
+ else
132
+ write_diff_text(diff, baseline_path, baseline_count, current_count)
133
+ end
134
+ end
135
+
136
+ def write_diff_json(diff, baseline_path, baseline_count, current_count)
137
+ @out.puts(JSON.pretty_generate(
138
+ "baseline" => baseline_path,
139
+ "baseline_count" => baseline_count,
140
+ "current_count" => current_count,
141
+ "new" => diff[:new],
142
+ "fixed" => diff[:fixed]
143
+ ))
144
+ end
145
+
146
+ def write_diff_text(diff, baseline_path, baseline_count, current_count)
147
+ @out.puts("# diff against #{baseline_path} (#{baseline_count} baseline / #{current_count} current)")
148
+ diff[:new].each { |d| @out.puts("+ NEW #{render_diagnostic(d)}") }
149
+ diff[:fixed].each { |d| @out.puts("- FIXED #{render_diagnostic(d)}") }
150
+ @out.puts("")
151
+ @out.puts("#{diff[:new].size} new, #{diff[:fixed].size} fixed")
152
+ end
153
+
154
+ def render_diagnostic(diagnostic)
155
+ rule = qualified_rule_for(diagnostic)
156
+ position = "#{diagnostic['path']}:#{diagnostic['line']}:#{diagnostic['column']}"
157
+ "#{position} [#{rule}] #{diagnostic['message']}"
158
+ end
159
+
160
+ def qualified_rule_for(diagnostic)
161
+ family = diagnostic["source_family"]
162
+ rule = diagnostic["rule"]
163
+ return rule if family.nil? || family == "" || family == "builtin"
164
+
165
+ "#{family}.#{rule}"
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ require_relative "../analysis/rule_catalog"
7
+
8
+ module Rigor
9
+ class CLI
10
+ # Executes `rigor explain <rule>`. Prints the catalog entry for
11
+ # one canonical rule id, a legacy alias, or a family wildcard
12
+ # (`call`, `flow`, `assert`, `dump`, `def`).
13
+ #
14
+ # Without arguments lists every rule's id and one-line summary.
15
+ #
16
+ # The command is read-only: no parser, no analyzer, no I/O
17
+ # beyond the rendered catalog. Useful when a user sees a
18
+ # diagnostic in the editor and wants to know what the rule
19
+ # means without leaving the terminal.
20
+ class ExplainCommand
21
+ USAGE = "Usage: rigor explain [options] [<rule>]"
22
+
23
+ def initialize(argv:, out:, err:)
24
+ @argv = argv
25
+ @out = out
26
+ @err = err
27
+ end
28
+
29
+ # @return [Integer] CLI exit status.
30
+ def run
31
+ options = parse_options
32
+
33
+ if @argv.empty?
34
+ render_index(options.fetch(:format))
35
+ return 0
36
+ end
37
+
38
+ token = @argv.shift
39
+ entries = Analysis::RuleCatalog.resolve(token)
40
+ if entries.empty?
41
+ @err.puts("Unknown rule: #{token}")
42
+ @err.puts("Run `rigor explain` with no arguments to list every rule.")
43
+ return CLI::EXIT_USAGE
44
+ end
45
+
46
+ render_entries(entries, options.fetch(:format))
47
+ 0
48
+ end
49
+
50
+ private
51
+
52
+ def parse_options
53
+ options = { format: "text" }
54
+ OptionParser.new do |opt|
55
+ opt.banner = USAGE
56
+ opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
57
+ options[:format] = fmt
58
+ end
59
+ end.parse!(@argv)
60
+ options
61
+ end
62
+
63
+ def render_index(format)
64
+ case format
65
+ when "json" then @out.puts(JSON.pretty_generate(Analysis::RuleCatalog.all.map(&:to_h)))
66
+ else render_index_text
67
+ end
68
+ end
69
+
70
+ def render_index_text
71
+ @out.puts("Available rules:")
72
+ @out.puts("")
73
+ Analysis::RuleCatalog.all.each do |entry|
74
+ @out.puts(" #{entry.id.ljust(33)} #{entry.summary}")
75
+ end
76
+ @out.puts("")
77
+ @out.puts("Run `rigor explain <rule>` for the full description.")
78
+ @out.puts("Family wildcards (`call`, `flow`, `assert`, `dump`, `def`) print every rule under that prefix.")
79
+ end
80
+
81
+ def render_entries(entries, format)
82
+ case format
83
+ when "json" then @out.puts(JSON.pretty_generate(entries.map(&:to_h)))
84
+ else
85
+ entries.each_with_index do |entry, index|
86
+ @out.puts("") if index.positive?
87
+ render_entry_text(entry)
88
+ end
89
+ end
90
+ end
91
+
92
+ def render_entry_text(entry)
93
+ @out.puts(entry.id)
94
+ @out.puts("=" * entry.id.length)
95
+ @out.puts("")
96
+ @out.puts(entry.summary)
97
+ @out.puts("")
98
+ render_aliases(entry)
99
+ render_severity(entry)
100
+ render_section("Fires when:", entry.fires_when)
101
+ render_section("Does not fire when:", entry.does_not_fire_when)
102
+ @out.puts("Suppression: #{entry.suppression}")
103
+ @out.puts("Since: rigor #{entry.since}")
104
+ end
105
+
106
+ def render_aliases(entry)
107
+ return if entry.aliases.empty?
108
+
109
+ @out.puts("Legacy aliases: #{entry.aliases.join(', ')}")
110
+ @out.puts("")
111
+ end
112
+
113
+ def render_severity(entry)
114
+ @out.puts("Authored severity: :#{entry.severity_authored}")
115
+ profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
116
+ @out.puts("Severity by profile: #{profile_table}")
117
+ @out.puts("")
118
+ end
119
+
120
+ def render_section(heading, items)
121
+ return if items.empty?
122
+
123
+ @out.puts(heading)
124
+ items.each { |item| @out.puts(" - #{item}") }
125
+ @out.puts("")
126
+ end
127
+ end
128
+ end
129
+ end
data/lib/rigor/cli.rb CHANGED
@@ -22,7 +22,9 @@ module Rigor
22
22
  "check" => :run_check,
23
23
  "init" => :run_init,
24
24
  "type-of" => :run_type_of,
25
- "type-scan" => :run_type_scan
25
+ "type-scan" => :run_type_scan,
26
+ "explain" => :run_explain,
27
+ "diff" => :run_diff
26
28
  }.freeze
27
29
 
28
30
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -207,6 +209,7 @@ module Rigor
207
209
  # most likely to want to edit.
208
210
  def init_template
209
211
  <<~YAML
212
+ # yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
210
213
  # Rigor configuration. See docs/CURRENT_WORK.md for the
211
214
  # full set of features the analyzer ships in this preview.
212
215
  #
@@ -264,6 +267,18 @@ module Rigor
264
267
  TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
265
268
  end
266
269
 
270
+ def run_explain
271
+ require_relative "cli/explain_command"
272
+
273
+ ExplainCommand.new(argv: @argv, out: @out, err: @err).run
274
+ end
275
+
276
+ def run_diff
277
+ require_relative "cli/diff_command"
278
+
279
+ DiffCommand.new(argv: @argv, out: @out, err: @err).run
280
+ end
281
+
267
282
  def write_result(result, format)
268
283
  case format
269
284
  when "json"
@@ -301,6 +316,8 @@ module Rigor
301
316
  init Create a starter .rigor.yml
302
317
  type-of Print the inferred type at FILE:LINE:COL
303
318
  type-scan Report Scope#type_of coverage across PATHs
319
+ explain Print the description of one or all CheckRules
320
+ diff Compare current diagnostics to a saved baseline JSON
304
321
  version Print the Rigor version
305
322
  help Print this help
306
323
  HELP
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Configuration
5
+ # Parsed `dependencies:` section of `.rigor.yml`. Per
6
+ # [ADR-10](../../../docs/adr/10-dependency-source-inference.md),
7
+ # the only nested key today is `source_inference:`, listing
8
+ # gems whose Ruby implementation Rigor MAY walk during
9
+ # inference instead of degrading to `Dynamic[top]` at the
10
+ # dependency boundary.
11
+ #
12
+ # Slice 1 lands the parser only — `Configuration#dependencies`
13
+ # is read, but no analyzer machinery consumes it yet. Slice 2
14
+ # wires `Analysis::DependencySourceInference` against this
15
+ # value object.
16
+ class Dependencies # rubocop:disable Metrics/ClassLength
17
+ # Walking modes per
18
+ # [ADR-10 § "Decision"](../../../docs/adr/10-dependency-source-inference.md#decision).
19
+ VALID_MODES = %i[disabled when_missing full].freeze
20
+
21
+ # Default `roots:` for an entry that does not supply one.
22
+ # The hard-excluded directories (`spec/` / `test/` / `bin/`
23
+ # / C extensions) are enforced by the walker, not the
24
+ # parser — see ADR-10 § "Hard exclusions".
25
+ DEFAULT_ROOTS = %w[lib].freeze
26
+
27
+ # Default per-gem catalog cap. ADR-10 slice 4 picks
28
+ # 5000 method definitions: it covers Rack (~1500),
29
+ # Faraday (~500), Sidekiq (~800) and other realistic
30
+ # opt-in targets, while still surfacing a diagnostic for
31
+ # ActiveSupport-class libraries (~10 000+ methods) where
32
+ # the user should ship RBS or de-list the gem instead.
33
+ DEFAULT_BUDGET_PER_GEM = 5000
34
+
35
+ # Range bounds per ADR-10 § "Budget interaction"
36
+ # ("range 0.25× – 4×"). Configured against the default,
37
+ # this lands at 1250 – 20 000.
38
+ MIN_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 0.25).to_i
39
+ MAX_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 4).to_i
40
+
41
+ # ADR-10 5b — budget-overrun strategy enum.
42
+ #
43
+ # - `:walker_cap` (default): the (α) semantics. The
44
+ # walker stops harvesting at the cap; methods past the
45
+ # cap fall through to the existing user-class fallback
46
+ # path. Existing v0.1.3 behaviour.
47
+ # - `:dependency_silence`: the (β) semantics. Same
48
+ # walker behaviour, but the dispatcher additionally
49
+ # consults `Index#class_to_gem` after a catalog miss.
50
+ # When the receiver's class belongs to a budget-
51
+ # exceeded gem, the call resolves to `Dynamic[top]`
52
+ # rather than falling through to user-class fallback.
53
+ # This silences `call.undefined-method` for unrecorded
54
+ # methods at the cost of weaker static checking on
55
+ # that gem's surface.
56
+ VALID_BUDGET_OVERRUN_STRATEGIES = %i[walker_cap dependency_silence].freeze
57
+ DEFAULT_BUDGET_OVERRUN_STRATEGY = :walker_cap
58
+
59
+ # Frozen value object describing a single per-gem opt-in.
60
+ # `gem:` is the gem name (matched against the bundle at
61
+ # walk time); `mode:` is one of {VALID_MODES}; `roots:` is
62
+ # the list of subdirectories within the gem's installation
63
+ # directory to walk (defaults to `["lib"]`).
64
+ Entry = Data.define(:gem, :mode, :roots) do
65
+ def disabled? = mode == :disabled
66
+ def when_missing? = mode == :when_missing
67
+ def full? = mode == :full
68
+ end
69
+
70
+ attr_reader :source_inference, :budget_per_gem, :budget_overrun_strategy, :warnings
71
+
72
+ # Parse the YAML-shaped `dependencies:` value into a
73
+ # frozen {Dependencies}. Accepts `nil` / `{}` / a Hash with
74
+ # `source_inference:` and / or `budget_per_gem:` /
75
+ # `budget_overrun_strategy:` present.
76
+ def self.from_h(data)
77
+ return new([]) if data.nil?
78
+ raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
79
+
80
+ raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
81
+ entries, warnings = dedupe_entries(raw_entries)
82
+ budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
83
+ strategy = coerce_budget_overrun_strategy(
84
+ data.fetch("budget_overrun_strategy", DEFAULT_BUDGET_OVERRUN_STRATEGY)
85
+ )
86
+ new(entries, budget, warnings, strategy)
87
+ end
88
+
89
+ def initialize(source_inference, budget_per_gem = DEFAULT_BUDGET_PER_GEM,
90
+ warnings = [], budget_overrun_strategy = DEFAULT_BUDGET_OVERRUN_STRATEGY)
91
+ @source_inference = source_inference.freeze
92
+ @budget_per_gem = budget_per_gem
93
+ @warnings = warnings.freeze
94
+ @budget_overrun_strategy = budget_overrun_strategy
95
+ freeze
96
+ end
97
+
98
+ def to_h
99
+ {
100
+ "source_inference" => @source_inference.map do |entry|
101
+ {
102
+ "gem" => entry.gem,
103
+ "mode" => entry.mode.to_s,
104
+ "roots" => entry.roots
105
+ }
106
+ end,
107
+ "budget_per_gem" => @budget_per_gem,
108
+ "budget_overrun_strategy" => @budget_overrun_strategy.to_s
109
+ }
110
+ end
111
+
112
+ def empty? = @source_inference.empty?
113
+
114
+ class << self
115
+ # ADR-10 § "config-conflict diagnostic" — merges a
116
+ # potentially-duplicated entry list (the `includes:`
117
+ # chain produces concatenated arrays via
118
+ # `Configuration.deep_merge`'s special-case for
119
+ # `dependencies.source_inference`) into a single
120
+ # canonical entry per gem name. The merge rules:
121
+ #
122
+ # - Same gem, same all fields → idempotent collapse
123
+ # (no warning).
124
+ # - Same gem, different `mode:` → keep the LAST entry
125
+ # (matches existing right-wins semantics elsewhere)
126
+ # AND emit a `:warning` so the user knows their
127
+ # `includes:` chain is ambiguous.
128
+ # - Same gem, different `roots:` → union the roots
129
+ # silently (no warning). The walker is happy to
130
+ # visit the union.
131
+ #
132
+ # Returns `[entries, warnings]` so the caller can
133
+ # plumb the warning list through to the Runner for
134
+ # diagnostic emission.
135
+ def dedupe_entries(entries)
136
+ warnings = []
137
+ by_gem = {}
138
+ entries.each do |entry|
139
+ existing = by_gem[entry.gem]
140
+ by_gem[entry.gem] = if existing.nil?
141
+ entry
142
+ else
143
+ merge_entry_pair(existing, entry, warnings)
144
+ end
145
+ end
146
+ [by_gem.values, warnings]
147
+ end
148
+
149
+ def merge_entry_pair(existing, incoming, warnings)
150
+ if existing.mode != incoming.mode
151
+ warnings << "dependencies.source_inference[].gem #{incoming.gem.inspect} declared with " \
152
+ "conflicting modes (#{existing.mode.inspect} vs #{incoming.mode.inspect}); " \
153
+ "the later (#{incoming.mode.inspect}) wins."
154
+ end
155
+ merged_roots = (existing.roots + incoming.roots).uniq.freeze
156
+ Entry.new(gem: incoming.gem, mode: incoming.mode, roots: merged_roots)
157
+ end
158
+
159
+ private
160
+
161
+ def coerce_entry(raw)
162
+ unless raw.is_a?(Hash)
163
+ raise ArgumentError,
164
+ "dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
165
+ end
166
+
167
+ Entry.new(
168
+ gem: coerce_gem(raw["gem"]),
169
+ mode: coerce_mode(raw["mode"]),
170
+ roots: coerce_roots(raw)
171
+ )
172
+ end
173
+
174
+ def coerce_gem(value)
175
+ unless value.is_a?(String) && !value.empty?
176
+ raise ArgumentError,
177
+ "dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
178
+ end
179
+
180
+ value.dup.freeze
181
+ end
182
+
183
+ def coerce_mode(value)
184
+ mode = (value || "when_missing").to_sym
185
+ return mode if VALID_MODES.include?(mode)
186
+
187
+ raise ArgumentError,
188
+ "dependencies.source_inference[].mode must be one of " \
189
+ "#{VALID_MODES.inspect}, got #{value.inspect}"
190
+ end
191
+
192
+ def coerce_roots(raw)
193
+ roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
194
+ return roots unless roots.empty?
195
+
196
+ raise ArgumentError,
197
+ "dependencies.source_inference[].roots must not be empty when supplied " \
198
+ "(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
199
+ end
200
+
201
+ def coerce_budget_overrun_strategy(value)
202
+ symbol = value.to_sym
203
+ return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
204
+
205
+ raise ArgumentError,
206
+ "dependencies.budget_overrun_strategy must be one of " \
207
+ "#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
208
+ end
209
+
210
+ # ADR-10 slice 4. Per-gem catalog cap is mandatory
211
+ # (the parser supplies the default before this is
212
+ # called, so `nil` only reaches here on an explicit
213
+ # `budget_per_gem: ~`). Range bounds match
214
+ # MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM
215
+ # (i.e. 0.25× – 4× of the default).
216
+ def coerce_budget_per_gem(value)
217
+ unless value.is_a?(Integer)
218
+ raise ArgumentError,
219
+ "dependencies.budget_per_gem must be an Integer, " \
220
+ "got #{value.inspect}"
221
+ end
222
+
223
+ unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
224
+ raise ArgumentError,
225
+ "dependencies.budget_per_gem must be in the range " \
226
+ "#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
227
+ "got #{value.inspect}"
228
+ end
229
+
230
+ value
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -43,9 +43,14 @@ module Rigor
43
43
  "call.argument-type-mismatch" => :warning,
44
44
  "call.possible-nil-receiver" => :warning,
45
45
  "flow.always-raises" => :warning,
46
+ "flow.unreachable-branch" => :info,
47
+ "flow.dead-assignment" => :info,
48
+ "flow.always-truthy-condition" => :info,
46
49
  "assert.type-mismatch" => :error,
47
50
  "dump.type" => :info,
48
- "def.return-type-mismatch" => :warning
51
+ "def.return-type-mismatch" => :warning,
52
+ "def.method-visibility-mismatch" => :warning,
53
+ "def.ivar-write-mismatch" => :warning
49
54
  }.freeze,
50
55
  balanced: {
51
56
  "call.undefined-method" => :error,
@@ -53,9 +58,14 @@ module Rigor
53
58
  "call.argument-type-mismatch" => :error,
54
59
  "call.possible-nil-receiver" => :error,
55
60
  "flow.always-raises" => :error,
61
+ "flow.unreachable-branch" => :warning,
62
+ "flow.dead-assignment" => :warning,
63
+ "flow.always-truthy-condition" => :warning,
56
64
  "assert.type-mismatch" => :error,
57
65
  "dump.type" => :info,
58
- "def.return-type-mismatch" => :warning
66
+ "def.return-type-mismatch" => :warning,
67
+ "def.method-visibility-mismatch" => :error,
68
+ "def.ivar-write-mismatch" => :warning
59
69
  }.freeze,
60
70
  strict: {
61
71
  "call.undefined-method" => :error,
@@ -63,9 +73,14 @@ module Rigor
63
73
  "call.argument-type-mismatch" => :error,
64
74
  "call.possible-nil-receiver" => :error,
65
75
  "flow.always-raises" => :error,
76
+ "flow.unreachable-branch" => :error,
77
+ "flow.dead-assignment" => :error,
78
+ "flow.always-truthy-condition" => :error,
66
79
  "assert.type-mismatch" => :error,
67
80
  "dump.type" => :error,
68
- "def.return-type-mismatch" => :error
81
+ "def.return-type-mismatch" => :error,
82
+ "def.method-visibility-mismatch" => :error,
83
+ "def.ivar-write-mismatch" => :error
69
84
  }.freeze
70
85
  }.freeze
71
86