rigortype 0.1.8 → 0.1.10

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
@@ -87,7 +87,11 @@ module Rigor
87
87
  zip: :tuple_zip,
88
88
  :[] => :tuple_index,
89
89
  fetch: :tuple_index,
90
- dig: :tuple_dig
90
+ dig: :tuple_dig,
91
+ values_at: :tuple_values_at,
92
+ :+ => :tuple_concat,
93
+ compact: :tuple_compact,
94
+ take: :tuple_take
91
95
  }.freeze
92
96
 
93
97
  HASH_SHAPE_HANDLERS = {
@@ -96,19 +100,41 @@ module Rigor
96
100
  count: :hash_size,
97
101
  empty?: :hash_empty?,
98
102
  any?: :hash_any?,
103
+ none?: :hash_none?,
104
+ one?: :hash_one?,
99
105
  keys: :hash_keys,
100
106
  values: :hash_values,
101
107
  first: :hash_first,
102
108
  flatten: :hash_flatten,
103
109
  compact: :hash_compact,
104
110
  to_a: :hash_to_a,
111
+ entries: :hash_to_a,
105
112
  to_h: :hash_to_h,
113
+ to_hash: :hash_to_h,
114
+ deconstruct_keys: :hash_deconstruct_keys,
106
115
  invert: :hash_invert,
107
116
  merge: :hash_merge,
117
+ slice: :hash_slice,
118
+ except: :hash_except,
108
119
  :[] => :hash_lookup,
109
120
  fetch: :hash_lookup,
110
121
  dig: :hash_dig,
111
- values_at: :hash_values_at
122
+ values_at: :hash_values_at,
123
+ fetch_values: :hash_fetch_values,
124
+ assoc: :hash_assoc,
125
+ key: :hash_key,
126
+ has_key?: :hash_has_key?,
127
+ key?: :hash_has_key?,
128
+ member?: :hash_has_key?,
129
+ include?: :hash_has_key?,
130
+ has_value?: :hash_has_value?,
131
+ value?: :hash_has_value?,
132
+ default: :hash_default,
133
+ default_proc: :hash_default,
134
+ :< => :hash_compare,
135
+ :<= => :hash_compare,
136
+ :> => :hash_compare,
137
+ :>= => :hash_compare
112
138
  }.freeze
113
139
 
114
140
  # @return [Rigor::Type, nil] the precise element/value type, or
@@ -191,6 +217,16 @@ module Rigor
191
217
  end
192
218
 
193
219
  def dispatch_nominal_size(nominal, method_name, args)
220
+ if nominal.class_name == "String" && args.size == 1
221
+ string_binary = dispatch_string_binary_from_arg(method_name, args.first)
222
+ return string_binary if string_binary
223
+ end
224
+
225
+ if nominal.class_name == "Integer" && args.size == 1
226
+ integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
227
+ return integer_binary if integer_binary
228
+ end
229
+
194
230
  return nil unless args.empty?
195
231
 
196
232
  selectors = SIZE_RETURNING_NOMINALS[nominal.class_name]
@@ -199,6 +235,38 @@ module Rigor
199
235
  Type::Combinator.non_negative_int
200
236
  end
201
237
 
238
+ # Arg-type-driven String binary projections for any String-typed
239
+ # receiver (including Nominal, Refined, and Difference fallbacks).
240
+ # Called before the no-arg size guard so binary operators are seen.
241
+ #
242
+ # - `String + non-empty-string` → `non-empty-string`
243
+ # (arg guarantees the concatenation is non-empty)
244
+ # - `String * Constant[0]` → `Constant[""]`
245
+ # (every string repeated 0 times is the empty string)
246
+ def dispatch_string_binary_from_arg(method_name, arg)
247
+ case method_name
248
+ when :+
249
+ return Type::Combinator.non_empty_string if Type::Combinator.non_empty_string_compatible?(arg)
250
+ when :*
251
+ if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
252
+ return Type::Combinator.constant_of("")
253
+ end
254
+ end
255
+ nil
256
+ end
257
+
258
+ # Arg-type-driven Integer binary projections for any Integer-typed
259
+ # receiver (including Nominal, Refined, and Difference fallbacks).
260
+ #
261
+ # - `Integer * Constant[0]` → `Constant[0]`
262
+ # (any integer multiplied by 0 is 0)
263
+ def dispatch_integer_binary_from_arg(method_name, arg)
264
+ return nil unless method_name == :*
265
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
266
+
267
+ Type::Combinator.constant_of(0)
268
+ end
269
+
202
270
  # `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
