rigortype 0.0.2 → 0.0.4

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -7
  3. data/data/builtins/ruby_core/array.yml +1470 -0
  4. data/data/builtins/ruby_core/file.yml +501 -0
  5. data/data/builtins/ruby_core/hash.yml +936 -0
  6. data/data/builtins/ruby_core/io.yml +1594 -0
  7. data/data/builtins/ruby_core/numeric.yml +1809 -0
  8. data/data/builtins/ruby_core/range.yml +389 -0
  9. data/data/builtins/ruby_core/set.yml +594 -0
  10. data/data/builtins/ruby_core/string.yml +1850 -0
  11. data/data/builtins/ruby_core/time.yml +750 -0
  12. data/lib/rigor/analysis/check_rules.rb +97 -4
  13. data/lib/rigor/analysis/runner.rb +4 -0
  14. data/lib/rigor/builtins/imported_refinements.rb +251 -0
  15. data/lib/rigor/configuration.rb +6 -1
  16. data/lib/rigor/inference/acceptance.rb +324 -6
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  19. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  20. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  21. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  22. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  23. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  24. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  25. data/lib/rigor/inference/expression_typer.rb +48 -1
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
  27. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  30. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
  32. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +376 -4
  35. data/lib/rigor/inference/scope_indexer.rb +10 -2
  36. data/lib/rigor/inference/statement_evaluator.rb +213 -2
  37. data/lib/rigor/rbs_extended.rb +230 -15
  38. data/lib/rigor/scope.rb +14 -0
  39. data/lib/rigor/type/combinator.rb +159 -1
  40. data/lib/rigor/type/difference.rb +155 -0
  41. data/lib/rigor/type/integer_range.rb +137 -0
  42. data/lib/rigor/type/intersection.rb +135 -0
  43. data/lib/rigor/type/refined.rb +174 -0
  44. data/lib/rigor/type.rb +4 -0
  45. data/lib/rigor/version.rb +1 -1
  46. data/sig/rigor/rbs_extended.rbs +14 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +91 -1
  49. metadata +25 -1
@@ -53,6 +53,7 @@ module Rigor
53
53
  RULE_NIL_RECEIVER = "possible-nil-receiver"
54
54
  RULE_DUMP_TYPE = "dump-type"
55
55
  RULE_ASSERT_TYPE = "assert-type"
56
+ RULE_ALWAYS_RAISES = "always-raises"
56
57
 
57
58
  ALL_RULES = [
58
59
  RULE_UNDEFINED_METHOD,
@@ -60,7 +61,8 @@ module Rigor
60
61
  RULE_ARGUMENT_TYPE,
61
62
  RULE_NIL_RECEIVER,
62
63
  RULE_DUMP_TYPE,
63
- RULE_ASSERT_TYPE
64
+ RULE_ASSERT_TYPE,
65
+ RULE_ALWAYS_RAISES
64
66
  ].freeze
65
67
 
66
68
  module_function
@@ -97,6 +99,9 @@ module Rigor
97
99
 
98
100
  assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
99
101
  diagnostics << assert_diagnostic if assert_diagnostic
102
+
103
+ raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
104
+ diagnostics << raises_diagnostic if raises_diagnostic
100
105
  end
101
106
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
102
107
  end
@@ -333,6 +338,13 @@ module Rigor
333
338
  end
334
339
 
335
340
  def arity_eligible?(function)
341
+ # `RBS::Types::UntypedFunction` (used for `(?) ->`
342
+ # untyped sigs) does not expose the per-arity
343
+ # accessors. Treating it as ineligible is the
344
+ # correct conservative move: an untyped function
345
+ # has no static arity to enforce.
346
+ return false unless function.respond_to?(:required_keywords)
347
+
336
348
  function.required_keywords.empty? && function.trailing_positionals.empty?
337
349
  end
338
350
 
@@ -562,6 +574,74 @@ module Rigor
562
574
  )
563
575
  end
564
576
 
