rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Folds `Regexp` class-method calls on statically known arguments.
9
+ #
10
+ # === Supported methods
11
+ #
12
+ # * `escape(str)` / `quote(str)` — escapes regexp meta-characters.
13
+ # Returns `Constant[String]`.
14
+ # * `new(str)` / `new(str, opts)` — constructs a Regexp at fold time
15
+ # when the pattern argument is a `Constant[String]`. The optional
16
+ # second argument may be a `Constant[Integer]` (flag bits), a
17
+ # `Constant[true/false]` (IGNORECASE shorthand), or absent.
18
+ # Returns `Constant[Regexp]`.
19
+ #
20
+ # === Non-constant / unsupported cases
21
+ #
22
+ # Returns `nil` (deferring to the next dispatcher tier) when:
23
+ # - the receiver is not `Singleton[Regexp]`,
24
+ # - the required pattern argument is not a `Constant[String]`,
25
+ # - the method is not in the supported set.
26
+ module RegexpFolding
27
+ REGEXP_ESCAPE_METHODS = Set[:escape, :quote].freeze
28
+ private_constant :REGEXP_ESCAPE_METHODS
29
+
30
+ module_function
31
+
32
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
33
+ def try_dispatch(receiver:, method_name:, args:)
34
+ return nil unless dispatch_target?(receiver)
35
+ return fold_escape(args) if REGEXP_ESCAPE_METHODS.include?(method_name)
36
+ return fold_new(args) if method_name == :new
37
+
38
+ nil
39
+ end
40
+
41
+ def dispatch_target?(receiver)
42
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Regexp"
43
+ end
44
+
45
+ # `Regexp.escape(str)` / `.quote(str)` — one String arg.
46
+ def fold_escape(args)
47
+ return nil unless args.size == 1
48
+
49
+ arg = args.first
50
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
51
+
52
+ Type::Combinator.constant_of(Regexp.escape(arg.value))
53
+ end
54
+
55
+ # `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — constructs
56
+ # the pattern at inference time. Delegates to Ruby's real
57
+ # `Regexp.new` so all option forms (Integer flags, `true`/`false`,
58
+ # option strings) are handled without case-analysis; non-constant or
59
+ # invalid arguments decline through to the RBS tier.
60
+ def fold_new(args)
61
+ return nil if args.empty? || args.size > 2
62
+
63
+ pattern_arg = args.first
64
+ return nil unless pattern_arg.is_a?(Type::Constant) &&
65
+ pattern_arg.value.is_a?(String)
66
+
67
+ opts = args.size == 2 ? constant_value_or_nil(args[1]) : 0
68
+ return nil if args.size == 2 && opts.nil?
69
+
70
+ Type::Combinator.constant_of(Regexp.new(pattern_arg.value, opts))
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def constant_value_or_nil(type)
76
+ type.is_a?(Type::Constant) ? type.value : nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Folds `Set` constructor calls into `Constant[Set]` when all
9
+ # arguments are statically known.
10
+ #
11
+ # Ruby 3.2+ implements Set in C (`set.c`). The constructor is
12
+ # deterministic — it reads only its arguments and writes nothing
13
+ # to global state.
14
+ #
15
+ # === Supported methods
16
+ #
17
+ # * `Set[]` — variadic constructor; each argument must be a
18
+ # `Constant[T]`. Returns `Constant[Set]`.
19
+ # * `Set.new` — zero-argument form; returns `Constant[Set.new]`.
20
+ # * `Set.new(tuple)` — the argument must be a `Tuple` whose
21
+ # every element is a `Constant[T]`. Returns `Constant[Set]`.
22
+ #
23
+ # === Non-constant / unsupported cases
24
+ #
25
+ # Returns `nil` (deferring to the next dispatcher tier) when:
26
+ # - the receiver is not `Singleton[Set]`,
27
+ # - the method is not `[]` or `new`,
28
+ # - any argument is non-constant or structurally opaque.
29
+ module SetFolding
30
+ module_function
31
+
32
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
33
+ def try_dispatch(receiver:, method_name:, args:)
34
+ return nil unless dispatch_target?(receiver)
35
+
36
+ case method_name
37
+ when :[] then fold_bracket(args)
38
+ when :new then fold_new(args)
39
+ end
40
+ end
41
+
42
+ def dispatch_target?(receiver)
43
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Set"
44
+ end
45
+
46
+ # `Set["a", "b", "c"]` — all positional args must be Constant.
47
+ def fold_bracket(args)
48
+ values = args.map do |a|
49
+ return nil unless a.is_a?(Type::Constant)
50
+
51
+ a.value
52
+ end
53
+
54
+ Type::Combinator.constant_of(::Set.new(values))
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # `Set.new` / `Set.new(tuple_of_constants)`.
60
+ def fold_new(args)
61
+ return Type::Combinator.constant_of(::Set.new) if args.empty?
62
+ return nil if args.size > 1
63
+
64
+ arg = args.first
65
+ values = case arg
66
+ when Type::Tuple
67
+ return nil unless arg.elements.all?(Type::Constant)
68
+
69
+ arg.elements.map(&:value)
70
+ else
71
+ return nil
72
+ end
73
+
74
+ Type::Combinator.constant_of(::Set.new(values))
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -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
@@ -654,6 +680,67 @@ module Rigor
654
680
  [key.value, value]