203
271
  # When the range's lower bound is `>= 0`, every member is
204
272
  # a non-negative integer and `to_s(base)` returns a
@@ -255,7 +323,7 @@ module Rigor
255
323
  return nil unless base.is_a?(Type::Nominal)
256
324
 
257
325
  if removes_empty_witness?(difference)
258
- precise = empty_removal_projection(base, method_name, args)
326
+ precise = empty_removal_projection(difference, method_name, args)
259
327
  return precise if precise
260
328
  end
261
329
 
@@ -279,14 +347,62 @@ module Rigor
279
347
  !!(predicate && predicate.call(difference.removed))
280
348
  end
281
349
 
282
- def empty_removal_projection(base, method_name, args)
283
- return nil unless args.empty?
350
+ # Methods on a non-empty String that preserve non-emptiness
351
+ # (they transform characters but never reduce the string to "").
352
+ NON_EMPTY_STRING_PRESERVING_UNARY = Set[:upcase, :downcase, :capitalize, :swapcase, :reverse].freeze
353
+ # Methods on non-zero-int that return a non-zero-int (identity ops).
354
+ # Negation of a non-zero integer is non-zero; `to_i`/`to_int` are
355
+ # identity operations on Integer.
356
+ NON_ZERO_INT_PRESERVING_UNARY = Set[:-@, :+@, :to_i, :to_int].freeze
357
+ private_constant :NON_EMPTY_STRING_PRESERVING_UNARY, :NON_ZERO_INT_PRESERVING_UNARY
284
358
 
285
- if %i[size length count bytesize].include?(method_name)
286
- return size_returning_for_empty_removal(base, method_name)
287
- end
359
+ def empty_removal_projection(difference, method_name, args)
360
+ base = difference.base
361
+ return empty_removal_unary(difference, base, method_name) if args.empty?
362
+
363
+ empty_removal_binary(difference, base, method_name, args)
364
+ end
365
+
366
+ def empty_removal_unary(difference, base, method_name)
367
+ return size_returning_for_empty_removal(base, method_name) if
368
+ %i[size length count bytesize].include?(method_name)
369
+
370
+ predicate_result = empty_predicate_projection(base, method_name)
371
+ return predicate_result if predicate_result
372
+
373
+ return difference if base.class_name == "String" &&
374
+ NON_EMPTY_STRING_PRESERVING_UNARY.include?(method_name)
375
+
376
+ non_zero_int_unary_projection(difference, base, method_name)
377
+ end
288
378
 
289
- empty_predicate_projection(base, method_name)
379
+ def non_zero_int_unary_projection(difference, base, method_name)
380
+ return nil unless base.class_name == "Integer"
381
+ return Type::Combinator.positive_int if %i[abs magnitude].include?(method_name)
382
+ return difference if NON_ZERO_INT_PRESERVING_UNARY.include?(method_name)
383
+
384
+ nil
385
+ end
386
+
387
+ def empty_removal_binary(difference, base, method_name, args)
388
+ return empty_string_binary(difference, method_name, args) if base.class_name == "String"
389
+ return empty_integer_binary(difference, method_name, args) if base.class_name == "Integer"
390
+
391
+ nil
392
+ end
393
+
394
+ def empty_string_binary(difference, method_name, args)
395
+ return difference if method_name == :+ && args.size == 1
396
+ return non_empty_string_repeat(difference, args.first) if method_name == :* && args.size == 1
397
+
398
+ nil
399
+ end
400
+
401
+ def empty_integer_binary(difference, method_name, args)
402
+ return nil unless method_name == :* && args.size == 1
403
+ return nil unless Type::Combinator.non_zero_int_compatible?(args.first)
404
+
405
+ difference
290
406
  end
