rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hkt_body"
4
+ require_relative "../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ # ADR-20 slice 2b — parses the body of an
9
+ # `HktRegistry::Definition` (a `String`, as populated by
10
+ # Slice 1's `HktDirectives.parse_define` from
11
+ # `%a{rigor:v1:hkt_define}` payloads) into the `HktBody`
12
+ # node tree the Slice 2a reducer evaluates against.
13
+ #
14
+ # The minimum-viable grammar covered here is the
15
+ # union-of-atoms-and-parameterised-forms subset of ADR-20
16
+ # § D3 — sufficient for `JSON.parse`'s `json::value`
17
+ # recursive sum and for any other recursive-data-shape
18
+ # signatures (Lisp value trees, dry-types refinements
19
+ # without conditionals). The conditional / indexed-access
20
+ # forms (`E <: T ? A : B`, `E in [k1, k2]`) drafted in D3
21
+ # remain a follow-up slice — bodies that contain `?`
22
+ # raise `ParseError` and the calling directive parser
23
+ # drops the body_tree (the body String remains stored and
24
+ # the reducer falls back to `app.bound`).
25
+ #
26
+ # ## Grammar (slice 2b)
27
+ #
28
+ # body := union
29
+ # union := type_expr ("|" type_expr)*
30
+ # type_expr := atom | nominal_app | app_ref | param
31
+ # atom := "nil" | "true" | "false" | "bool" | "untyped"
32
+ # param := UCNAME (when UCNAME ∈ params)
33
+ # nominal_app := class_name ("[" type_expr ("," type_expr)* "]")?
34
+ # class_name := "::"? UCNAME ("::" UCNAME)*
35
+ # app_ref := "App" "[" uri "," type_expr ("," type_expr)* "]"
36
+ # uri := IDENT ("::" IDENT)+
37
+ # UCNAME := /[A-Z]\w*/
38
+ # IDENT := /[a-z_]\w*/
39
+ #
40
+ # ## Disambiguation
41
+ #
42
+ # The same syntactic UCNAME spells both a parameter
43
+ # reference (`K` when `params = [:K]`) and a nominal class
44
+ # name (`Integer`). The parser resolves by checking the
45
+ # `params` set passed to {.parse}; an unknown UCNAME is
46
+ # treated as a nominal class name. `App` is reserved at
47
+ # the head position of an `App[...]` form; using `App` as
48
+ # a class name is therefore not supported.
49
+ #
50
+ # Atoms are kept verbatim as `HktBody::TypeLeaf` nodes
51
+ # wrapping the appropriate `Rigor::Type::*` carrier:
52
+ # `nil` / `true` / `false` produce `Constant` carriers;
53
+ # `bool` produces the `Constant<true> | Constant<false>`
54
+ # union; `untyped` produces `Combinator.untyped`
55
+ # (i.e. `Dynamic[Top]`). Nominal class names produce raw
56
+ # `Type::Nominal` carriers (no `name_scope` resolution at
57
+ # this slice — the reducer trusts the name verbatim).
58
+ module HktBodyParser
59
+ class ParseError < StandardError; end
60
+
61
+ module_function
62
+
63
+ def parse(string, params:)
64
+ raise ArgumentError, "string must be a String, got #{string.class}" unless string.is_a?(String)
65
+ raise ArgumentError, "params must be an Array, got #{params.class}" unless params.is_a?(Array)
66
+
67
+ params_set = params.to_h { |p| [p.to_sym, true] }
68
+ tokens = Tokenizer.new(string).tokenize
69
+ parser = Parser.new(tokens, params_set)
70
+ result = parser.parse_union
71
+ parser.expect_eof!
72
+ result
73
+ end
74
+
75
+ Token = Data.define(:kind, :value, :pos)
76
+
77
+ class Tokenizer
78
+ SCANNER_REGEX = /
79
+ \G
80
+ (?:
81
+ (?<ws>\s+)
82
+ | (?<lb>\[)
83
+ | (?<rb>\])
84
+ | (?<lparen>\()
85
+ | (?<rparen>\))
86
+ | (?<comma>,)
87
+ | (?<pipe>\|)
88
+ | (?<sub><:)
89
+ | (?<eq>==)
90
+ | (?<sep>::)
91
+ | (?<colon>:(?!:))
92
+ | (?<question>\?)
93
+ | (?<ident>[a-z_][a-zA-Z0-9_]*)
94
+ | (?<ucname>[A-Z][a-zA-Z0-9_]*)
95
+ )
96
+ /x
97
+
98
+ def initialize(string)
99
+ @string = string
100
+ end
101
+
102
+ TOKEN_KINDS = SCANNER_REGEX.named_captures.keys.freeze
103
+ private_constant :TOKEN_KINDS
104
+
105
+ def tokenize
106
+ tokens = []
107
+ pos = 0
108
+ while pos < @string.size
109
+ match = SCANNER_REGEX.match(@string, pos)
110
+ raise ParseError, "unexpected character at position #{pos}: #{@string[pos].inspect}" if match.nil?
111
+
112
+ kind = TOKEN_KINDS.find { |k| match[k] }
113
+ raise ParseError, "internal tokenizer error at position #{pos}" if kind.nil?
114
+
115
+ value = match[kind.to_sym]
116
+ raise ParseError, "internal tokenizer error: no match for #{kind}" if value.nil?
117
+
118
+ pos += value.size
119
+ next if kind == "ws"
120
+
121
+ tokens << Token.new(kind: kind.to_sym, value: value, pos: pos - value.size)
122
+ end
123
+ tokens
124
+ end
125
+ end
126
+
127
+ class Parser
128
+ def initialize(tokens, params_set)
129
+ @tokens = tokens
130
+ @pos = 0
131
+ @params_set = params_set
132
+ end
133
+
134
+ def parse_union
135
+ arms = [parse_type_expr]
136
+ while peek_kind == :pipe
137
+ consume
138
+ arms << parse_type_expr
139
+ end
140
+ return arms.first if arms.size == 1
141
+
142
+ HktBody::Union.new(arms: arms)
143
+ end
144
+
145
+ def parse_type_expr
146
+ tok = peek
147
+ raise ParseError, "unexpected end of input; expected type expression" if tok.nil?
148
+
149
+ case tok.kind
150
+ when :lparen then parse_conditional
151
+ when :ident then parse_lowercase_atom
152
+ when :ucname then parse_ucname_form
153
+ when :sep then parse_classname_with_leading_sep
154
+ else
155
+ raise ParseError, "unexpected token #{tok.kind} (#{tok.value.inspect}) at position #{tok.pos}"
156
+ end
157
+ end
158
+
159
+ # ADR-20 § D3 conditional parser. Grammar:
160
+ #
161
+ # conditional := "(" test "?" union ":" union ")"
162
+ # test := type_expr ("<:" | "==") type_expr
163
+ #
164
+ # Parens delimit a conditional unambiguously — bare
165
+ # `(type_expr)` grouping is not supported at this slice
166
+ # (no use case yet). Branches can be unions; test sides
167
+ # are single arms (wrap in `App[my_union, ...]` if you
168
+ # need a union there). `in [opt1, opt2]` membership
169
+ # tests are programmatically supported via
170
+ # `HktBody::TestMembership` but the parser does not yet
171
+ # recognise the `in` keyword (no concrete demand yet).
172
+ def parse_conditional
173
+ expect!(:lparen)
174
+ test = parse_test
175
+ expect!(:question)
176
+ then_branch = parse_union
177
+ expect!(:colon)
178
+ else_branch = parse_union
179
+ expect!(:rparen)
180
+ HktBody::Conditional.new(test: test, then_branch: then_branch, else_branch: else_branch)
181
+ end
182
+
183
+ def parse_test
184
+ left = parse_type_expr
185
+ op = peek
186
+ case op&.kind
187
+ when :sub
188
+ consume
189
+ HktBody::TestSubtype.new(left: left, right: parse_type_expr)
190
+ when :eq
191
+ consume
192
+ HktBody::TestEquality.new(left: left, right: parse_type_expr)
193
+ when :ident
194
+ parse_in_membership(left, op_token: op)
195
+ else
196
+ actual = op.nil? ? "end of input" : "#{op.kind} (#{op.value.inspect})"
197
+ raise ParseError, "expected `<:`, `==`, or `in` in conditional test, got #{actual}"
198
+ end
199
+ end
200
+
201
+ # `left in [opt1, opt2, ...]` membership test.
202
+ # Distinguished from a lowercase atom by the
203
+ # subsequent `[` — the only place an identifier
204
+ # `in` is permitted at this position is membership
205
+ # syntax.
206
+ def parse_in_membership(left, op_token:)
207
+ unless op_token.value == "in"
208
+ raise ParseError,
209
+ "expected `<:`, `==`, or `in` in conditional test, got ident (#{op_token.value.inspect})"
210
+ end
211
+
212
+ consume # in
213
+ expect!(:lb)
214
+ options = [parse_type_expr]
215
+ while peek_kind == :comma
216
+ consume
217
+ options << parse_type_expr
218
+ end
219
+ expect!(:rb)
220
+ HktBody::TestMembership.new(left: left, options: options)
221
+ end
222
+
223
+ def parse_lowercase_atom
224
+ tok = consume
225
+ type = case tok.value
226
+ when "nil" then Type::Constant.new(nil)
227
+ when "true" then Type::Constant.new(true)
228
+ when "false" then Type::Constant.new(false)
229
+ when "bool" then Type::Combinator.union(Type::Constant.new(true), Type::Constant.new(false))
230
+ when "untyped" then Type::Combinator.untyped
231
+ else raise ParseError, "unknown atom #{tok.value.inspect} at position #{tok.pos}"
232
+ end
233
+ HktBody::TypeLeaf.new(type: type)
234
+ end
235
+
236
+ def parse_ucname_form
237
+ tok = peek
238
+ return parse_app_ref if tok.value == "App"
239
+
240
+ if @params_set.key?(tok.value.to_sym) && !class_continuation?
241
+ consume
242
+ return HktBody::Param.new(name: tok.value.to_sym)
243
+ end
244
+
245
+ parse_nominal_or_param_with_args
246
+ end
247
+
248
+ # Returns true when the current UCName is followed by
249
+ # `::` (qualified class name continuation) or `[`
250
+ # (parameterised application). In either case the
251
+ # token is a nominal, not a param ref — Slice 2b's
252
+ # `Param` nodes are always single bare identifiers.
253
+ def class_continuation?
254
+ next_tok = @tokens[@pos + 1]
255
+ next_tok && %i[sep lb].include?(next_tok.kind)
256
+ end
257
+
258
+ def parse_nominal_or_param_with_args
259
+ class_name = parse_class_name
260
+ if peek_kind == :lb
261
+ consume
262
+ args = parse_arg_list
263
+ expect!(:rb)
264
+ HktBody::NominalApp.new(class_name: class_name, args: args)
265
+ else
266
+ HktBody::TypeLeaf.new(type: Type::Nominal.new(class_name))
267
+ end
268
+ end
269
+
270
+ def parse_classname_with_leading_sep
271
+ # The leading "::" form (`::Foo::Bar`). Consume the
272
+ # separator so the rest threads through parse_class_name.
273
+ consume
274
+ tok = peek
275
+ raise ParseError, "expected class name after `::`" if tok.nil? || tok.kind != :ucname
276
+
277
+ parse_nominal_or_param_with_args
278
+ end
279
+
280
+ def parse_class_name
281
+ parts = [expect!(:ucname).value]
282
+ while peek_kind == :sep && @tokens[@pos + 1]&.kind == :ucname
283
+ consume # ::
284
+ parts << expect!(:ucname).value
285
+ end
286
+ parts.join("::")
287
+ end
288
+
289
+ def parse_app_ref
290
+ tok = consume
291
+ raise ParseError, "expected `App[...]`, got #{tok.value.inspect}" unless tok.value == "App"
292
+
293
+ expect!(:lb)
294
+ uri = parse_uri
295
+ expect!(:comma)
296
+ args = parse_arg_list
297
+ expect!(:rb)
298
+ HktBody::AppRef.new(uri: uri, args: args)
299
+ end
300
+
301
+ def parse_uri
302
+ parts = [expect!(:ident).value]
303
+ while peek_kind == :sep
304
+ consume
305
+ parts << expect!(:ident).value
306
+ end
307
+ raise ParseError, "uri must be namespaced (`a::b`), got #{parts.first.inspect}" if parts.size < 2
308
+
309
+ parts.join("::").to_sym
310
+ end
311
+
312
+ # Arg list for `Foo[A, B, C]` and `App[uri, A, B]`
313
+ # forms. Each arg is parsed as a union so per-arg
314
+ # `A | B` forms work (`Array[K | nil]`); the COMMA
315
+ # at the top level still separates args, so
316
+ # `Hash[K, V]` reads as two args (each a single-arm
317
+ # union that collapses to the arm) rather than one
318
+ # union of two.
319
+ def parse_arg_list
320
+ args = [parse_union]
321
+ while peek_kind == :comma
322
+ consume
323
+ args << parse_union
324
+ end
325
+ args
326
+ end
327
+
328
+ def expect_eof!
329
+ return if @pos >= @tokens.size
330
+
331
+ tok = @tokens[@pos]
332
+ raise ParseError, "expected end of input, got #{tok.kind} (#{tok.value.inspect}) at position #{tok.pos}"
333
+ end
334
+
335
+ private
336
+
337
+ def peek
338
+ @tokens[@pos]
339
+ end
340
+
341
+ def peek_kind
342
+ @tokens[@pos]&.kind
343
+ end
344
+
345
+ def consume
346
+ tok = @tokens[@pos]
347
+ @pos += 1
348
+ tok
349
+ end
350
+
351
+ def expect!(kind)
352
+ tok = @tokens[@pos]
353
+ if tok.nil? || tok.kind != kind
354
+ actual = tok.nil? ? "end of input" : "#{tok.kind} (#{tok.value.inspect})"
355
+ raise ParseError, "expected #{kind}, got #{actual}"
356
+ end
357
+ @pos += 1
358
+ tok
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hkt_body"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # ADR-20 Slice 2a — reducer that walks a `Definition`'s
8
+ # `body_tree` against a concrete `Type::App` and returns a
9
+ # fully-typed `Rigor::Type`.
10
+ #
11
+ # Reduction is the operational interpretation of ADR-20
12
+ # § D4 ("Evaluation rules"):
13
+ #
14
+ # 1. **Resolve `F`.** Look up the registered body via
15
+ # `registry.definition(uri)`.
16
+ # 2. **Substitute arguments.** Walk the body tree, replacing
17
+ # `{HktBody::Param}` nodes with the matching positional
18
+ # arg from the application.
19
+ # 3. **Build types.** `{HktBody::TypeLeaf}` returns its
20
+ # wrapped type as-is; `{HktBody::Union}` and
21
+ # `{HktBody::NominalApp}` route their reduced children
22
+ # through `Type::Combinator.union` / `.nominal_of` so
23
+ # normalization applies.
24
+ # 4. **Recurse on `{HktBody::AppRef}` nodes.** Reduce the
25
+ # args first; if the resulting `(uri, args)` matches an
26
+ # App already on the current reduction stack, return the
27
+ # in-progress `Type::App` carrier as-is (lazy
28
+ # self-reference handling — the standard "tying the
29
+ # knot" trick for recursive type aliases like
30
+ # `json::value`). Otherwise build a fresh
31
+ # `Type::App` and recursively reduce it against the
32
+ # same registry, sharing the fuel budget.
33
+ # 5. **Fuel budget.** Each visited node consumes one unit.
34
+ # On exhaustion, reduction unwinds to `app.bound`.
35
+ #
36
+ # The reducer is **pure** with respect to its inputs (the
37
+ # registry + the App) but uses a per-call mutable state
38
+ # bag for fuel + cycle tracking. Concurrent reductions
39
+ # MUST allocate fresh reducers (or fresh `_reduce` calls)
40
+ # — the per-call state is not shared.
41
+ class HktReducer
42
+ DEFAULT_FUEL = 64
43
+
44
+ class FuelExhausted < StandardError; end
45
+
46
+ def initialize(registry)
47
+ raise ArgumentError, "registry must be an HktRegistry" unless registry.is_a?(HktRegistry)
48
+
49
+ @registry = registry
50
+ end
51
+
52
+ # Reduce `app` against the registry.
53
+ #
54
+ # @param app [Rigor::Type::App]
55
+ # @param fuel [Integer] reduction-step budget (default 64
56
+ # per ADR-20 WD3). Each visited body node costs one
57
+ # unit. On exhaustion the reduction returns `app.bound`.
58
+ # @return [Rigor::Type] the reduced type, or `app.bound`
59
+ # when reduction is impossible (URI not defined, arity
60
+ # mismatch, body_tree absent, fuel exhausted).
61
+ def reduce(app, fuel: DEFAULT_FUEL)
62
+ raise ArgumentError, "expected a Rigor::Type::App, got #{app.class}" unless app.is_a?(Type::App)
63
+
64
+ definition = @registry.definition(app.uri)
65
+ return app.bound if definition.nil? || definition.body_tree.nil?
66
+ return app.bound if definition.params.size != app.args.size
67
+
68
+ state = State.new(fuel: fuel)
69
+ begin
70
+ state.with_in_progress(app.uri, app.args, app) do
71
+ walk(definition.body_tree, bindings: bindings_for(definition, app.args), state: state) || app.bound
72
+ end
73
+ rescue FuelExhausted
74
+ app.bound
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def bindings_for(definition, args)
81
+ definition.params.zip(args).to_h
82
+ end
83
+
84
+ def walk(node, bindings:, state:)
85
+ state.consume_fuel!
86
+
87
+ case node
88
+ when HktBody::TypeLeaf
89
+ node.type
90
+ when HktBody::Param
91
+ bindings.fetch(node.name) do
92
+ raise ArgumentError, "unknown param #{node.name.inspect}; declared: #{bindings.keys}"
93
+ end
94
+ when HktBody::Union
95
+ reduced = node.arms.map { |arm| walk(arm, bindings: bindings, state: state) }
96
+ Type::Combinator.union(*reduced)
97
+ when HktBody::NominalApp
98
+ reduced_args = node.args.map { |arg| walk(arg, bindings: bindings, state: state) }
99
+ Type::Combinator.nominal_of(node.class_name, type_args: reduced_args)
100
+ when HktBody::AppRef
101
+ reduced_args = node.args.map { |arg| walk(arg, bindings: bindings, state: state) }
102
+ reduce_app_ref(node.uri, reduced_args, state: state)
103
+ when HktBody::Conditional
104
+ walk_conditional(node, bindings: bindings, state: state)
105
+ else
106
+ raise ArgumentError, "unknown body node: #{node.class}"
107
+ end
108
+ end
109
+
110
+ # ADR-20 § D3 conditional reduction. Resolves the test
111
+ # against the current bindings and picks a branch:
112
+ #
113
+ # - test = `yes` → return the reduced `then_branch`.
114
+ # - test = `no` → return the reduced `else_branch`.
115
+ # - test = `maybe` → widen to the union of both
116
+ # reduced branches (per ADR-20 WD7).
117
+ def walk_conditional(node, bindings:, state:)
118
+ verdict = evaluate_test(node.test, bindings: bindings, state: state)
119
+ case verdict
120
+ when :yes
121
+ walk(node.then_branch, bindings: bindings, state: state)
122
+ when :no
123
+ walk(node.else_branch, bindings: bindings, state: state)
124
+ else
125
+ # `:maybe` — widen to the union of both branches.
126
+ then_t = walk(node.then_branch, bindings: bindings, state: state)
127
+ else_t = walk(node.else_branch, bindings: bindings, state: state)
128
+ Type::Combinator.union(then_t, else_t)
129
+ end
130
+ end
131
+
132
+ def evaluate_test(test, bindings:, state:)
133
+ case test
134
+ when HktBody::TestSubtype
135
+ left = walk(test.left, bindings: bindings, state: state)
136
+ right = walk(test.right, bindings: bindings, state: state)
137
+ subtype_verdict(left, right)
138
+ when HktBody::TestEquality
139
+ left = walk(test.left, bindings: bindings, state: state)
140
+ right = walk(test.right, bindings: bindings, state: state)
141
+ equality_verdict(left, right)
142
+ when HktBody::TestMembership
143
+ left = walk(test.left, bindings: bindings, state: state)
144
+ options = test.options.map { |o| walk(o, bindings: bindings, state: state) }
145
+ membership_verdict(left, options)
146
+ else
147
+ raise ArgumentError, "unknown test node: #{test.class}"
148
+ end
149
+ end
150
+
151
+ # `left <: right`. Slice 7a verdict policy: structural
152
+ # equality is `:yes`; clearly-disjoint nominal /
153
+ # constant pairs are `:no`; everything else is `:maybe`
154
+ # (widens to the union per ADR-20 WD7 — robustness
155
+ # principle keeps us conservative on undecided tests).
156
+ def subtype_verdict(left, right)
157
+ return :yes if left == right
158
+
159
+ # Both Nominals with different class names AND
160
+ # neither carries Dynamic — `:no` (statically
161
+ # disjoint). For any other shape pair, `:maybe`.
162
+ return :no if disjoint_nominals?(left, right)
163
+ return :no if disjoint_constants?(left, right)
164
+
165
+ :maybe
166
+ end
167
+
168
+ # Structural equality verdict — same shape as
169
+ # subtype_verdict but symmetric.
170
+ def equality_verdict(left, right)
171
+ return :yes if left == right
172
+ return :no if disjoint_nominals?(left, right)
173
+ return :no if disjoint_constants?(left, right)
174
+
175
+ :maybe
176
+ end
177
+
178
+ def membership_verdict(left, options)
179
+ verdicts = options.map { |o| equality_verdict(left, o) }
180
+ return :yes if verdicts.include?(:yes)
181
+ return :no if verdicts.all? { |v| v == :no }
182
+
183
+ :maybe
184
+ end
185
+
186
+ def disjoint_nominals?(left, right)
187
+ return false unless left.is_a?(Type::Nominal) && right.is_a?(Type::Nominal)
188
+
189
+ left.class_name != right.class_name
190
+ end
191
+
192
+ def disjoint_constants?(left, right)
193
+ return false unless left.is_a?(Type::Constant) && right.is_a?(Type::Constant)
194
+
195
+ left.value != right.value
196
+ end
197
+
198
+ def reduce_app_ref(uri, reduced_args, state:)
199
+ # Cycle detection — when the same `(uri, args)` is
200
+ # already on the reduction stack, return the
201
+ # in-progress App carrier as-is so recursive type
202
+ # aliases (`Array[App[json::value, K]]` inside the
203
+ # `json::value` body) terminate.
204
+ existing = state.in_progress_for(uri, reduced_args)
205
+ return existing if existing
206
+
207
+ registration = @registry.registration(uri)
208
+ bound = registration&.bound || Type::Combinator.untyped
209
+ new_app = Type::App.new(uri, reduced_args, bound: bound)
210
+
211
+ definition = @registry.definition(uri)
212
+ return new_app if definition.nil? || definition.body_tree.nil?
213
+ return new_app if definition.params.size != reduced_args.size
214
+
215
+ state.with_in_progress(uri, reduced_args, new_app) do
216
+ walk(definition.body_tree, bindings: bindings_for(definition, reduced_args), state: state) || new_app
217
+ end
218
+ end
219
+
220
+ # Per-call mutable bag carrying the remaining fuel
221
+ # budget and the active reduction stack (for cycle
222
+ # detection). Not shared across `reduce` calls.
223
+ class State
224
+ def initialize(fuel:)
225
+ @fuel = fuel
226
+ @in_progress = {}
227
+ end
228
+
229
+ def consume_fuel!
230
+ raise FuelExhausted if @fuel <= 0
231
+
232
+ @fuel -= 1
233
+ end
234
+
235
+ def in_progress_for(uri, args)
236
+ @in_progress[[uri, args]]
237
+ end
238
+
239
+ def with_in_progress(uri, args, app)
240
+ key = [uri, args]
241
+ previous = @in_progress[key]
242
+ @in_progress[key] = app
243
+ begin
244
+ yield
245
+ ensure
246
+ if previous.nil?
247
+ @in_progress.delete(key)
248
+ else
249
+ @in_progress[key] = previous
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end