655
681
  end
656
682
 
683
+ # `tuple.values_at(i1, i2, ...)` — returns a Tuple of
684
+ # per-index elements. Each argument must be a
685
+ # `Constant[Integer]`. Out-of-range indices fill with
686
+ # `Constant[nil]`, mirroring Ruby's runtime behaviour.
687
+ # Declines when any argument is non-static.
688
+ def tuple_values_at(tuple, _method_name, args)
689
+ return nil if args.empty?
690
+
691
+ values = args.map do |arg|
692
+ return nil unless arg.is_a?(Type::Constant)
693
+ return nil unless arg.value.is_a?(Integer)
694
+
695
+ idx = normalise_index(arg.value, tuple.elements.size)
696
+ idx ? tuple.elements[idx] : Type::Combinator.constant_of(nil)
697
+ end
698
+
699
+ Type::Combinator.tuple_of(*values)
700
+ end
701
+
702
+ # `tuple + other` — concatenates two Tuples. Both sides
703
+ # must be `Type::Tuple`. Returns a new Tuple whose
704
+ # elements are those of the receiver followed by those
705
+ # of the argument.
706
+ def tuple_concat(tuple, _method_name, args)
707
+ return nil unless args.size == 1
708
+
709
+ other = args.first
710
+ return nil unless other.is_a?(Type::Tuple)
711
+
712
+ Type::Combinator.tuple_of(*tuple.elements, *other.elements)
713
+ end
714
+
715
+ # `tuple.compact` — removes every element that is
716
+ # `Constant[nil]`. Folds only when every element is a
717
+ # `Constant` (so the nil set is decidable). Mixed-shape
718
+ # elements decline so the RBS tier widens.
719
+ def tuple_compact(tuple, _method_name, args)
720
+ return nil unless args.empty?
721
+ return nil unless tuple.elements.all?(Type::Constant)
722
+
723
+ kept = tuple.elements.reject { |e| e.is_a?(Type::Constant) && e.value.nil? }
724
+ Type::Combinator.tuple_of(*kept)
725
+ end
726
+
727
+ # `tuple.take(n)` — returns the first n elements as a
728
+ # new Tuple. The argument must be a `Constant[Integer]`.
729
+ # n <= 0 returns the empty Tuple; n >= size returns the
730
+ # full receiver.
731
+ def tuple_take(tuple, _method_name, args)
732
+ return nil unless args.size == 1
733
+
734
+ arg = args.first
735
+ return nil unless arg.is_a?(Type::Constant)
736
+ return nil unless arg.value.is_a?(Integer)
737
+
738
+ n = arg.value
739
+ return Type::Combinator.tuple_of if n <= 0
740
+
741
+ Type::Combinator.tuple_of(*tuple.elements.take(n))
742
+ end
743
+
657
744
  # Returns `true` / `false` if every element's truthiness
658
745
  # agrees, nil for mixed-or-unknown shapes. `all: true`
659
746
  # checks every element is truthy; `all: false` checks
@@ -817,6 +904,185 @@ module Rigor
817
904
 
818
905
  Type::Combinator.constant_of(!shape.pairs.empty?)
819
906
  end