577
+ # Diagnoses calls that the analyzer can prove will always
578
+ # raise. Today the only triggering shape is integer
579
+ # division/modulo by a literal zero divisor:
580
+ #
581
+ # 5 / 0 # => ZeroDivisionError
582
+ # x.modulo(0) # => ZeroDivisionError when x: Integer
583
+ # xs.size % 0 # same — non_negative_int / Constant[0]
584
+ #
585
+ # Float divmod by zero returns Infinity/NaN at runtime, so
586
+ # the rule restricts to Integer-rooted receivers (`Constant`,
587
+ # `IntegerRange`, `Nominal[Integer]`). The argument MUST be a
588
+ # `Constant<Integer>` whose value is exactly zero — a
589
+ # `Union[Constant[0], Constant[2]]` divisor "may" raise,
590
+ # which we surface separately (future slice).
591
+ INTEGER_RAISING_OPERATORS = %i[/ % div modulo divmod].freeze
592
+ private_constant :INTEGER_RAISING_OPERATORS
593
+
594
+ def always_raises_diagnostic(path, call_node, scope_index)
595
+ return nil unless integer_zero_division?(call_node, scope_index)
596
+
597
+ build_always_raises_diagnostic(path, call_node)
598
+ end
599
+
600
+ def integer_zero_division?(call_node, scope_index)
601
+ return false unless raising_call_shape?(call_node)
602
+
603
+ scope = scope_index[call_node]
604
+ return false if scope.nil?
605
+ return false unless integer_rooted_for_diagnostic?(scope.type_of(call_node.receiver))
606
+
607
+ arg = single_argument(call_node)
608
+ arg && integer_zero_constant?(scope.type_of(arg))
609
+ end
610
+
611
+ def raising_call_shape?(call_node)
612
+ !call_node.receiver.nil? && INTEGER_RAISING_OPERATORS.include?(call_node.name)
613
+ end
614
+
615
+ def single_argument(call_node)
616
+ args = call_node.arguments&.arguments || []
617
+ args.size == 1 ? args.first : nil
618
+ end
619
+
620
+ def integer_rooted_for_diagnostic?(type)
621
+ case type
622
+ when Type::Constant then type.value.is_a?(Integer)
623
+ when Type::IntegerRange then true
624
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
625
+ else false
626
+ end
627
+ end
628
+
629
+ def integer_zero_constant?(type)
630
+ type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.zero?
631
+ end
632
+
633
+ def build_always_raises_diagnostic(path, call_node)
634
+ location = call_node.message_loc || call_node.location
635
+ Diagnostic.new(
636
+ rule: RULE_ALWAYS_RAISES,
637
+ path: path,
638
+ line: location.start_line,
639
+ column: location.start_column + 1,
640
+ message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
641
+ severity: :error
642
+ )
643
+ end
644
+
565
645
  # v0.0.2 #4 — argument-type-mismatch diagnostic.
566
646
  # Walks a call's positional arguments and checks each
567
647
  # against the matching parameter's RBS type via
@@ -613,14 +693,16 @@ module Rigor
613
693
  return nil if method_def.nil? || method_def == true
614
694
  return nil unless method_def.method_types.size == 1
615
695
 
616
- mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope)
696
+ param_overrides = Rigor::RbsExtended.param_type_override_map(method_def)
697
+ mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope, param_overrides)
617
698
  return nil if mismatch.nil?
618
699
 
619
700
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
620
701
  end
621
702
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
622
703
 
