rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../../type"
4
4
  require_relative "singleton_folding"
5
+ require_relative "member_shape_projection"
5
6
 
6
7
  module Rigor
7
8
  module Inference
@@ -30,6 +31,10 @@ module Rigor
30
31
  module DataFolding
31
32
  module_function
32
33
 
34
+ # The `[]` / `to_h` / `deconstruct` / `members` / `with` projections
35
+ # and the reader-redefinition guard are shared with {StructFolding}.
36
+ extend MemberShapeProjection
37
+
33
38
  # @return [Rigor::Type, nil] the folded result, or nil to defer.
34
39
  def try_dispatch(context)
35
40
  receiver = context.receiver
@@ -165,81 +170,12 @@ module Rigor
165
170
  when :deconstruct then instance_deconstruct(instance)
166
171
  when :deconstruct_keys then instance_deconstruct_keys(instance, args)
167
172
  when :members then instance_members(instance)
168
- when :with then instance_with(instance, args)
173
+ when :with
174
+ instance_with(instance, args) do |members, class_name|
175
+ Type::Combinator.data_instance_of(members: members, class_name: class_name)
176
+ end
169
177
  end
170
178
  end
171
-
172
- # A `Data.define` class body (the `class Point < Data.define(:x);
173
- # def x; …; end; end` subclass body, or a `Const = Data.define(:x) do
174
- # def x; …; end; end` block) can redefine a member's synthesised
175
- # reader. When it does, `inst.x` runs that `def`, NOT the member, so
176
- # folding the read to the member type would be unsound (a downstream
177
- # FP). Both named forms register the override as a real `def` node
178
- # under the class name, so an entry in the project def-node table is
179
- # the discriminator (the synthesised reader has no def node). The
180
- # value accessors `[]` / `to_h` / `deconstruct` bypass the reader and
181
- # stay foldable, so this gate is on the bare member read only.
182
- def reader_overridden?(instance, method_name, scope)
183
- class_name = instance.class_name
184
- return false if class_name.nil? || scope.nil?
185
-
186
- !scope.user_def_for(class_name, method_name).nil?
187
- end
188
-
189
- def instance_index(instance, args)
190
- return nil unless args.size == 1
191
-
192
- arg = args.first
193
- return nil unless arg.is_a?(Type::Constant)
194
-
195
- key = arg.value
196
- case key
197
- when Symbol
198
- instance.members[key]
199
- when Integer
200
- values = instance.members.values
201
- idx = key.negative? ? key + values.size : key
202
- values[idx] if idx && idx >= 0 && idx < values.size
203
- end
204
- end
205
-
206
- def instance_to_h(instance)
207
- Type::Combinator.hash_shape_of(instance.members.dup)
208
- end
209
-
210
- def instance_deconstruct(instance)
211
- Type::Combinator.tuple_of(*instance.members.values)
212
- end
213
-
214
- # `deconstruct_keys(nil)` / `deconstruct_keys([:x])` both yield a
215
- # subset of the member map; the conservative, always-correct answer
216
- # is the full closed member shape.
217
- def instance_deconstruct_keys(instance, args)
218
- return nil unless args.size <= 1
219
-
220
- Type::Combinator.hash_shape_of(instance.members.dup)
221
- end
222
-
223
- def instance_members(instance)
224
- Type::Combinator.tuple_of(*instance.member_names.map { |name| Type::Combinator.constant_of(name) })
225
- end
226
-
227
- # `Data#with(x: 9)` returns a new frozen copy with the named members
228
- # overridden. Only a closed keyword `HashShape` whose keys are a
229
- # subset of the members folds; anything else defers (RBS resolves
230
- # `with` to `self`, returning the unchanged instance type).
231
- def instance_with(instance, args)
232
- return instance if args.empty?
233
- return nil unless args.size == 1
234
-
235
- shape = args.first
236
- return nil unless shape.is_a?(Type::HashShape) && shape.closed?
237
- return nil unless shape.optional_keys.empty?
238
- return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
239
-
240
- merged = instance.members.merge(shape.pairs)
241
- Type::Combinator.data_instance_of(members: merged, class_name: instance.class_name)
242
- end
243
179
  end