907
+
908
+ # `shape.none?` (no block, no arg) — mirror of `any?`.
909
+ # Folds to `Constant[shape.pairs.empty?]` for closed
910
+ # shapes with no optional keys.
911
+ def hash_none?(shape, _method_name, args)
912
+ return nil unless args.empty?
913
+ return nil unless shape.closed?
914
+ return nil unless shape.optional_keys.empty?
915
+
916
+ Type::Combinator.constant_of(shape.pairs.empty?)
917
+ end
918
+
919
+ # `shape.one?` (no block, no arg) — folds to
920
+ # `Constant[shape.pairs.size == 1]` for a closed shape
921
+ # with no optional keys.
922
+ def hash_one?(shape, _method_name, args)
923
+ return nil unless args.empty?
924
+ return nil unless shape.closed?
925
+ return nil unless shape.optional_keys.empty?
926
+
927
+ Type::Combinator.constant_of(shape.pairs.size == 1)
928
+ end
929
+
930
+ # `shape.deconstruct_keys(keys)` — Ruby's `Hash#deconstruct_keys`
931
+ # returns the receiver itself regardless of the `keys`
932
+ # argument, so the precise answer is the shape unchanged.
933
+ def hash_deconstruct_keys(shape, _method_name, args)
934
+ return nil unless args.size == 1
935
+
936
+ shape
937
+ end
938
+
939
+ # `shape.fetch_values(:a, :b, ...)` — like `values_at` but
940
+ # raises `KeyError` on a missing key. Folds to `Tuple[V…]`
941
+ # only when every requested key is present; a missing key
942
+ # declines so the RBS tier reflects the raise.
943
+ def hash_fetch_values(shape, _method_name, args)
944
+ return nil if args.empty?
945
+ return nil unless shape.closed?
946
+ return nil unless shape.optional_keys.empty?
947
+
948
+ values = []
949
+ args.each do |arg|
950
+ return nil unless arg.is_a?(Type::Constant)
951
+
952
+ key = arg.value
953
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
954
+ return nil unless shape.pairs.key?(key)
955
+
956
+ values << shape.pairs[key]
957
+ end
958
+ Type::Combinator.tuple_of(*values)
959
+ end
960
+
961
+ # `shape.assoc(key)` — returns `Tuple[Constant[k], V]` for a
962
+ # known key, `Constant[nil]` for a missing key.
963
+ def hash_assoc(shape, _method_name, args)
964
+ return nil unless args.size == 1
965
+ return nil unless shape.closed?
966
+ return nil unless shape.optional_keys.empty?
967
+
968
+ arg = args.first
969
+ return nil unless arg.is_a?(Type::Constant)
970
+
971
+ key = arg.value
972
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
973
+ return Type::Combinator.constant_of(nil) unless shape.pairs.key?(key)
974
+
975
+ Type::Combinator.tuple_of(Type::Combinator.constant_of(key), shape.pairs[key])
976
+ end
977
+
978
+ # `shape.key(value)` — reverse lookup. Folds when every
979
+ # value is a `Constant` so equality is decidable: returns
980
+ # `Constant[k]` for the first matching key, `Constant[nil]`
981
+ # when no value matches.
982
+ def hash_key(shape, _method_name, args)
983
+ return nil unless args.size == 1
984
+ return nil unless shape.closed?
985
+ return nil unless shape.optional_keys.empty?
986
+ return nil unless shape.pairs.values.all?(Type::Constant)
987
+
988
+ arg = args.first
989
+ return nil unless arg.is_a?(Type::Constant)
990
+
991
+ pair = shape.pairs.find { |_k, v| v.value == arg.value }
992
+ Type::Combinator.constant_of(pair&.first)
993
+ end
994
+
995
+ # `shape.has_value?(v)` / `value?(v)` — folds to
996
+ # `Constant[true/false]` when every value is a `Constant`
997
+ # (so equality is decidable) and the argument is a
998
+ # `Constant`.
999
+ def hash_has_value?(shape, _method_name, args)
1000
+ return nil unless args.size == 1
1001
+ return nil unless shape.closed?
1002
+ return nil unless shape.optional_keys.empty?
1003
+ return nil unless shape.pairs.values.all?(Type::Constant)
1004
+
1005
+ arg = args.first
1006
+ return nil unless arg.is_a?(Type::Constant)
1007
+
1008
+ found = shape.pairs.values.any? { |v| v.value == arg.value }
1009
+ Type::Combinator.constant_of(found)
1010
+ end
1011
+
1012
+ # `shape.default` / `default_proc` — a literal `HashShape`
1013
+ # carries no default value or proc, so both fold to
1014
+ # `Constant[nil]`. `default` accepts an optional key
1015
+ # argument (still returns the default), `default_proc`
1016
+ # takes none — the `args.size <= 1` guard covers both.
1017
+ def hash_default(shape, _method_name, args)
1018
+ return nil unless args.size <= 1
1019
+ return nil unless shape.closed?
1020
+
1021
+ Type::Combinator.constant_of(nil)
1022
+ end
1023
+
1024
+ # `shape < other` / `<=` / `>` / `>=` — Hash containment
1025
+ # comparison. Both sides must be closed `HashShape`s whose
1026
+ # values are all `Constant` (so pair equality is
1027
+ # decidable). `<` / `>` are proper-subset / -superset.
1028
+ def hash_compare(shape, method_name, args)
1029
+ return nil unless args.size == 1
1030
+ return nil unless shape.closed? && shape.optional_keys.empty?
1031
+
1032
+ other = args.first
1033
+ return nil unless other.is_a?(Type::HashShape)
1034
+ return nil unless other.closed? && other.optional_keys.empty?
1035
+
1036
+ left = constant_pairs(shape)
1037
+ right = constant_pairs(other)
1038
+ return nil if left.nil? || right.nil?
1039
+
1040
+ Type::Combinator.constant_of(hash_containment(method_name, left, right))
1041
+ end
1042
+
1043
+ # Unwraps a closed shape's pairs to a plain Ruby Hash of
1044
+ # `key => value` for value-equality comparison. Returns nil
1045
+ # when any value is not a `Constant`.
1046
+ def constant_pairs(shape)
1047
+ return nil unless shape.pairs.values.all?(Type::Constant)
1048
+
1049
+ shape.pairs.transform_values(&:value)
1050
+ end
1051
+
1052
+ def hash_containment(method_name, left, right)
1053
+ case method_name
1054
+ when :< then hash_proper_subset?(left, right)
1055
+ when :<= then hash_subset?(left, right)
1056
+ when :> then hash_proper_subset?(right, left)
1057
+ when :>= then hash_subset?(right, left)
1058
+ end
1059
+ end
1060
+
1061
+ def hash_subset?(left, right)
1062
+ left.all? { |k, v| right.key?(k) && right[k] == v }
1063
+ end
1064
+
1065
+ def hash_proper_subset?(left, right)
1066
+ left.size < right.size && hash_subset?(left, right)
1067
+ end
1068
+
1069
+ # `shape.has_key?(k)` / `key?(k)` / `member?(k)` /
1070
+ # `include?(k)` — folds to `Constant[true/false]` when
1071
+ # the argument is a `Constant[Symbol|String]` and the
1072
+ # shape is closed with no optional keys.
1073
+ def hash_has_key?(shape, _method_name, args)
1074
+ return nil unless args.size == 1
1075
+ return nil unless shape.closed?
1076
+ return nil unless shape.optional_keys.empty?
1077
+
1078
+ arg = args.first
1079
+ return nil unless arg.is_a?(Type::Constant)
1080
+
1081
+ key = arg.value
1082
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1083
+
1084
+ Type::Combinator.constant_of(shape.pairs.key?(key))
1085
+ end
820
1086
  # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
