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.
- checksums.yaml +4 -4
- data/README.md +45 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +269 -7
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
- data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
- data/lib/rigor/cache/rbs_constant_table.rb +2 -2
- data/lib/rigor/cache/rbs_descriptor.rb +2 -0
- data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
- data/lib/rigor/cache/store.rb +2 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +20 -7
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +286 -15
- data/lib/rigor/environment/rbs_loader.rb +89 -13
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/flow_contribution/conflict.rb +81 -0
- data/lib/rigor/flow_contribution/element.rb +53 -0
- data/lib/rigor/flow_contribution/fact.rb +88 -0
- data/lib/rigor/flow_contribution/merge_result.rb +67 -0
- data/lib/rigor/flow_contribution/merger.rb +275 -0
- data/lib/rigor/flow_contribution.rb +51 -0
- data/lib/rigor/inference/block_parameter_binder.rb +15 -0
- data/lib/rigor/inference/expression_typer.rb +87 -6
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +246 -127
- data/lib/rigor/inference/scope_indexer.rb +124 -16
- data/lib/rigor/inference/statement_evaluator.rb +406 -37
- data/lib/rigor/plugin/access_denied_error.rb +24 -0
- data/lib/rigor/plugin/base.rb +284 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +102 -0
- data/lib/rigor/plugin/load_error.rb +35 -0
- data/lib/rigor/plugin/loader.rb +307 -0
- data/lib/rigor/plugin/manifest.rb +203 -0
- data/lib/rigor/plugin/registry.rb +50 -0
- data/lib/rigor/plugin/services.rb +77 -0
- data/lib/rigor/plugin/trust_policy.rb +99 -0
- data/lib/rigor/plugin.rb +62 -0
- data/lib/rigor/rbs_extended.rb +57 -9
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +7 -0
- data/sig/rigor/environment.rbs +10 -3
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +7 -0
- data/sig/rigor.rbs +8 -2
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
216
|
-
#
|
|
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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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"))
|
|
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
|
-
|
|
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
|