rigortype 0.1.0 → 0.1.2
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 +7 -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/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- data/data/builtins/ruby_core/time.yml +3 -3
- 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/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +90 -6
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -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 +29 -5
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +186 -13
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/inference/expression_typer.rb +3 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/narrowing.rb +150 -6
- data/lib/rigor/inference/scope_indexer.rb +220 -17
- data/lib/rigor/inference/statement_evaluator.rb +29 -0
- data/lib/rigor/plugin/base.rb +43 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/load_error.rb +14 -2
- data/lib/rigor/plugin/loader.rb +116 -0
- data/lib/rigor/plugin/manifest.rb +75 -6
- data/lib/rigor/plugin/services.rb +14 -2
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +8 -2
- metadata +9 -1
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,10 +6,44 @@ require_relative "configuration/severity_profile"
|
|
|
6
6
|
|
|
7
7
|
module Rigor
|
|
8
8
|
class Configuration # rubocop:disable Metrics/ClassLength
|
|
9
|
-
|
|
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
|
+
|
|
10
43
|
DEFAULTS = {
|
|
11
44
|
"target_ruby" => "4.0",
|
|
12
45
|
"paths" => ["lib"],
|
|
46
|
+
"exclude" => [],
|
|
13
47
|
"plugins" => [],
|
|
14
48
|
"disable" => [],
|
|
15
49
|
"libraries" => [],
|
|
@@ -20,33 +54,145 @@ module Rigor
|
|
|
20
54
|
},
|
|
21
55
|
"plugins_io" => {
|
|
22
56
|
"network" => "disabled",
|
|
23
|
-
"allowed_paths" => []
|
|
57
|
+
"allowed_paths" => [],
|
|
58
|
+
"allowed_url_hosts" => []
|
|
24
59
|
},
|
|
25
60
|
"severity_profile" => "balanced",
|
|
26
61
|
"severity_overrides" => {}
|
|
27
62
|
}.freeze
|
|
28
63
|
|
|
29
|
-
|
|
64
|
+
# Top-level keys whose values are file/directory paths that
|
|
65
|
+
# MUST be resolved relative to the config file's directory.
|
|
66
|
+
# `exclude:` is intentionally NOT in this list — its entries
|
|
67
|
+
# are glob patterns (`**/vendor/**`), not paths.
|
|
68
|
+
PATH_KEYS = %w[paths signature_paths].freeze
|
|
69
|
+
private_constant :PATH_KEYS
|
|
70
|
+
|
|
71
|
+
attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
|
|
30
72
|
:libraries, :signature_paths, :fold_platform_specific_paths,
|
|
31
73
|
:plugins_io_network, :plugins_io_allowed_paths,
|
|
74
|
+
:plugins_io_allowed_url_hosts,
|
|
32
75
|
:severity_profile, :severity_overrides
|
|
33
76
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
# Loads a configuration file.
|
|
78
|
+
#
|
|
79
|
+
# `path == nil` triggers auto-discovery against
|
|
80
|
+
# {DISCOVERY_ORDER}. The first present file in that list is
|
|
81
|
+
# loaded; if none exist the built-in {DEFAULTS} are used.
|
|
82
|
+
#
|
|
83
|
+
# When a path is supplied (whether by auto-discovery or by
|
|
84
|
+
# the caller) the YAML body is processed for `includes:`
|
|
85
|
+
# recursively, and every relative path inside path-bearing
|
|
86
|
+
# keys (`paths:`, `signature_paths:`, `plugins_io.allowed_paths:`,
|
|
87
|
+
# `includes:`) is resolved against THAT file's directory.
|
|
88
|
+
# The resolution is per-file: an included file's relative
|
|
89
|
+
# paths resolve against the included file's directory, not
|
|
90
|
+
# the top-level file. Path resolution mirrors
|
|
91
|
+
# [PHPStan](https://phpstan.org/config-reference#paths).
|
|
92
|
+
def self.load(path = nil)
|
|
93
|
+
resolved = path || discover
|
|
94
|
+
return new(DEFAULTS) if resolved.nil? || !File.exist?(resolved)
|
|
40
95
|
|
|
96
|
+
data = load_with_includes(resolved)
|
|
41
97
|
new(DEFAULTS.merge(data))
|
|
42
98
|
end
|
|
43
99
|
|
|
44
|
-
|
|
100
|
+
# Returns the path to the config file Rigor would load
|
|
101
|
+
# under auto-discovery, or `nil` when neither candidate
|
|
102
|
+
# exists. Public so the CLI / spec drift checks can
|
|
103
|
+
# introspect the resolved file.
|
|
104
|
+
def self.discover
|
|
105
|
+
DISCOVERY_ORDER.find { |candidate| File.exist?(candidate) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reads `path` (which MUST exist) plus every file listed in
|
|
109
|
+
# its `includes:` chain, merging them under the order:
|
|
110
|
+
# included files first (in declaration order), then the
|
|
111
|
+
# current file's own keys override. Relative paths inside
|
|
112
|
+
# each file are resolved against that file's directory.
|
|
113
|
+
def self.load_with_includes(path, visited: Set.new)
|
|
114
|
+
absolute = File.expand_path(path)
|
|
115
|
+
raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
|
|
116
|
+
|
|
117
|
+
raw = YAML.safe_load_file(absolute, aliases: false) || {}
|
|
118
|
+
raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
base_dir = File.dirname(absolute)
|
|
121
|
+
includes = Array(raw.delete("includes") || [])
|
|
122
|
+
data = resolve_paths_in(raw, base_dir)
|
|
123
|
+
next_visited = visited + [absolute]
|
|
124
|
+
merge_includes(data, includes, base_dir, next_visited)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.merge_includes(data, includes, base_dir, visited)
|
|
128
|
+
return data if includes.empty?
|
|
129
|
+
|
|
130
|
+
accumulated = {}
|
|
131
|
+
includes.each do |inc|
|
|
132
|
+
inc_path = File.expand_path(inc.to_s, base_dir)
|
|
133
|
+
unless File.exist?(inc_path)
|
|
134
|
+
raise ArgumentError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
|
|
138
|
+
end
|
|
139
|
+
deep_merge(accumulated, data)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Per-file path resolution. Each path-bearing key listed in
|
|
143
|
+
# {PATH_KEYS} plus the nested `plugins_io.allowed_paths:`
|
|
144
|
+
# entries get their relative paths expanded against the
|
|
145
|
+
# config file's directory. `cache.path:` is intentionally
|
|
146
|
+
# left as-is so end-user messages (e.g. `--cache-stats`
|
|
147
|
+
# output) keep the project-relative form the user wrote.
|
|
148
|
+
def self.resolve_paths_in(data, base_dir)
|
|
149
|
+
return data unless data.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
out = data.dup
|
|
152
|
+
PATH_KEYS.each { |key| resolve_path_key!(out, key, base_dir) }
|
|
153
|
+
resolve_plugins_io_paths!(out, base_dir)
|
|
154
|
+
out
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.resolve_path_key!(out, key, base_dir)
|
|
158
|
+
return unless out.key?(key) && !out[key].nil?
|
|
159
|
+
|
|
160
|
+
out[key] = Array(out[key]).map { |p| File.expand_path(p.to_s, base_dir) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.resolve_plugins_io_paths!(out, base_dir)
|
|
164
|
+
plugins_io = out["plugins_io"]
|
|
165
|
+
return unless plugins_io.is_a?(Hash) && plugins_io["allowed_paths"]
|
|
166
|
+
|
|
167
|
+
duped = plugins_io.dup
|
|
168
|
+
duped["allowed_paths"] = Array(plugins_io["allowed_paths"]).map { |p| File.expand_path(p.to_s, base_dir) }
|
|
169
|
+
out["plugins_io"] = duped
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.deep_merge(left, right)
|
|
173
|
+
return right unless left.is_a?(Hash) && right.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
merged = left.dup
|
|
176
|
+
right.each do |key, value|
|
|
177
|
+
merged[key] = if merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
178
|
+
deep_merge(merged[key], value)
|
|
179
|
+
else
|
|
180
|
+
value
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
merged
|
|
184
|
+
end
|
|
185
|
+
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
|
|
186
|
+
|
|
187
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
188
|
+
def initialize(data = DEFAULTS)
|
|
45
189
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
46
190
|
plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
|
|
47
191
|
|
|
48
|
-
@target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby"))
|
|
192
|
+
@target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
|
|
49
193
|
@paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
|
|
194
|
+
user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
|
|
195
|
+
@exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
|
|
50
196
|
@plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
|
|
51
197
|
coerce_plugin_entry(entry)
|
|
52
198
|
end.freeze
|
|
@@ -60,6 +206,7 @@ module Rigor
|
|
|
60
206
|
@cache_path = cache.fetch("path").to_s
|
|
61
207
|
@plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
|
|
62
208
|
@plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
|
|
209
|
+
@plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
|
|
63
210
|
@severity_profile = coerce_severity_profile(
|
|
64
211
|
data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
|
|
65
212
|
)
|
|
@@ -67,11 +214,13 @@ module Rigor
|
|
|
67
214
|
data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
|
|
68
215
|
)
|
|
69
216
|
end
|
|
217
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
70
218
|
|
|
71
219
|
def to_h
|
|
72
220
|
{
|
|
73
221
|
"target_ruby" => target_ruby,
|
|
74
222
|
"paths" => paths,
|
|
223
|
+
"exclude" => exclude_patterns - BUILTIN_EXCLUDES,
|
|
75
224
|
"plugins" => plugins,
|
|
76
225
|
"disable" => disabled_rules,
|
|
77
226
|
"libraries" => libraries,
|
|
@@ -82,7 +231,8 @@ module Rigor
|
|
|
82
231
|
},
|
|
83
232
|
"plugins_io" => {
|
|
84
233
|
"network" => plugins_io_network.to_s,
|
|
85
|
-
"allowed_paths" => plugins_io_allowed_paths
|
|
234
|
+
"allowed_paths" => plugins_io_allowed_paths,
|
|
235
|
+
"allowed_url_hosts" => plugins_io_allowed_url_hosts
|
|
86
236
|
},
|
|
87
237
|
"severity_profile" => severity_profile.to_s,
|
|
88
238
|
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
|
|
@@ -107,6 +257,29 @@ module Rigor
|
|
|
107
257
|
end
|
|
108
258
|
end
|
|
109
259
|
|
|
260
|
+
# `target_ruby` is passed to `Prism.parse_file(path, version:)` at
|
|
261
|
+
# the analyser's three parse sites (`Analysis::Runner`,
|
|
262
|
+
# `CLI::TypeOfCommand`, `CLI::TypeScanCommand`) so projects that
|
|
263
|
+
# target an older Ruby get parse errors for syntax their target
|
|
264
|
+
# doesn't support. Format validation here is loose — accepts
|
|
265
|
+
# any `<major>.<minor>` or `<major>.<minor>.<patch>` form, plus
|
|
266
|
+
# the literal `"latest"`. Prism itself enforces the supported
|
|
267
|
+
# set and raises `ArgumentError` for versions it does not
|
|
268
|
+
# recognise (e.g. `"1.0"`); the parse-time error message names
|
|
269
|
+
# the version, so the user can correct the setting.
|
|
270
|
+
TARGET_RUBY_FORMAT = /\A(?:\d+\.\d+(?:\.\d+)?|latest)\z/
|
|
271
|
+
private_constant :TARGET_RUBY_FORMAT
|
|
272
|
+
|
|
273
|
+
def coerce_target_ruby(value)
|
|
274
|
+
s = value.to_s
|
|
275
|
+
unless s.match?(TARGET_RUBY_FORMAT)
|
|
276
|
+
raise ArgumentError,
|
|
277
|
+
"target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
s.dup.freeze
|
|
281
|
+
end
|
|
282
|
+
|
|
110
283
|
# Slice 2 only accepts `:disabled` for the network policy. The
|
|
111
284
|
# YAML scalar may arrive as a String (`"disabled"`) or already
|
|
112
285
|
# as the Symbol; coerce to the canonical Symbol shape so the
|
|
@@ -117,7 +290,7 @@ module Rigor
|
|
|
117
290
|
# `Configuration` does not require the plugin namespace at
|
|
118
291
|
# load time (Configuration is loaded before Plugin in
|
|
119
292
|
# `lib/rigor.rb`); the two stay in lockstep via spec.
|
|
120
|
-
VALID_NETWORK_POLICIES = %i[disabled].freeze
|
|
293
|
+
VALID_NETWORK_POLICIES = %i[disabled allowlist].freeze
|
|
121
294
|
private_constant :VALID_NETWORK_POLICIES
|
|
122
295
|
|
|
123
296
|
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
|
|
44
|
+
attr_reader :class_registry, :rbs_loader, :plugin_registry
|
|
45
45
|
|
|
46
46
|
# @param class_registry [Rigor::Environment::ClassRegistry]
|
|
47
47
|
# @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
|
|
@@ -50,9 +50,17 @@ module Rigor
|
|
|
50
50
|
# wires the shared core loader, which is itself lazy: requesting an
|
|
51
51
|
# environment instance does NOT load RBS until a method or class
|
|
52
52
|
# query actually consults the loader.
|
|
53
|
-
|
|
53
|
+
# @param plugin_registry [Rigor::Plugin::Registry, nil] v0.1.1
|
|
54
|
+
# Track 2 slice 7. The per-run plugin registry the
|
|
55
|
+
# inference engine consults at call sites for plugin
|
|
56
|
+
# `#flow_contribution_for` overrides. When nil (the
|
|
57
|
+
# default), no plugin-level return-type contribution
|
|
58
|
+
# participates — useful for tests, the `Environment.default`
|
|
59
|
+
# facade, and analyses that don't load plugins.
|
|
60
|
+
def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, plugin_registry: nil)
|
|
54
61
|
@class_registry = class_registry
|
|
55
62
|
@rbs_loader = rbs_loader
|
|
63
|
+
@plugin_registry = plugin_registry
|
|
56
64
|
freeze
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -82,7 +90,7 @@ module Rigor
|
|
|
82
90
|
# reflection artefacts) consult the cache. Pass `nil` (the
|
|
83
91
|
# default) to skip caching for this environment.
|
|
84
92
|
# @return [Rigor::Environment]
|
|
85
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil)
|
|
93
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, plugin_registry: nil)
|
|
86
94
|
resolved_paths = signature_paths || default_signature_paths(root)
|
|
87
95
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
88
96
|
loader = RbsLoader.new(
|
|
@@ -90,7 +98,7 @@ module Rigor
|
|
|
90
98
|
signature_paths: resolved_paths,
|
|
91
99
|
cache_store: cache_store
|
|
92
100
|
)
|
|
93
|
-
new(rbs_loader: loader)
|
|
101
|
+
new(rbs_loader: loader, plugin_registry: plugin_registry)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
private
|
|
@@ -42,14 +42,45 @@ module Rigor
|
|
|
42
42
|
}.freeze
|
|
43
43
|
private_constant :NUMERIC_CONSTRUCTORS
|
|
44
44
|
|
|
45
|
+
# `Kernel#Integer(s)` predicate-aware refinement set
|
|
46
|
+
# (v0.1.1 Track 1 slice 2b). Both `decimal-int-string` and
|
|
47
|
+
# `numeric-string` describe digit-only ASCII strings, so
|
|
48
|
+
# `Integer(s)` is total over the carrier domain and the
|
|
49
|
+
# result is `>= 0`. The default `base: 10` invocation
|
|
50
|
+
# accepts the same shape `String#to_i` does for these
|
|
51
|
+
# predicates; the `Integer(s, base)` overload is left for
|
|
52
|
+
# a later slice.
|
|
53
|
+
INTEGER_REFINEMENT_PREDICATES = Set[:decimal_int, :numeric].freeze
|
|
54
|
+
private_constant :INTEGER_REFINEMENT_PREDICATES
|
|
55
|
+
|
|
45
56
|
def try_dispatch(receiver:, method_name:, args:)
|
|
46
57
|
return nil if receiver.nil?
|
|
47
58
|
return try_array(args) if method_name == :Array
|
|
48
59
|
return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
|
|
60
|
+
return try_integer_from_refinement(args) if method_name == :Integer
|
|
49
61
|
|
|
50
62
|
nil
|
|
51
63
|
end
|
|
52
64
|
|
|
65
|
+
# `Kernel#Integer(s)` over a `Refined[String, predicate]`
|
|
66
|
+
# whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
|
|
67
|
+
# Mirrors the `String#to_i` projection in `ShapeDispatch`
|
|
68
|
+
# (v0.1.1 slice 2a) — the result is always
|
|
69
|
+
# `non-negative-int`. Returns nil for any other arg shape
|
|
70
|
+
# so the RBS tier handles the generic `Integer(arg)` case.
|
|
71
|
+
def try_integer_from_refinement(args)
|
|
72
|
+
return nil unless args.size == 1
|
|
73
|
+
|
|
74
|
+
arg = args.first
|
|
75
|
+
return nil unless arg.is_a?(Type::Refined)
|
|
76
|
+
|
|
77
|
+
base = arg.base
|
|
78
|
+
return nil unless base.is_a?(Type::Nominal) && base.class_name == "String"
|
|
79
|
+
return nil unless INTEGER_REFINEMENT_PREDICATES.include?(arg.predicate_id)
|
|
80
|
+
|
|
81
|
+
Type::Combinator.non_negative_int
|
|
82
|
+
end
|
|
83
|
+
|
|
53
84
|
def try_array(args)
|
|
54
85
|
return nil if args.length != 1
|
|
55
86
|
|
|
@@ -52,7 +52,27 @@ module Rigor
|
|
|
52
52
|
|
|
53
53
|
CONCAT_METHODS = %i[+ << concat].freeze
|
|
54
54
|
FORMAT_METHODS = %i[format sprintf].freeze
|
|
55
|
-
|
|
55
|
+
# v0.1.1 Track 1 slice 5a — methods that, called with no
|
|
56
|
+
# arguments on a literal-bearing receiver, return a value
|
|
57
|
+
# that is also literal-bearing. `#strip` / `#lstrip` /
|
|
58
|
+
# `#rstrip` / `#chomp` (no-arg) / `#chop` strip a known
|
|
59
|
+
# subset of characters from the ends, so the survivors
|
|
60
|
+
# are always a substring of an already-literal value.
|
|
61
|
+
# `#scrub` (no-arg) replaces invalid bytes; a literal-string
|
|
62
|
+
# value comes from source code and is always valid UTF-8,
|
|
63
|
+
# so the result is identical to the receiver. None of
|
|
64
|
+
# these preserve `non-empty-string`-ness (e.g. `" ".strip
|
|
65
|
+
# == ""`); the carrier collapses from `non-empty-literal-string`
|
|
66
|
+
# down to plain `literal-string`.
|
|
67
|
+
LITERAL_PRESERVING_METHODS = %i[strip lstrip rstrip chomp chop scrub].freeze
|
|
68
|
+
# v0.1.1 Track 1 slice 5c — width-padding methods. `center`
|
|
69
|
+
# / `ljust` / `rjust` take a `width` Integer plus an
|
|
70
|
+
# optional literal padding `String`. When the receiver
|
|
71
|
+
# and the (default or supplied) padding are both
|
|
72
|
+
# literal-bearing, the result is literal-bearing too.
|
|
73
|
+
WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
|
|
74
|
+
private_constant :CONCAT_METHODS, :FORMAT_METHODS,
|
|
75
|
+
:LITERAL_PRESERVING_METHODS, :WIDTH_PADDING_METHODS
|
|
56
76
|
|
|
57
77
|
def try_dispatch(receiver:, method_name:, args:, **) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
58
78
|
return fold_array_join(receiver, args) if method_name == :join
|
|
@@ -61,6 +81,10 @@ module Rigor
|
|
|
61
81
|
return nil unless Type::Combinator.literal_string_compatible?(receiver)
|
|
62
82
|
|
|
63
83
|
return fold_string_percent(args) if method_name == :%
|
|
84
|
+
if args.empty?
|
|
85
|
+
return LITERAL_PRESERVING_METHODS.include?(method_name) ? Type::Combinator.literal_string : nil
|
|
86
|
+
end
|
|
87
|
+
return fold_width_pad(args) if WIDTH_PADDING_METHODS.include?(method_name)
|
|
64
88
|
return nil unless args.size == 1
|
|
65
89
|
|
|
66
90
|
if CONCAT_METHODS.include?(method_name)
|
|
@@ -70,6 +94,23 @@ module Rigor
|
|
|
70
94
|
end
|
|
71
95
|
end
|
|
72
96
|
|
|
97
|
+
# `String#center` / `#ljust` / `#rjust` — first argument is
|
|
98
|
+
# the target width (Integer-typed), optional second
|
|
99
|
+
# argument is the padding string (must be literal-bearing
|
|
100
|
+
# for the result to stay literal). The default padding
|
|
101
|
+
# (a space) is always literal so the no-second-arg form
|
|
102
|
+
# passes through. Width is allowed to be any Integer
|
|
103
|
+
# because Ruby's runtime accepts negative widths and
|
|
104
|
+
# widths smaller than the receiver's length without
|
|
105
|
+
# raising.
|
|
106
|
+
def fold_width_pad(args)
|
|
107
|
+
return nil unless [1, 2].include?(args.size)
|
|
108
|
+
return nil unless integer_typed?(args[0])
|
|
109
|
+
return nil if args.size == 2 && !Type::Combinator.literal_string_compatible?(args[1])
|
|
110
|
+
|
|
111
|
+
Type::Combinator.literal_string
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
def fold_concat(arg_type)
|
|
74
115
|
return nil unless Type::Combinator.literal_string_compatible?(arg_type)
|
|
75
116
|
|
|
@@ -165,7 +206,7 @@ module Rigor
|
|
|
165
206
|
end
|
|
166
207
|
|
|
167
208
|
private_class_method :fold_concat, :fold_repeat, :fold_array_join,
|
|
168
|
-
:fold_format, :fold_string_percent,
|
|
209
|
+
:fold_format, :fold_string_percent, :fold_width_pad,
|
|
169
210
|
:literal_or_constant?, :integer_typed?
|
|
170
211
|
end
|
|
171
212
|
end
|
|
@@ -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:)
|