rigortype 0.0.9 → 0.1.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +269 -7
  13. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  16. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  18. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  19. data/lib/rigor/cache/store.rb +2 -0
  20. data/lib/rigor/cli/type_of_command.rb +3 -3
  21. data/lib/rigor/cli/type_scan_command.rb +4 -4
  22. data/lib/rigor/cli.rb +20 -7
  23. data/lib/rigor/configuration/severity_profile.rb +109 -0
  24. data/lib/rigor/configuration.rb +286 -15
  25. data/lib/rigor/environment/rbs_loader.rb +89 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  28. data/lib/rigor/flow_contribution/element.rb +53 -0
  29. data/lib/rigor/flow_contribution/fact.rb +88 -0
  30. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  31. data/lib/rigor/flow_contribution/merger.rb +275 -0
  32. data/lib/rigor/flow_contribution.rb +51 -0
  33. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  34. data/lib/rigor/inference/expression_typer.rb +87 -6
  35. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  36. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
  37. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  38. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  39. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  40. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  41. data/lib/rigor/inference/narrowing.rb +246 -127
  42. data/lib/rigor/inference/scope_indexer.rb +124 -16
  43. data/lib/rigor/inference/statement_evaluator.rb +406 -37
  44. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  45. data/lib/rigor/plugin/base.rb +284 -0
  46. data/lib/rigor/plugin/fact_store.rb +92 -0
  47. data/lib/rigor/plugin/io_boundary.rb +102 -0
  48. data/lib/rigor/plugin/load_error.rb +35 -0
  49. data/lib/rigor/plugin/loader.rb +307 -0
  50. data/lib/rigor/plugin/manifest.rb +203 -0
  51. data/lib/rigor/plugin/registry.rb +50 -0
  52. data/lib/rigor/plugin/services.rb +77 -0
  53. data/lib/rigor/plugin/trust_policy.rb +99 -0
  54. data/lib/rigor/plugin.rb +62 -0
  55. data/lib/rigor/rbs_extended.rb +57 -9
  56. data/lib/rigor/reflection.rb +2 -2
  57. data/lib/rigor/trinary.rb +1 -1
  58. data/lib/rigor/type/integer_range.rb +6 -2
  59. data/lib/rigor/version.rb +1 -1
  60. data/lib/rigor.rb +7 -0
  61. data/sig/rigor/environment.rbs +10 -3
  62. data/sig/rigor/inference.rbs +1 -0
  63. data/sig/rigor/rbs_extended.rbs +2 -0
  64. data/sig/rigor/scope.rbs +1 -0
  65. data/sig/rigor/type.rbs +7 -0
  66. data/sig/rigor.rbs +8 -2
  67. metadata +20 -1
data/lib/rigor/cli.rb CHANGED
@@ -70,11 +70,11 @@ module Rigor
70
70
 
71
71
  options = parse_check_options
72
72
 
73
- cache_root = ".rigor/cache"
73
+ configuration = Configuration.load(options.fetch(:config))
74
+ cache_root = configuration.cache_path
74
75
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
75
76
  cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
76
77
 
77
- configuration = Configuration.load(options.fetch(:config))
78
78
  paths = @argv.empty? ? configuration.paths : @argv