244
180
  end
245
181
  end
@@ -54,11 +54,10 @@ module Rigor
54
54
  # *correctness-preservingly* proved" excludes Constants whose
55
55
  # value is host-specific.
56
56
  module FileFolding
57
- # File class methods that the analyzer can fold *when the
58
- # fold is platform-safe to perform*. Today every entry is
59
- # platform-sensitive (every one observes `File::SEPARATOR`
60
- # or `File::ALT_SEPARATOR`); the gate below requires the
61
- # opt-in flag for any of them to fire.
57
+ # File class methods the analyzer can fold when the opt-in
58
+ # flag is set. Currently identical to PLATFORM_DEPENDENT_METHODS
59
+ # — separated for a future non-platform-sensitive tier that
60
+ # can fold without the opt-in flag.
62
61
  FILE_PURE_CLASS_METHODS = Set[
63
62
  :basename,
64
63
  :dirname,
@@ -72,8 +71,8 @@ module Rigor
72
71
  # Methods whose result depends on host directory-separator
73
72
  # semantics (`/` on POSIX, `/` AND `\` on Windows, drive
74
73
  # letters, UNC paths). Folding these would bake the
75
- # analyzer-host's platform into the inferred type. The opt-
76
- # in flag below controls whether to do it anyway.
74
+ # analyzer-host's platform into the inferred type. The opt-in
75
+ # flag below controls whether to do it anyway.
77
76
  PLATFORM_DEPENDENT_METHODS = Set[
78
77
  :basename, :dirname, :extname, :join, :split, :absolute_path?
79
78
  ].freeze
@@ -175,24 +175,18 @@ module Rigor
175
175
  type.is_a?(Type::Constant) && type.value.is_a?(Symbol)
176
176
  end
177
177
 
178
- # Element-yielding Enumerable methods covered as a v0.0.5
179
- # placeholder. RBS already binds the block parameter
180
- # correctly for plain `Array[T]` / `Set[T]` / `Range[T]`
181
- # receivers via generic substitution; this tier exists so
182
- # Tuple- and HashShape-shaped receivers reach the block
183
- # body with the precise per-position element union /
184
- # `Tuple[K, V]` pair rather than the projected
178
+ # Element-yielding Enumerable methods covered as a placeholder.
179
+ # RBS already binds the block parameter correctly for plain
180
+ # `Array[T]` / `Set[T]` / `Range[T]` receivers via generic
181
+ # substitution; this tier exists so Tuple- and HashShape-shaped
182
+ # receivers reach the block body with the precise per-position
183
+ # element union / `Tuple[K, V]` pair rather than the projected
185
184
  # `Array[union]` / `Hash[K, V]` widening.
186
185
  #
187
- # NOTE (v0.0.5): the per-method coverage here (group_by,
188
- # partition, each_slice, each_cons) is intentionally
189
- # narrow. The longer-term direction is to move
190
- # Enumerable-aware projections into a plugin tier modelled
191
- # after PHPStan's extension API (ADR-2). The placeholders
192
- # below stay until the plugin surface is in place; once it
193
- # ships, this dispatcher loses these arms and the
194
- # equivalent rules move into a built-in plugin loaded at
195
- # boot.
186
+ # NOTE: `Plugin::NodeRuleWalk` (ADR-52 WD4) is now in place as
187
+ # the intended migration target for these Enumerable projections.
188
+ # The four methods (group_by, partition, each_slice, each_cons)
189
+ # remain here pending that migration.
196
190
  def single_element_block_params(receiver)
197
191
  element = element_type_of(receiver)
198
192
  return nil if element.nil?
@@ -43,14 +43,21 @@ module Rigor
43
43
  private_constant :NUMERIC_CONSTRUCTORS
44
44
 
45
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
46
+ # (v0.1.1 Track 1 slice 2b). `decimal-int-string` is the
47
+ # only string refinement whose every inhabitant `Integer(s)`
48
+ # parses without remainder, so the result is a plain
49
+ # `Integer` but NOT `non-negative-int`: the predicate
50
+ # `/\A-?\d+\z/` admits a leading sign, so `"-7"` is a valid
51
+ # decimal-int-string and `Integer("-7") == -7 < 0`. The
52
+ # narrowing is total (every inhabitant parses) but not `>= 0`,
53
+ # so it lands on `universal_int`. `numeric-string` is
54
+ # deliberately NOT in this set at all: since it was widened to
55
+ # the full Ruby numeric-literal grammar (floats, hex, rational,
56
+ # imaginary, signs), `Integer(numeric_string)` would raise for
57
+ # a `"1.5"` / `"2i"` inhabitant — not even total — so it falls
58
+ # through to RBS `Integer`. The `Integer(s, base)` overload is
59
+ # left for a later slice.
60
+ INTEGER_REFINEMENT_PREDICATES = Set[:decimal_int].freeze
54
61
  private_constant :INTEGER_REFINEMENT_PREDICATES
55
62
 
56
63
  def try_dispatch(context)
@@ -70,7 +77,7 @@ module Rigor
70
77
  # paths, tried in order:
71
78
  #
72
79
  # 1. A `Refined[String, predicate]` argument whose predicate
73
- # is a digit-only carrier narrows to `non-negative-int`
80
+ # is a total-parse carrier narrows to `universal_int`
74
81
  # (see {try_integer_from_refinement}).
75
82
  # 2. A `Constant` String or Numeric argument — optionally
76
83
  # with a `Constant[Integer]` base — runs the actual
@@ -120,9 +127,14 @@ module Rigor
120
127
  # `Kernel#Integer(s)` over a `Refined[String, predicate]`
121
128
  # whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
122
129
  # Mirrors the `String#to_i` projection in `ShapeDispatch`
123
- # (v0.1.1 slice 2a) — the result is always
124
- # `non-negative-int`. Returns nil for any other arg shape
125
- # so the RBS tier handles the generic `Integer(arg)` case.
130
+ # (v0.1.1 slice 2a) — the result is `universal_int`, NOT
131
+ # `non-negative-int`: a decimal-int-string admits a leading
132
+ # sign (`"-7"`), so the parsed Integer can be negative. The
133
+ # carrier stays an `IntegerRange` (rather than declining to
134
+ # the RBS `Nominal[Integer]`) so downstream range narrowing
135
+ # still has a range to intersect. Returns nil for any other
136
+ # arg shape so the RBS tier handles the generic `Integer(arg)`
137
+ # case.
126
138
  def try_integer_from_refinement(args)
127
139
  return nil unless args.size == 1
128
140
 
@@ -133,7 +145,7 @@ module Rigor
133
145
  return nil unless base.is_a?(Type::Nominal) && base.class_name == "String"
134
146
  return nil unless INTEGER_REFINEMENT_PREDICATES.include?(arg.predicate_id)
135
147
 
136
- Type::Combinator.non_negative_int
148
+ Type::Combinator.universal_int
137
149
  end
138
150
 
139
151
  def try_array(args)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # The member-shape projections shared by {DataFolding} and
9
+ # {StructFolding}. A `DataInstance` and a `StructInstance` expose the
10
+ # same surface — an ordered `members` map, `member_names`, and a
11
+ # `class_name` — so the value projections off that surface
12
+ # (`[]` / `to_h` / `deconstruct` / `deconstruct_keys` / `members` /
13
+ # `with`) and the reader-redefinition guard are identical between the
14
+ # two folders. Only `#with`'s carrier constructor differs
15
+ # (`data_instance_of` vs `struct_instance_of`), so it takes a block
16
+ # that builds the new instance from the merged member map.
17
+ #
18
+ # Both folders `extend` this module so the projections resolve as
19
+ # their own module functions (matching their `module_function` style).
20
+ module MemberShapeProjection
21
+ # A Data/Struct subclass body can redefine a member's synthesised
22
+ # reader (`def x`); when it does, `inst.x` runs that `def`, not the
23
+ # member, so folding the read would be unsound. A real `def` node
24
+ # under the class name is the discriminator (the synthesised reader
25
+ # has none), so an entry in the project def-node table gates the
26
+ # bare member read off.
27
+ def reader_overridden?(instance, method_name, scope)
28
+ class_name = instance.class_name
29
+ return false if class_name.nil? || scope.nil?
30
+
31
+ !scope.user_def_for(class_name, method_name).nil?
32
+ end
33
+
34
+ def instance_index(instance, args)
35
+ return nil unless args.size == 1
36
+
37
+ arg = args.first
38
+ return nil unless arg.is_a?(Type::Constant)
39
+
40
+ key = arg.value
41
+ case key
42
+ when Symbol
43
+ instance.members[key]
44
+ when Integer
45
+ values = instance.members.values
46
+ idx = key.negative? ? key + values.size : key
47
+ values[idx] if idx && idx >= 0 && idx < values.size
48
+ end
49
+ end
50
+
51
+ def instance_to_h(instance)
52
+ Type::Combinator.hash_shape_of(instance.members.dup)
53
+ end
54
+
55
+ def instance_deconstruct(instance)
56
+ Type::Combinator.tuple_of(*instance.members.values)
57
+ end
58
+
59
+ # `deconstruct_keys(nil)` / `deconstruct_keys([:x])` both yield a
60
+ # subset of the member map; the conservative, always-correct answer
61
+ # is the full closed member shape.
62
+ def instance_deconstruct_keys(instance, args)
63
+ return nil unless args.size <= 1
64
+
65
+ Type::Combinator.hash_shape_of(instance.members.dup)
66
+ end
67
+
68
+ def instance_members(instance)
69
+ Type::Combinator.tuple_of(*instance.member_names.map { |name| Type::Combinator.constant_of(name) })
70
+ end
71
+
72
+ # `#with(x: 9)` returns a new copy with the named members
73
+ # overridden. Only a closed keyword `HashShape` whose keys are a
74
+ # subset of the members folds; anything else defers (RBS resolves
75
+ # `with` to `self`, returning the unchanged instance type). The
76
+ # carrier constructor differs per folder, so the caller supplies it
77
+ # as a block taking the merged member map and the class name.
78
+ def instance_with(instance, args)
79
+ return instance if args.empty?
80
+ return nil unless args.size == 1
81
+
82
+ shape = args.first
83
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
84
+ return nil unless shape.optional_keys.empty?
85
+ return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
86
+
87
+ merged = instance.members.merge(shape.pairs)
88
+ yield(merged, instance.class_name)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -25,9 +25,7 @@ module Rigor
25
25
  # so without this preference, an alias-typed overload like
26
26
  # `Array#[](::int) -> Elem` would beat the strict
27
27
  # `Array#[](Range) -> Array[Elem]?` overload for a Range
28
- # argument. (Surfaced during v0.1.1 self-analysis; see the
29
- # "Interface-strictness on overload selection" item in
30
- # `docs/ROADMAP.md`.)
28
+ # argument.
31
29
  # 3. **Pass 2 — gradual fall-back.** If no fully strict overload
32
30
  # matches, accept the first arity-and-gradual-accept match
33
31
  # (the v0.1.1 behaviour). Alias / Interface / Intersection
@@ -45,8 +45,9 @@ module Rigor
45
45
  # machinery works without duplication: `Tuple[Integer, String]`
46
46
  # dispatches as `Array[Integer | String]`, and
47
47
  # `HashShape{a: Integer}` dispatches as `Hash[Symbol, Integer]`.
48
- # Tuple-aware refinements (e.g., `tuple[0]` returning the precise
49
- # member) are deferred to Slice 5 phase 2.
48
+ # Tuple/HashShape element precision (e.g., `tuple[0]` returning
49
+ # the precise member) is handled by the preceding `ShapeDispatch`
50
+ # tier.
50
51
  #
51
52
  # Remaining limitations:
52
53
  #
@@ -107,17 +108,11 @@ module Rigor
107
108
  # @return [Rigor::Type, nil] inferred return type, or `nil`
108
109
  # when no rule resolves (no class name, no method, dispatch
109
110
  # on a Top/Dynamic[Top] receiver, etc.).
110
- # @param scope [Rigor::Scope, nil] when supplied, enables
111
- # ADR-43 RBS-complete-ancestor resolution: a call on a
112
- # Ruby-source subclass not known to RBS, whose discovered
113
- # superclass chain reaches an allow-listed RBS-complete
114
- # ancestor (e.g. `Rigor::Plugin::Base`), resolves against
115
- # that ancestor's RBS. `nil` (the default for every caller
116
- # that does not thread a scope) keeps the legacy behaviour —
117
- # such an inherited call stays unresolved and degrades to
118
- # `Dynamic[Top]`, which is the false-positive-safe default
119
- # for the open hierarchies (`< ActionController::Base`, …)
120
- # the allow-list deliberately excludes.
111
+ # @param scope [Rigor::Scope, nil] when supplied, enables ADR-43
112
+ # RBS-complete-ancestor resolution against
113
+ # `ALLOWED_RBS_COMPLETE_ANCESTORS`. `nil` keeps inherited calls
114
+ # unresolved (`Dynamic[Top]`) the FP-safe default for open
115
+ # hierarchies (`< ActionController::Base`, …).
121
116
  def try_dispatch(context)
122
117
  environment = context.environment
123
118
  return nil if environment.nil?
@@ -249,15 +244,8 @@ module Rigor
249
244
  ["Array", :instance, tuple_type_args(receiver)]
250
245
  when Type::HashShape
251
246
  ["Hash", :instance, hash_shape_type_args(receiver)]
252
- when Type::DataInstance
253
- # ADR-48 — project a member-instance carrier to its tagging
254
- # class (or the `Data` supertype) so non-member calls
255
- # (`inspect`, `==`, `frozen?`, ...) resolve through RBS
256
- # rather than mis-firing undefined-method. Member reads were
257
- # already folded by DataFolding above this tier.
258
- [receiver.class_name || "Data", :instance, []]
259
- when Type::DataClass
260
- [receiver.class_name || "Data", :singleton, []]
247
+ when Type::DataInstance, Type::DataClass, Type::StructInstance, Type::StructClass
248
+ member_carrier_descriptor(receiver)
261
249
  when Type::BoundMethod
262
250
  # `BoundMethod` is a precision-bearing alias for
263
251
  # `Nominal[Method]`: it carries the
@@ -275,6 +263,20 @@ module Rigor
275
263
  end
276
264
  end
277
265
 
266
+ # ADR-48 — project a `Data`/`Struct` member carrier to its tagging
267
+ # class (or the `Data`/`Struct` supertype) so non-member calls
268
+ # (`inspect`, `==`, `frozen?`, ...) resolve through RBS rather than
269
+ # mis-firing undefined-method. Precise member reads were already
270
+ # folded by DataFolding / StructFolding above this tier.
271
+ def member_carrier_descriptor(receiver)
272
+ case receiver
273
+ when Type::DataInstance then [receiver.class_name || "Data", :instance, []]
274
+ when Type::DataClass then [receiver.class_name || "Data", :singleton, []]
275
+ when Type::StructInstance then [receiver.class_name || "Struct", :instance, []]
276
+ when Type::StructClass then [receiver.class_name || "Struct", :singleton, []]
277
+ end
278
+ end
279
+
278
280
  def tuple_type_args(tuple)
279
281
  return [] if tuple.elements.empty?
280
282
 
@@ -81,6 +81,7 @@ module Rigor
81
81
  sum: :tuple_sum,
82
82
  min: :tuple_min,
83
83
  max: :tuple_max,
84
+ minmax: :tuple_minmax_pair,
84
85
  sort: :tuple_sort,
85
86
  reverse: :tuple_reverse,
86
87
  to_a: :tuple_to_a,
@@ -99,9 +100,15 @@ module Rigor
99
100
  index: :tuple_find_index,
100
101
  find_index: :tuple_find_index,
101
102
  rindex: :tuple_rindex,
102
- flatten: :tuple_flatten
103
+ flatten: :tuple_flatten,
104
+ join: :tuple_join
103
105
  }.freeze