291
407
 
292
408
  def empty_predicate_projection(base, method_name)
@@ -298,6 +414,24 @@ module Rigor
298
414
  end
299
415
  end
300
416
 
417
+ # `non-empty-string * n` result:
418
+ # - `n == 0` → `Constant[""]` (any string repeated 0 times is empty)
419
+ # - `n >= 1` → `difference` (non-empty-string stays non-empty)
420
+ # - otherwise → nil (fall through, e.g. unknown n or non-negative-int)
421
+ def non_empty_string_repeat(difference, arg)
422
+ case arg
423
+ when Type::Constant
424
+ return nil unless arg.value.is_a?(Integer)
425
+
426
+ return Type::Combinator.constant_of("") if arg.value.zero?
427
+ return difference if arg.value.positive?
428
+ when Type::IntegerRange
429
+ return Type::Combinator.constant_of("") if arg.lower.zero? && arg.upper.zero?
430
+ return difference if arg.lower >= 1
431
+ end
432
+ nil
433
+ end
434
+
301
435
  def size_returning_for_empty_removal(base, method_name)
302
436
  return nil if base.class_name == "Integer" # Integer has no size method on Difference
303
437
 
@@ -654,6 +788,67 @@ module Rigor
654
788
  [key.value, value]
655
789
  end
656
790
 
791
+ # `tuple.values_at(i1, i2, ...)` — returns a Tuple of
792
+ # per-index elements. Each argument must be a
793
+ # `Constant[Integer]`. Out-of-range indices fill with
794
+ # `Constant[nil]`, mirroring Ruby's runtime behaviour.
795
+ # Declines when any argument is non-static.
796
+ def tuple_values_at(tuple, _method_name, args)
797
+ return nil if args.empty?
798
+
799
+ values = args.map do |arg|
800
+ return nil unless arg.is_a?(Type::Constant)
801
+ return nil unless arg.value.is_a?(Integer)
802
+
803
+ idx = normalise_index(arg.value, tuple.elements.size)
804
+ idx ? tuple.elements[idx] : Type::Combinator.constant_of(nil)
805
+ end
806
+
807
+ Type::Combinator.tuple_of(*values)
808
+ end
809
+
810
+ # `tuple + other` — concatenates two Tuples. Both sides
811
+ # must be `Type::Tuple`. Returns a new Tuple whose
812
+ # elements are those of the receiver followed by those
813
+ # of the argument.
814
+ def tuple_concat(tuple, _method_name, args)
815
+ return nil unless args.size == 1
816
+
817
+ other = args.first
818
+ return nil unless other.is_a?(Type::Tuple)
819
+
820
+ Type::Combinator.tuple_of(*tuple.elements, *other.elements)
821
+ end
822
+
823
+ # `tuple.compact` — removes every element that is
824
+ # `Constant[nil]`. Folds only when every element is a
825
+ # `Constant` (so the nil set is decidable). Mixed-shape
826
+ # elements decline so the RBS tier widens.
827
+ def tuple_compact(tuple, _method_name, args)
828
+ return nil unless args.empty?
829
+ return nil unless tuple.elements.all?(Type::Constant)
830
+
831
+ kept = tuple.elements.reject { |e| e.is_a?(Type::Constant) && e.value.nil? }
832
+ Type::Combinator.tuple_of(*kept)
833
+ end
834
+
835
+ # `tuple.take(n)` — returns the first n elements as a
836
+ # new Tuple. The argument must be a `Constant[Integer]`.
837
+ # n <= 0 returns the empty Tuple; n >= size returns the
838
+ # full receiver.
839
+ def tuple_take(tuple, _method_name, args)
840
+ return nil unless args.size == 1
841
+
842
+ arg = args.first
843
+ return nil unless arg.is_a?(Type::Constant)
844
+ return nil unless arg.value.is_a?(Integer)
845
+
846
+ n = arg.value
847
+ return Type::Combinator.tuple_of if n <= 0
848
+
849
+ Type::Combinator.tuple_of(*tuple.elements.take(n))
850
+ end
851
+
657
852
  # Returns `true` / `false` if every element's truthiness