821
1087
 
822
1088
  # `shape.keys` — returns a `Tuple[Constant<k>…]` for a
@@ -1027,6 +1293,54 @@ module Rigor
1027
1293
  Type::Combinator.tuple_of(*values)
1028
1294
  end
1029
1295
 
1296
+ # `shape.slice(:a, :b, ...)` — returns a sub-HashShape
1297
+ # containing only the specified keys. All arguments must
1298
+ # be `Constant[Symbol|String]`. Keys not present in the
1299
+ # shape are silently omitted (matching Ruby's runtime
1300
+ # semantics — no nil padding). Declines on open shapes
1301
+ # or when any argument is not a static key.
1302
+ def hash_slice(shape, _method_name, args)
1303
+ return nil if args.empty?
1304
+ return nil unless shape.closed?
1305
+ return nil unless shape.optional_keys.empty?
1306
+
1307
+ requested = []
1308
+ args.each do |arg|
1309
+ return nil unless arg.is_a?(Type::Constant)
1310
+
1311
+ key = arg.value
1312
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1313
+
1314
+ requested << key
1315
+ end
1316
+
1317
+ Type::Combinator.hash_shape_of(shape.pairs.slice(*requested))
1318
+ end
1319
+
1320
+ # `shape.except(:a, :b, ...)` — returns a sub-HashShape
1321
+ # with the specified keys removed. All arguments must be
1322
+ # `Constant[Symbol|String]`. Keys not present in the shape
1323
+ # are silently ignored. Declines on open shapes or when
1324
+ # any argument is not a static key.
1325
+ def hash_except(shape, _method_name, args)
1326
+ return nil if args.empty?
1327
+ return nil unless shape.closed?
1328
+ return nil unless shape.optional_keys.empty?
1329
+
1330
+ excluded = {}
1331
+ args.each do |arg|
1332
+ return nil unless arg.is_a?(Type::Constant)
1333
+
1334
+ key = arg.value
1335
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1336
+
1337
+ excluded[key] = true
1338
+ end
1339
+
1340
+ kept = shape.pairs.reject { |k, _v| excluded.key?(k) }
1341
+ Type::Combinator.hash_shape_of(kept)
1342
+ end
1343
+
1030
1344
  # Continues a `dig` chain after the first step. Tuple and
1031
1345
  # HashShape members re-dispatch into the catalogue;
1032
1346
  # `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