104
106
 
107
+ # Byte cap on a folded `tuple.join` result — a huge tuple times a
108
+ # long separator must not materialise an unbounded `Constant`.
109
+ TUPLE_JOIN_BYTE_LIMIT = 4096
110
+ private_constant :TUPLE_JOIN_BYTE_LIMIT
111
+
105
112
  HASH_SHAPE_HANDLERS = {
106
113
  size: :hash_size,
107
114
  length: :hash_size,
@@ -573,8 +580,17 @@ module Rigor
573
580
  %i[lowercase upcase] => :uppercase_string,
574
581
  %i[uppercase upcase] => :refined_self,
575
582
  %i[uppercase downcase] => :lowercase_string,
583
+ # `numeric-string` is the full Ruby numeric-literal
584
+ # grammar (since the predicate delegates to the
585
+ # parser). `#downcase` preserves it — lowercasing a
586
+ # literal (hex digits, `0X` / `E` prefixes) yields a
587
+ # valid lowercase literal — but `#upcase` does NOT:
588
+ # the rational / imaginary suffixes are lowercase-only
589
+ # (`"1r".upcase == "1R"` is not a literal), so `upcase`
590
+ # drops to the plain base `String` — still sound (the
591
+ # result is a String), just no longer numeric.
576
592
  %i[numeric downcase] => :refined_self,
577
- %i[numeric upcase] => :refined_self,
593
+ %i[numeric upcase] => :base_string,
578
594
  # Digit-only strings are case-invariant; the prefix
579
595
  # letters in `0o…` / `0x…` are accepted by the
580
596
  # predicate in either case so the predicate-subset
@@ -587,19 +603,19 @@ module Rigor
587
603
  %i[hex_int downcase] => :refined_self,
588
604
  %i[hex_int upcase] => :refined_self,
589
605
  # v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
590
- # known digit-only string. `decimal-int-string`
591
- # (`/\A\d+\z/`) and `numeric-string` (Rigor's
592
- # numeric-string predicate, ASCII digits) are
593
- # predicates over digit-only strings, so the parse
594
- # is total over the carrier domain and the result
595
- # is always `>= 0`. `non-negative-int` is the
596
- # tightest carrier that captures both the lower
597
- # bound and the integer-ness without inventing a
598
- # narrower carrier.
599
- %i[decimal_int to_i] => :non_negative_int,
600
- %i[decimal_int to_int] => :non_negative_int,
601
- %i[numeric to_i] => :non_negative_int,
602
- %i[numeric to_int] => :non_negative_int
606
+ # `decimal-int-string` parses to an `Integer`. The
607
+ # carrier is `universal_int`, NOT `non-negative-int`:
608
+ # the predicate `/\A-?\d+\z/` admits a leading sign, so
609
+ # `"-7"` is a valid decimal-int-string and
610
+ # `"-7".to_i == -7 < 0`. `String#to_i` is total (never
611
+ # raises), so the projection is sound — just signed.
612
+ # `numeric-string` is deliberately NOT projected to
613
+ # `to_i` at all: it now spans the full numeric-literal
614
+ # grammar, so a `"1.5"` / `"2i"` inhabitant has a
615
+ # fractional or non-Integer parse — it falls through to
616
+ # the RBS `Integer`.
617
+ %i[decimal_int to_i] => :universal_int,
618
+ %i[decimal_int to_int] => :universal_int
603
619
  })
