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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/range.yml +6 -4
  10. data/data/builtins/ruby_core/string.yml +15 -10
  11. data/data/builtins/ruby_core/time.yml +3 -3
  12. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  13. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  14. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  15. data/lib/rigor/analysis/check_rules.rb +346 -18
  16. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  17. data/lib/rigor/analysis/runner.rb +90 -6
  18. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  19. data/lib/rigor/cli/diff_command.rb +169 -0
  20. data/lib/rigor/cli/explain_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +3 -3
  22. data/lib/rigor/cli/type_scan_command.rb +4 -4
  23. data/lib/rigor/cli.rb +29 -5
  24. data/lib/rigor/configuration/severity_profile.rb +18 -3
  25. data/lib/rigor/configuration.rb +186 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/inference/expression_typer.rb +3 -1
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  29. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  32. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  33. data/lib/rigor/inference/narrowing.rb +150 -6
  34. data/lib/rigor/inference/scope_indexer.rb +220 -17
  35. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  36. data/lib/rigor/plugin/base.rb +43 -0
  37. data/lib/rigor/plugin/fact_store.rb +92 -0
  38. data/lib/rigor/plugin/io_boundary.rb +92 -19
  39. data/lib/rigor/plugin/load_error.rb +14 -2
  40. data/lib/rigor/plugin/loader.rb +116 -0
  41. data/lib/rigor/plugin/manifest.rb +75 -6
  42. data/lib/rigor/plugin/services.rb +14 -2
  43. data/lib/rigor/plugin/trust_policy.rb +30 -7
  44. data/lib/rigor/plugin.rb +1 -0
  45. data/lib/rigor/scope.rb +30 -5
  46. data/lib/rigor/trinary.rb +1 -1
  47. data/lib/rigor/type/integer_range.rb +6 -2
  48. data/lib/rigor/version.rb +1 -1
  49. data/sig/rigor/environment.rbs +3 -2
  50. data/sig/rigor/scope.rbs +3 -0
  51. data/sig/rigor.rbs +8 -2
  52. metadata +9 -1
@@ -6,10 +6,44 @@ require_relative "configuration/severity_profile"
6
6
 
7
7
  module Rigor
8
8
  class Configuration # rubocop:disable Metrics/ClassLength
9
- DEFAULT_PATH = ".rigor.yml"
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
- attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
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
- def self.load(path = DEFAULT_PATH)
35
- data = if File.exist?(path)
36
- YAML.safe_load_file(path, aliases: false) || {}
37
- else
38
- {}
39
- end
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
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
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")).to_s
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)
@@ -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
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil)
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
@@ -981,7 +981,9 @@ module Rigor
981
981
  method_name: node.name,
982
982
  arg_types: arg_types,
983
983
  block_type: block_type,
984
- environment: scope.environment
984
+ environment: scope.environment,
985
+ call_node: node,
986
+ scope: scope
985
987
  )
986
988
  return result if result
987
989
 
@@ -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
- private_constant :CONCAT_METHODS, :FORMAT_METHODS
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. Within the arity-matching overloads, accept the first one
18
- # whose every (param, arg) pair returns a `yes` or `maybe`
19
- # answer from `Rigor::Type#accepts(arg, mode: :gradual)`.
20
- # 3. If no overload matches, fall back to `method_types.first`
21
- # so existing call sites keep their phase 1 / 2b behavior.
22
- # This preserves the fail-soft invariant of the dispatcher.
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:)