658
853
  # agrees, nil for mixed-or-unknown shapes. `all: true`
659
854
  # checks every element is truthy; `all: false` checks
@@ -817,6 +1012,185 @@ module Rigor
817
1012
 
818
1013
  Type::Combinator.constant_of(!shape.pairs.empty?)
819
1014
  end
1015
+
1016
+ # `shape.none?` (no block, no arg) — mirror of `any?`.
1017
+ # Folds to `Constant[shape.pairs.empty?]` for closed
1018
+ # shapes with no optional keys.
1019
+ def hash_none?(shape, _method_name, args)
1020
+ return nil unless args.empty?
1021
+ return nil unless shape.closed?
1022
+ return nil unless shape.optional_keys.empty?
1023
+
1024
+ Type::Combinator.constant_of(shape.pairs.empty?)
1025
+ end
1026
+
1027
+ # `shape.one?` (no block, no arg) — folds to
1028
+ # `Constant[shape.pairs.size == 1]` for a closed shape
1029
+ # with no optional keys.
1030
+ def hash_one?(shape, _method_name, args)
1031
+ return nil unless args.empty?
1032
+ return nil unless shape.closed?
1033
+ return nil unless shape.optional_keys.empty?
1034
+
1035
+ Type::Combinator.constant_of(shape.pairs.size == 1)
1036
+ end
1037
+
1038
+ # `shape.deconstruct_keys(keys)` — Ruby's `Hash#deconstruct_keys`
1039
+ # returns the receiver itself regardless of the `keys`
1040
+ # argument, so the precise answer is the shape unchanged.
1041
+ def hash_deconstruct_keys(shape, _method_name, args)
1042
+ return nil unless args.size == 1
1043
+
1044
+ shape
1045
+ end
1046
+
1047
+ # `shape.fetch_values(:a, :b, ...)` — like `values_at` but
1048
+ # raises `KeyError` on a missing key. Folds to `Tuple[V…]`
1049
+ # only when every requested key is present; a missing key
1050
+ # declines so the RBS tier reflects the raise.
1051
+ def hash_fetch_values(shape, _method_name, args)
1052
+ return nil if args.empty?
1053
+ return nil unless shape.closed?
1054
+ return nil unless shape.optional_keys.empty?
1055
+
1056
+ values = []
1057
+ args.each do |arg|
1058
+ return nil unless arg.is_a?(Type::Constant)
1059
+
1060
+ key = arg.value
1061
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1062
+ return nil unless shape.pairs.key?(key)
1063
+
1064
+ values << shape.pairs[key]
1065
+ end
1066
+ Type::Combinator.tuple_of(*values)
1067
+ end
1068
+
1069
+ # `shape.assoc(key)` — returns `Tuple[Constant[k], V]` for a
1070
+ # known key, `Constant[nil]` for a missing key.
1071
+ def hash_assoc(shape, _method_name, args)
1072
+ return nil unless args.size == 1
1073
+ return nil unless shape.closed?
1074
+ return nil unless shape.optional_keys.empty?
1075
+
1076
+ arg = args.first
1077
+ return nil unless arg.is_a?(Type::Constant)
1078
+
1079
+ key = arg.value
1080
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1081
+ return Type::Combinator.constant_of(nil) unless shape.pairs.key?(key)
1082
+
1083
+ Type::Combinator.tuple_of(Type::Combinator.constant_of(key), shape.pairs[key])
1084
+ end
1085
+
1086
+ # `shape.key(value)` — reverse lookup. Folds when every
1087
+ # value is a `Constant` so equality is decidable: returns
1088
+ # `Constant[k]` for the first matching key, `Constant[nil]`
1089
+ # when no value matches.
1090
+ def hash_key(shape, _method_name, args)
1091
+ return nil unless args.size == 1
1092
+ return nil unless shape.closed?
1093
+ return nil unless shape.optional_keys.empty?
1094
+ return nil unless shape.pairs.values.all?(Type::Constant)
1095
+
1096
+ arg = args.first
1097
+ return nil unless arg.is_a?(Type::Constant)
1098
+
1099
+ pair = shape.pairs.find { |_k, v| v.value == arg.value }
1100
+ Type::Combinator.constant_of(pair&.first)
1101
+ end
1102
+
1103
+ # `shape.has_value?(v)` / `value?(v)` — folds to
1104
+ # `Constant[true/false]` when every value is a `Constant`
1105
+ # (so equality is decidable) and the argument is a
1106
+ # `Constant`.
1107
+ def hash_has_value?(shape, _method_name, args)
1108
+ return nil unless args.size == 1
1109
+ return nil unless shape.closed?
1110
+ return nil unless shape.optional_keys.empty?
1111
+ return nil unless shape.pairs.values.all?(Type::Constant)
1112
+
1113
+ arg = args.first
1114
+ return nil unless arg.is_a?(Type::Constant)
1115
+
1116
+ found = shape.pairs.values.any? { |v| v.value == arg.value }
1117
+ Type::Combinator.constant_of(found)
1118
+ end
1119
+
1120
+ # `shape.default` / `default_proc` — a literal `HashShape`
1121
+ # carries no default value or proc, so both fold to
1122
+ # `Constant[nil]`. `default` accepts an optional key
1123
+ # argument (still returns the default), `default_proc`
1124
+ # takes none — the `args.size <= 1` guard covers both.
1125
+ def hash_default(shape, _method_name, args)
1126
+ return nil unless args.size <= 1
1127
+ return nil unless shape.closed?
1128
+
1129
+ Type::Combinator.constant_of(nil)
1130
+ end
1131
+
1132
+ # `shape < other` / `<=` / `>` / `>=` — Hash containment
1133
+ # comparison. Both sides must be closed `HashShape`s whose
1134
+ # values are all `Constant` (so pair equality is
1135
+ # decidable). `<` / `>` are proper-subset / -superset.
1136
+ def hash_compare(shape, method_name, args)
1137
+ return nil unless args.size == 1
1138
+ return nil unless shape.closed? && shape.optional_keys.empty?
1139
+
1140
+ other = args.first
1141
+ return nil unless other.is_a?(Type::HashShape)
1142
+ return nil unless other.closed? && other.optional_keys.empty?
1143
+
1144
+ left = constant_pairs(shape)
1145
+ right = constant_pairs(other)
1146
+ return nil if left.nil? || right.nil?
1147
+
1148
+ Type::Combinator.constant_of(hash_containment(method_name, left, right))
1149
+ end
1150
+
1151
+ # Unwraps a closed shape's pairs to a plain Ruby Hash of
1152
+ # `key => value` for value-equality comparison. Returns nil
1153
+ # when any value is not a `Constant`.
1154
+ def constant_pairs(shape)
1155
+ return nil unless shape.pairs.values.all?(Type::Constant)
1156
+
1157
+ shape.pairs.transform_values(&:value)
1158
+ end
1159
+
1160
+ def hash_containment(method_name, left, right)
1161
+ case method_name
1162
+ when :< then hash_proper_subset?(left, right)
1163
+ when :<= then hash_subset?(left, right)
1164
+ when :> then hash_proper_subset?(right, left)
1165
+ when :>= then hash_subset?(right, left)
1166
+ end
1167
+ end
1168
+
1169
+ def hash_subset?(left, right)
1170
+ left.all? { |k, v| right.key?(k) && right[k] == v }
1171
+ end
1172
+
1173
+ def hash_proper_subset?(left, right)
1174
+ left.size < right.size && hash_subset?(left, right)
1175
+ end
1176
+
1177
+ # `shape.has_key?(k)` / `key?(k)` / `member?(k)` /
1178
+ # `include?(k)` — folds to `Constant[true/false]` when
1179
+ # the argument is a `Constant[Symbol|String]` and the
1180
+ # shape is closed with no optional keys.
1181
+ def hash_has_key?(shape, _method_name, args)
1182
+ return nil unless args.size == 1
1183
+ return nil unless shape.closed?
1184
+ return nil unless shape.optional_keys.empty?
1185
+
1186
+ arg = args.first
1187
+ return nil unless arg.is_a?(Type::Constant)
1188
+
1189
+ key = arg.value
1190
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1191
+
1192
+ Type::Combinator.constant_of(shape.pairs.key?(key))
1193
+ end
820
1194
  # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