604
620
  private_constant :REFINED_STRING_PROJECTIONS
605
621
 
@@ -624,6 +640,8 @@ module Rigor
624
640
  when :uppercase_string then Type::Combinator.uppercase_string
625
641
  when :lowercase_string then Type::Combinator.lowercase_string
626
642
  when :non_negative_int then Type::Combinator.non_negative_int
643
+ when :universal_int then Type::Combinator.universal_int
644
+ when :base_string then refined.base
627
645
  end
628
646
  end
629
647
 
@@ -791,6 +809,37 @@ module Rigor
791
809
  Type::Combinator.constant_of(values.sum)
792
810
  end
793
811
 
812
+ # `tuple.join(sep = "")` — fold to the joined `Constant[String]`
813
+ # when every element is a `Constant` (its `to_s` is deterministic
814
+ # for the scalar value classes) and the separator is absent or a
815
+ # `Constant[String]`. Capped at `TUPLE_JOIN_BYTE_LIMIT`.
816
+ def tuple_join(tuple, _method_name, args)
817
+ sep = tuple_join_separator(args)
818
+ return nil if sep.nil?
819
+
820
+ values = constant_values(tuple.elements)
821
+ return nil if values.nil?
822
+
823
+ result = values.join(sep)
824
+ return nil if result.bytesize > TUPLE_JOIN_BYTE_LIMIT
825
+
826
+ Type::Combinator.constant_of(result)
827
+ rescue StandardError
828
+ nil
829
+ end
830
+
831
+ # The join separator: `""` for the no-arg form, the value of a
832
+ # single `Constant[String]` arg, or `nil` to decline.
833
+ def tuple_join_separator(args)
834
+ return "" if args.empty?
835
+ return nil unless args.size == 1
836
+
837
+ arg = args.first
838
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
839
+
840
+ arg.value
841
+ end
842
+
794
843
  # `tuple.min` / `tuple.max` — fold when every element is
