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
@@ -6,9 +6,13 @@ require_relative "dynamic"
6
6
  require_relative "nominal"
7
7
  require_relative "singleton"
8
8
  require_relative "constant"
9
+ require_relative "integer_range"
9
10
  require_relative "tuple"
10
11
  require_relative "hash_shape"
11
12
  require_relative "union"
13
+ require_relative "difference"
14
+ require_relative "refined"
15
+ require_relative "intersection"
12
16
 
13
17
  module Rigor
14
18
  module Type
@@ -20,7 +24,7 @@ module Rigor
20
24
  #
21
25
  # See docs/internal-spec/internal-type-api.md and
22
26
  # docs/type-specification/normalization.md.
23
- module Combinator
27
+ module Combinator # rubocop:disable Metrics/ModuleLength
24
28
  module_function
25
29
 
26
30
  def top
@@ -65,6 +69,131 @@ module Rigor
65
69
  Constant.new(value)
66
70
  end
67
71
 
72
+ # Bounded-integer carrier. Each bound is either an `Integer` or
73
+ # one of `:neg_infinity` / `:pos_infinity` (sentinels exposed as
74
+ # `IntegerRange::NEG_INFINITY` / `POS_INFINITY`).
75
+ def integer_range(min, max)
76
+ IntegerRange.new(min, max)
77
+ end
78
+
79
+ # Convenience aliases for the most common bounded shapes. The
80
+ # named alias survives roundtrip through `describe` for nicer
81
+ # human-facing output.
82
+ def positive_int
83
+ IntegerRange.new(1, IntegerRange::POS_INFINITY)
84
+ end
85
+
86
+ def non_negative_int
87
+ IntegerRange.new(0, IntegerRange::POS_INFINITY)
88
+ end
89
+
90
+ def negative_int
91
+ IntegerRange.new(IntegerRange::NEG_INFINITY, -1)
92
+ end
93
+
94
+ def non_positive_int
95
+ IntegerRange.new(IntegerRange::NEG_INFINITY, 0)
96
+ end
97
+
98
+ def universal_int
99
+ IntegerRange.new(IntegerRange::NEG_INFINITY, IntegerRange::POS_INFINITY)
100
+ end
101
+
102
+ # Point-removal refinement carrier (ADR-3 OQ3 Option C). Use
103
+ # `non_empty_string` / `non_zero_int` / `non_empty_array` /
104
+ # `non_empty_hash` for the imported built-in shapes; raw
105
+ # `difference(base, removed)` for ad-hoc refinements an
106
+ # `RBS::Extended` annotation introduces.
107
+ def difference(base, removed)
108
+ Difference.new(base, removed)
109
+ end
110
+
111
+ def non_empty_string
112
+ Difference.new(nominal_of("String"), constant_of(""))
113
+ end
114
+
115
+ def non_zero_int
116
+ Difference.new(nominal_of("Integer"), constant_of(0))
117
+ end
118
+
119
+ # `non-empty-array[T]` requires the element type so the
120
+ # `Nominal[Array, [T]]` projection through Array#first /
121
+ # #last keeps element precision intact. The default
122
+ # `Top` admits any array element when the caller does
123
+ # not have a more specific element type.
124
+ def non_empty_array(element = top)
125
+ Difference.new(
126
+ nominal_of("Array", type_args: [element]),
127
+ tuple_of
128
+ )
129
+ end
130
+
131
+ def non_empty_hash(key = top, value = top)
132
+ Difference.new(
133
+ nominal_of("Hash", type_args: [key, value]),
134
+ hash_shape_of({})
135
+ )
136
+ end
137
+
138
+ # Predicate-subset refinement carrier (ADR-3 OQ3 Option C,
139
+ # second half). Use `lowercase_string` /
140
+ # `uppercase_string` / `numeric_string` for the imported
141
+ # built-in shapes; raw `refined(base, predicate_id)` for
142
+ # ad-hoc refinements introduced by an `RBS::Extended`
143
+ # annotation or a plugin-contributed predicate.
144
+ def refined(base, predicate_id)
145
+ Refined.new(base, predicate_id)
146
+ end
147
+
148
+ def lowercase_string
149
+ Refined.new(nominal_of("String"), :lowercase)
150
+ end
151
+
152
+ def uppercase_string
153
+ Refined.new(nominal_of("String"), :uppercase)
154
+ end
155
+
156
+ def numeric_string
157
+ Refined.new(nominal_of("String"), :numeric)
158
+ end
159
+
160
+ def decimal_int_string
161
+ Refined.new(nominal_of("String"), :decimal_int)
162
+ end
163
+
164
+ def octal_int_string
165
+ Refined.new(nominal_of("String"), :octal_int)
166
+ end
167
+
168
+ def hex_int_string
169
+ Refined.new(nominal_of("String"), :hex_int)
170
+ end
171
+
172
+ # Normalised intersection. Flattens nested Intersections,
173
+ # drops `Top` members, collapses to `Bot` if any member is
174
+ # `Bot`, deduplicates structurally-equal members, sorts the
175
+ # survivors by `describe(:short)`, and collapses 0-/1-member
176
+ # results so a degenerate intersection never reaches the
177
+ # carrier. See ADR-3 OQ3 for the rationale; the lattice
178
+ # algebra is in
179
+ # [`value-lattice.md`](docs/type-specification/value-lattice.md).
180
+ def intersection(*members)
181
+ collapse_intersection(normalised_intersection_members(members))
182
+ end
183
+
184
+ # `non-empty-lowercase-string` = non-empty-string ∩
185
+ # lowercase-string. Composes the point-removal half
186
+ # (`Difference[String, ""]`) with the predicate-subset half
187
+ # (`Refined[String, :lowercase]`). Both members erase to
188
+ # `String` so the carrier's RBS erasure is unambiguous.
189
+ def non_empty_lowercase_string
190
+ intersection(non_empty_string, lowercase_string)
191
+ end
192
+
193
+ def non_empty_uppercase_string
194
+ intersection(non_empty_string, uppercase_string)
195
+ end
196
+
68
197
  # Constructs a heterogeneous, fixed-arity Tuple from positional