821
1195
 
822
1196
  # `shape.keys` — returns a `Tuple[Constant<k>…]` for a
@@ -1027,6 +1401,54 @@ module Rigor
1027
1401
  Type::Combinator.tuple_of(*values)
1028
1402
  end
1029
1403
 
1404
+ # `shape.slice(:a, :b, ...)` — returns a sub-HashShape
1405
+ # containing only the specified keys. All arguments must
1406
+ # be `Constant[Symbol|String]`. Keys not present in the
1407
+ # shape are silently omitted (matching Ruby's runtime
1408
+ # semantics — no nil padding). Declines on open shapes
1409
+ # or when any argument is not a static key.
1410
+ def hash_slice(shape, _method_name, args)
1411
+ return nil if args.empty?
1412
+ return nil unless shape.closed?
1413
+ return nil unless shape.optional_keys.empty?
1414
+
1415
+ requested = []
1416
+ args.each do |arg|
1417
+ return nil unless arg.is_a?(Type::Constant)
1418
+
1419
+ key = arg.value
1420
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1421
+
1422
+ requested << key
1423
+ end
1424
+
1425
+ Type::Combinator.hash_shape_of(shape.pairs.slice(*requested))
1426
+ end
1427
+
1428
+ # `shape.except(:a, :b, ...)` — returns a sub-HashShape
1429
+ # with the specified keys removed. All arguments must be
1430
+ # `Constant[Symbol|String]`. Keys not present in the shape
1431
+ # are silently ignored. Declines on open shapes or when
1432
+ # any argument is not a static key.
1433
+ def hash_except(shape, _method_name, args)
1434
+ return nil if args.empty?
1435
+ return nil unless shape.closed?
1436
+ return nil unless shape.optional_keys.empty?
1437
+
1438
+ excluded = {}
1439
+ args.each do |arg|
1440
+ return nil unless arg.is_a?(Type::Constant)
1441
+
1442
+ key = arg.value
1443
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1444
+
1445
+ excluded[key] = true
1446
+ end
1447
+
1448
+ kept = shape.pairs.reject { |k, _v| excluded.key?(k) }
1449
+ Type::Combinator.hash_shape_of(kept)
1450
+ end
1451
+
1030
1452
  # Continues a `dig` chain after the first step. Tuple and
