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.
- checksums.yaml +4 -4
- data/README.md +10 -0
- data/data/builtins/ruby_core/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
- data/lib/rigor/analysis/check_rules.rb +346 -18
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
- data/lib/rigor/analysis/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +96 -6
- data/lib/rigor/cache/descriptor.rb +58 -5
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -0
- data/lib/rigor/cli.rb +18 -1
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +53 -13
- data/lib/rigor/environment.rb +16 -4
- data/lib/rigor/flow_contribution/merger.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher.rb +87 -0
- data/lib/rigor/inference/scope_indexer.rb +171 -2
- data/lib/rigor/inference/statement_evaluator.rb +65 -1
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/manifest.rb +26 -5
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- metadata +13 -1
data/lib/rigor/configuration.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require_relative "configuration/dependencies"
|
|
5
6
|
require_relative "configuration/severity_profile"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
@@ -54,10 +55,15 @@ module Rigor
|
|
|
54
55
|
},
|
|
55
56
|
"plugins_io" => {
|
|
56
57
|
"network" => "disabled",
|
|
57
|
-
"allowed_paths" => []
|
|
58
|
+
"allowed_paths" => [],
|
|
59
|
+
"allowed_url_hosts" => []
|
|
58
60
|
},
|
|
59
61
|
"severity_profile" => "balanced",
|
|
60
|
-
"severity_overrides" => {}
|
|
62
|
+
"severity_overrides" => {},
|
|
63
|
+
"dependencies" => {
|
|
64
|
+
"source_inference" => [],
|
|
65
|
+
"budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
|
|
66
|
+
}
|
|
61
67
|
}.freeze
|
|
62
68
|
|
|
63
69
|
# Top-level keys whose values are file/directory paths that
|
|
@@ -70,7 +76,9 @@ module Rigor
|
|
|
70
76
|
attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
|
|
71
77
|
:libraries, :signature_paths, :fold_platform_specific_paths,
|
|
72
78
|
:plugins_io_network, :plugins_io_allowed_paths,
|
|
73
|
-
:
|
|
79
|
+
:plugins_io_allowed_url_hosts,
|
|
80
|
+
:severity_profile, :severity_overrides,
|
|
81
|
+
:dependencies
|
|
74
82
|
|
|
75
83
|
# Loads a configuration file.
|
|
76
84
|
#
|
|
@@ -172,17 +180,42 @@ module Rigor
|
|
|
172
180
|
|
|
173
181
|
merged = left.dup
|
|
174
182
|
right.each do |key, value|
|
|
175
|
-
merged[key] =
|
|
176
|
-
deep_merge(merged[key], value)
|
|
177
|
-
else
|
|
178
|
-
value
|
|
179
|
-
end
|
|
183
|
+
merged[key] = merge_value(key, merged, value)
|
|
180
184
|
end
|
|
181
185
|
merged
|
|
182
186
|
end
|
|
183
|
-
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
# Most keys are right-wins (override) or recursively
|
|
189
|
+
# merged hashes. ADR-10 § "config-conflict diagnostic"
|
|
190
|
+
# carves out `dependencies.source_inference[]`: the
|
|
191
|
+
# per-gem merge across `includes:` chains needs union
|
|
192
|
+
# behaviour with mode-conflict detection. The Hash itself
|
|
193
|
+
# still merges deeply; only the inner array gets
|
|
194
|
+
# concatenated so {Dependencies.from_h} sees every
|
|
195
|
+
# contributor's entries and can dedupe them.
|
|
196
|
+
def self.merge_value(key, merged, value)
|
|
197
|
+
if key == "dependencies" && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
198
|
+
merge_dependencies_hash(merged[key], value)
|
|
199
|
+
elsif merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
200
|
+
deep_merge(merged[key], value)
|
|
201
|
+
else
|
|
202
|
+
value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.merge_dependencies_hash(left, right)
|
|
207
|
+
out = deep_merge(left, right)
|
|
208
|
+
left_si = Array(left["source_inference"])
|
|
209
|
+
right_si = Array(right["source_inference"])
|
|
210
|
+
both_empty = left_si.empty? && right_si.empty?
|
|
211
|
+
out["source_inference"] = left_si + right_si unless both_empty # rigor:disable flow.always-truthy-condition
|
|
212
|
+
out
|
|
213
|
+
end
|
|
214
|
+
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
|
|
215
|
+
:merge_value, :merge_dependencies_hash
|
|
216
|
+
|
|
217
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
218
|
+
def initialize(data = DEFAULTS)
|
|
186
219
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
187
220
|
plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
|
|
188
221
|
|
|
@@ -203,13 +236,18 @@ module Rigor
|
|
|
203
236
|
@cache_path = cache.fetch("path").to_s
|
|
204
237
|
@plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
|
|
205
238
|
@plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
|
|
239
|
+
@plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
|
|
206
240
|
@severity_profile = coerce_severity_profile(
|
|
207
241
|
data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
|
|
208
242
|
)
|
|
209
243
|
@severity_overrides = coerce_severity_overrides(
|
|
210
244
|
data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
|
|
211
245
|
)
|
|
246
|
+
@dependencies = Dependencies.from_h(
|
|
247
|
+
data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
|
|
248
|
+
)
|
|
212
249
|
end
|
|
250
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
213
251
|
|
|
214
252
|
def to_h
|
|
215
253
|
{
|
|
@@ -226,10 +264,12 @@ module Rigor
|
|
|
226
264
|
},
|
|
227
265
|
"plugins_io" => {
|
|
228
266
|
"network" => plugins_io_network.to_s,
|
|
229
|
-
"allowed_paths" => plugins_io_allowed_paths
|
|
267
|
+
"allowed_paths" => plugins_io_allowed_paths,
|
|
268
|
+
"allowed_url_hosts" => plugins_io_allowed_url_hosts
|
|
230
269
|
},
|
|
231
270
|
"severity_profile" => severity_profile.to_s,
|
|
232
|
-
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
|
|
271
|
+
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
|
|
272
|
+
"dependencies" => dependencies.to_h
|
|
233
273
|
}
|
|
234
274
|
end
|
|
235
275
|
|
|
@@ -284,7 +324,7 @@ module Rigor
|
|
|
284
324
|
# `Configuration` does not require the plugin namespace at
|
|
285
325
|
# load time (Configuration is loaded before Plugin in
|
|
286
326
|
# `lib/rigor.rb`); the two stay in lockstep via spec.
|
|
287
|
-
VALID_NETWORK_POLICIES = %i[disabled].freeze
|
|
327
|
+
VALID_NETWORK_POLICIES = %i[disabled allowlist].freeze
|
|
288
328
|
private_constant :VALID_NETWORK_POLICIES
|
|
289
329
|
|
|
290
330
|
def coerce_network_policy(value)
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Rigor
|
|
|
41
41
|
prism rbs
|
|
42
42
|
].freeze
|
|
43
43
|
|
|
44
|
-
attr_reader :class_registry, :rbs_loader, :plugin_registry
|
|
44
|
+
attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index
|
|
45
45
|
|
|
46
46
|
# @param class_registry [Rigor::Environment::ClassRegistry]
|
|
47
47
|
# @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
|
|
@@ -57,10 +57,17 @@ module Rigor
|
|
|
57
57
|
# default), no plugin-level return-type contribution
|
|
58
58
|
# participates — useful for tests, the `Environment.default`
|
|
59
59
|
# facade, and analyses that don't load plugins.
|
|
60
|
-
|
|
60
|
+
# @param dependency_source_index [Rigor::Analysis::DependencySourceInference::Index, nil]
|
|
61
|
+
# ADR-10 slice 2b-ii. The per-run index of opt-in gem
|
|
62
|
+
# sources the dispatcher consults BELOW RBS dispatch.
|
|
63
|
+
# When nil (the default), no dep-source contribution
|
|
64
|
+
# participates and the dispatcher tier is a no-op.
|
|
65
|
+
def initialize(class_registry: ClassRegistry.default, rbs_loader: nil,
|
|
66
|
+
plugin_registry: nil, dependency_source_index: nil)
|
|
61
67
|
@class_registry = class_registry
|
|
62
68
|
@rbs_loader = rbs_loader
|
|
63
69
|
@plugin_registry = plugin_registry
|
|
70
|
+
@dependency_source_index = dependency_source_index
|
|
64
71
|
freeze
|
|
65
72
|
end
|
|
66
73
|
|
|
@@ -90,7 +97,8 @@ module Rigor
|
|
|
90
97
|
# reflection artefacts) consult the cache. Pass `nil` (the
|
|
91
98
|
# default) to skip caching for this environment.
|
|
92
99
|
# @return [Rigor::Environment]
|
|
93
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
|
|
100
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/ParameterLists
|
|
101
|
+
plugin_registry: nil, dependency_source_index: nil)
|
|
94
102
|
resolved_paths = signature_paths || default_signature_paths(root)
|
|
95
103
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
96
104
|
loader = RbsLoader.new(
|
|
@@ -98,7 +106,11 @@ module Rigor
|
|
|
98
106
|
signature_paths: resolved_paths,
|
|
99
107
|
cache_store: cache_store
|
|
100
108
|
)
|
|
101
|
-
new(
|
|
109
|
+
new(
|
|
110
|
+
rbs_loader: loader,
|
|
111
|
+
plugin_registry: plugin_registry,
|
|
112
|
+
dependency_source_index: dependency_source_index
|
|
113
|
+
)
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
private
|
|
@@ -8,24 +8,40 @@ module Rigor
|
|
|
8
8
|
module Inference
|
|
9
9
|
module MethodDispatcher
|
|
10
10
|
# Picks the RBS overload that should answer a call given the
|
|
11
|
-
# caller's actual argument types. Slice 4 phase 2c shape
|
|
11
|
+
# caller's actual argument types. Slice 4 phase 2c shape (with
|
|
12
|
+
# the v0.1.2 interface-strictness preference layered on top):
|
|
12
13
|
#
|
|
13
14
|
# 1. Filter overloads by positional arity (required, optional and
|
|
14
15
|
# rest_positionals are honored; required_keywords disqualify the
|
|
15
16
|
# overload because we do not yet thread keyword args through
|
|
16
17
|
# `call_arg_types`).
|
|
17
|
-
# 2.
|
|
18
|
-
# whose every (param, arg)
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
18
|
+
# 2. **Pass 1 — strict matches first.** Among the arity-matching
|
|
19
|
+
# overloads, prefer the first one whose every (param, arg)
|
|
20
|
+
# pair returns a `yes` or `maybe` answer AND whose param
|
|
21
|
+
# types do NOT translate through `RBS::Types::Alias` /
|
|
22
|
+
# `Interface` / `Intersection`. The translator demotes those
|
|
23
|
+
# to `Dynamic[Top]`, which gradually accepts any argument —
|
|
24
|
+
# so without this preference, an alias-typed overload like
|
|
25
|
+
# `Array#[](::int) -> Elem` would beat the strict
|
|
26
|
+
# `Array#[](Range) -> Array[Elem]?` overload for a Range
|
|
27
|
+
# argument. (Surfaced during v0.1.1 self-analysis; see the
|
|
28
|
+
# "Interface-strictness on overload selection" item in
|
|
29
|
+
# `docs/MILESTONES.md`.)
|
|
30
|
+
# 3. **Pass 2 — gradual fall-back.** If no fully strict overload
|
|
31
|
+
# matches, accept the first arity-and-gradual-accept match
|
|
32
|
+
# (the v0.1.1 behaviour). Alias / Interface / Intersection
|
|
33
|
+
# params still reach this pass, so call sites whose only
|
|
34
|
+
# candidate IS an alias-typed overload keep working.
|
|
35
|
+
# 4. If no overload matches at all, fall back to
|
|
36
|
+
# `method_types.first` so existing call sites keep their
|
|
37
|
+
# phase 1 / 2b behavior. This preserves the fail-soft
|
|
38
|
+
# invariant of the dispatcher.
|
|
23
39
|
#
|
|
24
40
|
# The selector is intentionally agnostic about the dispatch kind
|
|
25
41
|
# (instance vs singleton). Both kinds share the same arity and
|
|
26
42
|
# acceptance shape; the difference is only in which `Definition`
|
|
27
43
|
# the caller fetched.
|
|
28
|
-
module OverloadSelector
|
|
44
|
+
module OverloadSelector # rubocop:disable Metrics/ModuleLength
|
|
29
45
|
module_function
|
|
30
46
|
|
|
31
47
|
# @param method_definition [RBS::Definition::Method]
|
|
@@ -61,6 +77,18 @@ module Rigor
|
|
|
61
77
|
# compatibility.
|
|
62
78
|
param_overrides = RbsExtended.param_type_override_map(method_definition)
|
|
63
79
|
|
|
80
|
+
# Pass 1: prefer overloads whose param types stay strict —
|
|
81
|
+
# no translator-induced `Dynamic[Top]` from Alias /
|
|
82
|
+
# Interface / Intersection. The pass is skipped
|
|
83
|
+
# entirely when any arg is `Dynamic[Top]` (literally
|
|
84
|
+
# `untyped`), because gradual acceptance against an
|
|
85
|
+
# untyped arg accepts every param indiscriminately and
|
|
86
|
+
# would let pass 1 lock in an arbitrary strict overload
|
|
87
|
+
# (e.g. `Regexp#=~(nil) -> nil` over the
|
|
88
|
+
# `(::interned?) -> Integer?` overload). Pass 2 falls
|
|
89
|
+
# back to the original gradual matcher so overloads
|
|
90
|
+
# that legitimately rely on duck-typed params still
|
|
91
|
+
# resolve when nothing stricter applies.
|
|
64
92
|
match = find_matching_overload(
|
|
65
93
|
overloads,
|
|
66
94
|
arg_types: arg_types,
|
|
@@ -68,7 +96,17 @@ module Rigor
|
|
|
68
96
|
instance_type: instance_type,
|
|
69
97
|
type_vars: type_vars,
|
|
70
98
|
block_required: block_required,
|
|
71
|
-
param_overrides: param_overrides
|
|
99
|
+
param_overrides: param_overrides,
|
|
100
|
+
strict: true
|
|
101
|
+
) || find_matching_overload(
|
|
102
|
+
overloads,
|
|
103
|
+
arg_types: arg_types,
|
|
104
|
+
self_type: self_type,
|
|
105
|
+
instance_type: instance_type,
|
|
106
|
+
type_vars: type_vars,
|
|
107
|
+
block_required: block_required,
|
|
108
|
+
param_overrides: param_overrides,
|
|
109
|
+
strict: false
|
|
72
110
|
)
|
|
73
111
|
return match if match
|
|
74
112
|
return overloads.find { |mt| overload_has_block?(mt) } if block_required
|
|
@@ -84,11 +122,14 @@ module Rigor
|
|
|
84
122
|
class << self
|
|
85
123
|
private
|
|
86
124
|
|
|
87
|
-
# rubocop:disable Metrics/ParameterLists
|
|
125
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
88
126
|
def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:,
|
|
89
|
-
param_overrides:)
|
|
127
|
+
param_overrides:, strict:)
|
|
128
|
+
return nil if strict && arg_types.any? { |t| untyped_arg?(t) }
|
|
129
|
+
|
|
90
130
|
overloads.find do |method_type|
|
|
91
131
|
next false if block_required && !OverloadSelector.overload_has_block?(method_type)
|
|
132
|
+
next false if strict && !strictly_typed_params?(method_type, arg_types.size)
|
|
92
133
|
|
|
93
134
|
matches?(
|
|
94
135
|
method_type,
|
|
@@ -100,7 +141,58 @@ module Rigor
|
|
|
100
141
|
)
|
|
101
142
|
end
|
|
102
143
|
end
|
|
103
|
-
# rubocop:enable Metrics/ParameterLists
|
|
144
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
145
|
+
|
|
146
|
+
# Treats the literal `untyped` carrier (`Dynamic[Top]`)
|
|
147
|
+
# as too imprecise to drive a strict-pass match. Other
|
|
148
|
+
# `Dynamic`-wrapped types with a concrete static facet
|
|
149
|
+
# carry enough information to pick a sensible overload.
|
|
150
|
+
def untyped_arg?(type)
|
|
151
|
+
type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns true when every positional param the call
|
|
155
|
+
# site engages translates to a non-`Dynamic[Top]`
|
|
156
|
+
# carrier. Alias / Interface / Intersection RBS types
|
|
157
|
+
# all degrade to `Dynamic[Top]` per the translator's
|
|
158
|
+
# current shape — those gradually accept any arg, so
|
|
159
|
+
# an overload that includes one would beat strictly-
|
|
160
|
+
# typed alternatives in pass 2 of the selector.
|
|
161
|
+
def strictly_typed_params?(method_type, actual_count)
|
|
162
|
+
fun = method_type.type
|
|
163
|
+
return false unless arity_compatible?(fun, actual_count)
|
|
164
|
+
|
|
165
|
+
params = positional_params_for(fun, actual_count)
|
|
166
|
+
params.all? { |param| !alias_or_interface_param?(param.type) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Recursive: an Optional / Union wrapper is strict iff
|
|
170
|
+
# every member is strict. Type args of a ClassInstance
|
|
171
|
+
# are NOT walked — `Range[::int]` is a Range carrier
|
|
172
|
+
# at the param level; the alias only colours the
|
|
173
|
+
# element type, which is checked separately when the
|
|
174
|
+
# element is actually accessed.
|
|
175
|
+
#
|
|
176
|
+
# `RBS::Types::Bases::Any` (the explicit `untyped`
|
|
177
|
+
# keyword) is treated like Alias / Interface /
|
|
178
|
+
# Intersection — both translate to `Dynamic[Top]`,
|
|
179
|
+
# both gradually accept anything. A `(untyped) -> T`
|
|
180
|
+
# catch-all overload that comes after the strictly-
|
|
181
|
+
# typed ones must lose pass 1 so the typed overloads
|
|
182
|
+
# win when their param actually fits the arg.
|
|
183
|
+
def alias_or_interface_param?(rbs_type)
|
|
184
|
+
case rbs_type
|
|
185
|
+
when RBS::Types::Alias, RBS::Types::Interface,
|
|
186
|
+
RBS::Types::Intersection, RBS::Types::Bases::Any
|
|
187
|
+
true
|
|
188
|
+
when RBS::Types::Optional
|
|
189
|
+
alias_or_interface_param?(rbs_type.type)
|
|
190
|
+
when RBS::Types::Union
|
|
191
|
+
rbs_type.types.any? { |t| alias_or_interface_param?(t) }
|
|
192
|
+
else
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
104
196
|
|
|
105
197
|
# rubocop:disable Metrics/ParameterLists
|
|
106
198
|
def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:, param_overrides:)
|
|
@@ -86,6 +86,18 @@ module Rigor
|
|
|
86
86
|
)
|
|
87
87
|
return rbs_result if rbs_result
|
|
88
88
|
|
|
89
|
+
# ADR-10 slice 2b-ii — dependency-source inference tier.
|
|
90
|
+
# Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
|
|
91
|
+
# stubs / plugin contracts always win) and ABOVE the
|
|
92
|
+
# user-class fallback so a method defined in an opt-in
|
|
93
|
+
# gem stops emitting `call.undefined-method` even when
|
|
94
|
+
# no signature contract resolves. Returns
|
|
95
|
+
# `Dynamic[top]` — slice 2b-ii deliberately stops at the
|
|
96
|
+
# dynamic-origin envelope; per-method return-type
|
|
97
|
+
# precision is queued for a later slice.
|
|
98
|
+
dep_source_result = try_dependency_source(receiver_type, method_name, environment)
|
|
99
|
+
return dep_source_result if dep_source_result
|
|
100
|
+
|
|
89
101
|
# Slice 7 phase 10 — user-class ancestor fallback. When
|
|
90
102
|
# the receiver is `Nominal[T]` or `Singleton[T]` for a
|
|
91
103
|
# class not in the RBS environment (typically a
|
|
@@ -125,6 +137,81 @@ module Rigor
|
|
|
125
137
|
FlowContribution::Merger.merge(contributions).return_type
|
|
126
138
|
end
|
|
127
139
|
|
|
140
|
+
# ADR-10 slice 2b-ii. Consults the per-run
|
|
141
|
+
# `Analysis::DependencySourceInference::Index` carried by
|
|
142
|
+
# the environment for `(class_name, method_name)`
|
|
143
|
+
# observations harvested from opt-in gems' `roots:`. On a
|
|
144
|
+
# hit, returns `Combinator.untyped` so the call site
|
|
145
|
+
# carries the `Dynamic[top]` provenance (per ADR-10's
|
|
146
|
+
# "Inference contract": gem-source-inferred shapes never
|
|
147
|
+
# publish as ground-truth `T`). Returns `nil` when the
|
|
148
|
+
# environment carries no index, the index has no entry, or
|
|
149
|
+
# the receiver has no nominal class to look up.
|
|
150
|
+
def try_dependency_source(receiver_type, method_name, environment)
|
|
151
|
+
index = environment&.dependency_source_index
|
|
152
|
+
return nil if index.nil? || index.empty?
|
|
153
|
+
|
|
154
|
+
class_name = dep_source_class_name(receiver_type)
|
|
155
|
+
return nil if class_name.nil?
|
|
156
|
+
|
|
157
|
+
# ADR-10 5a — per-receiver plugin veto. When a
|
|
158
|
+
# registered plugin declares `manifest(owns_receivers:
|
|
159
|
+
# [<class>])` AND the call's receiver IS that class
|
|
160
|
+
# (or a subclass), decline and let plugins handle the
|
|
161
|
+
# call. Plugins that own a receiver are the
|
|
162
|
+
# authoritative source for that type; gem-source
|
|
163
|
+
# inference must not contribute behind their backs.
|
|
164
|
+
return nil if plugin_owns_receiver?(class_name, environment)
|
|
165
|
+
|
|
166
|
+
contribution_kind = index.contribution_for(class_name: class_name, method_name: method_name)
|
|
167
|
+
return Type::Combinator.untyped if contribution_kind
|
|
168
|
+
|
|
169
|
+
# ADR-10 5b — β budget semantics. On a catalog miss,
|
|
170
|
+
# if the receiver class belongs to a budget-exceeded
|
|
171
|
+
# gem AND the user opted into `:dependency_silence`,
|
|
172
|
+
# return `Dynamic[top]` rather than falling through to
|
|
173
|
+
# the user-class fallback. The user-class fallback
|
|
174
|
+
# would otherwise emit `call.undefined-method` for
|
|
175
|
+
# methods Rigor's catalog couldn't reach because the
|
|
176
|
+
# walker hit its cap.
|
|
177
|
+
budget_silence_result(class_name, index, environment)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def budget_silence_result(class_name, index, _environment)
|
|
181
|
+
return nil unless index.budget_overrun_strategy == :dependency_silence
|
|
182
|
+
|
|
183
|
+
owning_gem = index.gem_for(class_name)
|
|
184
|
+
return nil if owning_gem.nil?
|
|
185
|
+
return nil unless index.budget_exceeded.include?(owning_gem)
|
|
186
|
+
|
|
187
|
+
Type::Combinator.untyped
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def plugin_owns_receiver?(class_name, environment)
|
|
191
|
+
registry = environment&.plugin_registry
|
|
192
|
+
return false if registry.nil? || registry.empty?
|
|
193
|
+
|
|
194
|
+
registry.plugins.any? do |plugin|
|
|
195
|
+
owns = plugin.manifest.owns_receivers # rigor:disable undefined-method
|
|
196
|
+
owns.any? { |owner| receiver_matches_owner?(class_name, owner, environment) }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def receiver_matches_owner?(class_name, owner, environment)
|
|
201
|
+
return true if class_name == owner
|
|
202
|
+
|
|
203
|
+
ordering = environment.class_ordering(class_name, owner)
|
|
204
|
+
%i[equal subclass].include?(ordering)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def dep_source_class_name(receiver_type)
|
|
210
|
+
case receiver_type
|
|
211
|
+
when Type::Nominal, Type::Singleton then receiver_type.class_name
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
128
215
|
def collect_plugin_contributions(registry, call_node, scope)
|
|
129
216
|
registry.plugins.filter_map do |plugin|
|
|
130
217
|
contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
|
|
@@ -106,6 +106,14 @@ module Rigor
|
|
|
106
106
|
discovered_def_nodes = build_discovered_def_nodes(root)
|
|
107
107
|
seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
|
|
108
108
|
|
|
109
|
+
# v0.1.2 — per-class table of method visibilities
|
|
110
|
+
# (`:public` / `:private` / `:protected`). The
|
|
111
|
+
# `def.method-visibility-mismatch` CheckRule consults
|
|
112
|
+
# the table to flag explicit-non-self calls to a
|
|
113
|
+
# private user method.
|
|
114
|
+
discovered_method_visibilities = build_discovered_method_visibilities(root)
|
|
115
|
+
seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
|
|
116
|
+
|
|
109
117
|
table = {}.compare_by_identity
|
|
110
118
|
table.default = seeded_scope
|
|
111
119
|
|
|
@@ -340,7 +348,8 @@ module Rigor
|
|
|
340
348
|
accumulator.transform_values(&:freeze).freeze
|
|
341
349
|
end
|
|
342
350
|
|
|
343
|
-
|
|
351
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
352
|
+
def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
|
|
344
353
|
return unless node.is_a?(Prism::Node)
|
|
345
354
|
|
|
346
355
|
case node
|
|
@@ -356,6 +365,12 @@ module Rigor
|
|
|
356
365
|
walk_methods(node.body, qualified_prefix, true, accumulator)
|
|
357
366
|
return
|
|
358
367
|
end
|
|
368
|
+
when Prism::ConstantWriteNode
|
|
369
|
+
if meta_new_block_body(node)
|
|
370
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
371
|
+
walk_methods(meta_new_block_body(node), child_prefix, false, accumulator)
|
|
372
|
+
return
|
|
373
|
+
end
|
|
359
374
|
when Prism::DefNode
|
|
360
375
|
record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
|
|
361
376
|
return
|
|
@@ -370,6 +385,24 @@ module Rigor
|
|
|
370
385
|
walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
|
|
371
386
|
end
|
|
372
387
|
end
|
|
388
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
389
|
+
|
|
390
|
+
# v0.1.2 — when a `Const = Data.define(*sym) do ... end`
|
|
391
|
+
# / `Const = Struct.new(*sym) do ... end` constant write
|
|
392
|
+
# carries a block, the block body holds method overrides
|
|
393
|
+
# whose canonical class is `Const`. Returns the block body
|
|
394
|
+
# node (a `Prism::StatementsNode`) when the rvalue
|
|
395
|
+
# matches; nil otherwise. Used by `walk_methods` /
|
|
396
|
+
# `walk_def_nodes` to push `Const` onto the qualified
|
|
397
|
+
# prefix before recursing.
|
|
398
|
+
def meta_new_block_body(node)
|
|
399
|
+
return nil unless node.is_a?(Prism::ConstantWriteNode)
|
|
400
|
+
|
|
401
|
+
rvalue = node.value
|
|
402
|
+
return nil unless data_define_call?(rvalue) || struct_new_call?(rvalue)
|
|
403
|
+
|
|
404
|
+
rvalue.block&.body
|
|
405
|
+
end
|
|
373
406
|
|
|
374
407
|
def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
|
|
375
408
|
return if qualified_prefix.empty?
|
|
@@ -397,7 +430,8 @@ module Rigor
|
|
|
397
430
|
accumulator.transform_values(&:freeze).freeze
|
|
398
431
|
end
|
|
399
432
|
|
|
400
|
-
|
|
433
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
434
|
+
def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
|
|
401
435
|
return unless node.is_a?(Prism::Node)
|
|
402
436
|
|
|
403
437
|
case node
|
|
@@ -413,6 +447,12 @@ module Rigor
|
|
|
413
447
|
walk_def_nodes(node.body, qualified_prefix, true, accumulator)
|
|
414
448
|
return
|
|
415
449
|
end
|
|
450
|
+
when Prism::ConstantWriteNode
|
|
451
|
+
if meta_new_block_body(node)
|
|
452
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
453
|
+
walk_def_nodes(meta_new_block_body(node), child_prefix, false, accumulator)
|
|
454
|
+
return
|
|
455
|
+
end
|
|
416
456
|
when Prism::DefNode
|
|
417
457
|
record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
|
|
418
458
|
return
|
|
@@ -422,6 +462,7 @@ module Rigor
|
|
|
422
462
|
walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
|
|
423
463
|
end
|
|
424
464
|
end
|
|
465
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
425
466
|
|
|
426
467
|
# v0.0.3 A — sentinel key under which `record_def_node`
|
|
427
468
|
# files DefNodes that live outside any class / module
|
|
@@ -440,6 +481,134 @@ module Rigor
|
|
|
440
481
|
accumulator[class_name][def_node.name] = def_node
|
|
441
482
|
end
|
|
442
483
|
|
|
484
|
+
VISIBILITY_MODIFIERS = %i[public private protected].freeze
|
|
485
|
+
|
|
486
|
+
# v0.1.2 — per-class method-visibility table for the
|
|
487
|
+
# `def.method-visibility-mismatch` CheckRule.
|
|
488
|
+
#
|
|
489
|
+
# Tracks two visibility-changing forms:
|
|
490
|
+
#
|
|
491
|
+
# - **Modifier blocks**: a bare `private` / `protected` /
|
|
492
|
+
# `public` call inside a class body switches the
|
|
493
|
+
# "current default" visibility for every subsequent
|
|
494
|
+
# `def` until another modifier flips it again.
|
|
495
|
+
# - **Named-argument form**: `private :foo, :bar` (or
|
|
496
|
+
# the same with `protected` / `public`) marks specific
|
|
497
|
+
# names already-recorded under the class. Symbol-only
|
|
498
|
+
# args are recognised; `private def foo; end` (the
|
|
499
|
+
# wrap-around form) is not yet — it would need
|
|
500
|
+
# tracking the def-call's return-value visibility,
|
|
501
|
+
# which is a separate slice.
|
|
502
|
+
#
|
|
503
|
+
# Top-level (no surrounding class) defs do not contribute
|
|
504
|
+
# — Ruby's top-level visibility nuances (private at
|
|
505
|
+
# top-level marks the method on `Object`) are out of
|
|
506
|
+
# scope for v0.1.2.
|
|
507
|
+
def build_discovered_method_visibilities(root)
|
|
508
|
+
accumulator = {}
|
|
509
|
+
walk_method_visibilities(root, [], false, :public, accumulator)
|
|
510
|
+
accumulator.transform_values(&:freeze).freeze
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
514
|
+
def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
515
|
+
return current_visibility unless node.is_a?(Prism::Node)
|
|
516
|
+
|
|
517
|
+
case node
|
|
518
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
519
|
+
name = qualified_name_for(node.constant_path)
|
|
520
|
+
if name
|
|
521
|
+
child_prefix = qualified_prefix + [name]
|
|
522
|
+
walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
|
|
523
|
+
return current_visibility
|
|
524
|
+
end
|
|
525
|
+
when Prism::SingletonClassNode
|
|
526
|
+
if node.expression.is_a?(Prism::SelfNode) && node.body
|
|
527
|
+
walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
|
|
528
|
+
return current_visibility
|
|
529
|
+
end
|
|
530
|
+
when Prism::ConstantWriteNode
|
|
531
|
+
if meta_new_block_body(node)
|
|
532
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
533
|
+
walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
|
|
534
|
+
return current_visibility
|
|
535
|
+
end
|
|
536
|
+
when Prism::DefNode
|
|
537
|
+
record_def_visibility(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
538
|
+
return current_visibility
|
|
539
|
+
when Prism::CallNode
|
|
540
|
+
updated = apply_visibility_call(node, qualified_prefix, current_visibility, accumulator)
|
|
541
|
+
return updated unless updated.equal?(current_visibility)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Statement-position StatementsNode preserves
|
|
545
|
+
# left-to-right visibility flow; everything else
|
|
546
|
+
# recurses with the entry visibility unchanged.
|
|
547
|
+
if node.is_a?(Prism::StatementsNode)
|
|
548
|
+
local_visibility = current_visibility
|
|
549
|
+
node.compact_child_nodes.each do |child|
|
|
550
|
+
local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
|
|
551
|
+
local_visibility, accumulator)
|
|
552
|
+
end
|
|
553
|
+
else
|
|
554
|
+
node.compact_child_nodes.each do |child|
|
|
555
|
+
walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
current_visibility
|
|
559
|
+
end
|
|
560
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
561
|
+
|
|
562
|
+
def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
563
|
+
return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
|
|
564
|
+
return if qualified_prefix.empty?
|
|
565
|
+
|
|
566
|
+
class_name = qualified_prefix.join("::")
|
|
567
|
+
accumulator[class_name] ||= {}
|
|
568
|
+
accumulator[class_name][def_node.name] = current_visibility
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Recognises modifier calls on the implicit-self receiver
|
|
572
|
+
# inside a class body. Returns the (possibly updated)
|
|
573
|
+
# current visibility:
|
|
574
|
+
#
|
|
575
|
+
# - `private` / `public` / `protected` (no args) —
|
|
576
|
+
# switch the running default for subsequent defs.
|
|
577
|
+
# - `private :foo, :bar` — back-patch the named methods
|
|
578
|
+
# in the accumulator. Returns `current_visibility`
|
|
579
|
+
# unchanged because the running default does NOT
|
|
580
|
+
# change for this form.
|
|
581
|
+
def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
|
|
582
|
+
return current_visibility unless call_node.receiver.nil?
|
|
583
|
+
return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
|
|
584
|
+
return current_visibility if qualified_prefix.empty?
|
|
585
|
+
|
|
586
|
+
args = call_node.arguments&.arguments || []
|
|
587
|
+
if args.empty?
|
|
588
|
+
call_node.name
|
|
589
|
+
else
|
|
590
|
+
apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
|
|
591
|
+
current_visibility
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
|
|
596
|
+
class_name = qualified_prefix.join("::")
|
|
597
|
+
args.each do |arg|
|
|
598
|
+
name = visibility_target_name(arg)
|
|
599
|
+
next if name.nil?
|
|
600
|
+
|
|
601
|
+
accumulator[class_name] ||= {}
|
|
602
|
+
accumulator[class_name][name] = visibility
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def visibility_target_name(arg)
|
|
607
|
+
return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
|
|
608
|
+
|
|
609
|
+
nil
|
|
610
|
+
end
|
|
611
|
+
|
|
443
612
|
# Registers the alias name in the `discovered_methods` table so
|
|
444
613
|
# `undefined-method` diagnostics are not emitted for calls to the
|
|
445
614
|
# aliased name. The kind mirrors the surrounding class context
|