69
198
  # element types. `tuple_of()` produces the empty tuple `Tuple[]`,
70
199
  # which is structurally distinct from the raw `Nominal[Array]`.
@@ -117,6 +246,35 @@ module Rigor
117
246
  end
118
247
  end
119
248
 
249
+ # Symmetric counterparts to the Union normalisers. The
250
+ # absorbing element is `Bot` (anything intersected with
251
+ # nothing is nothing) and the identity element is `Top`
252
+ # (intersecting with the universal type is a no-op).
253
+ def normalised_intersection_members(types)
254
+ flattened = []
255
+ types.each { |t| flatten_intersection_into(flattened, t) }
256
+ return [bot] if flattened.any?(Bot)
257
+
258
+ flattened.reject! { |t| t.is_a?(Top) }
259
+ unique_members(flattened)
260
+ end
261
+
262
+ def collapse_intersection(types)
263
+ case types.size
264
+ when 0 then top
265
+ when 1 then types.first
266
+ else Intersection.new(sort_members(types))
267
+ end
268
+ end
269
+
270
+ def flatten_intersection_into(acc, type)
271
+ if type.is_a?(Intersection)
272
+ type.members.each { |m| flatten_intersection_into(acc, m) }
273
+ else
274
+ acc << type
275
+ end
276
+ end
277
+
120
278
  def resolve_class_name(class_name_or_object)
121
279
  name =