1031
1453
  # HashShape members re-dispatch into the catalogue;
1032
1454
  # `Constant[nil]` short-circuits the chain (Hash#dig and
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `Shellwords` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `Shellwords` is a pure, side-effect-free module whose functions
13
+ # are deterministic over their inputs — the same string always
14
+ # produces the same escaped / split / joined result. When all
15
+ # relevant arguments are `Constant[String]` (or `Tuple` of them
16
+ # for `join`), the analyzer can evaluate the call at inference
17
+ # time and return the concrete `Constant<T>` result.
18
+ #
19
+ # === Supported methods
20
+ #
21
+ # * `escape` / `shellescape(str)` — returns `Constant[String]`.
22
+ # `Shellwords.escape("")` → `"''"`, so the result is always
23
+ # non-empty; callers that care about the `non-empty-string`
24
+ # refinement will get it for free once that carrier is wired
25
+ # to scalar-constant returns.
26
+ #
27
+ # * `split` / `shellsplit` / `shellwords(line)` — splits the
28
+ # shell command line into tokens, returns
29
+ # `Tuple[Constant[String], …]`. Raises `ArgumentError` on
30
+ # unmatched quotes; the handler declines (returns `nil`) so
31
+ # the RBS tier widens gracefully.
32
+ #
33
+ # * `join` / `shelljoin(array)` — joins an array of tokens into
34
+ # a shell-safe string. Requires a `Tuple` whose every element
35
+ # is a `Constant[String]`; returns `Constant[String]`.
36
+ #
37
+ # === Non-constant / unsupported cases
38
+ #
39
+ # Any call where:
40
+ # - the receiver is not `Singleton[Shellwords]`,
41
+ # - the required argument is not a `Constant[String]` (or
42
+ # `Tuple[Constant[String]…]` for `join`),
43
+ # - the method is not in the supported set, or
44
+ # - `Shellwords.split` raises on malformed input
45
+ #
46
+ # returns `nil`, deferring to the next dispatcher tier.
47
+ module ShellwordsFolding
48
+ SHELLWORDS_ESCAPE_METHODS = Set[:escape, :shellescape].freeze
49
+ SHELLWORDS_SPLIT_METHODS = Set[:split, :shellsplit, :shellwords].freeze
50
+ SHELLWORDS_JOIN_METHODS = Set[:join, :shelljoin].freeze
51
+ SHELLWORDS_ALL_METHODS = (
52
+ SHELLWORDS_ESCAPE_METHODS | SHELLWORDS_SPLIT_METHODS | SHELLWORDS_JOIN_METHODS
53
+ ).freeze
54
+
55
+ # Maximum number of tokens `split` may produce before we
56
+ # decline the fold (same rationale as STRING_ARRAY_LIFT_LIMIT
57
+ # in ConstantFolding — keep the result Tuple manageable).
58
+ SHELLWORDS_SPLIT_LIMIT = 64
59
+
60
+ private_constant :SHELLWORDS_ESCAPE_METHODS, :SHELLWORDS_SPLIT_METHODS,
61
+ :SHELLWORDS_JOIN_METHODS, :SHELLWORDS_ALL_METHODS,
62
+ :SHELLWORDS_SPLIT_LIMIT
63
+
64
+ module_function
65
+
66
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
67
+ def try_dispatch(receiver:, method_name:, args:)
68
+ return nil unless dispatch_target?(receiver)
69
+ return nil unless SHELLWORDS_ALL_METHODS.include?(method_name)
70
+
71
+ if SHELLWORDS_ESCAPE_METHODS.include?(method_name)
72
+ fold_escape(args)
73
+ elsif SHELLWORDS_SPLIT_METHODS.include?(method_name)
74
+ fold_split(args)
75
+ else
76
+ fold_join(args)
77
+ end
78
+ end
79
+
80
+ def dispatch_target?(receiver)
81
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Shellwords"
82
+ end
83
+
84
+ # `Shellwords.escape(str)` / `.shellescape(str)` — one String arg.
85
+ def fold_escape(args)
86
+ return nil unless args.size == 1
87
+
88
+ arg = args.first
89
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
90
+
91
+ Type::Combinator.constant_of(Shellwords.escape(arg.value))
92
+ end
93
+
94
+ # `Shellwords.split(line)` / `.shellsplit` / `.shellwords` —
95
+ # one String arg, result lifted to Tuple[Constant[String]…].
96
+ def fold_split(args)
97
+ return nil unless args.size == 1
98
+
99
+ arg = args.first
100
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
101
+
102
+ tokens = Shellwords.split(arg.value)
103
+ return nil if tokens.size > SHELLWORDS_SPLIT_LIMIT
104
+
105
+ Type::Combinator.tuple_of(*tokens.map { |t| Type::Combinator.constant_of(t) })
106
+ rescue ArgumentError
107
+ # Unmatched quotes / invalid shell syntax — decline so the
108
+ # RBS tier returns the widened Array[String] envelope.
109
+ nil
110
+ end
111
+
112
+ # `Shellwords.join(array)` / `.shelljoin` — one Tuple arg
113
+ # whose every element is `Constant[String]`.
114
+ def fold_join(args)
115
+ return nil unless args.size == 1
116
+
117
+ arg = args.first
118
+ return nil unless arg.is_a?(Type::Tuple)
119
+ return nil unless arg.elements.all? { |e| e.is_a?(Type::Constant) && e.value.is_a?(String) }
120
+
121
+ Type::Combinator.constant_of(Shellwords.join(arg.elements.map(&:value)))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end