623
- def first_argument_mismatch(method_type, call_node, scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
704
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
705
+ def first_argument_mismatch(method_type, call_node, scope, param_overrides)
624
706
  function = method_type.type
625
707
  return nil unless argument_check_eligible?(function)
626
708
 
@@ -630,7 +712,12 @@ module Rigor
630
712
  param = params[index]
631
713
  next if param.nil? # arity mismatch is the wrong-arity rule's concern.
632
714
 
633
- param_type = translate_param_type(param.type, scope.environment)
715
+ # `rigor:v1:param: <name> <refinement>` annotations
716
+ # tighten the RBS-declared parameter type. The
717
+ # override is the authoritative contract when
718
+ # present; otherwise we translate the RBS type as
719
+ # before.
720
+ param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
634
721
  next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
635
722
 
636
723
  arg_type = scope.type_of(arg)
@@ -641,8 +728,14 @@ module Rigor
641
728
  end
642
729
  nil
643
730
  end
731
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
644
732
 
645
733
  def argument_check_eligible?(function)
734
+ # See `arity_eligible?`: `UntypedFunction` lacks
735
+ # the per-arity accessors. Treat it as ineligible
736
+ # for argument-type-mismatch diagnostics.
737
+ return false unless function.respond_to?(:required_keywords)
738
+
646
739
  function.rest_positionals.nil? &&
647
740
  function.required_keywords.empty? &&
648
741
  function.optional_keywords.empty? &&
@@ -6,6 +6,7 @@ require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../inference/coverage_scanner"
8
8
  require_relative "../inference/scope_indexer"
9
+ require_relative "../inference/method_dispatcher/file_folding"
9
10
  require_relative "check_rules"
10
11
  require_relative "diagnostic"
11
12
  require_relative "result"
@@ -29,6 +30,9 @@ module Rigor
29
30
  # is built once at run start through `Environment.for_project`
30
31
  # so all files share the same RBS load.
31
32
  def run(paths = @configuration.paths)
33
+ Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
34
+ @configuration.fold_platform_specific_paths
35
+
32
36
  environment = Environment.for_project(
33
37
  libraries: @configuration.libraries,
34
38
  signature_paths: @configuration.signature_paths
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Builtins
9
+ # Canonical-name registry for the imported-built-in
10
+ # refinement catalogue. See `imported-built-in-types.md`
11
+ # in `docs/type-specification/` for the full catalogue
12
+ # rationale and the kebab-case naming rule.
13
+ #
14
+ # Maps kebab-case names (`non-empty-string`, `positive-int`,
15
+ # `non-empty-array`, …) to the Rigor type each name denotes.
16
+ # The registry is the single integration point for:
17
+ #
18
+ # - The `rigor:v1:return:` RBS::Extended directive
19
+ # ([`Rigor::RbsExtended.read_return_type_override`](../rbs_extended.rb)),
20
+ # which overrides a method's RBS-declared return type
21
+ # with a refinement carrier.
22
+ # - Future `RBS::Extended` directives that accept a
23
+ # refinement name in any type position (`param:`,
24
+ # `assert: x is non-empty-string`, …).
25
+ # - The display side: `Type::Difference#describe` already
26
+ # recognises the same shapes and prints the kebab-case
27
+ # spelling without consulting the registry.
28
+ #
29
+ # Names not in the registry resolve to `nil`; callers
30
+ # decide whether to fall back to the RBS-declared type or
31
+ # raise a parse error.
32
+ #
33
+ # The registry covers two surfaces:
34
+ #
35
+ # - **No-argument refinement names** (`non-empty-string`,
36
+ # `non-zero-int`, `lowercase-string`, …) live in `REGISTRY`
37
+ # and resolve through `lookup(name)`.
38
+ # - **Parameterised refinement payloads** (`non-empty-array[Integer]`,
39
+ # `non-empty-hash[Symbol, Integer]`, `int<5, 10>`) are
40
+ # accepted by `parse(payload)`. The full grammar is documented
41
+ # on `Parser`. The two surfaces share `REGISTRY` for the
42
+ # no-arg head names; the parameterised head names live in
43
+ # `PARAMETERISED_TYPE_BUILDERS` (square-bracket form, type
44
+ # args) and `PARAMETERISED_INT_BUILDERS` (angle-bracket form,
45
+ # integer bounds).
46
+ module ImportedRefinements
47
+ REGISTRY = {
48
+ "non-empty-string" => -> { Type::Combinator.non_empty_string },
49
+ "non-zero-int" => -> { Type::Combinator.non_zero_int },
50
+ "non-empty-array" => -> { Type::Combinator.non_empty_array },
51
+ "non-empty-hash" => -> { Type::Combinator.non_empty_hash },
52
+ "positive-int" => -> { Type::Combinator.positive_int },
53
+ "non-negative-int" => -> { Type::Combinator.non_negative_int },
54
+ "negative-int" => -> { Type::Combinator.negative_int },
55
+ "non-positive-int" => -> { Type::Combinator.non_positive_int },
56
+ "lowercase-string" => -> { Type::Combinator.lowercase_string },
57
+ "uppercase-string" => -> { Type::Combinator.uppercase_string },
58
+ "numeric-string" => -> { Type::Combinator.numeric_string },
59
+ "decimal-int-string" => -> { Type::Combinator.decimal_int_string },
60
+ "octal-int-string" => -> { Type::Combinator.octal_int_string },
61
+ "hex-int-string" => -> { Type::Combinator.hex_int_string },
62
+ "non-empty-lowercase-string" => -> { Type::Combinator.non_empty_lowercase_string },
63
+ "non-empty-uppercase-string" => -> { Type::Combinator.non_empty_uppercase_string }
64
+ }.freeze
65
+ private_constant :REGISTRY
66
+
67
+ # `name[T]` / `name[K, V]` — type-arg parameterised
68
+ # refinements. Each builder takes an `Array<Rigor::Type>`
69
+ # and returns a `Rigor::Type` (or `nil` on arity / shape
70
+ # mismatch so the caller surfaces a parse failure).
71
+ PARAMETERISED_TYPE_BUILDERS = {
72
+ "non-empty-array" => lambda { |args|
73
+ return nil unless args.size == 1
74
+
75
+ Type::Combinator.non_empty_array(args.first)
76
+ },
77
+ "non-empty-hash" => lambda { |args|
78
+ return nil unless args.size == 2
79
+
80
+ Type::Combinator.non_empty_hash(args[0], args[1])
81
+ }
82
+ }.freeze
83
+ private_constant :PARAMETERISED_TYPE_BUILDERS
84
+
85
+ # `name<min, max>` — integer-bound parameterised
86
+ # refinements. Each builder takes an `Array<Integer>` and
87
+ # returns a `Rigor::Type` (or `nil`). Bounds are signed
88
+ # integer literals; `min` MUST be ≤ `max` for the carrier
89
+ # to construct successfully (`Type::IntegerRange` enforces
90
+ # the invariant).
91
+ PARAMETERISED_INT_BUILDERS = {
92
+ "int" => lambda { |bounds|
93
+ return nil unless bounds.size == 2
94
+
95
+ Type::Combinator.integer_range(bounds[0], bounds[1])
96
+ }
97
+ }.freeze
98
+ private_constant :PARAMETERISED_INT_BUILDERS
99
+
100
+ module_function
101
+
102
+ # @param name [String] kebab-case refinement name.
103
+ # @return [Rigor::Type, nil] the matching refinement
104
+ # carrier, or `nil` if the name is not registered.
105
+ def lookup(name)
106
+ builder = REGISTRY[name.to_s]
107
+ builder&.call
108
+ end
109
+
110
+ # @param payload [String] the trailing payload of a
111
+ # `rigor:v1:return:` (or sibling) directive. Accepts
112
+ # the bare-name forms `lookup` already handles plus the
113
+ # parameterised forms documented on {Parser}.
114
+ # @return [Rigor::Type, nil] the resolved refinement
115
+ # carrier, or `nil` when the payload is unparseable or
116
+ # names a refinement / class not in the registry.
117
+ def parse(payload)
118
+ Parser.new(payload.to_s).parse
119
+ end
120
+
121
+ def known?(name)
122
+ REGISTRY.key?(name.to_s) ||
123
+ PARAMETERISED_TYPE_BUILDERS.key?(name.to_s) ||
124
+ PARAMETERISED_INT_BUILDERS.key?(name.to_s)
125
+ end
126
+
127
+ def known_names
128
+ REGISTRY.keys + PARAMETERISED_TYPE_BUILDERS.keys + PARAMETERISED_INT_BUILDERS.keys
129
+ end
130
+
131
+ # Recursive-descent parser for the refinement-payload
132
+ # grammar:
133
+ #
134
+ # type := simple_name | parametric
135
+ # simple_name := /[a-z][a-z0-9-]*/
136
+ # parametric := simple_name '[' type_arg_list ']'
137
+ # | simple_name '<' int_bound_list '>'
138
+ # type_arg_list := type_arg (',' type_arg)*
139
+ # type_arg := type | class_name
140
+ # class_name := /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
141
+ # int_bound_list := signed_int (',' signed_int)*
142
+ # signed_int := /-?\d+/
143
+ #
144
+ # Whitespace between tokens is ignored. The parser fails
145
+ # soft (returns `nil` from `parse`) on any deviation so the
146
+ # `RBS::Extended` directive site can fall back to the
147
+ # RBS-declared type rather than crash on a typo.
148
+ class Parser
149
+ def initialize(input)
150
+ @scanner = StringScanner.new(input.strip)
151
+ end
152
+
153
+ def parse
154
+ type = parse_type
155
+ return nil if type.nil?
156
+ return nil unless @scanner.eos?
157
+
158
+ type
159
+ end
160
+
161
+ private
162
+
163
+ SIMPLE_NAME = /[a-z][a-z0-9-]*/
164
+ CLASS_NAME = /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
165
+ SIGNED_INT = /-?\d+/
166
+ private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT
167
+
168
+ def parse_type
169
+ name = @scanner.scan(SIMPLE_NAME)
170
+ return nil if name.nil?
171
+
172
+ case @scanner.peek(1)
173
+ when "[" then parse_parametric_type_args(name)
174
+ when "<" then parse_parametric_int_bounds(name)
175
+ else ImportedRefinements.lookup(name)
176
+ end
177
+ end
178
+
179
+ def parse_parametric_type_args(name)
180
+ builder = PARAMETERISED_TYPE_BUILDERS[name]
181
+ return nil if builder.nil?
182
+
183
+ @scanner.getch # consume '['
184
+ args = parse_type_arg_list
185
+ return nil if args.nil?
186
+ return nil unless @scanner.getch == "]"
187
+
188
+ builder.call(args)
189
+ end
190
+
191
+ def parse_parametric_int_bounds(name)
192
+ builder = PARAMETERISED_INT_BUILDERS[name]
193
+ return nil if builder.nil?
194
+
195
+ @scanner.getch # consume '<'
196
+ bounds = parse_int_bound_list
197
+ return nil if bounds.nil?
198
+ return nil unless @scanner.getch == ">"
199
+
200
+ builder.call(bounds)
201
+ end
202
+
203
+ def parse_type_arg_list
204
+ collect_separated_list { parse_type_arg }
205
+ end
206
+
207
+ def parse_int_bound_list
208
+ collect_separated_list { parse_int_bound }
209
+ end
210
+
211
+ def collect_separated_list
212
+ items = []
213
+ loop do
214
+ skip_ws
215
+ item = yield
216
+ return nil if item.nil?
217
+
218
+ items << item
219
+ skip_ws
220
+ break unless @scanner.peek(1) == ","
221
+
222
+ @scanner.getch # consume ','
223
+ end
224
+ items
225
+ end
226
+
227
+ def parse_type_arg
228
+ skip_ws
229
+ if (class_name = @scanner.scan(CLASS_NAME))
230
+ Type::Combinator.nominal_of(class_name)
231
+ else
232
+ parse_type
233
+ end
234
+ end
235
+
236
+ def parse_int_bound
237
+ skip_ws
238
+ literal = @scanner.scan(SIGNED_INT)
239
+ return nil if literal.nil?
240
+
241
+ Integer(literal)
242
+ end
243
+
244
+ def skip_ws
245
+ @scanner.skip(/\s+/)
246
+ end
247
+ end
248
+ private_constant :Parser
249
+ end
250
+ end
251
+ end
@@ -12,13 +12,14 @@ module Rigor
12
12
  "disable" => [],
13
13
  "libraries" => [],
14
14
  "signature_paths" => nil,
15
+ "fold_platform_specific_paths" => false,
15
16
  "cache" => {
16
17
  "path" => ".rigor/cache"
17
18
  }
18
19
  }.freeze
19
20
 
20
21
  attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
21
- :libraries, :signature_paths
22
+ :libraries, :signature_paths, :fold_platform_specific_paths
22
23
 
23
24
  def self.load(path = DEFAULT_PATH)
24
25
  data = if File.exist?(path)
@@ -40,6 +41,9 @@ module Rigor
40
41
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
41
42
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
42
43
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
44
+ @fold_platform_specific_paths = data.fetch(
45
+ "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
46
+ ) == true
43
47
  @cache_path = cache.fetch("path").to_s
44
48
  end
45
49
 
@@ -51,6 +55,7 @@ module Rigor
51
55
  "disable" => disabled_rules,
52
56
  "libraries" => libraries,
53
57
  "signature_paths" => signature_paths,
58
+ "fold_platform_specific_paths" => fold_platform_specific_paths,
54
59
  "cache" => {
55
60
  "path" => cache_path
56
61
  }