795
844
  # a `Constant` whose values share a Ruby-comparable
796
845
  # domain. Empty tuples fold to `Constant[nil]`.
@@ -815,6 +864,32 @@ module Rigor
815
864
  nil
816
865
  end
817
866
 
867
+ # `tuple.minmax` — the `[min, max]` pair as a 2-slot
868
+ # `Tuple[Constant[min], Constant[max]]`, mirroring the
869
+ # `Range#minmax` fold. Every element must be a `Constant`
870
+ # and the values must Ruby-compare; an empty tuple folds to
871
+ # `Tuple[nil, nil]` (Ruby's `[].minmax`), incomparable
872
+ # mixed-class values decline.
873
+ def tuple_minmax_pair(tuple, _method_name, args)
874
+ return nil unless args.empty?
875
+
876
+ if tuple.elements.empty?
877
+ nil_const = Type::Combinator.constant_of(nil)
878
+ return Type::Combinator.tuple_of(nil_const, nil_const)
879
+ end
880
+
881
+ values = constant_values(tuple.elements)
882
+ return nil if values.nil?
883
+
884
+ low, high = values.minmax
885
+ Type::Combinator.tuple_of(
886
+ Type::Combinator.constant_of(low),
887
+ Type::Combinator.constant_of(high)
888
+ )
889
+ rescue StandardError
890
+ nil
891
+ end
892
+
818
893
  # `tuple.sort` — every element must be a `Constant` and
819
894
  # the values must Ruby-compare. The result is a Tuple
820
895
  # with the same elements in sorted order. Comparison