122
280
  case class_name_or_object
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+
5
+ module Rigor
6
+ module Type
7
+ # `Difference[base, removed]` — the value set of `base` minus
8
+ # the value set of `removed`. Implements the point-removal
9
+ # half of the OQ3 refinement-carrier strategy
10
+ # ([ADR-3](docs/adr/3-type-representation.md), Working
11
+ # Decision Option C):
12
+ #
13
+ # non-empty-string = Difference[Nominal[String], Constant[""]]
14
+ # non-zero-int = Difference[Nominal[Integer], Constant[0]]
15
+ # non-empty-array[T] = Difference[Nominal[Array, [T]], Tuple[]]
16
+ # non-empty-hash[K,V] = Difference[Nominal[Hash, [K,V]], HashShape{}]
17
+ #
18
+ # The carrier itself is structural: it stores `base` and
19
+ # `removed` as inner `Type` references and answers projection
20
+ # / acceptance / display questions by composing those inner
21
+ # answers per the lattice algebra in
22
+ # [`value-lattice.md`](docs/type-specification/value-lattice.md).
23
+ # The canonical-name registry (display side) lives in
24
+ # `Rigor::Type::Combinator` and prints kebab-case names like
25
+ # `non-empty-string` for the recognised shapes; unrecognised
26
+ # differences fall back to the raw `base - removed`
27
+ # operator form per [`type-operators.md`](docs/type-specification/type-operators.md).
28
+ #
29
+ # Construction goes through `Type::Combinator.difference` /
30
+ # `Combinator.non_empty_string` etc. — direct `.new` calls
31
+ # are an internal contract; callers MUST ensure both bounds
32
+ # are valid `Rigor::Type` values and that `removed` is a
33
+ # subtype-or-equal of `base` (otherwise the difference does
34
+ # not narrow anything and a normalisation upstream should
35
+ # collapse to `base`).
36
+ class Difference
37
+ attr_reader :base, :removed
38
+
39
+ def initialize(base, removed)
40
+ @base = base
41
+ @removed = removed
42
+ freeze
43
+ end
44
+
45
+ def describe(verbosity = :short)
46
+ named = canonical_name
47
+ return named if named
48
+
49
+ "#{base.describe(verbosity)} - #{removed.describe(verbosity)}"
50
+ end
51
+
52
+ # Erases to the base nominal: every refinement MUST erase
53
+ # to its base per [`rbs-erasure.md`](docs/type-specification/rbs-erasure.md).
54
+ def erase_to_rbs
55
+ base.erase_to_rbs
56
+ end
57
+
58
+ def top
59
+ Trinary.no
60
+ end
61
+
62
+ def bot
63
+ Trinary.no
64
+ end
65
+
66
+ def dynamic
67
+ base.respond_to?(:dynamic) ? base.dynamic : Trinary.no
68
+ end
69
+
70
+ def accepts(other, mode: :gradual)
71
+ Inference::Acceptance.accepts(self, other, mode: mode)
72
+ end
73
+
74
+ def ==(other)
75
+ other.is_a?(Difference) && base == other.base && removed == other.removed
76
+ end
77
+ alias eql? ==
78
+
79
+ def hash
80
+ [Difference, base, removed].hash
81
+ end
82
+
83
+ def inspect
84
+ "#<Rigor::Type::Difference #{describe(:short)}>"
85
+ end
86
+
87
+ private
88
+
89
+ # Renders the kebab-case shorthand for recognised
90
+ # imported-built-in shapes. Parameterised bases keep their
91
+ # type-args in the canonical form (`non-empty-array[T]`,
92
+ # `non-empty-hash[K, V]`) so element-precision survives the
93
+ # display round-trip. Unrecognised shapes fall back to the
94
+ # raw `base - removed` operator form.
95
+ #
96
+ # The recognised set is kept in sync with the imported-built-in
97
+ # catalogue ([`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md)).
98
+ def canonical_name
99
+ return nil unless base.is_a?(Nominal)
100
+
101
+ send(CANONICAL_HANDLERS[base.class_name] || :no_canonical_name)
102
+ end
103
+
104
+ CANONICAL_HANDLERS = {
105
+ "String" => :string_canonical_name,
106
+ "Integer" => :integer_canonical_name,
107
+ "Array" => :array_canonical_name_if_empty,
108
+ "Hash" => :hash_canonical_name_if_empty
109
+ }.freeze
110
+ private_constant :CANONICAL_HANDLERS
111
+
112
+ def no_canonical_name
113
+ nil
114
+ end
115
+
116
+ def string_canonical_name
117
+ return nil unless removed.is_a?(Constant) && removed.value == ""
118
+
119
+ "non-empty-string"
120
+ end
121
+
122
+ def integer_canonical_name
123
+ return nil unless removed.is_a?(Constant) && removed.value.is_a?(Integer) && removed.value.zero?
124
+
125
+ "non-zero-int"
126
+ end
127
+
128
+ def array_canonical_name_if_empty
129
+ return nil unless removed.is_a?(Tuple) && removed.elements.empty?
130
+
131
+ array_canonical_name
132
+ end
133
+
134
+ def hash_canonical_name_if_empty
135
+ return nil unless removed.is_a?(HashShape) && removed.pairs.empty?
136
+
137
+ hash_canonical_name
138
+ end
139
+
140
+ def array_canonical_name
141
+ elem = base.type_args.first
142
+ return "non-empty-array" if elem.nil?
143
+
144
+ "non-empty-array[#{elem.describe}]"
145
+ end
146
+
147
+ def hash_canonical_name
148
+ key, value = base.type_args
149
+ return "non-empty-hash" if key.nil? || value.nil?
150
+
151
+ "non-empty-hash[#{key.describe}, #{value.describe}]"
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+
5
+ module Rigor
6
+ module Type
7
+ # A bounded integer range carrier. Each bound is either an `Integer`
8
+ # or one of the symbolic infinities `:neg_infinity` / `:pos_infinity`.
9
+ # Inspired by PHPStan's `int<min, max>` family — the named aliases
10
+ # `positive-int` (1..), `non-negative-int` (0..), `negative-int`
11
+ # (..-1), `non-positive-int` (..0) all surface through this single
12
+ # carrier and are recovered in `describe` for human-friendly output.
13
+ #
14
+ # Constraints on construction:
15
+ # - both bounds must be either `Integer` or one of the two infinity
16
+ # sentinels;
17
+ # - if both bounds are concrete, `min <= max` must hold;
18
+ # - the universal case `(-∞, +∞)` is structurally distinct from
19
+ # `Nominal[Integer]` — it carries no extra information today but
20
+ # keeps the carrier closed under range narrowing.
21
+ #
22
+ # Erasure to RBS is always "Integer": RBS itself does not natively
23
+ # express bounded integer ranges.
24
+ class IntegerRange
25
+ NEG_INFINITY = :neg_infinity
26
+ POS_INFINITY = :pos_infinity
27
+ INFINITIES = [NEG_INFINITY, POS_INFINITY].freeze
28
+
29
+ attr_reader :min, :max
30
+
31
+ def initialize(min, max)
32
+ validate_bound!(min, "min")
33
+ validate_bound!(max, "max")
34
+ if min.is_a?(Integer) && max.is_a?(Integer) && min > max
35
+ raise ArgumentError, "IntegerRange requires min (#{min}) <= max (#{max})"
36
+ end
37
+ if min == POS_INFINITY || max == NEG_INFINITY
38
+ raise ArgumentError, "IntegerRange bounds out of order: min=#{min.inspect}, max=#{max.inspect}"
39
+ end
40
+
41
+ @min = min
42
+ @max = max
43
+ freeze
44
+ end
45
+
46
+ def universal?
47
+ min == NEG_INFINITY && max == POS_INFINITY
48
+ end
49
+
50
+ def finite?
51
+ min.is_a?(Integer) && max.is_a?(Integer)
52
+ end
53
+
54
+ def cardinality
55
+ finite? ? (max - min + 1) : Float::INFINITY
56
+ end
57
+
58
+ def covers?(int)
59
+ return false unless int.is_a?(Integer)
60
+
61
+ int.between?(lower, upper)
62
+ end
63
+
64
+ # Returns the lower bound as a numeric (with `-Float::INFINITY` for
65
+ # `:neg_infinity`). Use this in arithmetic comparisons; never compare
66
+ # `:neg_infinity` directly with an `Integer`.
67
+ def lower
68
+ min == NEG_INFINITY ? -Float::INFINITY : min
69
+ end
70
+
71
+ def upper
72
+ max == POS_INFINITY ? Float::INFINITY : max
73
+ end
74
+
75
+ ALIAS_NAMES = {
76
+ [NEG_INFINITY, POS_INFINITY] => "int",
77
+ [1, POS_INFINITY] => "positive-int",
78
+ [0, POS_INFINITY] => "non-negative-int",
79
+ [NEG_INFINITY, -1] => "negative-int",
80
+ [NEG_INFINITY, 0] => "non-positive-int"
81
+ }.freeze
82
+
83
+ def describe(_verbosity = :short)
84
+ ALIAS_NAMES[[min, max]] || generic_description
85
+ end
86
+
87
+ def generic_description
88
+ return "int<#{min}, max>" if max == POS_INFINITY
89
+ return "int<min, #{max}>" if min == NEG_INFINITY
90
+
91
+ "int<#{min}, #{max}>"
92
+ end
93
+
94
+ def erase_to_rbs
95
+ "Integer"
96
+ end
97
+
98
+ def top
99
+ Trinary.no
100
+ end
101
+
102
+ def bot
103
+ Trinary.no
104
+ end
105
+
106
+ def dynamic
107
+ Trinary.no
108
+ end
109
+
110
+ def accepts(other, mode: :gradual)
111
+ Inference::Acceptance.accepts(self, other, mode: mode)
112
+ end
113
+
114
+ def ==(other)
115
+ other.is_a?(IntegerRange) && min == other.min && max == other.max
116
+ end
117
+ alias eql? ==
118
+
119
+ def hash
120
+ [IntegerRange, min, max].hash
121
+ end
122
+
123
+ def inspect
124
+ "#<Rigor::Type::IntegerRange #{describe(:short)}>"
125
+ end
126
+
127
+ private
128
+
129
+ def validate_bound!(bound, label)
130
+ return if bound.is_a?(Integer) || INFINITIES.include?(bound)
131
+
132
+ raise ArgumentError,
133
+ "IntegerRange #{label} must be Integer or :neg_infinity/:pos_infinity, got #{bound.inspect}"
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+
5
+ module Rigor
6
+ module Type
7
+ # `Intersection[M1, M2, …]` — value set is the meet of every
8
+ # member's value set. The carrier composes refinements that
9
+ # share a base, in particular the catalogued
10
+ # `non-empty-lowercase-string` (= `Difference[String, ""] &
11
+ # Refined[String, :lowercase]`) and
12
+ # `non-empty-uppercase-string` shapes from
13
+ # [`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md).
14
+ # See [ADR-3](docs/adr/3-type-representation.md) for the
15
+ # OQ3 working decision and the rationale for keeping
16
+ # Intersection a thin wrapper rather than per-shape carriers.
17
+ #
18
+ # Construction MUST go through `Type::Combinator.intersection`
19
+ # (or the per-name factories
20
+ # `Combinator.non_empty_lowercase_string` /
21
+ # `Combinator.non_empty_uppercase_string`). The factory:
22
+ #
23
+ # - flattens nested intersections,
24
+ # - drops `Top` members (Top is the identity of intersection),
25
+ # - collapses to `Bot` if any member is `Bot` (Bot is absorbing),
26
+ # - deduplicates structurally-equal members,
27
+ # - sorts the surviving members by `describe(:short)` so two
28
+ # structurally-equal intersections built in different orders
29
+ # compare equal,
30
+ # - returns `Top` for the empty intersection,
31
+ # - returns the lone member for a 1-element intersection (so
32
+ # the carrier is never inhabited by a degenerate single-member
33
+ # shape).
34
+ #
35
+ # Direct `.new` callers MUST pass an already-normalised member
36
+ # list and are expected to be tests or the combinator itself.
37
+ class Intersection
38
+ attr_reader :members
39
+
40
+ def initialize(members)
41
+ @members = members.dup.freeze
42
+ freeze
43
+ end
44
+
45
+ def describe(verbosity = :short)
46
+ named = canonical_name
47
+ return named if named
48
+
49
+ members.map { |m| m.describe(verbosity) }.join(" & ")
50
+ end
51
+
52
+ # An intersection of refinements over the same base type
53
+ # erases to that base. We use the first member's erasure
54
+ # because the v0.0.4 catalogue (`non-empty-lowercase-string`
55
+ # etc.) is restricted to same-base composition; richer
56
+ # cross-base intersections will need a stricter erasure
57
+ # rule (likely "lowest common ancestor" via the inference
58
+ # engine's class hierarchy).
59
+ def erase_to_rbs
60
+ members.first.erase_to_rbs
61
+ end
62
+
63
+ def top
64
+ Trinary.no
65
+ end
66
+
67
+ def bot
68
+ Trinary.no
69
+ end
70
+
71
+ def dynamic
72
+ Trinary.no
73
+ end
74
+
75
+ def accepts(other, mode: :gradual)
76
+ Inference::Acceptance.accepts(self, other, mode: mode)
77
+ end
78
+
79
+ def ==(other)
80
+ other.is_a?(Intersection) && members == other.members
81
+ end
82
+ alias eql? ==
83
+
84
+ def hash
85
+ [Intersection, members].hash
86
+ end
87
+
88
+ def inspect
89
+ "#<Rigor::Type::Intersection #{describe(:short)}>"
90
+ end
91
+
92
+ private
93
+
94
+ # Maps a structurally-recognised composite shape to its
95
+ # kebab-case canonical name. The recognised set is kept in
96
+ # sync with the imported-built-in catalogue
97
+ # ([`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md)).
98
+ #
99
+ # Detection is order-independent — `Combinator.intersection`
100
+ # sorts the canonical member list, but reading the registry
101
+ # the other way around (a user-authored Intersection built
102
+ # in any order) MUST still print in its canonical spelling.
103
+ def canonical_name
104
+ return nil unless members.size == 2
105
+
106
+ bases = members.map { |m| canonical_role(m) }.compact
107
+ return nil unless bases.size == 2
108
+
109
+ roles = bases.sort
110
+ case roles
111
+ when %w[lowercase non_empty_string] then "non-empty-lowercase-string"
112
+ when %w[non_empty_string uppercase] then "non-empty-uppercase-string"
113
+ end
114
+ end
115
+
116
+ # Returns a stable role tag for the recognised composite
117
+ # members so `canonical_name` can pattern-match on a sorted
118
+ # role pair regardless of construction order. Returns nil
119
+ # when the member is not part of any catalogued composite —
120
+ # any nil contribution disqualifies the canonical-name path
121
+ # and the operator-form fallback kicks in.
122
+ def canonical_role(member)
123
+ case member
124
+ when Difference
125
+ "non_empty_string" if member == Type::Combinator.non_empty_string
126
+ when Refined
127
+ case member
128
+ when Type::Combinator.lowercase_string then "lowercase"
129
+ when Type::Combinator.uppercase_string then "uppercase"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end