79
79
  runner = Analysis::Runner.new(
80
80
  configuration: configuration,
@@ -90,7 +90,9 @@ module Rigor
90
90
 
91
91
  def parse_check_options
92
92
  options = {
93
- config: Configuration::DEFAULT_PATH,
93
+ # `nil` triggers `Configuration.discover` (`.rigor.yml` then
94
+ # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
95
+ config: nil,
94
96
  format: "text",
95
97
  explain: false,
96
98
  cache_stats: false,
@@ -170,9 +172,14 @@ module Rigor
170
172
  end
171
173
 
172
174
  def run_init
175
+ # Default destination is `.rigor.dist.yml` — the
176
+ # project-default config that gets committed. Developers
177
+ # who want a personal override layer create `.rigor.yml`
178
+ # alongside it (auto-discovery prefers `.rigor.yml` when
179
+ # both are present; no implicit merge).
173
180
  options = {
174
181
  force: false,
175
- path: Configuration::DEFAULT_PATH
182
+ path: ".rigor.dist.yml"
176
183
  }
177
184
 
178
185
  parser = OptionParser.new do |opts|
@@ -211,9 +218,15 @@ module Rigor
211
218
  # (no plugins are loaded today).
212
219
  # - disable: list of `rigor check` rule identifiers to
213
220
  # silence project-wide. The shipped rules are
214
- # undefined-method, wrong-arity,
215
- # argument-type-mismatch, possible-nil-receiver,
216
- # dump-type, assert-type. In-source
221
+ # call.undefined-method, call.wrong-arity,
222
+ # call.argument-type-mismatch,
223
+ # call.possible-nil-receiver, dump.type,
224
+ # assert.type-mismatch, flow.always-raises.
225
+ # A bare family token (`call`, `flow`,
226
+ # `assert`, `dump`, `def`) wildcards every
227
+ # rule under that prefix. Legacy unprefixed
228
+ # names (`undefined-method`, …) still
229
+ # resolve. In-source
217
230
  # `# rigor:disable <rule>` comments at the end
218
231
  # of an offending line silence per-line; use
219
232
  # `# rigor:disable all` to suppress every rule.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Configuration
5
+ # ADR-8 § "Severity profile" — three named profiles tune the
6
+ # severity of every built-in `Analysis::CheckRules` rule for
7
+ # the run. Profiles are applied as a **final filter** on
8
+ # `Diagnostic#severity`: rules emit with their authored
9
+ # severity, then `Analysis::Runner` re-stamps the severity
10
+ # from the active profile before adding the diagnostic to
11
+ # the result.
12
+ #
13
+ # Three profiles:
14
+ #
15
+ # - `lenient`: Only proven (`:no`) diagnostics are errors;
16
+ # uncertain (`:maybe`) drop to `:warning`. Useful for
17
+ # incremental adoption on legacy code.
18
+ # - `balanced` (**default**): Current Rigor stance — most
19
+ # rules `:error`; `dump.type` `:info`; uncertain rules
20
+ # `:warning`.
21
+ # - `strict`: Every rule is `:error`. CI-friendly.
22
+ #
23
+ # The profile resolution order:
24
+ #
25
+ # 1. Profile-specific entry for the canonical rule id.
26
+ # 2. The diagnostic's own authored severity (the rule's
27
+ # default).
28
+ # 3. `:error` (catch-all so an unrecognised rule still emits
29
+ # visibly — the public-API drift spec catches the
30
+ # bookkeeping gap separately).
31
+ module SeverityProfile
32
+ VALID_PROFILES = %i[lenient balanced strict].freeze
33
+ VALID_SEVERITIES = %i[error warning info off].freeze
34
+
35
+ DEFAULT_PROFILE = :balanced
36
+
37
+ # Per-profile severity tables. Missing keys fall back to
38
+ # the diagnostic's authored severity (typically `:error`).
39
+ PROFILES = {
40
+ lenient: {
41
+ "call.undefined-method" => :error,
42
+ "call.wrong-arity" => :error,
43
+ "call.argument-type-mismatch" => :warning,
44
+ "call.possible-nil-receiver" => :warning,
45
+ "flow.always-raises" => :warning,
46
+ "assert.type-mismatch" => :error,
47
+ "dump.type" => :info,
48
+ "def.return-type-mismatch" => :warning
49
+ }.freeze,
50
+ balanced: {
51
+ "call.undefined-method" => :error,
52
+ "call.wrong-arity" => :error,
53
+ "call.argument-type-mismatch" => :error,
54
+ "call.possible-nil-receiver" => :error,
55
+ "flow.always-raises" => :error,
56
+ "assert.type-mismatch" => :error,
57
+ "dump.type" => :info,
58
+ "def.return-type-mismatch" => :warning
59
+ }.freeze,
60
+ strict: {
61
+ "call.undefined-method" => :error,
62
+ "call.wrong-arity" => :error,
63
+ "call.argument-type-mismatch" => :error,
64
+ "call.possible-nil-receiver" => :error,
65
+ "flow.always-raises" => :error,
66
+ "assert.type-mismatch" => :error,
67
+ "dump.type" => :error,
68
+ "def.return-type-mismatch" => :error
69
+ }.freeze
70
+ }.freeze
71
+
72
+ module_function
73
+
74
+ # Resolves the configured severity for a diagnostic given
75
+ # the active profile and any per-rule overrides.
76
+ #
77
+ # @param rule [String, nil] canonical rule id (`call.undefined-method`).
78
+ # @param authored_severity [Symbol] severity the rule emitted
79
+ # the diagnostic with (`:error`, `:warning`, `:info`).
80
+ # @param profile [Symbol] one of {VALID_PROFILES}; falls back
81
+ # to {DEFAULT_PROFILE} for unknown values.
82
+ # @param overrides [Hash{String => Symbol}] per-rule severity
83
+ # overrides from `.rigor.yml`'s `severity_overrides:` map.
84
+ # Keys are canonical rule ids; values are
85
+ # {VALID_SEVERITIES} symbols. Family-wildcard keys
86
+ # (`call`) match every rule under that prefix.
87
+ # @return [Symbol] the resolved severity. Returns `:off` to
88
+ # mean "drop the diagnostic entirely".
89
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {})
90
+ return authored_severity if rule.nil?
91
+
92
+ override = overrides[rule] || family_override(rule, overrides)
93
+ return override.to_sym if override
94
+
95
+ profile_table = PROFILES[profile] || PROFILES.fetch(DEFAULT_PROFILE)
96
+ profile_table.fetch(rule, authored_severity)
97
+ end
98
+
99
+ def family_override(rule, overrides)
100
+ family = rule.split(".").first
101
+ return nil if family.nil?
102
+
103
+ overrides[family]
104
+ end
105
+
106
+ private_class_method :family_override
107
+ end
108
+ end
109
+ end
@@ -2,12 +2,48 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "configuration/severity_profile"
6
+
5
7
  module Rigor
6
- class Configuration
7
- DEFAULT_PATH = ".rigor.yml"
8
+ class Configuration # rubocop:disable Metrics/ClassLength
9
+ # File-discovery order for `Configuration.load(nil)`.
10
+ #
11
+ # The first file present is loaded; the others are NOT
12
+ # implicitly merged. To extend a base config explicitly the
13
+ # winning file MUST list the base via `includes:`.
14
+ #
15
+ # `.rigor.yml` is a developer-local override (typically
16
+ # gitignored); `.rigor.dist.yml` is the project default
17
+ # (committed to the repo). When both are present the
18
+ # developer's local override wins outright — there is no
19
+ # implicit auto-merge.
20
+ DISCOVERY_ORDER = %w[.rigor.yml .rigor.dist.yml].freeze
21
+ # Back-compat alias. Keep here so external callers that read
22
+ # `Configuration::DEFAULT_PATH` for help text / fixture paths
23
+ # still work; the discovery list is the canonical source.
24
+ DEFAULT_PATH = DISCOVERY_ORDER.first
25
+
26
+ # Built-in exclusion patterns appended to `exclude:` so vendored
27
+ # dependencies, Bundler artefacts, and JavaScript node_modules are
28
+ # never analysed by accident when a directory glob expands. Users
29
+ # cannot disable these defaults; the trade-off is that analysing
30
+ # any of these paths is essentially never what the user wants
31
+ # (they're build outputs / external dependencies, not source).
32
+ #
33
+ # We deliberately keep this list narrow. `tmp/` and similar
34
+ # directories vary across project layouts (Rails has `tmp/`,
35
+ # libraries usually don't); user-supplied `exclude:` entries
36
+ # in `.rigor.yml` cover the project-specific cases.
37
+ BUILTIN_EXCLUDES = %w[
38
+ **/vendor/bundle/**
39
+ **/.bundle/**
40
+ **/node_modules/**
41
+ ].freeze
42
+
8
43
  DEFAULTS = {
9
44
  "target_ruby" => "4.0",
10
45
  "paths" => ["lib"],
46
+ "exclude" => [],
11
47
  "plugins" => [],
12
48
  "disable" => [],
13
49
  "libraries" => [],
@@ -15,28 +51,148 @@ module Rigor
15
51
  "fold_platform_specific_paths" => false,
16
52
  "cache" => {
17
53
  "path" => ".rigor/cache"
18
- }
54
+ },
55
+ "plugins_io" => {
56
+ "network" => "disabled",
57
+ "allowed_paths" => []
58
+ },
59
+ "severity_profile" => "balanced",
60
+ "severity_overrides" => {}
19
61
  }.freeze
20
62
 
21
- attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
22
- :libraries, :signature_paths, :fold_platform_specific_paths
63
+ # Top-level keys whose values are file/directory paths that
64
+ # MUST be resolved relative to the config file's directory.
65
+ # `exclude:` is intentionally NOT in this list — its entries
66
+ # are glob patterns (`**/vendor/**`), not paths.
67
+ PATH_KEYS = %w[paths signature_paths].freeze
68
+ private_constant :PATH_KEYS
69
+
70
+ attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
71
+ :libraries, :signature_paths, :fold_platform_specific_paths,
72
+ :plugins_io_network, :plugins_io_allowed_paths,
73
+ :severity_profile, :severity_overrides
23
74
 
24
- def self.load(path = DEFAULT_PATH)
25
- data = if File.exist?(path)
26
- YAML.safe_load_file(path, aliases: false) || {}
27
- else
28
- {}
29
- end
75
+ # Loads a configuration file.
76
+ #
77
+ # `path == nil` triggers auto-discovery against
78
+ # {DISCOVERY_ORDER}. The first present file in that list is
79
+ # loaded; if none exist the built-in {DEFAULTS} are used.
80
+ #
81
+ # When a path is supplied (whether by auto-discovery or by
82
+ # the caller) the YAML body is processed for `includes:`
83
+ # recursively, and every relative path inside path-bearing
84
+ # keys (`paths:`, `signature_paths:`, `plugins_io.allowed_paths:`,
85
+ # `includes:`) is resolved against THAT file's directory.
86
+ # The resolution is per-file: an included file's relative
87
+ # paths resolve against the included file's directory, not
88
+ # the top-level file. Path resolution mirrors
89
+ # [PHPStan](https://phpstan.org/config-reference#paths).
90
+ def self.load(path = nil)
91
+ resolved = path || discover
92
+ return new(DEFAULTS) if resolved.nil? || !File.exist?(resolved)
30
93
 
94
+ data = load_with_includes(resolved)
31
95
  new(DEFAULTS.merge(data))
32
96
  end
33
97
 
34
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize
98
+ # Returns the path to the config file Rigor would load
99
+ # under auto-discovery, or `nil` when neither candidate
100
+ # exists. Public so the CLI / spec drift checks can
101
+ # introspect the resolved file.
102
+ def self.discover
103
+ DISCOVERY_ORDER.find { |candidate| File.exist?(candidate) }
104
+ end
105
+
106
+ # Reads `path` (which MUST exist) plus every file listed in
107
+ # its `includes:` chain, merging them under the order:
108
+ # included files first (in declaration order), then the
109
+ # current file's own keys override. Relative paths inside
110
+ # each file are resolved against that file's directory.
111
+ def self.load_with_includes(path, visited: Set.new)
112
+ absolute = File.expand_path(path)
113
+ raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
114
+
115
+ raw = YAML.safe_load_file(absolute, aliases: false) || {}
116
+ raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
117
+
118
+ base_dir = File.dirname(absolute)
119
+ includes = Array(raw.delete("includes") || [])
120
+ data = resolve_paths_in(raw, base_dir)
121
+ next_visited = visited + [absolute]
122
+ merge_includes(data, includes, base_dir, next_visited)
123
+ end
124
+
125
+ def self.merge_includes(data, includes, base_dir, visited)
126
+ return data if includes.empty?
127
+
128
+ accumulated = {}
129
+ includes.each do |inc|
130
+ inc_path = File.expand_path(inc.to_s, base_dir)
131
+ unless File.exist?(inc_path)
132
+ raise ArgumentError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
133
+ end
134
+
135
+ accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
136
+ end
137
+ deep_merge(accumulated, data)
138
+ end
139
+
140
+ # Per-file path resolution. Each path-bearing key listed in
141
+ # {PATH_KEYS} plus the nested `plugins_io.allowed_paths:`
142
+ # entries get their relative paths expanded against the
143
+ # config file's directory. `cache.path:` is intentionally
144
+ # left as-is so end-user messages (e.g. `--cache-stats`
145
+ # output) keep the project-relative form the user wrote.
146
+ def self.resolve_paths_in(data, base_dir)
147
+ return data unless data.is_a?(Hash)
148
+
149
+ out = data.dup
150
+ PATH_KEYS.each { |key| resolve_path_key!(out, key, base_dir) }
151
+ resolve_plugins_io_paths!(out, base_dir)
152
+ out
153
+ end
154
+
155
+ def self.resolve_path_key!(out, key, base_dir)
156
+ return unless out.key?(key) && !out[key].nil?
157
+
158
+ out[key] = Array(out[key]).map { |p| File.expand_path(p.to_s, base_dir) }
159
+ end
160
+
161
+ def self.resolve_plugins_io_paths!(out, base_dir)
162
+ plugins_io = out["plugins_io"]
163
+ return unless plugins_io.is_a?(Hash) && plugins_io["allowed_paths"]
164
+
165
+ duped = plugins_io.dup
166
+ duped["allowed_paths"] = Array(plugins_io["allowed_paths"]).map { |p| File.expand_path(p.to_s, base_dir) }
167
+ out["plugins_io"] = duped
168
+ end
169
+
170
+ def self.deep_merge(left, right)
171
+ return right unless left.is_a?(Hash) && right.is_a?(Hash)
172
+
173
+ merged = left.dup
174
+ right.each do |key, value|
175
+ merged[key] = if merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
176
+ deep_merge(merged[key], value)
177
+ else
178
+ value
179
+ end
180
+ end
181
+ merged
182
+ end
183
+ private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
184
+
185
+ def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
186
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
187
+ plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
36
188
 
37
- @target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
189
+ @target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
38
190
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
39
- @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
191
+ user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
192
+ @exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
193
+ @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
194
+ coerce_plugin_entry(entry)
195
+ end.freeze
40
196
  @disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
41
197
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
42
198
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
@@ -45,12 +201,21 @@ module Rigor
45
201
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
46
202
  ) == true
47
203
  @cache_path = cache.fetch("path").to_s
204
+ @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
205
+ @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
206
+ @severity_profile = coerce_severity_profile(
207
+ data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
208
+ )
209
+ @severity_overrides = coerce_severity_overrides(
210
+ data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
211
+ )
48
212
  end
49
213
 
50
214
  def to_h
51
215
  {
52
216
  "target_ruby" => target_ruby,
53
217
  "paths" => paths,
218
+ "exclude" => exclude_patterns - BUILTIN_EXCLUDES,
54
219
  "plugins" => plugins,
55
220
  "disable" => disabled_rules,
56
221
  "libraries" => libraries,
@@ -58,8 +223,114 @@ module Rigor
58
223
  "fold_platform_specific_paths" => fold_platform_specific_paths,
59
224
  "cache" => {
60
225
  "path" => cache_path
61
- }
226
+ },
227
+ "plugins_io" => {
228
+ "network" => plugins_io_network.to_s,
229
+ "allowed_paths" => plugins_io_allowed_paths
230
+ },
231
+ "severity_profile" => severity_profile.to_s,
232
+ "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
62
233
  }
63
234
  end
235
+
236
+ private
237
+
238
+ # Accepts either `"rigor-foo"` (gem-name shorthand) or
239
+ # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
240
+ # (full form). Returns the canonical hash form so the loader
241
+ # works against a single shape.
242
+ def coerce_plugin_entry(entry)
243
+ case entry
244
+ when String
245
+ entry.dup.freeze
246
+ when Hash
247
+ entry.to_h { |k, v| [k.to_s, v] }.freeze
248
+ else
249
+ raise ArgumentError,
250
+ "plugin configuration entry must be a String or Hash, got #{entry.inspect}"
251
+ end
252
+ end
253
+
254
+ # `target_ruby` is passed to `Prism.parse_file(path, version:)` at
255
+ # the analyser's three parse sites (`Analysis::Runner`,
256
+ # `CLI::TypeOfCommand`, `CLI::TypeScanCommand`) so projects that
257
+ # target an older Ruby get parse errors for syntax their target
258
+ # doesn't support. Format validation here is loose — accepts
259
+ # any `<major>.<minor>` or `<major>.<minor>.<patch>` form, plus
260
+ # the literal `"latest"`. Prism itself enforces the supported
261
+ # set and raises `ArgumentError` for versions it does not
262
+ # recognise (e.g. `"1.0"`); the parse-time error message names
263
+ # the version, so the user can correct the setting.
264
+ TARGET_RUBY_FORMAT = /\A(?:\d+\.\d+(?:\.\d+)?|latest)\z/
265
+ private_constant :TARGET_RUBY_FORMAT
266
+
267
+ def coerce_target_ruby(value)
268
+ s = value.to_s
269
+ unless s.match?(TARGET_RUBY_FORMAT)
270
+ raise ArgumentError,
271
+ "target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
272
+ end
273
+
274
+ s.dup.freeze
275
+ end
276
+
277
+ # Slice 2 only accepts `:disabled` for the network policy. The
278
+ # YAML scalar may arrive as a String (`"disabled"`) or already
279
+ # as the Symbol; coerce to the canonical Symbol shape so the
280
+ # downstream `TrustPolicy` constructor stays strict.
281
+ #
282
+ # The accepted set is duplicated from
283
+ # {Rigor::Plugin::TrustPolicy::VALID_NETWORK_POLICIES} so
284
+ # `Configuration` does not require the plugin namespace at
285
+ # load time (Configuration is loaded before Plugin in
286
+ # `lib/rigor.rb`); the two stay in lockstep via spec.
287
+ VALID_NETWORK_POLICIES = %i[disabled].freeze
288
+ private_constant :VALID_NETWORK_POLICIES
289
+
290
+ def coerce_network_policy(value)
291
+ sym = value.to_sym
292
+ unless VALID_NETWORK_POLICIES.include?(sym)
293
+ raise ArgumentError,
294
+ "plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
295
+ end
296
+
297
+ sym
298
+ end
299
+
300
+ # ADR-8 § "Severity profile" — accepts the canonical Symbol
301
+ # form or its String spelling; rejects unknown profile names
302
+ # so typos fail loudly.
303
+ def coerce_severity_profile(value)
304
+ sym = value.to_sym
305
+ unless SeverityProfile::VALID_PROFILES.include?(sym)
306
+ raise ArgumentError,
307
+ "severity_profile must be one of " \
308
+ "#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
309
+ end
310
+
311
+ sym
312
+ end
313
+
314
+ # ADR-8 § "Severity profile" — `severity_overrides:` is a
315
+ # `{ rule => severity }` map. Keys are canonical rule ids
316
+ # (`call.undefined-method`) or family wildcards (`call`).
317
+ # Values are {SeverityProfile::VALID_SEVERITIES} symbols
318
+ # (`:error` / `:warning` / `:info` / `:off`). Unknown
319
+ # severities raise; unknown rule ids are silently kept (the
320
+ # override is inert until the rule lands).
321
+ def coerce_severity_overrides(value)
322
+ raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
323
+
324
+ value.to_h do |k, v|
325
+ sym = v.to_sym
326
+ unless SeverityProfile::VALID_SEVERITIES.include?(sym)
327
+ raise ArgumentError,
328
+ "severity_overrides[#{k.inspect}] must be one of " \
329
+ "#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
330
+ end
331
+
332
+ [k.to_s, sym]
333
+ end.freeze
334
+ end
64
335
  end
65
336
  end