autotype 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4613 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Syntax-less Ruby type inference engine.
4
+ #
5
+ # This is deliberately intraprocedural. Each method is analyzed independently:
6
+ # parameters and unknown values receive fresh type variables, message sends add
7
+ # structural capabilities, and the method boundary generalizes the variables.
8
+
9
+ require "json"
10
+ require "optparse"
11
+ require "prism"
12
+ require "set"
13
+ require "cgi"
14
+ require "digest"
15
+ begin
16
+ require "dotenv/load"
17
+ rescue LoadError
18
+ end
19
+ require "faraday"
20
+ require "async"
21
+ require "async/semaphore"
22
+
23
+ module Autotype
24
+ Named = Data.define(:name)
25
+ Generic = Data.define(:name, :arguments)
26
+ Union = Data.define(:members)
27
+ Function = Data.define(:parameters, :result)
28
+ Capability = Data.define(:receiver, :message, :arguments, :keywords, :result, :line)
29
+ Parameter = Data.define(:name, :type, :kind)
30
+ # A helper-type argument for consolidated definitions: a normalized slot
31
+ # string whose T-references are filled from the occurrence's own arguments.
32
+ TemplateArgument = Data.define(:template, :arguments)
33
+ MethodResult = Data.define(
34
+ :name, :owner, :kind, :method_name, :superclass, :line, :end_line,
35
+ :parameters, :result, :capabilities, :self_type, :ivars, :locals, :port_assignments,
36
+ :case_narrowings
37
+ )
38
+
39
+ class InferenceMetadata
40
+ PortWiring = Data.define(:handler_method, :dispatch_param, :dispatch_param_type, :entity_param)
41
+ OutputEmitWiring = Data.define(:message, :port_keyword, :argument_index)
42
+ ConfigHashWiring = Data.define(:init_param, :reader_method, :ivar)
43
+
44
+ attr_reader :member_types, :ports, :config_options, :structured_owners, :referenced_types,
45
+ :member_type_hints, :option_type_hints, :side_effect_methods,
46
+ :structured_type_prefixes, :port_wiring, :output_emit, :config_hash,
47
+ :framework_self_fallbacks, :type_locators
48
+
49
+ def self.empty
50
+ new
51
+ end
52
+
53
+ def self.from_collector(collector, **overrides)
54
+ new(
55
+ member_types: deep_copy_nested(collector.declared_member_types),
56
+ ports: deep_copy_ports(collector.declared_ports),
57
+ config_options: deep_copy_nested(collector.declared_config_options),
58
+ structured_owners: collector.structured_owners.dup,
59
+ referenced_types: collector.referenced_types.dup,
60
+ **overrides
61
+ )
62
+ end
63
+
64
+ def self.deep_copy_nested(source)
65
+ source.transform_values(&:dup)
66
+ end
67
+
68
+ def self.deep_copy_ports(source)
69
+ source.transform_values do |ports|
70
+ { inputs: ports[:inputs].dup, outputs: ports[:outputs].dup }
71
+ end
72
+ end
73
+
74
+ def initialize(
75
+ member_types: nil,
76
+ ports: nil,
77
+ config_options: nil,
78
+ structured_owners: nil,
79
+ referenced_types: nil,
80
+ member_type_hints: {},
81
+ option_type_hints: {},
82
+ side_effect_methods: nil,
83
+ structured_type_prefixes: [],
84
+ port_wiring: nil,
85
+ output_emit: nil,
86
+ config_hash: nil,
87
+ framework_self_fallbacks: {},
88
+ type_locators: []
89
+ )
90
+ @member_types = member_types || Hash.new { |hash, key| hash[key] = {} }
91
+ @ports = ports || Hash.new { |hash, key| hash[key] = { inputs: {}, outputs: {} } }
92
+ @config_options = config_options || Hash.new { |hash, key| hash[key] = {} }
93
+ @structured_owners = structured_owners || Set.new
94
+ @referenced_types = referenced_types || Set.new
95
+ @member_type_hints = member_type_hints
96
+ @option_type_hints = option_type_hints
97
+ @side_effect_methods = side_effect_methods || Set.new
98
+ @structured_type_prefixes = structured_type_prefixes
99
+ @port_wiring = port_wiring
100
+ @output_emit = output_emit
101
+ @config_hash = config_hash
102
+ @framework_self_fallbacks = framework_self_fallbacks
103
+ @type_locators = type_locators
104
+ end
105
+
106
+ def merge_ports!(source)
107
+ source.each do |owner, ports|
108
+ @ports[owner] ||= { inputs: {}, outputs: {} }
109
+ @ports[owner][:inputs].merge!(ports[:inputs])
110
+ @ports[owner][:outputs].merge!(ports[:outputs])
111
+ end
112
+ end
113
+
114
+ def merge_member_types!(source)
115
+ source.each { |owner, fields| @member_types[owner].merge!(fields) }
116
+ end
117
+
118
+ def merge_config_options!(source)
119
+ source.each { |owner, options| @config_options[owner].merge!(options) }
120
+ end
121
+
122
+ def port_wiring? = @port_wiring && @ports.values.any? { |ports| ports[:inputs].any? }
123
+ def output_emit? = @output_emit && @ports.values.any? { |ports| ports[:outputs].any? }
124
+ def config_hash? = @config_hash && @config_options.values.any?(&:any?)
125
+
126
+ def structured_type?(name)
127
+ return true if @structured_owners.include?(name)
128
+ return true if @member_types.key?(name) && @member_types[name].any?
129
+
130
+ @structured_type_prefixes.any? { |prefix| name.start_with?(prefix) }
131
+ end
132
+
133
+ def locate_type_file(type_name)
134
+ @type_locators.lazy.filter_map { |locator| locator.call(type_name) }.first
135
+ end
136
+ end
137
+
138
+ # Heuristic member-name -> type mappings used when no explicit declaration
139
+ # exists. Common field names across Ruby codebases, not framework-specific.
140
+ CORE_MEMBER_NAME_TYPES = {
141
+ name: "String",
142
+ id: "String",
143
+ text: "String",
144
+ content: "String",
145
+ title: "String",
146
+ url: "String",
147
+ uri: "String",
148
+ email: "String",
149
+ description: "String",
150
+ status: "String",
151
+ type: "String",
152
+ kind: "String",
153
+ key: "String",
154
+ label: "String",
155
+ path: "String",
156
+ token: "String",
157
+ query: "String",
158
+ message: "String",
159
+ body: "String",
160
+ uuid: "String",
161
+ code: "String",
162
+ role: "String",
163
+ object: "String",
164
+ source: "String",
165
+ step: "Integer",
166
+ sequence_id: "Integer",
167
+ count: "Integer",
168
+ size: "Integer",
169
+ page: "Integer",
170
+ per_page: "Integer",
171
+ timeout: "Integer",
172
+ iteration: "Integer",
173
+ status_code: "Integer",
174
+ total_count: "Integer",
175
+ concept_type: "String",
176
+ core_phrase: "String",
177
+ hashed_id: "String",
178
+ collection: "String",
179
+ distance: "Float"
180
+ }.freeze
181
+
182
+ # Kernel module functions invoked without an explicit receiver (`Array(x)`).
183
+ KERNEL_CLASS_CALLS = %i[Array Integer Float String Symbol Hash].freeze
184
+
185
+ # Type names that must not be treated as literal hash key strings.
186
+ RESERVED_TYPE_NAMES = %w[String Symbol Integer Float Rational Complex bool nil Object].freeze
187
+
188
+ class TypeVariable
189
+ attr_reader :scope, :id, :hint
190
+
191
+ def initialize(scope, id, hint = nil)
192
+ @scope = scope
193
+ @id = id
194
+ @hint = hint
195
+ end
196
+
197
+ def eql?(other) = other.is_a?(TypeVariable) && scope.equal?(other.scope) && id == other.id
198
+ def hash = [scope.object_id, id].hash
199
+ end
200
+
201
+ class Analyzer
202
+ attr_reader :capabilities
203
+
204
+ def initialize
205
+ @next_id = 0
206
+ @environment = {}
207
+ @local_bindings = {}
208
+ @block_locals = {}
209
+ @ivars = {}
210
+ @capabilities = []
211
+ @return_types = []
212
+ @port_assignments = []
213
+ @scope = Object.new
214
+ @self_type = fresh("self")
215
+ end
216
+
217
+ def analyze(node, qualified_name, owner:, kind:, superclass:)
218
+ @infer_cache = {}
219
+ @case_narrowings = Hash.new { |hash, key| hash[key] = [] }
220
+ parameters = bind_parameters(node.parameters)
221
+ result = infer(node.body)
222
+ result = Named.new("nil") unless result
223
+ @return_types.each { |return_type| result = union(result, return_type) }
224
+ if @yield_function && parameters.none? { |parameter| parameter.kind == :block }
225
+ parameters += [Parameter.new(name: :block, type: @yield_function, kind: :block)]
226
+ end
227
+
228
+ MethodResult.new(
229
+ name: qualified_name,
230
+ owner: owner,
231
+ kind: kind,
232
+ method_name: node.name,
233
+ superclass: superclass,
234
+ line: node.location.start_line,
235
+ end_line: node.location.end_line,
236
+ parameters: parameters,
237
+ result: result,
238
+ capabilities: capabilities,
239
+ self_type: @self_type,
240
+ ivars: @ivars.dup,
241
+ # A name bound both at method level and inside blocks (e.g.
242
+ # `x = nil` reassigned within an each) carries both types.
243
+ locals: @block_locals.merge(@environment) { |_name, inner, outer| union(inner, outer) },
244
+ port_assignments: @port_assignments.dup,
245
+ case_narrowings: @case_narrowings.transform_values { |types| types.uniq }
246
+ )
247
+ end
248
+
249
+ private
250
+
251
+ def fresh(hint = nil)
252
+ variable = TypeVariable.new(@scope, @next_id, hint)
253
+ @next_id += 1
254
+ variable
255
+ end
256
+
257
+ def bind_parameters(parameters_node)
258
+ return [] unless parameters_node
259
+
260
+ parameters = []
261
+ required = parameters_node.requireds + parameters_node.posts
262
+ required.each { |node| parameters << bind_parameter(node.name, :required) }
263
+ parameters_node.optionals.each do |node|
264
+ parameters << bind_parameter(node.name, :optional, default_node: node.value)
265
+ end
266
+
267
+ if parameters_node.rest
268
+ name = parameters_node.rest.name || :anonymous_rest
269
+ element = fresh(name)
270
+ @environment[name] = Generic.new("Array", [element])
271
+ parameters << Parameter.new(name: name, type: @environment[name], kind: :rest)
272
+ end
273
+
274
+ parameters_node.keywords.each do |node|
275
+ kind = node.is_a?(Prism::OptionalKeywordParameterNode) ? :optional_keyword : :keyword
276
+ default_node = node.value if node.is_a?(Prism::OptionalKeywordParameterNode)
277
+ parameters << bind_parameter(node.name, kind, default_node: default_node)
278
+ end
279
+
280
+ if parameters_node.keyword_rest
281
+ if parameters_node.keyword_rest.is_a?(Prism::ForwardingParameterNode)
282
+ type = fresh("forwarding")
283
+ parameters << Parameter.new(name: :"...", type: type, kind: :forwarding)
284
+ else
285
+ name = parameters_node.keyword_rest.name || :anonymous_keywords
286
+ key = Named.new("Symbol")
287
+ value = fresh(name)
288
+ @environment[name] = Generic.new("Hash", [key, value])
289
+ parameters << Parameter.new(name: name, type: @environment[name], kind: :keyword_rest)
290
+ end
291
+ end
292
+
293
+ if parameters_node.block
294
+ name = parameters_node.block.name || :anonymous_block
295
+ type = fresh(name)
296
+ @environment[name] = type
297
+ parameters << Parameter.new(name: name, type: type, kind: :block)
298
+ end
299
+
300
+ parameters
301
+ end
302
+
303
+ def bind_parameter(name, kind, default_node: nil)
304
+ type = if default_node
305
+ default_type = infer(default_node)
306
+ if nil_default_type?(default_type)
307
+ union(fresh(name), default_type)
308
+ else
309
+ default_type
310
+ end
311
+ else
312
+ fresh(name)
313
+ end
314
+ @environment[name] = type
315
+ Parameter.new(name: name, type: type, kind: kind)
316
+ end
317
+
318
+ def nil_default_type?(type)
319
+ type.is_a?(Named) && type.name == "nil"
320
+ end
321
+
322
+ def infer(node)
323
+ return Named.new("nil") unless node
324
+ return @infer_cache[node.object_id] if @infer_cache&.key?(node.object_id)
325
+
326
+ result = infer_node(node)
327
+ @infer_cache[node.object_id] = result if cache_infer_node?(node)
328
+ result
329
+ end
330
+
331
+ def cache_infer_node?(node)
332
+ case node
333
+ when Prism::CallNode, Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode,
334
+ Prism::StringNode, Prism::IntegerNode, Prism::FloatNode, Prism::SymbolNode,
335
+ Prism::InterpolatedSymbolNode, Prism::RegularExpressionNode,
336
+ Prism::TrueNode, Prism::FalseNode, Prism::NilNode, Prism::RangeNode,
337
+ Prism::RationalNode, Prism::ImaginaryNode
338
+ true
339
+ else
340
+ false
341
+ end
342
+ end
343
+
344
+ def infer_node(node)
345
+ case node
346
+ when Prism::StatementsNode
347
+ infer_statements(node)
348
+ when Prism::LocalVariableReadNode
349
+ @environment[node.name] ||= @local_bindings[node.name] ||= fresh(node.name)
350
+ when Prism::LocalVariableWriteNode
351
+ value = infer(node.value)
352
+ @environment[node.name] = value
353
+ @local_bindings[node.name] = value
354
+ when Prism::LocalVariableAndWriteNode, Prism::LocalVariableOrWriteNode,
355
+ Prism::LocalVariableOperatorWriteNode
356
+ current = @environment[node.name] ||= @local_bindings[node.name] ||= fresh(node.name)
357
+ value = infer(node.value)
358
+ @environment[node.name] = union(current, value)
359
+ @local_bindings[node.name] = @environment[node.name]
360
+ when Prism::InstanceVariableReadNode
361
+ @ivars[node.name] ||= fresh(node.name)
362
+ when Prism::InstanceVariableWriteNode
363
+ @ivars[node.name] = infer(node.value)
364
+ when Prism::CallNode
365
+ infer_call(node)
366
+ when Prism::YieldNode
367
+ infer_yield(node)
368
+ when Prism::InterpolatedStringNode
369
+ infer_interpolated_string(node)
370
+ when Prism::StringNode
371
+ Named.new("String")
372
+ when Prism::IntegerNode
373
+ Named.new("Integer")
374
+ when Prism::FloatNode
375
+ Named.new("Float")
376
+ when Prism::RationalNode
377
+ Named.new("Rational")
378
+ when Prism::ImaginaryNode
379
+ Named.new("Complex")
380
+ when Prism::SymbolNode, Prism::InterpolatedSymbolNode
381
+ Named.new("Symbol")
382
+ when Prism::TrueNode, Prism::FalseNode
383
+ Named.new("bool")
384
+ when Prism::NilNode
385
+ Named.new("nil")
386
+ when Prism::ArrayNode
387
+ elements = node.elements.map { |element| infer(element) }
388
+ Generic.new("Array", [union_all(elements)])
389
+ when Prism::HashNode, Prism::KeywordHashNode
390
+ infer_hash(node)
391
+ when Prism::RangeNode
392
+ Generic.new("Range", [union(infer(node.left), infer(node.right))])
393
+ when Prism::IfNode
394
+ infer(node.predicate)
395
+ union(infer(node.statements), infer(node.subsequent))
396
+ when Prism::UnlessNode
397
+ infer(node.predicate)
398
+ union(infer(node.statements), infer(node.else_clause&.statements))
399
+ when Prism::CaseNode
400
+ infer_case(node)
401
+ when Prism::AndNode, Prism::OrNode
402
+ union(infer(node.left), infer(node.right))
403
+ when Prism::ReturnNode
404
+ value = infer(node.arguments)
405
+ @return_types << value
406
+ value
407
+ when Prism::BreakNode, Prism::NextNode
408
+ infer(node.arguments)
409
+ when Prism::ArgumentsNode
410
+ values = node.arguments.map { |argument| infer(argument) }
411
+ values.length == 1 ? values.first : Generic.new("Tuple", values)
412
+ when Prism::ParenthesesNode
413
+ infer(node.body)
414
+ when Prism::BeginNode
415
+ infer_begin(node)
416
+ when Prism::BlockNode
417
+ infer_block(node)
418
+ when Prism::LambdaNode
419
+ infer_lambda(node)
420
+ when Prism::ConstantReadNode, Prism::ConstantPathNode,
421
+ Prism::ConstantPathTargetNode, Prism::ConstantTargetNode
422
+ Named.new("Class[#{node.slice}]")
423
+ when Prism::SelfNode
424
+ @self_type
425
+ when Prism::SourceFileNode, Prism::SourceLineNode, Prism::SourceEncodingNode
426
+ Named.new("String")
427
+ else
428
+ infer_children(node)
429
+ end
430
+ end
431
+
432
+ def infer_statements(node)
433
+ result = Named.new("nil")
434
+ node.body.each { |statement| result = infer(statement) }
435
+ result
436
+ end
437
+
438
+ def infer_call(node)
439
+ receiver =
440
+ if node.receiver
441
+ infer(node.receiver)
442
+ elsif KERNEL_CLASS_CALLS.include?(node.name)
443
+ Named.new("Class[#{node.name}]")
444
+ else
445
+ @self_type
446
+ end
447
+ positional = []
448
+ keywords = {}
449
+
450
+ node.arguments&.arguments&.each do |argument|
451
+ if argument.is_a?(Prism::KeywordHashNode)
452
+ argument.elements.each do |element|
453
+ if element.is_a?(Prism::AssocNode)
454
+ key = element.key.respond_to?(:value) ? element.key.value : element.key.slice
455
+ keywords[key.to_sym] = infer_keyword_argument(element.value)
456
+ else
457
+ positional << infer(element)
458
+ end
459
+ end
460
+ else
461
+ positional << infer_index_argument(node.name, argument)
462
+ end
463
+ end
464
+
465
+ if node.block
466
+ block_type = if node.block.is_a?(Prism::BlockNode)
467
+ infer_block(node.block)
468
+ else
469
+ infer(node.block.expression)
470
+ end
471
+ positional << block_type
472
+ end
473
+ result = fresh(node.name)
474
+ capabilities << Capability.new(
475
+ receiver: receiver,
476
+ message: node.name,
477
+ arguments: positional,
478
+ keywords: keywords,
479
+ result: result,
480
+ line: node.location.start_line
481
+ )
482
+ result
483
+ end
484
+
485
+ def infer_interpolated_string(node)
486
+ node.parts.each do |part|
487
+ next unless part.is_a?(Prism::EmbeddedStatementsNode)
488
+
489
+ value = infer(part.statements)
490
+ capabilities << Capability.new(
491
+ receiver: value,
492
+ message: :to_s,
493
+ arguments: [],
494
+ keywords: {},
495
+ result: Named.new("String"),
496
+ line: part.location.start_line
497
+ )
498
+ end
499
+ Named.new("String")
500
+ end
501
+
502
+ def infer_index_argument(message, node)
503
+ return infer(node) unless message == :[]
504
+
505
+ infer_literal_key(node)
506
+ end
507
+
508
+ def infer_literal_key(node)
509
+ case node
510
+ when Prism::StringNode then Named.new(node.unescaped)
511
+ when Prism::SymbolNode then Named.new(node.unescaped)
512
+ else infer(node)
513
+ end
514
+ end
515
+
516
+ def infer_keyword_argument(node)
517
+ case node
518
+ when Prism::SymbolNode then Named.new(node.unescaped)
519
+ else infer(node)
520
+ end
521
+ end
522
+
523
+ def infer_hash(node)
524
+ keys = []
525
+ values = []
526
+ node.elements.each do |element|
527
+ next unless element.is_a?(Prism::AssocNode)
528
+
529
+ keys << infer(element.key)
530
+ values << infer(element.value)
531
+ end
532
+ Generic.new("Hash", [union_all(keys), union_all(values)])
533
+ end
534
+
535
+ # `yield` gives the method an implicit block parameter; sharing one
536
+ # function type across yields lets call sites flow their block's return
537
+ # type into the yield expression.
538
+ def infer_yield(node)
539
+ arguments = Array(node.arguments&.arguments).map { |argument| infer(argument) }
540
+ @yield_function ||= Function.new(arguments, fresh("yield"))
541
+ @yield_function.result
542
+ end
543
+
544
+ def infer_case(node)
545
+ predicate_name = case_predicate_name(node.predicate)
546
+ result = Named.new("nil")
547
+ node.conditions.each do |when_node|
548
+ next unless when_node.is_a?(Prism::WhenNode)
549
+
550
+ port = when_port_name(when_node)
551
+ record_port_assignment(when_node, port) if predicate_name == :from && port
552
+ saved_environment = @environment.dup
553
+ narrow_case_branch!(node.predicate, when_node)
554
+ result = union(result, infer(when_node.statements))
555
+ @environment = saved_environment
556
+ end
557
+ result = union(result, infer(node.consequent)) if node.consequent
558
+ result
559
+ end
560
+
561
+ def narrow_case_branch!(predicate, when_node)
562
+ branch_type = type_from_when_conditions(when_node.conditions)
563
+ return unless branch_type
564
+
565
+ case predicate
566
+ when Prism::LocalVariableReadNode
567
+ @case_narrowings[predicate.name] << branch_type
568
+ end
569
+ end
570
+
571
+ def type_from_when_conditions(conditions)
572
+ types = conditions.filter_map { |condition| type_from_when_condition(condition) }
573
+ return nil if types.empty?
574
+
575
+ types.reduce { |left, right| union(left, right) }
576
+ end
577
+
578
+ def type_from_when_condition(condition)
579
+ class_name =
580
+ case condition
581
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
582
+ condition.slice.to_s.delete_prefix("::")
583
+ end
584
+ return nil unless class_name
585
+
586
+ case class_name
587
+ when "Hash"
588
+ Generic.new("Hash", [union(Named.new("String"), Named.new("Symbol")), Named.new("Object")])
589
+ when "Array"
590
+ Generic.new("Array", [Named.new("Object")])
591
+ when "String"
592
+ Named.new("String")
593
+ when "Integer"
594
+ Named.new("Integer")
595
+ when "Float"
596
+ Named.new("Float")
597
+ when "Symbol"
598
+ Named.new("Symbol")
599
+ end
600
+ end
601
+
602
+ def case_predicate_name(predicate)
603
+ case predicate
604
+ when Prism::LocalVariableReadNode
605
+ predicate.name
606
+ when Prism::CallNode
607
+ if predicate.name == :to_sym && predicate.receiver.is_a?(Prism::LocalVariableReadNode)
608
+ predicate.receiver.name
609
+ end
610
+ end
611
+ end
612
+
613
+ def when_port_name(when_node)
614
+ condition = when_node.conditions.first
615
+ case condition
616
+ when Prism::SymbolNode then condition.unescaped.to_sym
617
+ when Prism::StringNode then condition.unescaped.to_sym
618
+ when Prism::IntegerNode then condition.value
619
+ end
620
+ end
621
+
622
+ def record_port_assignment(when_node, port)
623
+ assignment = port_assignment_from_statements(when_node.statements)
624
+ return unless assignment
625
+
626
+ @port_assignments << assignment.merge(port: port)
627
+ end
628
+
629
+ def port_assignment_from_statements(statements_node)
630
+ walk = lambda do |node|
631
+ return nil unless node
632
+
633
+ case node
634
+ when Prism::StatementsNode
635
+ node.body.each { |statement| (result = walk.call(statement)) && (return result) }
636
+ when Prism::InstanceVariableWriteNode
637
+ if node.value.is_a?(Prism::LocalVariableReadNode)
638
+ return { param: node.value.name, ivar: node.name }
639
+ end
640
+ when Prism::CallNode
641
+ return { param: node.receiver.name, ivar: nil } if node.receiver.is_a?(Prism::LocalVariableReadNode)
642
+ end
643
+ nil
644
+ end
645
+ walk.call(statements_node)
646
+ end
647
+
648
+ def infer_begin(node)
649
+ result = infer(node.statements)
650
+ rescue_clause = node.rescue_clause
651
+ while rescue_clause
652
+ bind_rescue_reference(rescue_clause)
653
+ result = union(result, infer(rescue_clause.statements))
654
+ rescue_clause = rescue_clause.subsequent
655
+ end
656
+ infer(node.ensure_clause.statements) if node.ensure_clause
657
+ result
658
+ end
659
+
660
+ # `rescue SomeError => e` names an exception whose class is written right
661
+ # there; binding it makes `e.message` and friends resolvable.
662
+ def bind_rescue_reference(clause)
663
+ reference = clause.reference
664
+ return unless reference.is_a?(Prism::LocalVariableTargetNode)
665
+
666
+ exceptions = clause.exceptions.filter_map do |exception|
667
+ case exception
668
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
669
+ Named.new(exception.slice.delete_prefix("::"))
670
+ end
671
+ end
672
+ @environment[reference.name] =
673
+ if exceptions.empty?
674
+ Named.new("StandardError")
675
+ elsif exceptions.length == 1
676
+ exceptions.first
677
+ else
678
+ Union.new(exceptions)
679
+ end
680
+ end
681
+
682
+ def infer_block(node)
683
+ saved_environment = @environment.dup
684
+ parameter_types = []
685
+
686
+ if node.parameters&.parameters
687
+ block_parameters = node.parameters.parameters
688
+ (block_parameters.requireds + block_parameters.posts).each do |parameter|
689
+ parameter_types << bind_block_parameter(parameter)
690
+ end
691
+ end
692
+
693
+ result = infer(node.body)
694
+ capture_block_locals(saved_environment)
695
+ @environment = saved_environment
696
+ Function.new(parameter_types, result)
697
+ end
698
+
699
+ # Block-scoped bindings vanish when the outer environment is restored,
700
+ # but they are exactly what editor hovers need. Only bindings the block
701
+ # introduced or changed are kept, so a later block's view of an outer
702
+ # variable cannot clobber an earlier block's assignment.
703
+ def capture_block_locals(saved_environment)
704
+ @environment.each do |name, type|
705
+ next if saved_environment[name].equal?(type)
706
+
707
+ recorded = @block_locals[name]
708
+ @block_locals[name] = recorded ? union(recorded, type) : type
709
+ end
710
+ end
711
+
712
+ def bind_block_parameter(parameter)
713
+ if parameter.is_a?(Prism::MultiTargetNode)
714
+ members = (parameter.lefts + parameter.rights).map { |member| bind_block_parameter(member) }
715
+ if parameter.rest&.expression
716
+ rest_type = fresh(parameter.rest.expression.name)
717
+ @environment[parameter.rest.expression.name] = Generic.new("Array", [rest_type])
718
+ members << @environment[parameter.rest.expression.name]
719
+ end
720
+ return Generic.new("Tuple", members)
721
+ end
722
+
723
+ type = fresh(parameter.name)
724
+ @environment[parameter.name] = type
725
+ type
726
+ end
727
+
728
+ def infer_lambda(node)
729
+ saved_environment = @environment.dup
730
+ parameters = bind_parameters(node.parameters&.parameters)
731
+ result = infer(node.body)
732
+ capture_block_locals(saved_environment)
733
+ @environment = saved_environment
734
+ Function.new(parameters.map(&:type), result)
735
+ end
736
+
737
+ def infer_children(node)
738
+ result = Named.new("nil")
739
+ node.compact_child_nodes.each { |child| result = infer(child) }
740
+ result
741
+ end
742
+
743
+ def union_all(types)
744
+ types.compact.reduce { |left, right| union(left, right) } || fresh("element")
745
+ end
746
+
747
+ def union(left, right)
748
+ return right unless left
749
+ return left unless right
750
+ return left if left == right
751
+
752
+ members = []
753
+ members.concat(left.members) if left.is_a?(Union)
754
+ members << left unless left.is_a?(Union)
755
+ members.concat(right.members) if right.is_a?(Union)
756
+ members << right unless right.is_a?(Union)
757
+ Union.new(members.uniq)
758
+ end
759
+ end
760
+
761
+ class Collector < Prism::Visitor
762
+ attr_reader :methods, :constants, :includes, :declared_ports, :declared_config_options,
763
+ :declared_member_types, :structured_owners, :referenced_types
764
+
765
+ def initialize(path, profile: nil)
766
+ @path = path
767
+ @profile = profile
768
+ @namespace = []
769
+ @singleton = false
770
+ @methods = []
771
+ @superclasses = {}
772
+ @constants = {}
773
+ @includes = Hash.new { |hash, key| hash[key] = [] }
774
+ @declared_ports = Hash.new { |hash, key| hash[key] = { inputs: {}, outputs: {} } }
775
+ @declared_config_options = Hash.new { |hash, key| hash[key] = {} }
776
+ @declared_member_types = Hash.new { |hash, key| hash[key] = {} }
777
+ @structured_owners = Set.new
778
+ @referenced_types = Set.new
779
+ @structured_field_context = false
780
+ end
781
+
782
+ def singleton_context? = @singleton
783
+ def namespace_empty? = @namespace.empty?
784
+ def structured_field_context? = @structured_field_context
785
+
786
+ def superclass_for(owner)
787
+ @superclasses[owner]
788
+ end
789
+
790
+ def current_owner
791
+ @namespace.empty? ? File.basename(@path, ".rb") : @namespace.join("::")
792
+ end
793
+
794
+ def visit_module_node(node)
795
+ within_namespace(node.constant_path.slice) { super }
796
+ end
797
+
798
+ def visit_class_node(node)
799
+ name = node.constant_path.slice
800
+ @superclasses[qualified_owner(name)] = node.superclass&.slice
801
+ within_namespace(name) { super }
802
+ end
803
+
804
+ def visit_singleton_class_node(node)
805
+ previous = @singleton
806
+ @singleton = node.expression.is_a?(Prism::SelfNode)
807
+ super
808
+ ensure
809
+ @singleton = previous
810
+ end
811
+
812
+ def visit_def_node(node)
813
+ singleton = @singleton || !node.receiver.nil?
814
+ separator = singleton ? "." : "#"
815
+ owner = current_owner
816
+ if node.name == :initialize && !singleton
817
+ methods.reject! do |method|
818
+ method.owner == owner &&
819
+ method.method_name == :initialize &&
820
+ method.capabilities.empty?
821
+ end
822
+ end
823
+ analyzer = Analyzer.new
824
+ methods << analyzer.analyze(
825
+ node,
826
+ "#{owner}#{separator}#{node.name}",
827
+ owner: owner,
828
+ kind: singleton ? :singleton : :instance,
829
+ superclass: @superclasses[owner]
830
+ )
831
+ # A nested def has its own scope and is collected by the normal traversal.
832
+ super
833
+ end
834
+
835
+ def visit_call_node(node)
836
+ synthesize_attribute_methods(node) if node.receiver.nil?
837
+ record_include(node) if node.receiver.nil? && node.name == :include
838
+ @profile&.visit_call_node(self, node)
839
+ super
840
+ end
841
+
842
+ # `Hit = App::Entity.define do ... end`, `Foo = Class.new(Base) do ... end`,
843
+ # and similar block-based definitions are class bodies: methods defined
844
+ # inside belong to the assigned constant, not the enclosing module.
845
+ def visit_constant_write_node(node)
846
+ record_constant(node.name.to_s, node.value)
847
+ within_constant_definition(node.name.to_s, node.value) { super }
848
+ end
849
+
850
+ def visit_constant_path_write_node(node)
851
+ record_constant(node.target.slice, node.value)
852
+ within_constant_definition(node.target.slice, node.value) { super }
853
+ end
854
+
855
+ def symbol_arguments(node)
856
+ Array(node.arguments&.arguments).filter_map do |argument|
857
+ argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
858
+ end
859
+ end
860
+
861
+ def keyword_constant(arguments_node, key)
862
+ return nil unless arguments_node
863
+
864
+ Array(arguments_node.arguments).each do |argument|
865
+ next unless argument.is_a?(Prism::KeywordHashNode)
866
+
867
+ argument.elements.each do |element|
868
+ next unless element.is_a?(Prism::AssocNode)
869
+ next unless element.key.slice.delete_suffix(":").to_sym == key
870
+
871
+ return constant_argument_slice(element.value) || element.value.slice
872
+ end
873
+ end
874
+ nil
875
+ end
876
+
877
+ def core_member_type_hint(member)
878
+ type_name = CORE_MEMBER_NAME_TYPES[member]
879
+ Named.new(type_name) if type_name
880
+ end
881
+
882
+ private
883
+
884
+ def within_constant_definition(name, value, &)
885
+ if value.is_a?(Prism::CallNode) && record_definition_members(name, value) && !value.block
886
+ return yield
887
+ end
888
+ return yield unless value.is_a?(Prism::CallNode) && value.block
889
+
890
+ if value.name == :new && value.receiver&.slice == "Class"
891
+ @superclasses[qualified_owner(name)] = value.arguments&.arguments&.first&.slice
892
+ end
893
+ within_namespace(name, &)
894
+ end
895
+
896
+ # `Entity.define(:url, :title) { ... }`, `Data.define`, and `Struct.new`
897
+ # generate member readers and a keyword constructor. Without synthesizing
898
+ # them, every attribute read in the repo is an unresolvable send.
899
+ def record_definition_members(name, value)
900
+ entity_define = value.name == :define &&
901
+ @profile&.entity_define_receiver?(value.receiver&.slice)
902
+ members =
903
+ if entity_define || value.name == :define && %w[Entity Data].include?(value.receiver&.slice)
904
+ symbol_arguments(value)
905
+ elsif value.name == :new && value.receiver&.slice == "Struct"
906
+ symbol_arguments(value)
907
+ elsif value.name == :define && value.receiver&.slice == "Data"
908
+ symbol_arguments(value)
909
+ end
910
+ return false if members.nil? || members.empty?
911
+
912
+ within_namespace(name) do
913
+ @structured_field_context = entity_define
914
+ @structured_owners << current_owner
915
+ members.each { |member| methods << synthesized_accessor(member, value, writer: false) }
916
+ methods << synthesized_member_initializer(members, value)
917
+ members.each do |member|
918
+ hint = @profile&.member_type_hints&.fetch(member, nil) || core_member_type_hint(member)
919
+ declared_member_types[current_owner][member] = hint if hint
920
+ end
921
+ ensure
922
+ @structured_field_context = false
923
+ end
924
+ true
925
+ end
926
+
927
+ # A Data-style keyword constructor: each keyword parameter shares its
928
+ # type variable with the backing member ivar, so call-site argument
929
+ # types flow into the synthesized readers.
930
+ def synthesized_member_initializer(members, node)
931
+ scope = Object.new
932
+ parameters = []
933
+ ivars = {}
934
+ members.each_with_index do |member, index|
935
+ value = TypeVariable.new(scope, index, member)
936
+ parameters << Parameter.new(name: member, type: value, kind: :keyword)
937
+ ivars[:"@#{member}"] = value
938
+ end
939
+ owner = current_owner
940
+ MethodResult.new(
941
+ name: "#{owner}#initialize",
942
+ owner: owner,
943
+ kind: :instance,
944
+ method_name: :initialize,
945
+ superclass: @superclasses[owner],
946
+ line: node.location.start_line,
947
+ end_line: node.location.start_line,
948
+ parameters: parameters,
949
+ result: TypeVariable.new(scope, members.length, "instance"),
950
+ capabilities: [],
951
+ self_type: TypeVariable.new(scope, members.length + 1, "self"),
952
+ ivars: ivars,
953
+ locals: {},
954
+ port_assignments: [],
955
+ case_narrowings: {}
956
+ )
957
+ end
958
+
959
+ # Constants assigned literals (regexes, frozen hashes, word lists) are
960
+ # ubiquitous; recording their types lets the solver treat `RE.match?(x)`
961
+ # or `PATTERNS.fetch(key)` like sends on the literal itself.
962
+ def record_constant(name, value)
963
+ type = literal_constant_type(value)
964
+ @constants[qualified_owner(name)] = type if type
965
+ end
966
+
967
+ def literal_constant_type(node)
968
+ case node
969
+ when Prism::StringNode, Prism::InterpolatedStringNode then Named.new("String")
970
+ when Prism::SymbolNode then Named.new("Symbol")
971
+ when Prism::IntegerNode then Named.new("Integer")
972
+ when Prism::FloatNode then Named.new("Float")
973
+ when Prism::TrueNode, Prism::FalseNode then Named.new("bool")
974
+ when Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode then Named.new("Regexp")
975
+ when Prism::ArrayNode then literal_array_type(node)
976
+ when Prism::HashNode then literal_hash_type(node)
977
+ when Prism::CallNode then literal_call_type(node)
978
+ end
979
+ end
980
+
981
+ def literal_array_type(node)
982
+ elements = node.elements.map { |element| literal_constant_type(element) }
983
+ return nil if elements.empty? || elements.any?(&:nil?)
984
+
985
+ unique = elements.uniq
986
+ Generic.new("Array", [unique.length == 1 ? unique.first : Union.new(unique)])
987
+ end
988
+
989
+ def literal_hash_type(node)
990
+ keys = []
991
+ values = []
992
+ node.elements.each do |element|
993
+ return nil unless element.is_a?(Prism::AssocNode)
994
+
995
+ keys << literal_constant_type(element.key)
996
+ values << literal_constant_type(element.value)
997
+ end
998
+ return nil if keys.empty? || keys.any?(&:nil?) || values.any?(&:nil?)
999
+
1000
+ key = keys.uniq.length == 1 ? keys.first : Union.new(keys.uniq)
1001
+ value = values.uniq.length == 1 ? values.first : Union.new(values.uniq)
1002
+ Generic.new("Hash", [key, value])
1003
+ end
1004
+
1005
+ def literal_call_type(node)
1006
+ if %i[freeze dup].include?(node.name) && node.receiver
1007
+ literal_constant_type(node.receiver)
1008
+ elsif %i[new create_many].include?(node.name) && %w[Fast::Regexp Regexp].include?(node.receiver&.slice)
1009
+ Named.new("Regexp")
1010
+ elsif node.name == :to_set && node.receiver
1011
+ inner = literal_constant_type(node.receiver)
1012
+ Generic.new("Set", inner.arguments) if inner.is_a?(Generic) && inner.name == "Array"
1013
+ end
1014
+ end
1015
+
1016
+ # attr_reader/attr_writer/attr_accessor define real methods, so synthesize
1017
+ # entries for them. Their types are tied to the backing instance variable,
1018
+ # which lets the solver connect `@foo = ...` in one method to `foo` reads
1019
+ # everywhere else.
1020
+ def synthesize_attribute_methods(node)
1021
+ return unless %i[attr_reader attr_writer attr_accessor].include?(node.name)
1022
+
1023
+ names = Array(node.arguments&.arguments).filter_map do |argument|
1024
+ argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
1025
+ end
1026
+ names.each do |name|
1027
+ methods << synthesized_accessor(name, node, writer: false) unless node.name == :attr_writer
1028
+ methods << synthesized_accessor(name, node, writer: true) unless node.name == :attr_reader
1029
+ end
1030
+ end
1031
+
1032
+ def synthesized_accessor(name, node, writer:)
1033
+ scope = Object.new
1034
+ value = TypeVariable.new(scope, 0, name)
1035
+ owner = current_owner
1036
+ method_name = writer ? :"#{name}=" : name
1037
+ MethodResult.new(
1038
+ name: "#{owner}#{@singleton ? "." : "#"}#{method_name}",
1039
+ owner: owner,
1040
+ kind: @singleton ? :singleton : :instance,
1041
+ method_name: method_name,
1042
+ superclass: @superclasses[owner],
1043
+ line: node.location.start_line,
1044
+ end_line: node.location.start_line,
1045
+ parameters: writer ? [Parameter.new(name: name, type: value, kind: :required)] : [],
1046
+ result: value,
1047
+ capabilities: [],
1048
+ self_type: TypeVariable.new(scope, 1, "self"),
1049
+ ivars: { "@#{name}": value },
1050
+ locals: {},
1051
+ port_assignments: [],
1052
+ case_narrowings: {}
1053
+ )
1054
+ end
1055
+
1056
+ def within_namespace(name)
1057
+ @namespace << name
1058
+ yield
1059
+ ensure
1060
+ @namespace.pop
1061
+ end
1062
+
1063
+ def qualified_owner(name)
1064
+ (@namespace + [name]).join("::")
1065
+ end
1066
+
1067
+ def record_include(node)
1068
+ return if @singleton || @namespace.empty?
1069
+
1070
+ owner = current_owner
1071
+ Array(node.arguments&.arguments).each do |argument|
1072
+ name = constant_argument_slice(argument)
1073
+ @includes[owner] << name if name
1074
+ end
1075
+ end
1076
+
1077
+ def constant_argument_slice(node)
1078
+ case node
1079
+ when Prism::ConstantReadNode then node.name.to_s
1080
+ when Prism::ConstantPathNode then node.slice
1081
+ end
1082
+ end
1083
+ end
1084
+
1085
+ class FixedPointInferencer
1086
+ MAX_ITERATIONS = 30
1087
+ PREDICATES = %i[
1088
+ == != < <= > >= all? any? blank? connected? empty? eql? include?
1089
+ is_a? key? member? nil? present? respond_to? start_with? end_with? zero?
1090
+ ].to_set.freeze
1091
+ INTEGER_RESULTS = %i[count length size bytesize].to_set.freeze
1092
+ STRING_RESULTS = %i[
1093
+ strip lstrip rstrip chomp chop squeeze tr downcase upcase capitalize
1094
+ swapcase gsub sub join to_json encode truncate squish
1095
+ strip! chomp! gsub! sub! downcase! upcase! squish!
1096
+ ].to_set.freeze
1097
+ STRING_LIST_RESULTS = %i[split lines chars].to_set.freeze
1098
+ NORETURN = Named.new("noreturn")
1099
+
1100
+ attr_reader :iterations
1101
+
1102
+ def initialize(methods, constants: {}, includes: {}, metadata: InferenceMetadata.empty)
1103
+ # `initialize` is only reachable through `new`, which returns the
1104
+ # instance, so its meaningful type is the owner class.
1105
+ @methods = methods.map do |method|
1106
+ next method unless method.kind == :instance && method.method_name == :initialize
1107
+
1108
+ method.with(result: Named.new(method.owner))
1109
+ end
1110
+ @index = @methods.to_h { |method| [[method.owner, method.kind, method.method_name], method] }
1111
+ @superclasses = methods.filter_map do |method|
1112
+ [method.owner, method.superclass] if method.superclass
1113
+ end.to_h
1114
+ @constants = constants
1115
+ @includes = resolve_includes(includes)
1116
+ @metadata = metadata
1117
+ @duck_index = Hash.new { |hash, key| hash[key] = [] }
1118
+ @methods.each do |method|
1119
+ next unless method.kind == :instance && method.method_name != :initialize
1120
+
1121
+ @duck_index[method.method_name] << method
1122
+ end
1123
+ @substitutions = {}
1124
+ @call_mappings = {}
1125
+ @stable_variables = {}
1126
+ @fresh_scope = Object.new
1127
+ @fresh_id = 0
1128
+ @iterations = 0
1129
+ @converged = false
1130
+ end
1131
+
1132
+ def run
1133
+ apply_declared_annotations!
1134
+ anchor_constructor_definitions
1135
+ solve_to_fixed_point
1136
+
1137
+ # Newly grounded parameters make more receivers concrete, so another
1138
+ # solving round can resolve targets that failed before. The second
1139
+ # round lets values flow through one level of indirection (caller ->
1140
+ # factory -> constructor).
1141
+ 2.times do
1142
+ flow_call_arguments!
1143
+ flow_structural_arguments!
1144
+ solve_to_fixed_point
1145
+ end
1146
+
1147
+ apply_late_send_defaults!
1148
+ apply_definition_conventions!
1149
+ narrow_residual_returns!
1150
+ unify_instance_variables
1151
+ solve_to_fixed_point
1152
+ apply_intra_method_param_flow!
1153
+ apply_chained_send_narrowing!
1154
+ apply_block_argument_callee_flow!
1155
+ flow_call_arguments!
1156
+ solve_to_fixed_point
1157
+ reflow_callee_returns!
1158
+ solve_to_fixed_point
1159
+ apply_mutator_return_nil!
1160
+ apply_forced_return_conventions!
1161
+ @methods.map { |method| resolved_method(method) }
1162
+ end
1163
+
1164
+ def converged? = @converged
1165
+
1166
+ def apply_declared_annotations!
1167
+ apply_declared_member_types!
1168
+ apply_factory_param_bindings!
1169
+ apply_case_branch_narrowing!
1170
+ apply_core_member_type_conventions!
1171
+ apply_initialize_param_bindings!
1172
+ apply_port_handler_wiring! if @metadata.port_wiring?
1173
+ apply_output_emit_wiring! if @metadata.output_emit?
1174
+ apply_config_hash_wiring! if @metadata.config_hash?
1175
+ end
1176
+
1177
+ def apply_core_member_type_conventions!
1178
+ CORE_MEMBER_NAME_TYPES.each do |member, type_name|
1179
+ type = Named.new(type_name)
1180
+ @duck_index[member].each do |accessor|
1181
+ unify(accessor.result, type)
1182
+ accessor.ivars.each_value { |ivar| unify(ivar, type) }
1183
+ end
1184
+ end
1185
+ end
1186
+
1187
+ def apply_initialize_param_bindings!
1188
+ @methods.each do |method|
1189
+ next unless method.kind == :instance && method.method_name == :initialize
1190
+
1191
+ method.parameters.each do |parameter|
1192
+ ivar = method.ivars[:"@#{parameter.name}"]
1193
+ unify(parameter.type, ivar) if ivar
1194
+ end
1195
+ end
1196
+ end
1197
+
1198
+ def apply_declared_member_types!
1199
+ @metadata.member_types.each do |owner, fields|
1200
+ fields.each do |member, type|
1201
+ accessor = @index[[owner, :instance, member]]
1202
+ next unless accessor
1203
+
1204
+ unify(accessor.result, type)
1205
+ accessor.ivars.each_value { |ivar| unify(ivar, type) }
1206
+ end
1207
+ end
1208
+ end
1209
+
1210
+ def apply_factory_param_bindings!
1211
+ @metadata.member_types.each do |owner, fields|
1212
+ %i[create new].each do |method_name|
1213
+ factory = @index[[owner, :singleton, method_name]]
1214
+ next unless factory
1215
+
1216
+ factory.parameters.each do |parameter|
1217
+ next unless %i[keyword optional_keyword].include?(parameter.kind)
1218
+
1219
+ type = fields[parameter.name]
1220
+ unify(parameter.type, type) if type
1221
+ end
1222
+ end
1223
+ end
1224
+ end
1225
+
1226
+ def apply_case_branch_narrowing!
1227
+ @methods.each do |method|
1228
+ method.case_narrowings.each do |name, types|
1229
+ parameter = method.parameters.find { |candidate| candidate.name == name }
1230
+ next unless parameter
1231
+
1232
+ merged = types.reduce { |left, right| merge_union(left, right) }
1233
+ unify(parameter.type, merged) if merged
1234
+ end
1235
+ end
1236
+ end
1237
+
1238
+ def apply_port_handler_wiring!
1239
+ wiring = @metadata.port_wiring
1240
+ @metadata.ports.each do |owner, ports|
1241
+ inputs = ports[:inputs]
1242
+ next if inputs.empty?
1243
+
1244
+ handler = @index[[owner, :instance, wiring.handler_method]]
1245
+ next unless handler
1246
+
1247
+ entity_param = handler_entity_param(handler, wiring.entity_param)
1248
+ dispatch_param = handler.parameters.find do |parameter|
1249
+ parameter.name == wiring.dispatch_param &&
1250
+ %i[keyword optional_keyword].include?(parameter.kind)
1251
+ end
1252
+
1253
+ if entity_param
1254
+ entity_type = port_type_for_param(entity_param.name, inputs)
1255
+ unify(entity_param.type, entity_type) if entity_type
1256
+ end
1257
+
1258
+ unify(dispatch_param.type, wiring.dispatch_param_type) if dispatch_param
1259
+ end
1260
+ apply_port_assignments!
1261
+ end
1262
+
1263
+ def handler_entity_param(handler, selector)
1264
+ case selector
1265
+ when :first_required
1266
+ handler.parameters.find { |parameter| parameter.kind == :required }
1267
+ when Symbol
1268
+ handler.parameters.find { |parameter| parameter.name == selector }
1269
+ end
1270
+ end
1271
+
1272
+ def apply_output_emit_wiring!
1273
+ wiring = @metadata.output_emit
1274
+ @metadata.ports.each do |owner, ports|
1275
+ outputs = ports[:outputs]
1276
+ next if outputs.empty?
1277
+
1278
+ @methods.select { |method| method.owner == owner }.each do |method|
1279
+ method.capabilities.each do |capability|
1280
+ next unless capability.message == wiring.message
1281
+ next unless capability.receiver == method.self_type
1282
+
1283
+ port_key = literal_symbol_key(capability.keywords[wiring.port_keyword])
1284
+ next unless port_key
1285
+
1286
+ output_type = outputs[port_key]
1287
+ next unless output_type
1288
+
1289
+ payload = capability.arguments[wiring.argument_index]
1290
+ unify(payload, output_type) if payload
1291
+ end
1292
+ end
1293
+ end
1294
+ end
1295
+
1296
+ def port_type_for_param(param_name, inputs)
1297
+ return inputs.values.first if inputs.length == 1
1298
+
1299
+ matched = inputs.find { |port_name, _| param_matches_port?(param_name, port_name) }
1300
+ return matched.last if matched
1301
+
1302
+ members = inputs.values.uniq
1303
+ members.length == 1 ? members.first : Union.new(members)
1304
+ end
1305
+
1306
+ def param_matches_port?(param_name, port_name)
1307
+ param = param_name.to_s
1308
+ port = port_name.to_s
1309
+ return true if param == port
1310
+ return true if port == "#{param}s" || port == "#{param}es"
1311
+ return true if port.end_with?("_#{param}") || port.start_with?("#{param}_")
1312
+
1313
+ false
1314
+ end
1315
+
1316
+ def apply_port_assignments!
1317
+ @methods.each do |method|
1318
+ next if method.port_assignments.empty?
1319
+
1320
+ ports = @metadata.ports.dig(method.owner, :inputs) || {}
1321
+ method.port_assignments.each do |assignment|
1322
+ port_type = ports[assignment.fetch(:port)]
1323
+ next unless port_type
1324
+
1325
+ param = method.parameters.find { |parameter| parameter.name == assignment.fetch(:param) }
1326
+ unify(param.type, port_type) if param
1327
+
1328
+ ivar = method.ivars[assignment.fetch(:ivar)]
1329
+ unify(ivar, port_type) if assignment.fetch(:ivar) && ivar
1330
+ end
1331
+ end
1332
+ end
1333
+
1334
+ def apply_config_hash_wiring!
1335
+ wiring = @metadata.config_hash
1336
+ @metadata.config_options.each do |owner, options|
1337
+ next if options.empty?
1338
+
1339
+ init = @index[[owner, :instance, :initialize]]
1340
+ next unless init
1341
+
1342
+ config_param = init.parameters.find { |parameter| parameter.name == wiring.init_param }
1343
+ next unless config_param
1344
+
1345
+ value_type = options.values.uniq
1346
+ value_type = value_type.length == 1 ? value_type.first : Union.new(value_type)
1347
+ unify(config_param.type, Generic.new("Hash", [Named.new("Symbol"), value_type]))
1348
+ end
1349
+
1350
+ @methods.each do |method|
1351
+ options = @metadata.config_options[method.owner]
1352
+ next unless options
1353
+
1354
+ method.capabilities.each do |capability|
1355
+ next unless %i[fetch [] dig].include?(capability.message)
1356
+ next unless config_hash_receiver?(method, capability.receiver, wiring)
1357
+
1358
+ key = literal_symbol_key(capability.arguments.first)
1359
+ next unless key
1360
+
1361
+ option_type = options[key]
1362
+ unify(capability.result, option_type) if option_type
1363
+ end
1364
+ end
1365
+ end
1366
+
1367
+ def config_hash_receiver?(method, receiver, wiring)
1368
+ terminal = dereference(resolve(receiver))
1369
+ return true if terminal == Named.new("Hash")
1370
+
1371
+ method.ivars.key?(wiring.ivar) && dereference(method.ivars[wiring.ivar]) == terminal
1372
+ end
1373
+
1374
+ def literal_symbol_key(key)
1375
+ case key
1376
+ when Named then key.name.to_sym
1377
+ end
1378
+ end
1379
+
1380
+ def literal_hash_key?(hash_type, key)
1381
+ literal_key = literal_symbol_key(key) || literal_integer_key(key)
1382
+ return false unless literal_key
1383
+
1384
+ case hash_type
1385
+ when Generic
1386
+ return false unless hash?(hash_type)
1387
+
1388
+ key_type = dereference(hash_type.arguments[0])
1389
+ case key_type
1390
+ when Named then key_type.name == "Symbol" || key_type.name == "Integer"
1391
+ else false
1392
+ end
1393
+ when Union
1394
+ hash_type.members.any? { |member| literal_hash_key?(member, key) }
1395
+ else
1396
+ false
1397
+ end
1398
+ end
1399
+
1400
+ def literal_integer_key(key)
1401
+ case key
1402
+ when Named
1403
+ return key.name.to_i if key.name.match?(/\A-?\d+\z/)
1404
+
1405
+ nil
1406
+ end
1407
+ end
1408
+
1409
+ def literal_key_name(key)
1410
+ case key
1411
+ when Named
1412
+ return key.name unless RESERVED_TYPE_NAMES.include?(key.name)
1413
+
1414
+ nil
1415
+ end
1416
+ end
1417
+
1418
+ def core_member_type_for_key(key_arg)
1419
+ name = literal_key_name(key_arg)&.to_sym
1420
+ return unless name
1421
+
1422
+ if (type_name = CORE_MEMBER_NAME_TYPES[name])
1423
+ return Named.new(type_name)
1424
+ end
1425
+
1426
+ @metadata.member_type_hints[name]
1427
+ end
1428
+
1429
+ def hash_new_block_value_type(block)
1430
+ resolved = resolve(block.result)
1431
+ return resolved if hash?(resolved) || array?(resolved)
1432
+ return resolved if ground?(resolved) && !resolved.is_a?(Named)
1433
+
1434
+ nil
1435
+ end
1436
+
1437
+ def unify_hash_key_argument!(hash_type, key_arg)
1438
+ return unless hash?(hash_type)
1439
+
1440
+ terminal = dereference(key_arg)
1441
+ case terminal
1442
+ when Named
1443
+ if terminal.name == "Integer" || terminal.name == "Symbol"
1444
+ unify(hash_type.arguments[0], terminal)
1445
+ elsif terminal.name.match?(/\A-?\d+\z/)
1446
+ unify(hash_type.arguments[0], Named.new("Integer"))
1447
+ elsif literal_symbol_key(terminal)
1448
+ unify(hash_type.arguments[0], Named.new("Symbol"))
1449
+ end
1450
+ end
1451
+ end
1452
+
1453
+ def hash_key_type_compatible?(hash_type, key_arg)
1454
+ case hash_type
1455
+ when Generic
1456
+ return false unless hash?(hash_type)
1457
+
1458
+ dereference(hash_type.arguments[0]) == dereference(key_arg)
1459
+ when Union
1460
+ hash_type.members.any? { |member| hash_key_type_compatible?(member, key_arg) }
1461
+ else
1462
+ false
1463
+ end
1464
+ end
1465
+
1466
+ def hash_dig_type(hash_type, keys)
1467
+ current = hash_type
1468
+ keys.each do |key|
1469
+ return nil unless hash?(current)
1470
+
1471
+ if literal_hash_key?(current, key) || hash_key_type_compatible?(current, key)
1472
+ current = current.arguments[1]
1473
+ else
1474
+ current = current.arguments[1]
1475
+ end
1476
+ end
1477
+ current
1478
+ end
1479
+
1480
+ def structured_type?(type, _method)
1481
+ terminal = dereference(type)
1482
+ return false unless terminal.is_a?(Named)
1483
+
1484
+ structured_owner?(terminal.name)
1485
+ end
1486
+
1487
+ def structured_owner?(owner)
1488
+ return true if @metadata.structured_type?(owner)
1489
+
1490
+ init = @index[[owner, :instance, :initialize]]
1491
+ return false unless init
1492
+
1493
+ init.parameters.any? do |parameter|
1494
+ %i[keyword optional_keyword].include?(parameter.kind) &&
1495
+ (parameter.name == :id || @metadata.member_type_hints.key?(parameter.name))
1496
+ end
1497
+ end
1498
+
1499
+ def structured_owner_name(type)
1500
+ terminal = dereference(type)
1501
+ terminal.is_a?(Named) && structured_owner?(terminal.name) ? terminal.name : nil
1502
+ end
1503
+
1504
+ def entity_hash_type(owner)
1505
+ fields = @metadata.member_types[owner]
1506
+ if fields.nil? || fields.empty?
1507
+ return Generic.new("Hash", [Named.new("String"), Named.new("Object")])
1508
+ end
1509
+
1510
+ value_type = fields.values.reduce { |left, right| merge_union(left, right) }
1511
+ Generic.new("Hash", [Named.new("String"), value_type])
1512
+ end
1513
+
1514
+ private
1515
+
1516
+ def native_solver_enabled?
1517
+ return @native_solver_enabled if defined?(@native_solver_enabled)
1518
+
1519
+ @native_solver_enabled = begin
1520
+ require_relative "native_bridge"
1521
+ NativeBridge.enabled?
1522
+ rescue LoadError, StandardError
1523
+ false
1524
+ end
1525
+ end
1526
+
1527
+ def solve_to_fixed_point
1528
+ if native_solver_enabled?
1529
+ NativeBridge.prime!(self)
1530
+ end
1531
+
1532
+ MAX_ITERATIONS.times do |iteration|
1533
+ @changed = false
1534
+ @duck_cache = {}
1535
+ @methods.each do |method|
1536
+ method.capabilities.each do |capability|
1537
+ apply_builtin(method, capability)
1538
+ # Anchor before resolving the target so a delegating `new`
1539
+ # override cannot flood the call site with unresolved variables.
1540
+ apply_constructor(method, capability)
1541
+ target = resolve_target(method, capability)
1542
+ if target
1543
+ apply_method_call(method, capability, target)
1544
+ elsif apply_union_member_dispatch(method, capability)
1545
+ next
1546
+ else
1547
+ apply_duck_dispatch(method, capability)
1548
+ apply_fallback(method, capability)
1549
+ end
1550
+ end
1551
+ end
1552
+ @iterations = iteration + 1
1553
+ unless @changed
1554
+ @converged = true
1555
+ break
1556
+ end
1557
+ end
1558
+ end
1559
+
1560
+ # Singleton `new`/`create` definitions return an instance of their owner,
1561
+ # whatever their bodies delegate to.
1562
+ def anchor_constructor_definitions
1563
+ @methods.each do |method|
1564
+ next unless method.kind == :singleton && %i[new create].include?(method.method_name)
1565
+
1566
+ anchor = Named.new(method.owner)
1567
+ branches = method.result.is_a?(Union) ? method.result.members : [method.result]
1568
+ branches.each { |branch| unify(branch, anchor) }
1569
+ end
1570
+ end
1571
+
1572
+ def unify_instance_variables
1573
+ groups = Hash.new { |hash, key| hash[key] = [] }
1574
+ @methods.each do |method|
1575
+ method.ivars.each { |name, type| groups[[method.owner, name]] << type }
1576
+ end
1577
+
1578
+ groups.each_value do |types|
1579
+ observed = types.filter_map do |type|
1580
+ resolved = resolve(type)
1581
+ resolved unless resolved.is_a?(TypeVariable)
1582
+ end
1583
+ merged = observed.reduce { |left, right| merge_union(left, right) }
1584
+ next unless merged
1585
+
1586
+ types.each do |type|
1587
+ terminal = dereference(type)
1588
+ unify(terminal, merged) if terminal.is_a?(TypeVariable)
1589
+ end
1590
+ end
1591
+ end
1592
+
1593
+ def apply_builtin(method, capability)
1594
+ receiver = unwrap_nilable(resolve(capability.receiver))
1595
+ message = capability.message
1596
+ result = capability.result
1597
+
1598
+ if (constant = class_name_from(receiver))
1599
+ # Constant reads look like class references; when the constant is
1600
+ # actually a typed literal (regex, hash table, word list), sends on
1601
+ # it are sends on the literal.
1602
+ literal = constant_literal(constant, method.owner)
1603
+ if literal
1604
+ receiver = literal
1605
+ else
1606
+ apply_class_builtin(constant, capability)
1607
+ end
1608
+ end
1609
+
1610
+ if PREDICATES.include?(message)
1611
+ unify(result, Named.new("bool"))
1612
+ elsif INTEGER_RESULTS.include?(message)
1613
+ unify(result, Named.new("Integer"))
1614
+ elsif STRING_RESULTS.include?(message)
1615
+ unify(result, Named.new("String"))
1616
+ elsif STRING_LIST_RESULTS.include?(message)
1617
+ unify(result, Generic.new("Array", [Named.new("String")]))
1618
+ end
1619
+
1620
+ case message
1621
+ when :to_s, :inspect, :String
1622
+ unify(result, Named.new("String"))
1623
+ when :to_i, :ord, :Integer
1624
+ unify(result, Named.new("Integer"))
1625
+ when :to_f, :Float
1626
+ unify(result, Named.new("Float"))
1627
+ when :to_sym
1628
+ unify(result, Named.new("Symbol"))
1629
+ when :!
1630
+ unify(result, Named.new("bool"))
1631
+ when :raise, :throw
1632
+ unify(result, NORETURN)
1633
+ when :puts, :print
1634
+ unify(result, Named.new("nil"))
1635
+ when :emit, :close_input_queues!, :release_optional_inputs!, :close_input_port!
1636
+ unify(result, Named.new("nil")) if capability.keywords.key?(:to) || %i[close_input_queues! release_optional_inputs! close_input_port!].include?(message)
1637
+ when :debug, :info, :warn, :error, :fatal
1638
+ unify(result, Named.new("nil")) if receiver == Named.new("Logger")
1639
+ when :logger
1640
+ unify(result, Named.new("Logger"))
1641
+ when :tap, :itself, :dup, :clone, :freeze, :deep_dup, :with
1642
+ unify(result, receiver)
1643
+ when :synchronize
1644
+ block = capability.arguments.last
1645
+ unify(result, block.result) if receiver == Named.new("Mutex") && block.is_a?(Function)
1646
+ when :clock_gettime
1647
+ unify(result, Named.new("Float"))
1648
+ when :then, :yield_self
1649
+ block = capability.arguments.last
1650
+ unify(result, block.result) if block.is_a?(Function)
1651
+ when :presence
1652
+ unify(result, Union.new([receiver, Named.new("nil")].uniq)) unless receiver == Named.new("nil")
1653
+ when :new, :create
1654
+ class_name = class_name_from(receiver)
1655
+ if class_name == "Hash"
1656
+ block = capability.arguments.find { |argument| argument.is_a?(Function) }
1657
+ if block
1658
+ key_type = block.parameters[1] || stable_fresh([capability.object_id, :hash_key], "key")
1659
+ value_type = hash_new_block_value_type(block) || block.result
1660
+ unify(result, Generic.new("Hash", [key_type, value_type]))
1661
+ else
1662
+ unify(result, Named.new("Hash"))
1663
+ end
1664
+ elsif class_name
1665
+ unify(result, Named.new(class_name))
1666
+ end
1667
+ when :Array
1668
+ argument = capability.arguments.first || stable_fresh([capability.object_id, :array_element], "element")
1669
+ element = array_element(resolve(argument)) || resolve(argument)
1670
+ unify(result, Generic.new("Array", [element]))
1671
+ when :flatten
1672
+ element = array_element(receiver)
1673
+ if element
1674
+ flattened = array_element(element) || element
1675
+ unify(result, Generic.new("Array", [flattened]))
1676
+ end
1677
+ when :take, :uniq, :sort, :sort_by, :reverse,
1678
+ :drop, :take_while, :drop_while, :shuffle, :rotate, :to_a
1679
+ unify(result, receiver) if receiver.is_a?(Generic) && receiver.name == "Array"
1680
+ when :reject, :select, :filter, :find_all, :grep, :index_by
1681
+ apply_enumerable_block(receiver, capability, result, mode: :same_collection)
1682
+ when :compact, :compact!
1683
+ element = array_element(receiver)
1684
+ if element
1685
+ unify(result, Generic.new("Array", [strip_nil(element)]))
1686
+ elsif hash?(receiver)
1687
+ unify(result, Generic.new("Hash", [receiver.arguments[0], strip_nil(receiver.arguments[1])]))
1688
+ end
1689
+ when :to_set
1690
+ element = enumerable_element(receiver)
1691
+ unify(result, Generic.new("Set", [element])) if element
1692
+ when :<<, :push, :append, :unshift, :prepend
1693
+ if array?(receiver)
1694
+ capability.arguments.each do |argument|
1695
+ unify_array_elements(receiver, argument) unless argument.is_a?(Function)
1696
+ end
1697
+ unify(result, receiver)
1698
+ elsif receiver == Named.new("String")
1699
+ unify(result, receiver)
1700
+ end
1701
+ when :concat
1702
+ if array?(receiver)
1703
+ capability.arguments.each do |argument|
1704
+ unify(receiver, argument) unless argument.is_a?(Function)
1705
+ end
1706
+ unify(result, receiver)
1707
+ elsif receiver == Named.new("String")
1708
+ unify(result, receiver)
1709
+ end
1710
+ when :+, :-, :*, :/, :%, :**
1711
+ if receiver.is_a?(Named) && %w[Integer Float String].include?(receiver.name)
1712
+ unify(result, receiver)
1713
+ elsif array?(receiver) && %i[+ -].include?(message)
1714
+ unify(result, receiver)
1715
+ end
1716
+ when :first, :last, :min, :max, :find, :detect, :sample
1717
+ element = enumerable_element(receiver)
1718
+ unify(result, Union.new([element, Named.new("nil")])) if element
1719
+ when :sum
1720
+ element = enumerable_element(receiver)
1721
+ unify(result, element) if element.is_a?(Named) && %w[Integer Float].include?(element.name)
1722
+ when :map, :collect
1723
+ element = enumerable_element(receiver)
1724
+ block = capability.arguments.last
1725
+ if block.is_a?(Function)
1726
+ unify(block.parameters.first, element) if element && block.parameters.first
1727
+ unify(result, Generic.new("Array", [block.result]))
1728
+ elsif element
1729
+ unify(result, Generic.new("Enumerator", [element]))
1730
+ end
1731
+ when :flat_map
1732
+ block = capability.arguments.last
1733
+ if block.is_a?(Function)
1734
+ element = enumerable_element(receiver)
1735
+ unify(block.parameters.first, element) if element && block.parameters.first
1736
+ inner = resolve(block.result)
1737
+ unify(result, Generic.new("Array", [array_element(inner) || inner]))
1738
+ end
1739
+ when :filter_map
1740
+ block = capability.arguments.last
1741
+ unify(result, Generic.new("Array", [strip_nil(resolve(block.result))])) if block.is_a?(Function)
1742
+ when :each_with_object
1743
+ memo = capability.arguments.find { |argument| !argument.is_a?(Function) }
1744
+ block = capability.arguments.last
1745
+ if block.is_a?(Function)
1746
+ element = enumerable_element(receiver)
1747
+ unify(block.parameters[0], element) if element && block.parameters[0]
1748
+ unify(block.parameters[1], memo) if memo && block.parameters[1]
1749
+ end
1750
+ unify(result, memo) if memo
1751
+ when :reduce, :inject
1752
+ block = capability.arguments.last
1753
+ unify(result, block.result) if block.is_a?(Function)
1754
+ when :keys
1755
+ unify(result, Generic.new("Array", [receiver.arguments[0]])) if hash?(receiver)
1756
+ when :values
1757
+ unify(result, Generic.new("Array", [receiver.arguments[1]])) if hash?(receiver)
1758
+ when :to_h, :to_hash
1759
+ if (owner = structured_owner_name(receiver))
1760
+ unify(result, entity_hash_type(owner))
1761
+ elsif receiver.is_a?(Union)
1762
+ hash_types = receiver.members.filter_map { |member| structured_owner_name(member) }.map { |name| entity_hash_type(name) }
1763
+ unify(result, hash_types.reduce { |left, right| merge_union(left, right) }) if hash_types.any?
1764
+ elsif structured_owner?(method.owner) || structured_type?(receiver, method)
1765
+ unify(result, entity_hash_type(method.owner))
1766
+ elsif hash?(receiver)
1767
+ unify(result, receiver)
1768
+ end
1769
+ when :deep_stringify_keys, :stringify_keys
1770
+ if hash?(receiver)
1771
+ unify(result, Generic.new("Hash", [Named.new("String"), receiver.arguments[1]]))
1772
+ end
1773
+ when :merge, :merge!, :except, :slice, :with_indifferent_access, :deep_symbolize_keys, :symbolize_keys
1774
+ unify(result, receiver) if hash?(receiver)
1775
+ when :transform_values
1776
+ block = capability.arguments.last
1777
+ if hash?(receiver) && block.is_a?(Function)
1778
+ unify(block.parameters.first, receiver.arguments[1]) if block.parameters.first
1779
+ unify(result, Generic.new("Hash", [receiver.arguments[0], block.result]))
1780
+ end
1781
+ when :transform_keys
1782
+ block = capability.arguments.last
1783
+ if hash?(receiver) && block.is_a?(Function)
1784
+ unify(block.parameters.first, receiver.arguments[0]) if block.parameters.first
1785
+ unify(result, Generic.new("Hash", [block.result, receiver.arguments[1]]))
1786
+ end
1787
+ when :each_value
1788
+ if hash?(receiver)
1789
+ block = capability.arguments.last
1790
+ unify(block.parameters.first, receiver.arguments[1]) if block.is_a?(Function) && block.parameters.first
1791
+ unify(result, receiver)
1792
+ end
1793
+ when :each_key
1794
+ if hash?(receiver)
1795
+ block = capability.arguments.last
1796
+ unify(block.parameters.first, receiver.arguments[0]) if block.is_a?(Function) && block.parameters.first
1797
+ unify(result, receiver)
1798
+ end
1799
+ when :fetch
1800
+ if hash?(receiver)
1801
+ if (option_type = config_option_type(method, capability.arguments.first))
1802
+ unify(result, option_type)
1803
+ else
1804
+ unify(result, receiver.arguments[1])
1805
+ end
1806
+ elsif array?(receiver)
1807
+ unify(result, array_element(receiver))
1808
+ end
1809
+ when :delete
1810
+ if hash?(receiver)
1811
+ key_arg = capability.arguments.first
1812
+ unify(result, receiver.arguments[1])
1813
+ unify_hash_key_argument!(receiver, key_arg)
1814
+ end
1815
+ when :[]
1816
+ if hash?(receiver)
1817
+ key_arg = capability.arguments.first
1818
+ if (member_type = core_member_type_for_key(key_arg))
1819
+ unify(result, member_type)
1820
+ elsif (option_type = config_option_type(method, key_arg))
1821
+ unify(result, option_type)
1822
+ elsif literal_hash_key?(receiver, key_arg) || hash_key_type_compatible?(receiver, key_arg)
1823
+ unify(result, receiver.arguments[1])
1824
+ else
1825
+ unify(result, Union.new([receiver.arguments[1], Named.new("nil")]))
1826
+ end
1827
+ unify_hash_key_argument!(receiver, key_arg)
1828
+ elsif array?(receiver)
1829
+ unify(result, Union.new([array_element(receiver), Named.new("nil")]))
1830
+ elsif dereference(capability.receiver).is_a?(TypeVariable)
1831
+ key_arg = capability.arguments.first
1832
+ member_type = core_member_type_for_key(key_arg)
1833
+ unify(result, member_type) if member_type
1834
+ elsif receiver == Named.new("String") || receiver == Named.new("MatchData")
1835
+ unify(result, Union.new([Named.new("String"), Named.new("nil")]))
1836
+ end
1837
+ when :dig
1838
+ if hash?(receiver)
1839
+ dug = hash_dig_type(receiver, capability.arguments)
1840
+ if dug
1841
+ unify(result, Union.new([dug, Named.new("nil")]))
1842
+ elsif (option_type = config_option_type(method, capability.arguments.first))
1843
+ unify(result, option_type)
1844
+ else
1845
+ unify(result, Union.new([receiver.arguments[1], Named.new("nil")]))
1846
+ end
1847
+ elsif array?(receiver)
1848
+ unify(result, Union.new([array_element(receiver), Named.new("nil")]))
1849
+ end
1850
+ when :class
1851
+ case receiver
1852
+ when Generic
1853
+ unify(result, Named.new("Class[#{receiver.name}]"))
1854
+ when Named
1855
+ if receiver.name.start_with?("Class[") || %w[nil bool noreturn].include?(receiver.name)
1856
+ unify(result, Named.new("Class"))
1857
+ else
1858
+ unify(result, Named.new("Class[#{receiver.name}]"))
1859
+ end
1860
+ end
1861
+ when :name
1862
+ if receiver.is_a?(Named) && (receiver.name == "Class" || receiver.name.start_with?("Class["))
1863
+ unify(result, Named.new("String"))
1864
+ end
1865
+ when :message, :full_message
1866
+ unify(result, Named.new("String")) if exception_type?(receiver)
1867
+ when :backtrace
1868
+ if exception_type?(receiver)
1869
+ unify(result, Union.new([Generic.new("Array", [Named.new("String")]), Named.new("nil")]))
1870
+ end
1871
+ when :match
1872
+ if [Named.new("Regexp"), Named.new("String")].include?(receiver)
1873
+ unify(result, Union.new([Named.new("MatchData"), Named.new("nil")]))
1874
+ end
1875
+ when :"=~"
1876
+ if [Named.new("Regexp"), Named.new("String")].include?(receiver)
1877
+ unify(result, Union.new([Named.new("Integer"), Named.new("nil")]))
1878
+ end
1879
+ when :source
1880
+ unify(result, Named.new("String")) if receiver == Named.new("Regexp")
1881
+ when :[]=
1882
+ if hash?(receiver) && capability.arguments.length >= 2
1883
+ unify(receiver.arguments[0], capability.arguments[0])
1884
+ unify(receiver.arguments[1], capability.arguments[1])
1885
+ elsif array?(receiver) && capability.arguments.length >= 2
1886
+ unify(array_element(receiver), capability.arguments.last)
1887
+ end
1888
+ unify(result, capability.arguments.last) unless capability.arguments.empty?
1889
+ when :with_index
1890
+ apply_indexed_enumeration(receiver, capability, result)
1891
+ when :each, :each_with_index
1892
+ apply_each(receiver, capability, result, indexed: message == :each_with_index)
1893
+ end
1894
+
1895
+ # Methods that return their receiver regardless of its type: tying the
1896
+ # two variables lets either side ground the other.
1897
+ if SELF_RETURNING.include?(message) && dereference(result).is_a?(TypeVariable)
1898
+ unify(result, capability.receiver)
1899
+ end
1900
+ end
1901
+
1902
+ SELF_RETURNING = %i[
1903
+ each each_with_index each_entry tap dup clone freeze itself
1904
+ << clear push append unshift prepend concat
1905
+ sort! uniq! compact! reverse! flatten! map!
1906
+ ].to_set.freeze
1907
+
1908
+ def exception_type?(type)
1909
+ type.is_a?(Named) &&
1910
+ (type.name == "StandardError" || type.name.end_with?("Error", "Exception"))
1911
+ end
1912
+
1913
+ # Last-resort defaults for sends whose result survived every solving
1914
+ # pass: `.class` is always a Class, key-conversion methods always yield
1915
+ # hashes, `round` is numeric.
1916
+ def apply_late_send_defaults!
1917
+ @methods.each do |method|
1918
+ method.capabilities.each do |capability|
1919
+ next unless dereference(capability.result).is_a?(TypeVariable)
1920
+
1921
+ case capability.message
1922
+ when :class
1923
+ unify(capability.result, Named.new("Class"))
1924
+ when :round, :ceil, :floor
1925
+ unify(capability.result, capability.arguments.empty? ? Named.new("Integer") : Named.new("Float"))
1926
+ when :abs
1927
+ unify(capability.result, Named.new("Numeric"))
1928
+ when :deep_symbolize_keys, :symbolize_keys
1929
+ value = stable_fresh([capability.object_id, :hash_value], "value")
1930
+ unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value]))
1931
+ when :deep_stringify_keys, :stringify_keys, :with_indifferent_access, :to_unsafe_h
1932
+ value = stable_fresh([capability.object_id, :hash_value], "value")
1933
+ unify(capability.result, Generic.new("Hash", [Named.new("String"), value]))
1934
+ end
1935
+ end
1936
+ end
1937
+ end
1938
+
1939
+ # Library classes outside the repo with well-known return types.
1940
+ def apply_class_builtin(constant, capability)
1941
+ result = capability.result
1942
+ message = capability.message
1943
+ case constant
1944
+ when "Time", "DateTime", "Date"
1945
+ unify(result, Named.new(constant)) if %i[now at parse current zone today].include?(message)
1946
+ when "Process"
1947
+ unify(result, Named.new("Float")) if message == :clock_gettime
1948
+ when "ENV"
1949
+ case message
1950
+ when :[] then unify(result, Union.new([Named.new("String"), Named.new("nil")]))
1951
+ when :fetch then unify(result, Named.new("String"))
1952
+ end
1953
+ when "JSON", "YAML", "Oj"
1954
+ case message
1955
+ when :parse, :load, :safe_load
1956
+ value = stable_fresh([constant, :parsed_value], "value")
1957
+ unify(result, Generic.new("Hash", [Named.new("String"), value]))
1958
+ when :generate, :dump, :pretty_generate then unify(result, Named.new("String"))
1959
+ end
1960
+ when "Rails"
1961
+ case message
1962
+ when :logger then unify(result, Named.new("Logger"))
1963
+ when :env then unify(result, Named.new("String"))
1964
+ when :root then unify(result, Named.new("Pathname"))
1965
+ when :application then unify(result, Named.new("Rails::Application"))
1966
+ end
1967
+ when "File"
1968
+ case message
1969
+ when :read, :basename, :dirname, :extname, :join, :expand_path, :absolute_path, :realpath
1970
+ unify(result, Named.new("String"))
1971
+ when :readlines then unify(result, Generic.new("Array", [Named.new("String")]))
1972
+ end
1973
+ when "Dir"
1974
+ unify(result, Generic.new("Array", [Named.new("String")])) if %i[glob children entries].include?(message)
1975
+ when "SecureRandom"
1976
+ unify(result, Named.new("String")) if %i[uuid hex urlsafe_base64 alphanumeric base64].include?(message)
1977
+ when "Base64"
1978
+ unify(result, Named.new("String")) if message.to_s.include?("code64")
1979
+ when "Digest::SHA256", "Digest::SHA1", "Digest::MD5"
1980
+ unify(result, Named.new("String")) if %i[hexdigest base64digest digest].include?(message)
1981
+ when "Regexp"
1982
+ case message
1983
+ when :escape, :quote then unify(result, Named.new("String"))
1984
+ when :union then unify(result, Named.new("Regexp"))
1985
+ end
1986
+ when "URI"
1987
+ case message
1988
+ when :parse, :join then unify(result, Named.new("URI"))
1989
+ when :encode_www_form, :decode_www_form_component, :encode_www_form_component
1990
+ unify(result, Named.new("String"))
1991
+ end
1992
+ when "Fiber"
1993
+ unify(result, Named.new("Fiber")) if %i[current new].include?(message)
1994
+ when "Array"
1995
+ if message == :Array
1996
+ argument = capability.arguments.first
1997
+ if argument
1998
+ element = array_element(resolve(argument)) || resolve(argument)
1999
+ unify(result, Generic.new("Array", [element]))
2000
+ else
2001
+ unify(result, Generic.new("Array", [fresh("element")]))
2002
+ end
2003
+ end
2004
+ when "Dox::Metrics"
2005
+ unify(result, Named.new("nil")) if %i[increment gauge histogram timing measure].include?(message)
2006
+ end
2007
+ end
2008
+
2009
+ def constant_literal(name, lexical_owner)
2010
+ constant = name.delete_prefix("::")
2011
+ return @constants[constant] if name.start_with?("::")
2012
+
2013
+ parts = lexical_owner.split("::")
2014
+ parts.length.downto(0) do |length|
2015
+ candidate = (parts.first(length) + [constant]).join("::")
2016
+ type = @constants[candidate]
2017
+ return type if type
2018
+ end
2019
+ nil
2020
+ end
2021
+
2022
+ DUCK_UNION_LIMIT = 8
2023
+ DUCK_CORE_RECEIVERS = %w[String Integer Float Symbol bool Time Regexp MatchData noreturn].freeze
2024
+
2025
+ # When the receiver's class is unknown but the message is defined in the
2026
+ # repository, dispatch by name: exactly when one method defines it, or by
2027
+ # unioning return types when the few definitions agree enough. This is
2028
+ # what resolves entity-attribute reads on untyped pipeline inputs.
2029
+ def apply_union_member_dispatch(method, capability)
2030
+ return false unless dereference(capability.result).is_a?(TypeVariable)
2031
+
2032
+ receiver = resolve(capability.receiver)
2033
+ return false unless receiver.is_a?(Union)
2034
+
2035
+ targets = receiver.members.filter_map do |member|
2036
+ terminal = dereference(member)
2037
+ next unless terminal.is_a?(Named)
2038
+ next unless @metadata.structured_type?(terminal.name)
2039
+
2040
+ resolve_named_owner(terminal.name, method.owner, :instance, capability.message)
2041
+ end
2042
+ return false if targets.empty?
2043
+
2044
+ if targets.length == 1
2045
+ apply_method_call(method, capability, targets.first)
2046
+ else
2047
+ apply_union_method_call(method, capability, targets)
2048
+ end
2049
+ true
2050
+ end
2051
+
2052
+ def apply_union_method_call(caller, capability, callees)
2053
+ returns = callees.map do |callee|
2054
+ key = [caller.object_id, capability.object_id, callee.object_id]
2055
+ mapping = @call_mappings[key] ||= {}
2056
+ instantiated_parameters = callee.parameters.map do |parameter|
2057
+ Parameter.new(
2058
+ name: parameter.name,
2059
+ type: instantiate(resolve(parameter.type), mapping),
2060
+ kind: parameter.kind
2061
+ )
2062
+ end
2063
+ bind_arguments(instantiated_parameters, capability)
2064
+ instantiate(resolve(callee.result), mapping)
2065
+ end
2066
+ unified = returns.reduce { |left, right| Union.new([left, right].uniq) }
2067
+ unify(capability.result, unified)
2068
+ end
2069
+
2070
+ def apply_duck_dispatch(method, capability)
2071
+ return unless dereference(capability.result).is_a?(TypeVariable)
2072
+
2073
+ receiver = resolve(capability.receiver)
2074
+ return unless duck_receiver?(receiver)
2075
+
2076
+ candidates = @duck_index[capability.message]
2077
+ return if candidates.empty?
2078
+
2079
+ if candidates.length == 1
2080
+ apply_method_call(method, capability, candidates.first)
2081
+ else
2082
+ union = duck_union(capability.message)
2083
+ unify(capability.result, union) if union
2084
+ end
2085
+ end
2086
+
2087
+ # The union of ground return types across same-named definitions, cached
2088
+ # per solving iteration. Unresolved definitions are excluded: the ground
2089
+ # subset is the best evidence available either way.
2090
+ def duck_union(message)
2091
+ @duck_cache.fetch(message) do
2092
+ ground = @duck_index[message]
2093
+ .map { |candidate| resolve(candidate.result) }
2094
+ .select { |type| ground?(type) }
2095
+ .uniq
2096
+ @duck_cache[message] =
2097
+ if ground.empty? || ground.length > DUCK_UNION_LIMIT
2098
+ nil
2099
+ else
2100
+ ground.length == 1 ? ground.first : Union.new(ground)
2101
+ end
2102
+ end
2103
+ end
2104
+
2105
+ # A call's effective callee: hierarchy resolution first, then the duck
2106
+ # index when it names exactly one repository method.
2107
+ def call_target(caller, capability)
2108
+ target = resolve_target(caller, capability)
2109
+ return target if target
2110
+ return unless duck_receiver?(resolve(capability.receiver))
2111
+
2112
+ candidates = @duck_index[capability.message]
2113
+ candidates.length == 1 ? candidates.first : nil
2114
+ end
2115
+
2116
+ def duck_receiver?(receiver)
2117
+ case receiver
2118
+ when TypeVariable, Union
2119
+ true
2120
+ when Named
2121
+ return true if @metadata.structured_type?(receiver.name)
2122
+ !DUCK_CORE_RECEIVERS.include?(receiver.name) && !receiver.name.start_with?("Class[")
2123
+ else
2124
+ false
2125
+ end
2126
+ end
2127
+
2128
+ # Fallbacks for sends that resolve to no repository method. Ruby's `?`
2129
+ # suffix convention is reliable enough for a typedoc, and a bare
2130
+ # `new`/`create` inside a singleton method constructs the owner class.
2131
+ def apply_fallback(method, capability)
2132
+ unify(capability.result, Named.new("bool")) if capability.message.to_s.end_with?("?")
2133
+ return unless capability.receiver == method.self_type
2134
+
2135
+ # Rails controller/framework conventions for self-sends that resolve
2136
+ # to nothing in the repository.
2137
+ case capability.message
2138
+ when :params
2139
+ value = stable_fresh([capability.object_id, :params_value], "value")
2140
+ unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value]))
2141
+ when :request
2142
+ unify(capability.result, Named.new("ActionDispatch::Request"))
2143
+ when :response
2144
+ unify(capability.result, Named.new("ActionDispatch::Response"))
2145
+ when :logger
2146
+ unify(capability.result, Named.new("Logger"))
2147
+ when :headers
2148
+ unify(capability.result, Generic.new("Hash", [Named.new("String"), Named.new("String")]))
2149
+ when :options
2150
+ apply_config_hash_reader_fallback(method, capability)
2151
+ else
2152
+ fallback = @metadata.framework_self_fallbacks[capability.message]
2153
+ unify(capability.result, fallback.call(method, capability)) if fallback
2154
+ end
2155
+ end
2156
+
2157
+ def apply_config_hash_reader_fallback(method, capability)
2158
+ wiring = @metadata.config_hash
2159
+ options = @metadata.config_options[method.owner]
2160
+ reader = wiring&.reader_method || capability.message
2161
+ return unless capability.message == reader
2162
+
2163
+ if options&.any?
2164
+ values = options.values.uniq
2165
+ value_type = values.length == 1 ? values.first : Union.new(values)
2166
+ unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value_type]))
2167
+ else
2168
+ unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), Named.new("Object")]))
2169
+ end
2170
+ end
2171
+
2172
+ def config_option_type(method, key)
2173
+ literal_symbol_key(key)&.then { |symbol| @metadata.config_options.dig(method.owner, symbol) }
2174
+ end
2175
+
2176
+ def nullable_type(inner)
2177
+ Generic.new("Nullable", [inner])
2178
+ end
2179
+
2180
+ # A bare `new`/`create` inside a singleton method constructs the owner
2181
+ # class. Runs even when a `new` override is indexed, so overrides that
2182
+ # delegate to un-analyzable aliases still anchor to the nominal type.
2183
+ def apply_constructor(method, capability)
2184
+ return unless capability.receiver == method.self_type
2185
+ return unless method.kind == :singleton
2186
+ return unless %i[new create].include?(capability.message)
2187
+
2188
+ unify(capability.result, Named.new(method.owner))
2189
+ end
2190
+
2191
+ def unwrap_nilable(type)
2192
+ return type unless type.is_a?(Union)
2193
+
2194
+ members = type.members.reject { |member| [Named.new("nil"), NORETURN].include?(member) }
2195
+ members.length == 1 ? members.first : type
2196
+ end
2197
+
2198
+ def strip_nil(type)
2199
+ return type unless type.is_a?(Union)
2200
+
2201
+ members = type.members.reject { |member| member == Named.new("nil") }
2202
+ case members.length
2203
+ when 0 then type
2204
+ when 1 then members.first
2205
+ else Union.new(members)
2206
+ end
2207
+ end
2208
+
2209
+ def array?(type)
2210
+ case type
2211
+ when Generic
2212
+ type.name == "Array"
2213
+ when Union
2214
+ type.members.any? { |member| array?(member) }
2215
+ else
2216
+ false
2217
+ end
2218
+ end
2219
+
2220
+ def hash?(type) = type.is_a?(Generic) && type.name == "Hash" && type.arguments.length == 2
2221
+
2222
+ def hashish?(type)
2223
+ case type
2224
+ when Generic
2225
+ hash?(type)
2226
+ when Union
2227
+ type.members.any? { |member| hashish?(member) }
2228
+ else
2229
+ false
2230
+ end
2231
+ end
2232
+
2233
+ def hash_value_type(type)
2234
+ case type
2235
+ when Generic
2236
+ hash?(type) ? type.arguments[1] : nil
2237
+ when Union
2238
+ values = type.members.filter_map { |member| hash_value_type(member) }
2239
+ values.reduce { |left, right| merge_union(left, right) }
2240
+ end
2241
+ end
2242
+
2243
+ def unify_array_elements(type, value)
2244
+ case unwrap_nilable(resolve(type))
2245
+ when Generic
2246
+ element = array_element(type)
2247
+ unify(element, value) if element
2248
+ when Union
2249
+ type.members.each { |member| unify_array_elements(member, value) }
2250
+ end
2251
+ end
2252
+
2253
+ def apply_indexed_enumeration(receiver, capability, result)
2254
+ element = enumerable_element(receiver)
2255
+ block = capability.arguments.last
2256
+ return unless block.is_a?(Function)
2257
+
2258
+ unify(block.parameters[0], element) if element && block.parameters[0]
2259
+ unify(block.parameters[1], Named.new("Integer")) if block.parameters[1]
2260
+ unify(result, Generic.new("Array", [block.result]))
2261
+ end
2262
+
2263
+ def apply_enumerable_block(receiver, capability, result, mode:)
2264
+ element = enumerable_element(receiver)
2265
+ block = capability.arguments.last
2266
+ if block.is_a?(Function)
2267
+ unify(block.parameters.first, element) if element && block.parameters.first
2268
+ end
2269
+
2270
+ case mode
2271
+ when :same_collection
2272
+ unify(result, receiver) if array?(receiver)
2273
+ when :optional_element
2274
+ unify(result, Union.new([element, Named.new("nil")])) if element
2275
+ when :bool
2276
+ unify(result, Named.new("bool"))
2277
+ when :integer
2278
+ unify(result, Named.new("Integer"))
2279
+ end
2280
+ end
2281
+
2282
+ def apply_each(receiver, capability, result, indexed:)
2283
+ element = enumerable_element(receiver)
2284
+ block = capability.arguments.last
2285
+ if block.is_a?(Function)
2286
+ unify(block.parameters[0], element) if element && block.parameters[0]
2287
+ unify(block.parameters[1], Named.new("Integer")) if indexed && block.parameters[1]
2288
+ end
2289
+ unify(result, receiver)
2290
+ end
2291
+
2292
+ def resolve_target(method, capability)
2293
+ if capability.receiver == method.self_type
2294
+ resolve_in_hierarchy(method.owner, method.kind, capability.message)
2295
+ else
2296
+ receiver = resolve(capability.receiver)
2297
+ if (class_name = class_name_from(receiver))
2298
+ resolve_named_owner(class_name, method.owner, :singleton, capability.message)
2299
+ elsif receiver.is_a?(Named)
2300
+ resolve_named_owner(receiver.name, method.owner, :instance, capability.message)
2301
+ end
2302
+ end
2303
+ end
2304
+
2305
+ def resolve_in_hierarchy(owner, kind, method_name)
2306
+ current = owner
2307
+ visited = Set.new
2308
+ while current && !visited.include?(current)
2309
+ visited << current
2310
+ target = @index[[current, kind, method_name]]
2311
+ return target if target
2312
+
2313
+ current = resolve_superclass(current, @superclasses[current])
2314
+ end
2315
+
2316
+ @includes[owner]&.each do |mod|
2317
+ target = @index[[mod, kind, method_name]]
2318
+ return target if target
2319
+ end
2320
+ nil
2321
+ end
2322
+
2323
+ def resolve_named_owner(name, lexical_owner, kind, method_name)
2324
+ constant = name.delete_prefix("::")
2325
+ candidates = [constant]
2326
+ unless name.start_with?("::")
2327
+ parts = lexical_owner.split("::")
2328
+ parts.length.downto(1) { |length| candidates << (parts.first(length) + [constant]).join("::") }
2329
+ end
2330
+ candidates.uniq.each do |owner|
2331
+ target = resolve_in_hierarchy(owner, kind, method_name)
2332
+ return target if target
2333
+ end
2334
+ nil
2335
+ end
2336
+
2337
+ def resolve_includes(includes)
2338
+ result = Hash.new { |hash, key| hash[key] = [] }
2339
+ includes.each do |owner, names|
2340
+ names.each do |name|
2341
+ mod = resolve_module_constant(name, owner)
2342
+ result[owner] << mod if mod
2343
+ end
2344
+ end
2345
+ result.transform_values { |mods| mods.uniq }
2346
+ end
2347
+
2348
+ def resolve_module_constant(name, lexical_owner)
2349
+ return name.delete_prefix("::") if name.start_with?("::")
2350
+
2351
+ segments = name.split("::")
2352
+ parts = lexical_owner.split("::")
2353
+ parts.length.downto(0) do |length|
2354
+ candidate = (parts.first(length) + segments).join("::")
2355
+ return candidate if module_defined?(candidate)
2356
+ end
2357
+ (parts + segments).join("::")
2358
+ end
2359
+
2360
+ def module_defined?(name)
2361
+ @index.keys.any? { |(owner, _, _)| owner == name }
2362
+ end
2363
+
2364
+ def resolve_superclass(owner, superclass)
2365
+ return nil unless superclass
2366
+
2367
+ superclass = superclass.delete_prefix("::")
2368
+ return superclass if @methods.any? { |method| method.owner == superclass }
2369
+
2370
+ namespace = owner.split("::")[0...-1]
2371
+ namespace.length.downto(0) do |length|
2372
+ candidate = (namespace.first(length) + [superclass]).join("::")
2373
+ return candidate if @methods.any? { |method| method.owner == candidate }
2374
+ end
2375
+ superclass
2376
+ end
2377
+
2378
+ def apply_method_call(caller, capability, callee)
2379
+ # A recursive call unifies with the method's own variables directly:
2380
+ # instantiating them mints an endless chain of fresh variables that
2381
+ # prevents the fixed point from converging.
2382
+ if callee.equal?(caller)
2383
+ bind_arguments(callee.parameters, capability)
2384
+ unify(capability.result, callee.result)
2385
+ return
2386
+ end
2387
+
2388
+ key = [caller.object_id, capability.object_id, callee.object_id]
2389
+ mapping = @call_mappings[key] ||= {}
2390
+ instantiated_parameters = callee.parameters.map do |parameter|
2391
+ Parameter.new(
2392
+ name: parameter.name,
2393
+ type: instantiate(resolve(parameter.type), mapping),
2394
+ kind: parameter.kind
2395
+ )
2396
+ end
2397
+ bind_arguments(instantiated_parameters, capability)
2398
+ unify(capability.result, instantiate(resolve(callee.result), mapping))
2399
+ end
2400
+
2401
+ def bind_arguments(parameters, capability)
2402
+ positional = capability.arguments.dup
2403
+ parameters.each do |parameter|
2404
+ case parameter.kind
2405
+ when :required, :optional
2406
+ unify(parameter.type, positional.shift) unless positional.empty?
2407
+ when :rest
2408
+ elements = positional.reject { |argument| argument.is_a?(Function) }
2409
+ element_type = elements.reduce { |left, right| Union.new([left, right].uniq) } ||
2410
+ stable_fresh([capability.object_id, parameter.name, :rest_element], "element")
2411
+ unify(parameter.type, Generic.new("Array", [element_type]))
2412
+ when :keyword, :optional_keyword
2413
+ argument = capability.keywords[parameter.name]
2414
+ unify(parameter.type, argument) if argument
2415
+ when :block
2416
+ block = positional.reverse.find { |argument| argument.is_a?(Function) }
2417
+ next unless block
2418
+
2419
+ declared = resolve(parameter.type)
2420
+ if declared.is_a?(Function)
2421
+ # Block arity rarely matches the callee's yield shape exactly;
2422
+ # unify pairwise and always flow the return type.
2423
+ declared.parameters.zip(block.parameters).each { |left, right| unify(left, right) if left && right }
2424
+ unify(declared.result, block.result)
2425
+ else
2426
+ unify(parameter.type, block)
2427
+ end
2428
+ end
2429
+ end
2430
+ end
2431
+
2432
+ def instantiate(type, mapping)
2433
+ case type
2434
+ when TypeVariable
2435
+ mapping[type] ||= fresh(type.hint)
2436
+ when Generic
2437
+ Generic.new(type.name, type.arguments.map { |argument| instantiate(argument, mapping) })
2438
+ when Union
2439
+ Union.new(type.members.map { |member| instantiate(member, mapping) }.uniq)
2440
+ when Function
2441
+ Function.new(
2442
+ type.parameters.map { |parameter| instantiate(parameter, mapping) },
2443
+ instantiate(type.result, mapping)
2444
+ )
2445
+ else
2446
+ type
2447
+ end
2448
+ end
2449
+
2450
+ def unify(left, right)
2451
+ return unless left && right
2452
+
2453
+ left = dereference(left)
2454
+ right = dereference(right)
2455
+ return if left == right
2456
+
2457
+ if left.is_a?(TypeVariable)
2458
+ bind(left, right)
2459
+ elsif right.is_a?(TypeVariable)
2460
+ bind(right, left)
2461
+ elsif left.is_a?(Generic) && right.is_a?(Generic) &&
2462
+ left.name == right.name && left.arguments.length == right.arguments.length
2463
+ left.arguments.zip(right.arguments).each { |pair| unify(*pair) }
2464
+ elsif left.is_a?(Function) && right.is_a?(Function) &&
2465
+ left.parameters.length == right.parameters.length
2466
+ left.parameters.zip(right.parameters).each { |pair| unify(*pair) }
2467
+ unify(left.result, right.result)
2468
+ end
2469
+ end
2470
+
2471
+ def bind(variable, type)
2472
+ return if occurs?(variable, type)
2473
+
2474
+ @substitutions[variable] = type
2475
+ # Variable-to-variable links are alpha-moves carrying no new concrete
2476
+ # information. Counting them as progress lets recursive call cycles
2477
+ # mint fresh-variable chains that never converge.
2478
+ @changed = true unless type.is_a?(TypeVariable)
2479
+ end
2480
+
2481
+ def occurs?(variable, type)
2482
+ type = dereference(type)
2483
+ case type
2484
+ when TypeVariable
2485
+ type == variable
2486
+ when Generic
2487
+ type.arguments.any? { |argument| occurs?(variable, argument) }
2488
+ when Union
2489
+ type.members.any? { |member| occurs?(variable, member) }
2490
+ when Function
2491
+ type.parameters.any? { |parameter| occurs?(variable, parameter) } || occurs?(variable, type.result)
2492
+ else
2493
+ false
2494
+ end
2495
+ end
2496
+
2497
+ def dereference(type)
2498
+ seen = Set.new
2499
+ while type.is_a?(TypeVariable) && @substitutions.key?(type) && !seen.include?(type)
2500
+ seen << type
2501
+ type = @substitutions.fetch(type)
2502
+ end
2503
+ type
2504
+ end
2505
+
2506
+ def resolve(type)
2507
+ type = dereference(type)
2508
+ case type
2509
+ when Generic
2510
+ Generic.new(type.name, type.arguments.map { |argument| resolve(argument) })
2511
+ when Union
2512
+ members = type.members.flat_map do |member|
2513
+ resolved = resolve(member)
2514
+ resolved.is_a?(Union) ? resolved.members : resolved
2515
+ end.uniq
2516
+ # Raising branches contribute no value, so noreturn disappears from
2517
+ # unions unless the method can only raise.
2518
+ live = members.reject { |member| member == NORETURN }
2519
+ members = live unless live.empty?
2520
+ members.length == 1 ? members.first : Union.new(members)
2521
+ when Function
2522
+ Function.new(type.parameters.map { |parameter| resolve(parameter) }, resolve(type.result))
2523
+ else
2524
+ type
2525
+ end
2526
+ end
2527
+
2528
+ MAX_NARROWING_SENDS = 8
2529
+ NARROWING_ROUNDS = 3
2530
+
2531
+ MAX_CONSTRUCTOR_UNION = 4
2532
+
2533
+ # Calls bind instantiated copies of the callee's parameters, so the
2534
+ # originals (and everything downstream: ivars, accessors, receivers)
2535
+ # stay unresolved. Accumulating ground argument types from all call
2536
+ # sites and binding them into the original parameters narrows those
2537
+ # chains at the source.
2538
+ def flow_call_arguments!
2539
+ accumulated = Hash.new { |hash, key| hash[key] = [] }
2540
+ @methods.each do |caller|
2541
+ caller.capabilities.each do |capability|
2542
+ target = call_target(caller, capability)
2543
+ accumulate_call_arguments(target, capability, accumulated) if target
2544
+ next unless capability.message == :new
2545
+
2546
+ class_name =
2547
+ if capability.receiver == caller.self_type && caller.kind == :singleton
2548
+ caller.owner
2549
+ else
2550
+ class_name_from(resolve(capability.receiver))
2551
+ end
2552
+ next unless class_name
2553
+
2554
+ init = resolve_named_owner(class_name, caller.owner, :instance, :initialize)
2555
+ accumulate_call_arguments(init, capability, accumulated) if init
2556
+ end
2557
+ end
2558
+
2559
+ accumulated.each do |variable, types|
2560
+ next unless dereference(variable).is_a?(TypeVariable)
2561
+
2562
+ members = types.uniq
2563
+ next if members.empty? || members.length > MAX_CONSTRUCTOR_UNION
2564
+
2565
+ unify(variable, members.length == 1 ? members.first : Union.new(members))
2566
+ end
2567
+ end
2568
+
2569
+ def accumulate_call_arguments(callee, capability, accumulated)
2570
+ positional = capability.arguments.reject { |argument| argument.is_a?(Function) }
2571
+ callee.parameters.each do |parameter|
2572
+ argument =
2573
+ case parameter.kind
2574
+ when :required, :optional then positional.shift
2575
+ when :keyword, :optional_keyword then capability.keywords[parameter.name]
2576
+ end
2577
+ next unless argument
2578
+
2579
+ terminal = dereference(parameter.type)
2580
+ next unless terminal.is_a?(TypeVariable)
2581
+ next if structural_parameter?(callee, parameter)
2582
+
2583
+ resolved = resolve(argument)
2584
+ accumulated[terminal] << resolved if flowable_argument?(resolved)
2585
+ end
2586
+ end
2587
+
2588
+ def flowable_argument?(type)
2589
+ case dereference(type)
2590
+ when TypeVariable then false
2591
+ when Union then type.members.any? { |member| flowable_argument?(member) }
2592
+ when Function then false
2593
+ else true
2594
+ end
2595
+ end
2596
+
2597
+ def structural_parameter?(callee, parameter)
2598
+ terminal = dereference(parameter.type)
2599
+ return false unless terminal.is_a?(TypeVariable)
2600
+
2601
+ callee.capabilities.any? do |capability|
2602
+ capability.message == :[] && dereference(capability.receiver) == terminal
2603
+ end
2604
+ end
2605
+
2606
+ # When callers pass hashes or arrays into a parameter, the callee body's
2607
+ # literal index sends on that parameter tell us which slots to narrow.
2608
+ def flow_structural_arguments!
2609
+ @methods.each do |caller|
2610
+ caller.capabilities.each do |capability|
2611
+ callee = call_target(caller, capability)
2612
+ next unless callee
2613
+
2614
+ positional = capability.arguments.reject { |argument| argument.is_a?(Function) }
2615
+ callee.parameters.each do |parameter|
2616
+ next if parameter.kind == :block
2617
+
2618
+ argument = case parameter.kind
2619
+ when :required, :optional then positional.shift
2620
+ when :keyword, :optional_keyword then capability.keywords[parameter.name]
2621
+ end
2622
+ next unless argument
2623
+
2624
+ flow_argument_slots(callee, parameter.type, resolve(argument))
2625
+ end
2626
+ end
2627
+ end
2628
+ end
2629
+
2630
+ def flow_argument_slots(callee, parameter_type, argument_type)
2631
+ parameter_type = dereference(parameter_type)
2632
+ return unless parameter_type.is_a?(TypeVariable)
2633
+
2634
+ case unwrap_nilable(argument_type)
2635
+ when Generic
2636
+ if hash?(argument_type)
2637
+ value_type = argument_type.arguments[1]
2638
+ index_slots_for(callee, parameter_type).each { |slot| unify(slot, value_type) }
2639
+ elsif array?(argument_type)
2640
+ element_type = array_element(argument_type)
2641
+ index_slots_for(callee, parameter_type).each do |slot|
2642
+ unify(slot, Union.new([element_type, Named.new("nil")]))
2643
+ end
2644
+ end
2645
+ when Union
2646
+ argument_type.members.each { |member| flow_argument_slots(callee, parameter_type, member) }
2647
+ end
2648
+ end
2649
+
2650
+ def index_slots_for(callee, parameter_type)
2651
+ callee.capabilities.filter_map do |capability|
2652
+ next unless capability.message == :[]
2653
+ next unless dereference(capability.receiver) == parameter_type
2654
+
2655
+ capability.result
2656
+ end
2657
+ end
2658
+
2659
+ def ground?(type)
2660
+ case type
2661
+ when TypeVariable then false
2662
+ when Generic then type.arguments.all? { |argument| ground?(argument) }
2663
+ when Union then type.members.all? { |member| ground?(member) }
2664
+ when Function then type.parameters.all? { |parameter| ground?(parameter) } && ground?(type.result)
2665
+ else true
2666
+ end
2667
+ end
2668
+
2669
+ # The same naming conventions trusted for unresolved sends apply to
2670
+ # method definitions whose body defeated inference: `?` methods return
2671
+ # bool, `to_*`/`*_hash` converters return their conventional type.
2672
+ def apply_definition_conventions!
2673
+ @methods.each do |method|
2674
+ default = convention_default(method.method_name.to_s)
2675
+ next unless default
2676
+
2677
+ parameter_terminals = method.parameters.map { |parameter| dereference(parameter.type) }
2678
+ residual = residual_return_variables(method) - parameter_terminals
2679
+ residual.each { |variable| unify(variable, default) }
2680
+ end
2681
+ end
2682
+
2683
+ def apply_intra_method_param_flow!
2684
+ @methods.each do |method|
2685
+ method.parameters.each do |parameter|
2686
+ method.capabilities.each do |capability|
2687
+ next unless %i[<< push append unshift prepend].include?(capability.message)
2688
+
2689
+ receiver = resolve(capability.receiver)
2690
+ next unless array?(receiver)
2691
+
2692
+ capability.arguments.each do |argument|
2693
+ next if argument.is_a?(Function)
2694
+ next unless argument.equal?(parameter.type)
2695
+
2696
+ unify_array_elements(receiver, resolve(parameter.type))
2697
+ end
2698
+ end
2699
+ end
2700
+ end
2701
+ end
2702
+
2703
+ # Nested sends like `hash[key1][key2] << item` create separate capabilities
2704
+ # per subexpression; when the inner result is still a variable, propagate
2705
+ # the looked-up slot type to the outer receiver before solving `<<`.
2706
+ def apply_chained_send_narrowing!
2707
+ @methods.each do |method|
2708
+ method.capabilities.each do |capability|
2709
+ narrow_chained_receiver!(method, capability)
2710
+ end
2711
+ end
2712
+ end
2713
+
2714
+ def narrow_chained_receiver!(method, capability)
2715
+ receiver = capability.receiver
2716
+ return unless receiver.is_a?(TypeVariable)
2717
+
2718
+ source_type = resolve_chained_source_type(method, capability)
2719
+ return unless source_type
2720
+
2721
+ case capability.message
2722
+ when :[]
2723
+ if hashish?(source_type)
2724
+ key_arg = capability.arguments.first
2725
+ if literal_hash_key?(source_type, key_arg) || hash_key_type_compatible?(source_type, key_arg)
2726
+ value_type = hash_value_type(source_type)
2727
+ unify(receiver, value_type) if value_type
2728
+ end
2729
+ elsif array?(source_type)
2730
+ element = array_element(source_type)
2731
+ unify(receiver, Union.new([element, Named.new("nil")])) if element
2732
+ end
2733
+ when :<<, :push, :append, :unshift, :prepend
2734
+ if array?(source_type)
2735
+ unify(receiver, source_type)
2736
+ capability.arguments.each do |argument|
2737
+ unify_array_elements(source_type, argument) unless argument.is_a?(Function)
2738
+ end
2739
+ end
2740
+ end
2741
+ end
2742
+
2743
+ def resolve_chained_source_type(method, capability)
2744
+ receiver = resolve(capability.receiver)
2745
+ return receiver unless receiver.is_a?(TypeVariable)
2746
+
2747
+ source = method.capabilities.find { |other| other.result.equal?(receiver) }
2748
+ return nil unless source
2749
+
2750
+ parent_type = resolve_chained_source_type(method, source)
2751
+ return nil unless parent_type
2752
+
2753
+ case source.message
2754
+ when :[]
2755
+ if hashish?(parent_type)
2756
+ key_arg = source.arguments.first
2757
+ if literal_hash_key?(parent_type, key_arg) || hash_key_type_compatible?(parent_type, key_arg)
2758
+ hash_value_type(parent_type)
2759
+ end
2760
+ elsif array?(parent_type)
2761
+ array_element(parent_type)
2762
+ end
2763
+ else
2764
+ nil
2765
+ end
2766
+ end
2767
+
2768
+ # Block parameters from `.each { |x| foo(x) }` are fresh variables that
2769
+ # cross-method flow skips; unify callee parameters directly.
2770
+ def apply_block_argument_callee_flow!
2771
+ @methods.each do |method|
2772
+ method.capabilities.each do |capability|
2773
+ next unless %i[each each_with_index each_value each_key].include?(capability.message)
2774
+
2775
+ block = capability.arguments.last
2776
+ next unless block.is_a?(Function)
2777
+
2778
+ block_params = block.parameters
2779
+ next if block_params.empty?
2780
+
2781
+ method.capabilities.each do |inner|
2782
+ target = call_target(method, inner)
2783
+ next unless target
2784
+
2785
+ positional = inner.arguments.reject { |argument| argument.is_a?(Function) }
2786
+ positional.each_with_index do |argument, index|
2787
+ block_param = block_params[index]
2788
+ next unless block_param && argument.equal?(block_param)
2789
+
2790
+ param = target.parameters[index]
2791
+ unify(param.type, block_param) if param
2792
+ end
2793
+ end
2794
+ end
2795
+ end
2796
+ end
2797
+
2798
+ def apply_mutator_return_nil!
2799
+ @methods.each do |method|
2800
+ next unless method.method_name.to_s.end_with?("!")
2801
+ next if mutator_returns_receiver?(method)
2802
+
2803
+ force_return!(method, Named.new("nil"))
2804
+ end
2805
+ end
2806
+
2807
+ def mutator_returns_receiver?(method)
2808
+ method.capabilities.any? do |capability|
2809
+ SELF_RETURNING.include?(capability.message) && capability.receiver == method.self_type
2810
+ end
2811
+ end
2812
+
2813
+ # Call-site return types can be bound while callees still carry stale
2814
+ # intermediate variables (e.g. `<<` before array element narrowing).
2815
+ # Re-unify once callees have converged.
2816
+ def reflow_callee_returns!
2817
+ @methods.each do |caller|
2818
+ caller.capabilities.each do |capability|
2819
+ callee = call_target(caller, capability)
2820
+ next unless callee
2821
+ next if callee.equal?(caller)
2822
+
2823
+ result_var = capability.result
2824
+ next unless result_var.is_a?(TypeVariable)
2825
+
2826
+ bind(result_var, resolve(callee.result))
2827
+ end
2828
+ end
2829
+ end
2830
+
2831
+ def apply_forced_return_conventions!
2832
+ @methods.each do |method|
2833
+ default = convention_default(method.method_name.to_s)
2834
+ next unless default == Named.new("nil")
2835
+ next unless @metadata.side_effect_methods.include?(method.method_name) ||
2836
+ method.method_name.to_s.match?(/\Areset(?:_[a-z_]+)?!\z/)
2837
+
2838
+ force_return!(method, default)
2839
+ end
2840
+ end
2841
+
2842
+ def force_return!(method, type)
2843
+ return unless method.result.is_a?(TypeVariable)
2844
+
2845
+ @substitutions[method.result] = type
2846
+ @changed = true
2847
+ end
2848
+
2849
+ def convention_default(name)
2850
+ return Named.new("bool") if name.end_with?("?")
2851
+ return Named.new("nil") if @metadata.side_effect_methods.include?(name.to_sym)
2852
+ return Named.new("nil") if name.match?(/\Areset(?:_[a-z_]+)?!\z/)
2853
+
2854
+ case name
2855
+ when "to_s", "to_str", "inspect" then Named.new("String")
2856
+ when "to_i" then Named.new("Integer")
2857
+ when "to_f" then Named.new("Float")
2858
+ when "to_sym" then Named.new("Symbol")
2859
+ when "count", "size", "length" then Named.new("Integer")
2860
+ else
2861
+ if name == "to_h" || name == "to_hash" || name.end_with?("_h", "_hash") || name.end_with?("_count")
2862
+ name.end_with?("_count") ? Named.new("Integer") : Generic.new("Hash", [fresh(:key), fresh(:value)])
2863
+ end
2864
+ end
2865
+ end
2866
+
2867
+ # Usage-based narrowing: when a method's return stays unresolved, the
2868
+ # messages callers send to its result are structural evidence. Attaching
2869
+ # them as capabilities lets the renderer show a named structural type
2870
+ # instead of Object. Display-only: no unification happens here.
2871
+ def narrow_residual_returns!
2872
+ NARROWING_ROUNDS.times do
2873
+ break unless narrowing_round
2874
+ end
2875
+ end
2876
+
2877
+ # One narrowing round; returns true when new evidence was attached so a
2878
+ # follow-up round can propagate it through forwarding methods.
2879
+ def narrowing_round
2880
+ observations = Hash.new { |hash, key| hash[key] = [] }
2881
+ @methods.each do |caller|
2882
+ caller.capabilities.each do |capability|
2883
+ target = call_target(caller, capability)
2884
+ observations[target] << [caller, capability.result] if target
2885
+ end
2886
+ end
2887
+
2888
+ changed = false
2889
+ observations.each do |callee, calls|
2890
+ parameter_variables = callee.parameters.map { |parameter| dereference(parameter.type) }
2891
+ residual = residual_return_variables(callee) - parameter_variables
2892
+ next if residual.empty?
2893
+
2894
+ sends = collect_result_observations(calls)
2895
+ next if sends.empty?
2896
+
2897
+ residual.each do |variable|
2898
+ existing = callee.capabilities.select { |capability| dereference(capability.receiver) == variable }
2899
+ seen = existing.map { |capability| [capability.message, capability.arguments.length] }.to_set
2900
+ budget = MAX_NARROWING_SENDS - seen.size
2901
+ break if budget <= 0
2902
+
2903
+ sends.each do |send|
2904
+ key = [send.fetch(:message), send.fetch(:arguments).length]
2905
+ next if seen.include?(key)
2906
+
2907
+ callee.capabilities << Capability.new(
2908
+ receiver: variable,
2909
+ message: send.fetch(:message),
2910
+ arguments: send.fetch(:arguments),
2911
+ keywords: send.fetch(:keywords),
2912
+ result: send.fetch(:result),
2913
+ line: callee.line
2914
+ )
2915
+ seen << key
2916
+ changed = true
2917
+ break if seen.size >= MAX_NARROWING_SENDS
2918
+ end
2919
+ end
2920
+ end
2921
+ changed
2922
+ end
2923
+
2924
+ def residual_return_variables(method)
2925
+ resolved = resolve(method.result)
2926
+ case resolved
2927
+ when TypeVariable then [resolved]
2928
+ when Union then resolved.members.grep(TypeVariable)
2929
+ else []
2930
+ end
2931
+ end
2932
+
2933
+ def collect_result_observations(calls)
2934
+ groups = {}
2935
+ calls.each do |caller, result|
2936
+ terminal = dereference(result)
2937
+ next unless terminal.is_a?(TypeVariable)
2938
+
2939
+ caller.capabilities.each do |capability|
2940
+ next unless dereference(capability.receiver) == terminal
2941
+
2942
+ key = [capability.message, capability.arguments.length, capability.keywords.keys.sort]
2943
+ entry = groups[key] ||= {
2944
+ message: capability.message,
2945
+ arguments: capability.arguments.map { |argument| resolve(argument) },
2946
+ keywords: capability.keywords.transform_values { |value| resolve(value) },
2947
+ result: nil
2948
+ }
2949
+ observed = resolve(capability.result)
2950
+ entry[:result] = entry[:result] ? merge_union(entry[:result], observed) : observed
2951
+ end
2952
+ end
2953
+ groups.values.sort_by { |send| send.fetch(:message).to_s }
2954
+ end
2955
+
2956
+ def merge_union(left, right)
2957
+ return left if left == right
2958
+
2959
+ members = (left.is_a?(Union) ? left.members : [left]) +
2960
+ (right.is_a?(Union) ? right.members : [right])
2961
+ Union.new(members.uniq)
2962
+ end
2963
+
2964
+ def resolved_method(method)
2965
+ MethodResult.new(
2966
+ name: method.name,
2967
+ owner: method.owner,
2968
+ kind: method.kind,
2969
+ method_name: method.method_name,
2970
+ superclass: method.superclass,
2971
+ line: method.line,
2972
+ end_line: method.end_line,
2973
+ parameters: method.parameters.map do |parameter|
2974
+ Parameter.new(name: parameter.name, type: resolve(parameter.type), kind: parameter.kind)
2975
+ end,
2976
+ result: resolve(method.result),
2977
+ capabilities: method.capabilities.map do |capability|
2978
+ Capability.new(
2979
+ receiver: resolve(capability.receiver),
2980
+ message: capability.message,
2981
+ arguments: capability.arguments.map { |argument| resolve(argument) },
2982
+ keywords: capability.keywords.transform_values { |value| resolve(value) },
2983
+ result: resolve(capability.result),
2984
+ line: capability.line
2985
+ )
2986
+ end,
2987
+ self_type: resolve(method.self_type),
2988
+ ivars: method.ivars.transform_values { |type| resolve(type) },
2989
+ locals: method.locals.transform_values { |type| resolve(type) },
2990
+ port_assignments: method.port_assignments,
2991
+ case_narrowings: method.case_narrowings
2992
+ )
2993
+ end
2994
+
2995
+ def fresh(hint = nil)
2996
+ variable = TypeVariable.new(@fresh_scope, @fresh_id, hint)
2997
+ @fresh_id += 1
2998
+ variable
2999
+ end
3000
+
3001
+ def stable_fresh(key, hint = nil)
3002
+ @stable_variables[key] ||= fresh(hint)
3003
+ end
3004
+
3005
+ def class_name_from(type)
3006
+ return unless type.is_a?(Named)
3007
+
3008
+ match = type.name.match(/\AClass\[(.+)\]\z/)
3009
+ match && match[1].delete_prefix("::")
3010
+ end
3011
+
3012
+ def array_element(type)
3013
+ case type
3014
+ when Generic
3015
+ type.arguments.first if type.name == "Array"
3016
+ when Union
3017
+ elements = type.members.filter_map { |member| array_element(member) }
3018
+ elements.reduce { |left, right| merge_union(left, right) }
3019
+ end
3020
+ end
3021
+
3022
+ def enumerable_element(type)
3023
+ case type
3024
+ when Generic
3025
+ return type.arguments.first if %w[Array Enumerator Set].include?(type.name)
3026
+ when Union
3027
+ elements = type.members.filter_map { |member| enumerable_element(member) }
3028
+ return elements.reduce { |left, right| merge_union(left, right) } unless elements.empty?
3029
+ end
3030
+
3031
+ nil
3032
+ end
3033
+ end
3034
+
3035
+ class UniversalHelperRegistry
3036
+ def initialize(methods)
3037
+ @occurrences = {}
3038
+ @definitions = {}
3039
+ methods.each { |method| register_method(method) }
3040
+ consolidate!
3041
+ end
3042
+
3043
+ def occurrence(method, receiver)
3044
+ @occurrences[[method.object_id, receiver]]
3045
+ end
3046
+
3047
+ def occurrences_for(method)
3048
+ @occurrences.filter_map { |(method_id, _), occurrence| occurrence if method_id == method.object_id }
3049
+ end
3050
+
3051
+ def definitions
3052
+ @definitions.values.sort_by { |definition| definition.fetch(:name) }
3053
+ end
3054
+
3055
+ def apply_names!(names)
3056
+ @definitions.each_value do |definition|
3057
+ semantic_name = normalize_semantic_name(names[definition.fetch(:id)])
3058
+ definition[:name] = semantic_name if semantic_name
3059
+ end
3060
+ end
3061
+
3062
+ # Clusters of distinct definitions that would receive the same normalized
3063
+ # name. The namer re-prompts these cluster by cluster until empty.
3064
+ def collisions(names)
3065
+ @definitions.each_value
3066
+ .group_by { |definition| normalize_semantic_name(names[definition.fetch(:id)]) }
3067
+ .select { |name, definitions| name && definitions.length > 1 }
3068
+ end
3069
+
3070
+ def normalize_semantic_name(name)
3071
+ return nil if name.to_s.empty?
3072
+
3073
+ tokens = name.to_s.scan(/[A-Za-z0-9]+/)
3074
+ normalized = if tokens.length == 1
3075
+ tokens.first.sub(/\A[a-z]/, &:upcase)
3076
+ else
3077
+ tokens.map(&:capitalize).join
3078
+ end
3079
+ return nil if normalized.empty?
3080
+
3081
+ normalized += "Able" unless normalized.match?(/(?:able|ible|ifiable|izable)\z/i)
3082
+ normalized
3083
+ end
3084
+
3085
+ private
3086
+
3087
+ def register_method(method)
3088
+ groups = method.capabilities
3089
+ .select { |capability| capability.receiver.is_a?(TypeVariable) }
3090
+ .group_by(&:receiver)
3091
+
3092
+ groups.each do |receiver, capabilities|
3093
+ shape = build_shape(receiver, capabilities)
3094
+ name = "Capability_#{Digest::SHA256.hexdigest(shape.fetch(:definition))[0, 10]}"
3095
+ definition = @definitions[name] ||= {
3096
+ id: name,
3097
+ name: name,
3098
+ parameters: shape.fetch(:parameter_names),
3099
+ definition: shape.fetch(:definition),
3100
+ template: shape.fetch(:template),
3101
+ slots: shape.fetch(:slots),
3102
+ uses: 0
3103
+ }
3104
+ definition[:uses] += 1
3105
+ @occurrences[[method.object_id, receiver]] = {
3106
+ arguments: shape.fetch(:parameter_types),
3107
+ definition: definition
3108
+ }
3109
+ end
3110
+ end
3111
+
3112
+ # Final compression: definitions sharing the same method structure (same
3113
+ # messages, arities, and keyword names) merge into one type. Slots that
3114
+ # agree stay fixed, concrete slots that differ become unions, and slots
3115
+ # involving generics become shared parameters bound per occurrence.
3116
+ def consolidate!
3117
+ @definitions.values.group_by { |definition| definition.fetch(:template) }.each_value do |members|
3118
+ next if members.length == 1
3119
+
3120
+ members = members.sort_by { |member| member.fetch(:id) }
3121
+ slot_count = members.first.fetch(:slots).length
3122
+ roles = (0...slot_count).map do |index|
3123
+ values = members.map { |member| member.fetch(:slots)[index] }
3124
+ if values.any? { |value| value.match?(/\bT\d+\b/) }
3125
+ :parameter
3126
+ elsif values.uniq.length == 1
3127
+ values.first
3128
+ else
3129
+ values.flat_map { |value| value.split(" | ") }.uniq.join(" | ")
3130
+ end
3131
+ end
3132
+ parameter_slots = roles.each_index.select { |index| roles[index] == :parameter }
3133
+ merged_text = members.first.fetch(:template).gsub(/%(\d+)%/) do
3134
+ index = Regexp.last_match(1).to_i
3135
+ roles[index] == :parameter ? "T#{parameter_slots.index(index) + 1}" : roles[index]
3136
+ end
3137
+ merged_id = "Capability_#{Digest::SHA256.hexdigest(merged_text)[0, 10]}"
3138
+ merged = {
3139
+ id: merged_id,
3140
+ name: merged_id,
3141
+ parameters: parameter_slots.each_index.map { |index| "T#{index + 1}" },
3142
+ definition: merged_text,
3143
+ template: merged_text,
3144
+ slots: [],
3145
+ uses: members.sum { |member| member.fetch(:uses) }
3146
+ }
3147
+
3148
+ member_ids = members.to_set { |member| member.fetch(:id) }
3149
+ @occurrences.each_value do |occurrence|
3150
+ definition = occurrence.fetch(:definition)
3151
+ next unless member_ids.include?(definition.fetch(:id))
3152
+
3153
+ occurrence[:arguments] = parameter_slots.map do |index|
3154
+ TemplateArgument.new(
3155
+ template: definition.fetch(:slots)[index],
3156
+ arguments: occurrence.fetch(:arguments)
3157
+ )
3158
+ end
3159
+ occurrence[:definition] = merged
3160
+ end
3161
+ members.each { |member| @definitions.delete(member.fetch(:id)) }
3162
+ @definitions[merged_id] = merged
3163
+ end
3164
+ end
3165
+
3166
+ def build_shape(receiver, capabilities)
3167
+ parameter_types = []
3168
+ parameter_indexes = {}
3169
+ slots = []
3170
+ normalize = lambda do |type|
3171
+ case type
3172
+ when TypeVariable
3173
+ return "Self" if type == receiver
3174
+
3175
+ index = parameter_indexes[type] ||= begin
3176
+ parameter_types << type
3177
+ parameter_types.length
3178
+ end
3179
+ "T#{index}"
3180
+ when Named
3181
+ type.name
3182
+ when Generic
3183
+ "#{type.name}[#{type.arguments.map { |argument| normalize.call(argument) }.join(", ")}]"
3184
+ when Union
3185
+ format_union_slots(type.members.map { |member| normalize.call(member) }.uniq)
3186
+ when Function
3187
+ params = type.parameters.map { |parameter| normalize.call(parameter) }.join(", ")
3188
+ "(#{params}) -> #{normalize.call(type.result)}"
3189
+ else
3190
+ "Object"
3191
+ end
3192
+ end
3193
+ # Functions are structure (block arity), everything else is a type slot.
3194
+ slotify = lambda do |type|
3195
+ if type.is_a?(Function)
3196
+ params = type.parameters.map { |parameter| slotify.call(parameter) }.join(", ")
3197
+ "(#{params}) -> #{slotify.call(type.result)}"
3198
+ else
3199
+ slots << normalize.call(type)
3200
+ "%#{slots.length - 1}%"
3201
+ end
3202
+ end
3203
+
3204
+ sends = capabilities.sort_by { |capability| capability_sort_key(capability) }.map do |capability|
3205
+ arguments = capability.arguments.map { |argument| slotify.call(argument) }
3206
+ arguments.concat(
3207
+ capability.keywords.sort.map { |name, value| "#{name}: #{slotify.call(value)}" }
3208
+ )
3209
+ "#{capability.message}(#{arguments.join(", ")}) -> #{slotify.call(capability.result)}"
3210
+ end
3211
+ template = "{ #{sends.join("; ")} }"
3212
+
3213
+ {
3214
+ parameter_types: parameter_types,
3215
+ parameter_names: parameter_types.each_index.map { |index| "T#{index + 1}" },
3216
+ template: template,
3217
+ slots: slots,
3218
+ definition: template.gsub(/%(\d+)%/) { slots[Regexp.last_match(1).to_i] }
3219
+ }
3220
+ end
3221
+
3222
+ def capability_sort_key(capability)
3223
+ [
3224
+ capability.message.to_s,
3225
+ capability.arguments.length,
3226
+ capability.keywords.keys.map(&:to_s).sort.join(","),
3227
+ capability.line
3228
+ ]
3229
+ end
3230
+
3231
+ def format_union_slots(members)
3232
+ members = members.uniq
3233
+ has_nil = members.include?("nil")
3234
+ members = members.reject { |member| member == "nil" || member == "Nullable[]" }
3235
+
3236
+ nullable_inners = []
3237
+ others = []
3238
+ members.each do |member|
3239
+ if (match = member.match(/\ANullable\[(.*)\]\z/))
3240
+ nullable_inners << match[1]
3241
+ else
3242
+ others << member
3243
+ end
3244
+ end
3245
+
3246
+ if has_nil || nullable_inners.any?
3247
+ inner_parts = (others + nullable_inners).uniq.sort
3248
+ inner = inner_parts.length == 1 ? inner_parts.first : inner_parts.join(" | ")
3249
+ return "Nullable[#{inner}]"
3250
+ end
3251
+
3252
+ others.sort!
3253
+ others.length == 1 ? others.first : others.join(" | ")
3254
+ end
3255
+ end
3256
+
3257
+ class Renderer
3258
+ NULLABLE = "Nullable"
3259
+ CORE_KEY_TYPES = %w[String Symbol Integer Float Rational Complex bool nil Object].freeze
3260
+
3261
+ attr_reader :helper_registry
3262
+
3263
+ def initialize(method, helper_registry: UniversalHelperRegistry.new([method]), analyzed_method: method)
3264
+ @method = method
3265
+ @analyzed_method = analyzed_method
3266
+ @helper_registry = helper_registry
3267
+ @names = {}
3268
+ @helpers = analyzed_method.capabilities
3269
+ .map(&:receiver)
3270
+ .uniq
3271
+ .filter_map do |receiver|
3272
+ occurrence = helper_registry.occurrence(analyzed_method, receiver)
3273
+ [receiver, occurrence] if occurrence
3274
+ end
3275
+ .to_h
3276
+ end
3277
+
3278
+ def text
3279
+ params = @method.parameters.map { |parameter| render_parameter(parameter) }.join(", ")
3280
+ signature = "#{@method.name} : (#{params}) -> #{render(@method.result, grounded_only: true)}"
3281
+ helpers = helper_definitions.map do |helper|
3282
+ parameters = helper.fetch(:parameters)
3283
+ generic = parameters.empty? ? "" : "<#{parameters.join(", ")}>"
3284
+ definition = polish_type_string(helper.fetch(:definition))
3285
+ " type #{helper.fetch(:name)}#{generic} = #{definition}"
3286
+ end
3287
+ constraints = ungrouped_capabilities.map do |capability|
3288
+ " L#{capability.line}: #{render_capability(capability)}"
3289
+ end
3290
+ sections = [signature]
3291
+ sections << "helpers:\n#{helpers.join("\n")}" unless helpers.empty?
3292
+ sections << "constraints:\n#{constraints.join("\n")}" unless constraints.empty?
3293
+ sections.join("\n")
3294
+ end
3295
+
3296
+ def as_json
3297
+ {
3298
+ name: @method.name,
3299
+ line: @method.line,
3300
+ end_line: @method.end_line,
3301
+ signature: text.lines.first.chomp,
3302
+ helpers: helper_definitions,
3303
+ constraints: ungrouped_capabilities.map { |capability| render_capability(capability) },
3304
+ send_constraint_count: @method.capabilities.length,
3305
+ locals: rendered_bindings
3306
+ }
3307
+ end
3308
+
3309
+ private
3310
+
3311
+ # Local variables, parameters, and ivars with their rendered types, for
3312
+ # editor hover support.
3313
+ def rendered_bindings
3314
+ bindings = {}
3315
+ @method.locals.each { |name, type| bindings[name.to_s] = render(type, grounded_only: true, skip_helpers: true) }
3316
+ @method.ivars.each { |name, type| bindings[name.to_s] = render(type, grounded_only: true, skip_helpers: true) }
3317
+ bindings
3318
+ end
3319
+
3320
+ def render_parameter(parameter)
3321
+ return "... #{render(parameter.type)}" if parameter.kind == :forwarding
3322
+
3323
+ prefix = case parameter.kind
3324
+ when :rest then "*"
3325
+ when :keyword_rest then "**"
3326
+ when :block then "&"
3327
+ else ""
3328
+ end
3329
+ suffix = %i[keyword optional_keyword].include?(parameter.kind) ? ":" : ""
3330
+ optional = %i[optional optional_keyword].include?(parameter.kind) ? "?" : ""
3331
+ type_text = render_parameter_type(parameter)
3332
+ "#{prefix}#{parameter.name}#{suffix}#{optional} #{type_text}"
3333
+ end
3334
+
3335
+ def render_parameter_type(parameter)
3336
+ concrete = render(parameter.type, skip_helpers: true)
3337
+ return concrete if concrete_parameter_type?(concrete)
3338
+
3339
+ analyzed = @analyzed_method.parameters.find { |candidate| candidate.name == parameter.name }
3340
+ analyzed_variable = analyzed&.type
3341
+ analyzed_variable = nil unless analyzed_variable.is_a?(TypeVariable)
3342
+
3343
+ if analyzed_variable
3344
+ record = render_record_type(analyzed_variable)
3345
+ return record if record
3346
+
3347
+ return render_helper_reference(@helpers.fetch(analyzed_variable)) if @helpers.key?(analyzed_variable)
3348
+ end
3349
+
3350
+ render(parameter.type, structural: true)
3351
+ end
3352
+
3353
+ def concrete_parameter_type?(text)
3354
+ return false if text == "Object"
3355
+ return false if text.start_with?("{ ")
3356
+ return false if text.match?(/\ACapability_[a-f0-9]+/)
3357
+
3358
+ true
3359
+ end
3360
+
3361
+ def render_record_type(variable)
3362
+ capabilities = solved_capabilities_for(variable).select { |capability| capability.message == :[] }
3363
+ fields = Hash.new { |hash, key| hash[key] = [] }
3364
+ capabilities.each do |capability|
3365
+ field = field_name_from_index_key(capability.arguments.first)
3366
+ next unless field
3367
+
3368
+ fields[field] << capability.result
3369
+ end
3370
+ return nil if fields.empty?
3371
+
3372
+ body = fields.sort.map do |name, types|
3373
+ rendered = join_union(types.map { |type| render(type, grounded_only: true, skip_helpers: true) }.uniq)
3374
+ "#{name}: #{rendered}"
3375
+ end.join(", ")
3376
+ "{ #{body} }"
3377
+ end
3378
+
3379
+ def solved_capabilities_for(analyzed_variable)
3380
+ analyzed = @analyzed_method.capabilities.select { |capability| capability.receiver == analyzed_variable }
3381
+ analyzed.map do |analyzed_capability|
3382
+ @method.capabilities.find do |solved_capability|
3383
+ solved_capability.line == analyzed_capability.line &&
3384
+ solved_capability.message == analyzed_capability.message &&
3385
+ index_key_eq?(solved_capability.arguments, analyzed_capability.arguments)
3386
+ end || analyzed_capability
3387
+ end
3388
+ end
3389
+
3390
+ def index_key_eq?(left_args, right_args)
3391
+ left_args.zip(right_args).all? do |left, right|
3392
+ resolve_index_key(left) == resolve_index_key(right)
3393
+ end
3394
+ end
3395
+
3396
+ def resolve_index_key(key)
3397
+ case key
3398
+ when Named then key.name
3399
+ else render(key, skip_helpers: true)
3400
+ end
3401
+ end
3402
+
3403
+ def field_name_from_index_key(key)
3404
+ case key
3405
+ when Named
3406
+ return key.name unless CORE_KEY_TYPES.include?(key.name)
3407
+ end
3408
+ nil
3409
+ end
3410
+
3411
+ def record_parameter?(variable)
3412
+ @analyzed_method.capabilities.any? do |capability|
3413
+ capability.receiver == variable &&
3414
+ capability.message == :[] &&
3415
+ field_name_from_index_key(capability.arguments.first)
3416
+ end
3417
+ end
3418
+
3419
+ def record_field_slot?(capability)
3420
+ return false unless capability.message == :[]
3421
+
3422
+ receiver = capability.receiver
3423
+ return false unless receiver.is_a?(TypeVariable) && record_parameter?(receiver)
3424
+
3425
+ field_name_from_index_key(capability.arguments.first)
3426
+ end
3427
+
3428
+ def render(type, structural: false, grounded_only: false, skip_helpers: false, depth: 0)
3429
+ return "Object" if depth > 48
3430
+
3431
+ type = simplify(type)
3432
+ rendered = case type
3433
+ when TypeVariable
3434
+ if @helpers.key?(type) && !skip_helpers
3435
+ render_helper_reference(@helpers.fetch(type))
3436
+ else
3437
+ render_structural_variable(type, depth: depth + 1)
3438
+ end
3439
+ when Named
3440
+ type.name
3441
+ when Generic
3442
+ if type.name == NULLABLE && type.arguments.length == 1
3443
+ "Nullable[#{render(type.arguments.first, grounded_only:, skip_helpers:, depth: depth + 1)}]"
3444
+ else
3445
+ "#{type.name}[#{type.arguments.map { |argument| render(argument, grounded_only:, skip_helpers:, depth: depth + 1) }.join(", ")}]"
3446
+ end
3447
+ when Union
3448
+ join_union(type.members.map { |member| render(member, grounded_only:, skip_helpers:, depth: depth + 1) })
3449
+ when Function
3450
+ parameters = type.parameters.map { |parameter| render(parameter, grounded_only:, skip_helpers:, depth: depth + 1) }.join(", ")
3451
+ "(#{parameters}) -> #{render(type.result, grounded_only:, skip_helpers:, depth: depth + 1)}"
3452
+ else
3453
+ "Object"
3454
+ end
3455
+ polish_type_string(rendered)
3456
+ end
3457
+
3458
+ def simplify(type, seen = nil, depth = 0)
3459
+ return type if depth > 48
3460
+ seen ||= Set.new
3461
+ return type if seen.include?(type.object_id)
3462
+
3463
+ seen.add(type.object_id)
3464
+ case type
3465
+ when Union
3466
+ members = type.members.flat_map { |member| member.is_a?(Union) ? member.members : [member] }
3467
+ members = members.map { |member| simplify(member, seen, depth + 1) }
3468
+ members = dedupe_types(members)
3469
+ return members.first if members.length == 1
3470
+
3471
+ nil_members, rest = members.partition { |member| nil_type?(member) }
3472
+ if nil_members.any?
3473
+ inner = rest.length == 1 ? rest.first : Union.new(rest)
3474
+ inner = simplify(inner, seen, depth + 1)
3475
+ return Generic.new(NULLABLE, [inner]) unless nil_type?(inner)
3476
+
3477
+ return inner
3478
+ end
3479
+
3480
+ Union.new(members)
3481
+ when Generic
3482
+ arguments = type.arguments.map { |argument| simplify(argument, seen, depth + 1) }
3483
+ if type.name == NULLABLE && arguments.length == 1
3484
+ inner = unwrap_nullable(arguments.first)
3485
+ return Generic.new(NULLABLE, [inner.is_a?(Generic) && inner.name == NULLABLE ? inner.arguments.first : inner])
3486
+ end
3487
+
3488
+ Generic.new(type.name, arguments)
3489
+ when Function
3490
+ Function.new(
3491
+ type.parameters.map { |parameter| simplify(parameter, seen, depth + 1) },
3492
+ simplify(type.result, seen, depth + 1)
3493
+ )
3494
+ else
3495
+ type
3496
+ end
3497
+ end
3498
+
3499
+ def nil_type?(type)
3500
+ type.is_a?(Named) && type.name == "nil"
3501
+ end
3502
+
3503
+ def unwrap_nullable(type)
3504
+ while type.is_a?(Generic) && type.name == NULLABLE && type.arguments.length == 1
3505
+ type = type.arguments.first
3506
+ end
3507
+ type
3508
+ end
3509
+
3510
+ def nullable_type(inner)
3511
+ inner = unwrap_nullable(inner)
3512
+ return inner if nil_type?(inner)
3513
+
3514
+ Generic.new(NULLABLE, [inner])
3515
+ end
3516
+
3517
+ def flatten_nullable_type(type)
3518
+ return type unless type.is_a?(Generic) && type.name == NULLABLE && type.arguments.length == 1
3519
+
3520
+ Generic.new(NULLABLE, [unwrap_nullable(type.arguments.first)])
3521
+ end
3522
+
3523
+ def dedupe_types(types)
3524
+ types.uniq
3525
+ end
3526
+
3527
+ # Nullable[T] when nil is present; otherwise a sorted, deduped union. Never
3528
+ # drop members — an incomplete inference is shown as Object, not elided.
3529
+ def join_union(rendered)
3530
+ parts = []
3531
+ has_nil = false
3532
+ rendered.uniq.each do |member|
3533
+ case member
3534
+ when "nil"
3535
+ has_nil = true
3536
+ when "Nullable[]"
3537
+ next
3538
+ else
3539
+ if (match = member.match(/\ANullable\[(.*)\]\z/m))
3540
+ has_nil = true
3541
+ parts.concat(split_top_level_union(match[1]))
3542
+ else
3543
+ parts.concat(split_top_level_union(member))
3544
+ end
3545
+ end
3546
+ end
3547
+
3548
+ parts = parts.reject { |part| part == "nil" || part.empty? }.uniq.sort
3549
+ if has_nil
3550
+ inner = parts.length == 1 ? parts.first : parts.join(" | ")
3551
+ inner = inner.match(/\ANullable\[(.*)\]\z/m)&.then { |match| match[1] } || inner
3552
+ return "Nullable[#{inner}]"
3553
+ end
3554
+
3555
+ parts.length == 1 ? parts.first : parts.join(" | ")
3556
+ end
3557
+
3558
+ # Rewrite nil-union slots inside helper definition text and similar
3559
+ # pre-rendered type strings from the registry.
3560
+ def polish_type_string(text)
3561
+ return text unless text.is_a?(String)
3562
+
3563
+ text = flatten_nullable_text(text)
3564
+ 8.times do
3565
+ previous = text
3566
+ text = text.gsub(/([A-Za-z0-9_:|\[\], ]+ \|\ nil|nil \|\ [A-Za-z0-9_:|\[\], ]+)/) do |match|
3567
+ join_union(split_top_level_union(match))
3568
+ end
3569
+ text = text.gsub(/Nullable\[([^\]]+)\]/) do |_match|
3570
+ inner = join_union(split_top_level_union(Regexp.last_match(1)))
3571
+ inner.start_with?("Nullable[") ? inner : "Nullable[#{inner}]"
3572
+ end
3573
+ text = flatten_nullable_text(text)
3574
+ break if text == previous
3575
+ end
3576
+ text
3577
+ end
3578
+
3579
+ def flatten_nullable_text(text)
3580
+ 8.times do
3581
+ previous = text
3582
+ text = text.gsub("Nullable[Nullable[", "Nullable[")
3583
+ break if text == previous
3584
+ end
3585
+ text
3586
+ end
3587
+
3588
+ def split_top_level_union(text)
3589
+ parts = []
3590
+ depth = 0
3591
+ start = 0
3592
+ i = 0
3593
+ while i < text.length
3594
+ char = text[i]
3595
+ depth += 1 if char == "["
3596
+ depth -= 1 if char == "]"
3597
+ if depth.zero? && text[i, 3] == " | "
3598
+ parts << text[start...i].strip
3599
+ i += 3
3600
+ start = i
3601
+ next
3602
+ end
3603
+ i += 1
3604
+ end
3605
+ parts << text[start..].strip
3606
+ parts.reject(&:empty?)
3607
+ end
3608
+
3609
+ def render_structural_variable(variable, depth: 0)
3610
+ return "Object" if depth > 16
3611
+
3612
+ capabilities = @method.capabilities.select { |capability| capability.receiver == variable }
3613
+ return "Object" if capabilities.empty?
3614
+
3615
+ body = capabilities.first(24).map { |capability| render_send(capability, depth: depth + 1) }.uniq.join("; ")
3616
+ "{ #{body} }"
3617
+ end
3618
+
3619
+ def render_capability(capability)
3620
+ "#{render(capability.receiver)} responds to #{render_send(capability)}"
3621
+ end
3622
+
3623
+ def render_send(capability, depth: 0)
3624
+ return "..." if depth > 16
3625
+
3626
+ arguments = capability.arguments.map { |argument| render(argument, depth: depth + 1) }
3627
+ arguments.concat(capability.keywords.map { |name, value| "#{name}: #{render(value, depth: depth + 1)}" })
3628
+ "#{capability.message}(#{arguments.join(", ")}) -> #{render(capability.result, depth: depth + 1)}"
3629
+ end
3630
+
3631
+ def helper_definitions
3632
+ @helpers.values.uniq.map do |occurrence|
3633
+ definition = occurrence.fetch(:definition)
3634
+ {
3635
+ name: definition.fetch(:name),
3636
+ reference: render_helper_reference(occurrence),
3637
+ parameters: definition.fetch(:parameters),
3638
+ definition: polish_type_string(definition.fetch(:definition))
3639
+ }
3640
+ end.uniq { |helper| [helper.fetch(:name), helper.fetch(:definition)] }
3641
+ end
3642
+
3643
+ def ungrouped_capabilities
3644
+ grouped_lines = @analyzed_method.capabilities
3645
+ .select { |capability| capability.receiver.is_a?(TypeVariable) && @helpers.key?(capability.receiver) }
3646
+ .map(&:line)
3647
+ .to_set
3648
+ @method.capabilities.reject { |capability| grouped_lines.include?(capability.line) }
3649
+ end
3650
+
3651
+ def render_helper_reference(occurrence)
3652
+ arguments = occurrence.fetch(:arguments).map { |argument| render_helper_argument(argument) }
3653
+ name = occurrence.fetch(:definition).fetch(:name)
3654
+ reference = arguments.empty? ? name : "#{name}[#{arguments.join(", ")}]"
3655
+ polish_type_string(reference)
3656
+ end
3657
+
3658
+ def render_helper_argument(type)
3659
+ type = simplify(type) if type.is_a?(Union) || type.is_a?(Generic)
3660
+ case type
3661
+ when TemplateArgument
3662
+ substituted = type.template.gsub(/\bT(\d+)\b/) do
3663
+ render_helper_argument(type.arguments[Regexp.last_match(1).to_i - 1])
3664
+ end
3665
+ polish_type_string(substituted)
3666
+ when TypeVariable
3667
+ solved = solved_type_for_variable(type)
3668
+ case solved
3669
+ when Named then solved.name
3670
+ when Generic
3671
+ if solved.name == NULLABLE && solved.arguments.length == 1
3672
+ return "Nullable[#{render_helper_argument(solved.arguments.first)}]"
3673
+ end
3674
+
3675
+ "#{solved.name}[#{solved.arguments.map { |argument| render_helper_argument(argument) }.join(", ")}]"
3676
+ when Union
3677
+ join_union(solved.members.map { |member| render_helper_argument(member) })
3678
+ when Function
3679
+ parameters = solved.parameters.map { |parameter| render_helper_argument(parameter) }.join(", ")
3680
+ "(#{parameters}) -> #{render_helper_argument(solved.result)}"
3681
+ else
3682
+ "Object"
3683
+ end
3684
+ when Named
3685
+ type.name
3686
+ when Generic
3687
+ "#{type.name}[#{type.arguments.map { |argument| render_helper_argument(argument) }.join(", ")}]"
3688
+ when Union
3689
+ join_union(type.members.map { |member| render_helper_argument(member) })
3690
+ when Function
3691
+ parameters = type.parameters.map { |parameter| render_helper_argument(parameter) }.join(", ")
3692
+ "(#{parameters}) -> #{render_helper_argument(type.result)}"
3693
+ else
3694
+ "Object"
3695
+ end
3696
+ end
3697
+
3698
+ def solved_type_for_variable(analyzed_variable)
3699
+ analyzed_capability = @analyzed_method.capabilities.find { |capability| capability.result == analyzed_variable }
3700
+ return nil unless analyzed_capability
3701
+
3702
+ @method.capabilities.find do |capability|
3703
+ capability.line == analyzed_capability.line && capability.message == analyzed_capability.message
3704
+ end&.result
3705
+ end
3706
+ end
3707
+
3708
+ class HtmlDocument
3709
+ def initialize(reports, analyzed_files: reports.map(&:first).uniq, parse_errors: {})
3710
+ @reports = reports
3711
+ @grouped = reports.group_by(&:first)
3712
+ @analyzed_files = analyzed_files
3713
+ @parse_errors = parse_errors
3714
+ @helper_registry = reports.first&.last&.helper_registry
3715
+ end
3716
+
3717
+ def render
3718
+ method_count = @reports.length
3719
+ constraint_count = @reports.sum { |_, renderer| renderer.as_json.fetch(:send_constraint_count) }
3720
+ generated_at = Time.now.utc.strftime("%Y-%m-%d %H:%M UTC")
3721
+
3722
+ <<~HTML
3723
+ <!doctype html>
3724
+ <html lang="en">
3725
+ <head>
3726
+ <meta charset="utf-8">
3727
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3728
+ <title>Autotype inferred types</title>
3729
+ <style>
3730
+ :root {
3731
+ color-scheme: light dark;
3732
+ --bg: #111318;
3733
+ --panel: #181b22;
3734
+ --panel-2: #20242d;
3735
+ --text: #e8eaf0;
3736
+ --muted: #9ba3b4;
3737
+ --line: #343a46;
3738
+ --accent: #78a9ff;
3739
+ --capability: #b6e3a8;
3740
+ --code: #f1d18a;
3741
+ }
3742
+ * { box-sizing: border-box; }
3743
+ html { scroll-behavior: smooth; }
3744
+ body {
3745
+ margin: 0;
3746
+ background: var(--bg);
3747
+ color: var(--text);
3748
+ font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3749
+ }
3750
+ a { color: var(--accent); text-decoration: none; }
3751
+ a:hover { text-decoration: underline; }
3752
+ code {
3753
+ font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
3754
+ overflow-wrap: anywhere;
3755
+ }
3756
+ .layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; }
3757
+ aside {
3758
+ position: sticky;
3759
+ top: 0;
3760
+ height: 100vh;
3761
+ overflow: auto;
3762
+ border-right: 1px solid var(--line);
3763
+ background: var(--panel);
3764
+ padding: 20px 16px;
3765
+ }
3766
+ main { width: min(1100px, 100%); padding: 32px 42px 80px; }
3767
+ h1 { margin: 0 0 6px; font-size: 24px; }
3768
+ h2 { margin: 42px 0 12px; font-size: 18px; scroll-margin-top: 20px; }
3769
+ h3 { margin: 0; font-size: 14px; font-weight: 600; }
3770
+ .muted { color: var(--muted); }
3771
+ .summary { display: flex; gap: 24px; margin: 18px 0 28px; }
3772
+ .summary strong { display: block; font-size: 20px; }
3773
+ .summary span { color: var(--muted); font-size: 12px; }
3774
+ #search {
3775
+ width: 100%;
3776
+ margin: 18px 0;
3777
+ padding: 9px 10px;
3778
+ border: 1px solid var(--line);
3779
+ border-radius: 6px;
3780
+ background: var(--panel-2);
3781
+ color: var(--text);
3782
+ }
3783
+ nav ul { margin: 0; padding: 0; list-style: none; }
3784
+ nav li { margin: 5px 0; }
3785
+ nav a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3786
+ .method {
3787
+ margin: 10px 0;
3788
+ border: 1px solid var(--line);
3789
+ border-radius: 7px;
3790
+ background: var(--panel);
3791
+ overflow: hidden;
3792
+ }
3793
+ .method header { padding: 12px 15px; border-bottom: 1px solid var(--line); }
3794
+ .line { color: var(--muted); font-size: 12px; }
3795
+ .signature {
3796
+ display: block;
3797
+ padding: 13px 15px;
3798
+ color: var(--code);
3799
+ background: var(--panel-2);
3800
+ white-space: pre-wrap;
3801
+ overflow-wrap: anywhere;
3802
+ }
3803
+ .helpers { padding: 11px 15px 4px; border-top: 1px solid var(--line); }
3804
+ .helpers h4 { margin: 0 0 7px; color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
3805
+ .helper { display: block; padding: 5px 0 8px; }
3806
+ .helper-name { display: block; color: var(--accent); font-weight: 700; overflow-wrap: anywhere; }
3807
+ .helper-definition { display: block; padding-top: 2px; color: var(--capability); overflow-wrap: anywhere; }
3808
+ .universal-types { margin: 32px 0 44px; }
3809
+ .universal-type { padding: 10px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
3810
+ .universal-type code { display: block; }
3811
+ .uses { color: var(--muted); font-size: 12px; }
3812
+ .constraints { margin: 0; padding: 10px 15px 13px 34px; }
3813
+ .constraints li { padding: 2px 0; color: var(--capability); }
3814
+ .empty { padding: 10px 15px; color: var(--muted); }
3815
+ .warnings { margin: 24px 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 7px; }
3816
+ .warnings h2 { margin: 0 0 8px; font-size: 15px; }
3817
+ .warnings ul { margin: 0; padding-left: 20px; }
3818
+ .hidden { display: none; }
3819
+ @media (prefers-color-scheme: light) {
3820
+ :root {
3821
+ --bg: #f7f8fa;
3822
+ --panel: #fff;
3823
+ --panel-2: #f1f3f6;
3824
+ --text: #20242d;
3825
+ --muted: #677084;
3826
+ --line: #d9dde5;
3827
+ --accent: #245fb5;
3828
+ --capability: #276738;
3829
+ --code: #754f00;
3830
+ }
3831
+ }
3832
+ @media (max-width: 760px) {
3833
+ .layout { display: block; }
3834
+ aside { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
3835
+ main { padding: 24px 18px 60px; }
3836
+ }
3837
+ </style>
3838
+ </head>
3839
+ <body>
3840
+ <div class="layout">
3841
+ <aside>
3842
+ <h1>Inferred types</h1>
3843
+ <div class="muted">Autotype · #{escape(generated_at)}</div>
3844
+ <input id="search" type="search" placeholder="Filter methods or types…" aria-label="Filter methods or types">
3845
+ <nav aria-label="Source files">
3846
+ <ul>#{navigation}</ul>
3847
+ </nav>
3848
+ </aside>
3849
+ <main>
3850
+ <h1>Syntax-less Ruby typedoc</h1>
3851
+ <p class="muted">Intraprocedural structural inference from Ruby message sends. Signatures are generalized at each method boundary.</p>
3852
+ <div class="summary">
3853
+ <div><strong>#{@analyzed_files.length}</strong><span>files analyzed</span></div>
3854
+ <div><strong>#{@grouped.length}</strong><span>files with methods</span></div>
3855
+ <div><strong>#{method_count}</strong><span>methods</span></div>
3856
+ <div><strong>#{constraint_count}</strong><span>send constraints</span></div>
3857
+ <div><strong>#{universal_definitions.length}</strong><span>universal types</span></div>
3858
+ </div>
3859
+ #{parse_failure_section}
3860
+ #{universal_type_section}
3861
+ #{sections}
3862
+ </main>
3863
+ </div>
3864
+ <script>
3865
+ const search = document.querySelector("#search");
3866
+ search.addEventListener("input", () => {
3867
+ const query = search.value.trim().toLowerCase();
3868
+ document.querySelectorAll(".method").forEach((element) => {
3869
+ element.classList.toggle("hidden", query && !element.dataset.search.includes(query));
3870
+ });
3871
+ document.querySelectorAll(".file-section").forEach((section) => {
3872
+ const visible = section.querySelector(".method:not(.hidden)");
3873
+ section.classList.toggle("hidden", query && !visible);
3874
+ });
3875
+ });
3876
+ </script>
3877
+ </body>
3878
+ </html>
3879
+ HTML
3880
+ end
3881
+
3882
+ private
3883
+
3884
+ def navigation
3885
+ files = @grouped.keys.sort.each_with_index.map do |path, index|
3886
+ %(<li><a href="#file-#{index}" title="#{escape(path)}">#{escape(path)}</a></li>)
3887
+ end
3888
+ ([%(<li><a href="#universal-types">Universal capability types</a></li>)] + files).join
3889
+ end
3890
+
3891
+ def parse_failure_section
3892
+ return "" if @parse_errors.empty?
3893
+
3894
+ items = @parse_errors.sort.map do |path, errors|
3895
+ "<li><code>#{escape(path)}</code>: #{escape(errors.join("; "))}</li>"
3896
+ end.join
3897
+ <<~HTML
3898
+ <section class="warnings">
3899
+ <h2>Parse failures (#{@parse_errors.length})</h2>
3900
+ <ul>#{items}</ul>
3901
+ </section>
3902
+ HTML
3903
+ end
3904
+
3905
+ def sections
3906
+ @grouped.sort_by(&:first).each_with_index.map do |(path, entries), index|
3907
+ methods = entries.map { |_, renderer| method_card(renderer) }.join
3908
+ <<~HTML
3909
+ <section class="file-section" id="file-#{index}">
3910
+ <h2>#{escape(path)}</h2>
3911
+ #{methods}
3912
+ </section>
3913
+ HTML
3914
+ end.join
3915
+ end
3916
+
3917
+ def universal_type_section
3918
+ definitions = universal_definitions.map do |definition|
3919
+ parameters = definition.fetch(:parameters)
3920
+ generic = parameters.empty? ? "" : "&lt;#{escape(parameters.join(", "))}&gt;"
3921
+ <<~HTML
3922
+ <div class="universal-type" id="type-#{escape(definition.fetch(:name))}">
3923
+ <code><span class="helper-name">type #{escape(definition.fetch(:name))}#{generic}</span> = <span class="helper-definition">#{escape(definition.fetch(:definition))}</span></code>
3924
+ <span class="uses">Used by #{definition.fetch(:uses)} inferred receiver#{definition.fetch(:uses) == 1 ? "" : "s"}</span>
3925
+ </div>
3926
+ HTML
3927
+ end.join
3928
+
3929
+ <<~HTML
3930
+ <section class="universal-types" id="universal-types">
3931
+ <h2>Universal capability types</h2>
3932
+ <p class="muted">Canonical structural shapes shared by every analyzed file. Free types are represented as generic parameters.</p>
3933
+ #{definitions}
3934
+ </section>
3935
+ HTML
3936
+ end
3937
+
3938
+ def universal_definitions
3939
+ @helper_registry&.definitions || []
3940
+ end
3941
+
3942
+ def method_card(renderer)
3943
+ report = renderer.as_json
3944
+ helpers = report.fetch(:helpers)
3945
+ constraints = report.fetch(:constraints)
3946
+ helper_html = if helpers.empty?
3947
+ ""
3948
+ else
3949
+ rows = helpers.map do |helper|
3950
+ <<~HTML
3951
+ <div class="helper">
3952
+ <a class="helper-name" href="#type-#{escape(helper.fetch(:name))}">#{escape(helper.fetch(:name))}</a>
3953
+ <code class="helper-definition">#{escape(helper.fetch(:reference))}</code>
3954
+ </div>
3955
+ HTML
3956
+ end.join
3957
+ %(<section class="helpers"><h4>Uses universal types</h4>#{rows}</section>)
3958
+ end
3959
+ constraint_html = if constraints.empty?
3960
+ helpers.empty? ? %(<div class="empty">No structural send constraints</div>) : ""
3961
+ else
3962
+ items = constraints.map { |constraint| "<li><code>#{escape(constraint)}</code></li>" }.join
3963
+ %(<ol class="constraints">#{items}</ol>)
3964
+ end
3965
+ helper_text = helpers.flat_map { |helper| helper.values }
3966
+ searchable = ([report.fetch(:name), report.fetch(:signature)] + helper_text + constraints).join(" ").downcase
3967
+
3968
+ <<~HTML
3969
+ <article class="method" data-search="#{escape(searchable)}">
3970
+ <header>
3971
+ <h3>#{escape(report.fetch(:name))} <span class="line">line #{report.fetch(:line)}</span></h3>
3972
+ </header>
3973
+ <code class="signature">#{escape(report.fetch(:signature))}</code>
3974
+ #{helper_html}
3975
+ #{constraint_html}
3976
+ </article>
3977
+ HTML
3978
+ end
3979
+
3980
+ def escape(value)
3981
+ CGI.escapeHTML(value.to_s)
3982
+ end
3983
+ end
3984
+
3985
+ # Deterministic, LLM-free naming. Names are derived from the capability
3986
+ # shape itself: a Ruby-vocabulary table for common messages, snake_case
3987
+ # morphology for the rest, and a detail-escalation ladder that resolves
3988
+ # collisions by folding in more of the shape (all sends, then result types,
3989
+ # then argument types, then a full injective mangle of the definition).
3990
+ class HeuristicCapabilityNamer
3991
+ MAX_LEVEL = 5
3992
+ GREEK_WORDS = /(?:Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta|Iota|Kappa|Lambda|Mu|Nu|Xi|Omicron|Pi|Rho|Sigma|Tau|Upsilon|Phi|Chi|Psi|Omega)(?=[A-Z]|$)/
3993
+ KNOWN = {
3994
+ "to_s" => "Stringifiable", "to_str" => "Stringifiable", "inspect" => "Inspectable",
3995
+ "to_i" => "IntegerConvertible", "to_int" => "IntegerConvertible", "to_f" => "FloatConvertible",
3996
+ "to_h" => "HashConvertible", "to_a" => "ArrayConvertible", "to_sym" => "Symbolizable",
3997
+ "to_json" => "JsonSerializable", "to_set" => "SetConvertible", "to_proc" => "ProcConvertible",
3998
+ "each" => "Enumerable", "each_with_index" => "IndexEnumerable", "each_with_object" => "ObjectEnumerable",
3999
+ "each_pair" => "PairEnumerable", "each_key" => "KeyEnumerable", "each_value" => "ValueEnumerable",
4000
+ "map" => "Mappable", "collect" => "Mappable", "flat_map" => "FlatMappable", "filter_map" => "FilterMappable",
4001
+ "select" => "Selectable", "filter" => "Filterable", "reject" => "Rejectable",
4002
+ "reduce" => "Reducible", "inject" => "Reducible", "sum" => "Summable",
4003
+ "find" => "Findable", "detect" => "Findable", "include?" => "MembershipTestable",
4004
+ "size" => "Sizable", "length" => "Measurable", "count" => "Countable",
4005
+ "call" => "Callable", "new" => "Instantiable", "create" => "Creatable", "build" => "Buildable",
4006
+ "[]" => "Indexable", "[]=" => "IndexWritable", "<<" => "Appendable", "push" => "Pushable",
4007
+ "+" => "Addable", "-" => "Subtractable", "*" => "Multipliable", "/" => "Divisible",
4008
+ "%" => "Modulable", "**" => "Exponentiable",
4009
+ "==" => "Equatable", "!=" => "Equatable", "eql?" => "Equatable", "equal?" => "Equatable",
4010
+ "<=>" => "Comparable", "<" => "Comparable", ">" => "Comparable", "<=" => "Comparable", ">=" => "Comparable",
4011
+ "=~" => "Matchable", "match" => "Matchable", "match?" => "Matchable",
4012
+ "!" => "Negatable", "&" => "Conjoinable", "|" => "Disjoinable", "^" => "ExclusiveDisjoinable",
4013
+ "nil?" => "NilTestable", "empty?" => "EmptinessTestable", "any?" => "AnyTestable", "all?" => "AllTestable",
4014
+ "present?" => "PresenceTestable", "blank?" => "BlankTestable", "presence" => "PresenceReadable",
4015
+ "dup" => "Duplicable", "clone" => "Clonable", "freeze" => "Freezable", "frozen?" => "FrozenTestable",
4016
+ "tap" => "Tappable", "then" => "Chainable", "yield_self" => "Chainable", "itself" => "IdentityReadable",
4017
+ "fetch" => "Fetchable", "dig" => "Diggable", "key?" => "KeyTestable", "has_key?" => "KeyTestable",
4018
+ "keys" => "KeysReadable", "values" => "ValuesReadable", "merge" => "Mergeable", "merge!" => "Mergeable",
4019
+ "join" => "Joinable", "split" => "Splittable", "strip" => "Strippable", "chomp" => "Chompable",
4020
+ "gsub" => "GlobalSubstitutable", "sub" => "Substitutable",
4021
+ "downcase" => "Downcaseable", "upcase" => "Upcaseable", "capitalize" => "Capitalizable",
4022
+ "start_with?" => "PrefixTestable", "end_with?" => "SuffixTestable",
4023
+ "first" => "FirstReadable", "last" => "LastReadable", "min" => "MinReadable", "max" => "MaxReadable",
4024
+ "compact" => "Compactable", "flatten" => "Flattenable", "uniq" => "Uniquable", "sort" => "Sortable",
4025
+ "sort_by" => "KeySortable", "group_by" => "Groupable", "reverse" => "Reversible", "raise" => "Raisable",
4026
+ "emit" => "Emittable", "respond_to?" => "RespondTestable",
4027
+ "send" => "MessageSendable", "public_send" => "MessageSendable"
4028
+ }.freeze
4029
+
4030
+ def initialize(registry)
4031
+ @registry = registry
4032
+ end
4033
+
4034
+ def name!
4035
+ levels = Hash.new(1)
4036
+ names = {}
4037
+ (MAX_LEVEL * 4).times do
4038
+ names = @registry.definitions.to_h do |definition|
4039
+ [definition.fetch(:id), name_for(definition, levels[definition.fetch(:id)])]
4040
+ end
4041
+ clusters = @registry.definitions
4042
+ .group_by { |definition| @registry.normalize_semantic_name(names[definition.fetch(:id)]) }
4043
+ .select { |_, members| members.length > 1 }
4044
+ break if clusters.empty?
4045
+
4046
+ progressed = false
4047
+ clusters.each_value do |members|
4048
+ members.sort_by { |definition| [-definition.fetch(:uses), definition.fetch(:id)] }.drop(1).each do |definition|
4049
+ id = definition.fetch(:id)
4050
+ next unless levels[id] < MAX_LEVEL
4051
+
4052
+ levels[id] += 1
4053
+ progressed = true
4054
+ end
4055
+ end
4056
+ break unless progressed
4057
+ end
4058
+ names = de_greek_names!(names)
4059
+ @registry.apply_names!(names)
4060
+ names
4061
+ end
4062
+
4063
+ private
4064
+
4065
+ # Replace any leftover Alpha/Beta/… fragments with words derived from
4066
+ # the helper's concrete slot types, or drop generic slots entirely.
4067
+ def de_greek_names!(names)
4068
+ @registry.definitions.each_with_object(names.dup) do |definition, result|
4069
+ id = definition.fetch(:id)
4070
+ name = result[id]
4071
+ next unless greek?(name)
4072
+
4073
+ replacement = non_greek_name(definition)
4074
+ result[id] = replacement if replacement && !replacement.empty?
4075
+ end
4076
+ end
4077
+
4078
+ def greek?(name)
4079
+ name.match?(GREEK_WORDS)
4080
+ end
4081
+
4082
+ def non_greek_name(definition)
4083
+ MAX_LEVEL.downto(1) do |level|
4084
+ candidate = name_for(definition, level)
4085
+ return candidate unless greek?(candidate)
4086
+ end
4087
+
4088
+ sends = parse_sends(definition.fetch(:definition))
4089
+ compose(sends)
4090
+ end
4091
+
4092
+ def name_for(definition, level)
4093
+ sends = parse_sends(definition.fetch(:definition))
4094
+ slots = definition.fetch(:slots)
4095
+ case level
4096
+ when 1 then compose(sends.first(2))
4097
+ when 2 then compose(sends)
4098
+ when 3 then "#{type_words(sends.map { |send| send.fetch(:result) }, slots: slots)}#{compose(sends)}"
4099
+ when 4
4100
+ argument_words = type_words(sends.flat_map { |send| send.fetch(:arguments) }, slots: slots)
4101
+ result_words = type_words(sends.map { |send| send.fetch(:result) }, slots: slots)
4102
+ "#{argument_words}#{result_words}#{compose(sends)}"
4103
+ else
4104
+ "#{mangle(definition.fetch(:definition), slots: slots)}Able"
4105
+ end
4106
+ end
4107
+
4108
+ def compose(sends)
4109
+ final = single_name(sends.last)
4110
+ stems = sends[0...-1].map { |send| stem(send.fetch(:message)) }.uniq
4111
+ stems.reject! { |candidate| final.start_with?(candidate) }
4112
+ "#{stems.join}#{final}"
4113
+ end
4114
+
4115
+ def single_name(send)
4116
+ message = send.fetch(:message)
4117
+ return KNOWN.fetch(message) if KNOWN.key?(message)
4118
+ return "#{stem(message)}Testable" if message.end_with?("?")
4119
+ return "#{stem(message)}Writable" if message.end_with?("=")
4120
+
4121
+ tokens = message.delete_suffix("!").split("_")
4122
+ if send.fetch(:arguments).any? || message.end_with?("!")
4123
+ objects = tokens.drop(1).map(&:capitalize).join
4124
+ "#{objects}#{verbify(tokens.first).capitalize}"
4125
+ else
4126
+ "#{stem(message)}Readable"
4127
+ end
4128
+ end
4129
+
4130
+ def stem(message)
4131
+ unless message.match?(/[a-z0-9]/i)
4132
+ known = KNOWN[message]
4133
+ return known ? known.sub(/(?:t?able|ible)\z/, "") : "Op"
4134
+ end
4135
+
4136
+ message.delete_suffix("?").delete_suffix("!").delete_suffix("=").split("_").map(&:capitalize).join
4137
+ end
4138
+
4139
+ def verbify(word)
4140
+ if word.match?(/[^aeiouy]y\z/)
4141
+ "#{word[0..-2]}iable"
4142
+ elsif word.end_with?("e")
4143
+ "#{word[0..-2]}able"
4144
+ elsif word.match?(/[^aeiouy][aeiouy][bdgklmnprtv]\z/)
4145
+ "#{word}#{word[-1]}able"
4146
+ else
4147
+ "#{word}able"
4148
+ end
4149
+ end
4150
+
4151
+ def type_words(types, slots: [])
4152
+ dedupe_adjacent(type_tokens(types.join(", "), slots: slots)).join
4153
+ end
4154
+
4155
+ def dedupe_adjacent(words)
4156
+ words.each_with_object([]) { |word, acc| acc << word unless acc.last == word }
4157
+ end
4158
+
4159
+ def type_tokens(type, slots: [], depth: 0, resolving: nil)
4160
+ return [] if depth > 12
4161
+
4162
+ type.scan(/[A-Za-z0-9_]+|::|->|[|\[\],()]/).flat_map do |token|
4163
+ case token
4164
+ when "|" then ["Or"]
4165
+ when "[" then ["Of"]
4166
+ when "->" then ["To"]
4167
+ when "," then ["And"]
4168
+ when "]", "(", ")", "::" then []
4169
+ when "nil" then ["Nil"]
4170
+ when "noreturn" then ["Never"]
4171
+ when "Object" then ["Object"]
4172
+ when "bool" then ["Bool"]
4173
+ when /\AT(\d+)\z/
4174
+ index = Regexp.last_match(1).to_i
4175
+ next [] if resolving == index
4176
+
4177
+ slot_type_words(index, slots: slots, depth: depth + 1)
4178
+ else
4179
+ [token.split("_").map(&:capitalize).join]
4180
+ end
4181
+ end
4182
+ end
4183
+
4184
+ def slot_type_words(index, slots: [], depth: 0)
4185
+ return [] if depth > 12
4186
+
4187
+ slot = slots[index - 1]
4188
+ return [] if slot.nil? || slot.empty?
4189
+ return [] if slot.match?(/\AT\d+\z/)
4190
+ return [] if slot.match?(/\bT\d+\b/) && !slot.match?(/[A-Za-z_]|::|\[/)
4191
+
4192
+ type_tokens(slot, slots: slots, depth: depth + 1, resolving: index)
4193
+ end
4194
+
4195
+ def mangle(definition, slots: [])
4196
+ type_tokens(definition, slots: slots).join
4197
+ end
4198
+
4199
+ def parse_sends(definition)
4200
+ body = definition.delete_prefix("{ ").delete_suffix(" }")
4201
+ body.split("; ").map { |send| parse_send(send) }
4202
+ end
4203
+
4204
+ def parse_send(send)
4205
+ open = send.index("(")
4206
+ message = send[0...open]
4207
+ depth = 0
4208
+ close = open
4209
+ (open...send.length).each do |index|
4210
+ depth += 1 if send[index] == "("
4211
+ depth -= 1 if send[index] == ")"
4212
+ if depth.zero?
4213
+ close = index
4214
+ break
4215
+ end
4216
+ end
4217
+ {
4218
+ message: message,
4219
+ arguments: split_top_level(send[(open + 1)...close]),
4220
+ result: send[(close + 5)..].to_s
4221
+ }
4222
+ end
4223
+
4224
+ def split_top_level(text)
4225
+ return [] if text.empty?
4226
+
4227
+ parts = []
4228
+ depth = 0
4229
+ current = +""
4230
+ text.each_char do |char|
4231
+ depth += 1 if "([".include?(char)
4232
+ depth -= 1 if ")]".include?(char)
4233
+ if depth.zero? && char == "," && !current.empty?
4234
+ parts << current.strip
4235
+ current = +""
4236
+ else
4237
+ current << char
4238
+ end
4239
+ end
4240
+ parts << current.strip unless current.strip.empty?
4241
+ parts
4242
+ end
4243
+ end
4244
+
4245
+ class OpenAiCapabilityNamer
4246
+ DEFAULT_MODEL = "gpt-5.6-luna"
4247
+ DEFAULT_BATCH_SIZE = 100
4248
+ DEFAULT_CONCURRENCY = 16
4249
+ SYSTEM_PROMPT = <<~PROMPT.freeze
4250
+ Name every anonymous structural Ruby capability below.
4251
+
4252
+ Return one concise PascalCase interface name per id using the "-able"
4253
+ framework. Describe what values can do, not their implementation or a
4254
+ nominal class.
4255
+
4256
+ Examples: Stringifiable, Enumerable, KeyFetchable, EventEmittable,
4257
+ CurrentQuestionReadable, CacheClearable, ScoreRankable.
4258
+
4259
+ Rules:
4260
+ - Return every input id exactly once.
4261
+ - Use one to four semantic words.
4262
+ - End with Able, Ible, Ifiable, or Izable.
4263
+ - Prefer established Ruby vocabulary when precise.
4264
+ - Name the combined ability when multiple methods are present.
4265
+ - Never include hashes, ids, generic parameter names, or "Capability".
4266
+ - Return only a JSON object mapping each id to its name.
4267
+ PROMPT
4268
+
4269
+ def initialize(registry, cache_path:, model: DEFAULT_MODEL, batch_size: DEFAULT_BATCH_SIZE,
4270
+ concurrency: DEFAULT_CONCURRENCY, runner: nil)
4271
+ @registry = registry
4272
+ @cache_path = cache_path
4273
+ @model = model
4274
+ @batch_size = batch_size
4275
+ @concurrency = concurrency
4276
+ @runner = runner || method(:run_openai)
4277
+ end
4278
+
4279
+ MAX_COLLISION_ROUNDS = 5
4280
+ CLUSTER_PROMPT = <<~PROMPT.freeze
4281
+ These distinct structural Ruby capability types were all given the name
4282
+ "%<shared_name>s". Rename them so every id gets a unique PascalCase name.
4283
+
4284
+ Rules:
4285
+ - Return every input id exactly once, each with a different name.
4286
+ - Distinguish members using their methods, argument types, and result types.
4287
+ - The most general member may stay close to "%<shared_name>s"; the others
4288
+ must be more specific.
4289
+ - Never use numbers, hashes, or the words "Variant" or "Capability".
4290
+ - Use one to five semantic words ending with Able, Ible, Ifiable, or Izable.
4291
+ - Return only a JSON object mapping each id to its name.
4292
+ PROMPT
4293
+
4294
+ def name!
4295
+ names = load_cache
4296
+ missing = @registry.definitions.reject { |definition| names.key?(definition.fetch(:id)) }
4297
+ batches = missing.each_slice(@batch_size).to_a
4298
+ puts "Naming #{missing.length} capabilities in #{batches.length} parallel batches with #{@model}..." if batches.any?
4299
+ parallel_map(batches) { |batch| name_batch(batch) }.each { |batch_names| names.merge!(batch_names) }
4300
+ File.write(@cache_path, JSON.pretty_generate(names.sort.to_h)) if batches.any?
4301
+
4302
+ resolve_collisions!(names)
4303
+ @registry.apply_names!(names)
4304
+ names
4305
+ end
4306
+
4307
+ private
4308
+
4309
+ def resolve_collisions!(names)
4310
+ MAX_COLLISION_ROUNDS.times do
4311
+ clusters = @registry.collisions(names)
4312
+ break if clusters.empty?
4313
+
4314
+ puts "Renaming #{clusters.sum { |_, definitions| definitions.length }} capabilities across " \
4315
+ "#{clusters.length} collision clusters in parallel with #{@model}..."
4316
+ renames = parallel_map(clusters.to_a) do |shared_name, definitions|
4317
+ rename_cluster(shared_name, definitions)
4318
+ end
4319
+ renames.each { |cluster_names| names.merge!(cluster_names) }
4320
+ File.write(@cache_path, JSON.pretty_generate(names.sort.to_h))
4321
+ end
4322
+
4323
+ leftovers = @registry.collisions(names)
4324
+ return if leftovers.empty?
4325
+
4326
+ warn "Naming model left #{leftovers.length} name collisions after #{MAX_COLLISION_ROUNDS} rounds"
4327
+ end
4328
+
4329
+ # Fiber-based fan-out (this repo never uses threads). Failed calls return
4330
+ # an empty result: unresolved names simply stay missing or colliding and
4331
+ # the next round retries them.
4332
+ def parallel_map(items)
4333
+ return [] if items.empty?
4334
+
4335
+ Sync do |parent|
4336
+ semaphore = Async::Semaphore.new(@concurrency, parent: parent)
4337
+ items.map do |item|
4338
+ semaphore.async do
4339
+ yield item
4340
+ rescue StandardError => error
4341
+ warn "Naming call failed: #{error.message}"
4342
+ {}
4343
+ end
4344
+ end.map(&:wait)
4345
+ end
4346
+ end
4347
+
4348
+ def rename_cluster(shared_name, definitions)
4349
+ payload = definitions.map do |definition|
4350
+ { id: definition.fetch(:id), capability: definition.fetch(:definition) }
4351
+ end
4352
+ prompt = "#{format(CLUSTER_PROMPT, shared_name: shared_name)}\n\n#{JSON.generate(payload)}"
4353
+ parsed = parse_json_object(@runner.call(prompt, @model))
4354
+ parsed.slice(*definitions.map { |definition| definition.fetch(:id) })
4355
+ end
4356
+
4357
+ def load_cache
4358
+ return {} unless File.exist?(@cache_path)
4359
+
4360
+ JSON.parse(File.read(@cache_path))
4361
+ end
4362
+
4363
+ def name_batch(definitions, retry_count: 0)
4364
+ payload = definitions.map do |definition|
4365
+ { id: definition.fetch(:id), capability: definition.fetch(:definition) }
4366
+ end
4367
+ output = @runner.call("#{SYSTEM_PROMPT}\n\n#{JSON.generate(payload)}", @model)
4368
+ parsed = parse_json_object(output)
4369
+ expected_ids = definitions.map { |definition| definition.fetch(:id) }
4370
+ missing_ids = expected_ids - parsed.keys
4371
+ extra_ids = parsed.keys - expected_ids
4372
+ puts "Ignoring #{extra_ids.length} unknown capability ids..." unless extra_ids.empty?
4373
+ parsed = parsed.slice(*expected_ids)
4374
+ unless missing_ids.empty?
4375
+ raise "Naming model repeatedly omitted #{missing_ids.length} capabilities" if retry_count >= 2
4376
+
4377
+ puts "Retrying #{missing_ids.length} omitted capabilities..."
4378
+ missing_definitions = definitions.select { |definition| missing_ids.include?(definition.fetch(:id)) }
4379
+ parsed.merge!(name_batch(missing_definitions, retry_count: retry_count + 1))
4380
+ end
4381
+
4382
+ parsed
4383
+ end
4384
+
4385
+ def run_openai(prompt, model)
4386
+ connection = Faraday.new(url: ENV.fetch("OPENAI_URI_BASE", "https://api.openai.com"))
4387
+ response = connection.post("/v1/responses") do |request|
4388
+ request.headers["Authorization"] = "Bearer #{ENV.fetch("OPENAI_ACCESS_TOKEN")}"
4389
+ request.headers["Content-Type"] = "application/json"
4390
+ request.options.timeout = 180
4391
+ request.body = {
4392
+ model: model,
4393
+ input: prompt,
4394
+ max_output_tokens: 16_384,
4395
+ reasoning: { effort: "low" },
4396
+ text: { verbosity: "low" },
4397
+ store: false
4398
+ }.to_json
4399
+ end
4400
+ unless response.success?
4401
+ error = JSON.parse(response.body).dig("error", "message")
4402
+ raise "OpenAI Responses API failed (#{response.status}): #{error}"
4403
+ end
4404
+
4405
+ body = JSON.parse(response.body)
4406
+ output_text = body.fetch("output").filter_map do |item|
4407
+ next unless item["type"] == "message"
4408
+
4409
+ item.fetch("content").filter_map do |content|
4410
+ content["text"] if content["type"] == "output_text"
4411
+ end
4412
+ end.join
4413
+ raise "OpenAI response contained no output text" if output_text.empty?
4414
+
4415
+ output_text
4416
+ end
4417
+
4418
+ def parse_json_object(output)
4419
+ JSON.parse(output)
4420
+ rescue JSON::ParserError
4421
+ object = output[/\{.*\}/m]
4422
+ raise "Naming model did not return a JSON object" unless object
4423
+
4424
+ JSON.parse(object)
4425
+ end
4426
+ end
4427
+
4428
+ class CLI
4429
+ def self.skipped_files
4430
+ Autotype.configuration.skipped_files
4431
+ end
4432
+
4433
+ def self.default_profile
4434
+ Autotype.profile
4435
+ end
4436
+
4437
+ def self.expand_referenced_type_files(files, profile: default_profile)
4438
+ expanded = files.dup
4439
+ referenced = Set.new
4440
+ files.each do |path|
4441
+ next unless File.file?(path)
4442
+
4443
+ result = Prism.parse_file(path)
4444
+ next unless result.success?
4445
+
4446
+ collector = Collector.new(path, profile: profile)
4447
+ result.value.accept(collector)
4448
+ collector.referenced_types.each do |type_name|
4449
+ type_path = profile.locate_type_file(type_name)
4450
+ referenced << type_path if type_path
4451
+ end
4452
+ end
4453
+ (expanded + referenced.to_a).uniq
4454
+ end
4455
+
4456
+ def self.build_metadata(collectors, profile: default_profile)
4457
+ profile.finalize!(collectors) if profile.respond_to?(:finalize!)
4458
+ metadata = InferenceMetadata.new(
4459
+ member_type_hints: profile.member_type_hints,
4460
+ option_type_hints: profile.option_type_hints,
4461
+ side_effect_methods: profile.side_effect_methods,
4462
+ structured_type_prefixes: profile.structured_type_prefixes,
4463
+ port_wiring: profile.port_wiring,
4464
+ output_emit: profile.output_emit,
4465
+ config_hash: profile.config_hash,
4466
+ framework_self_fallbacks: profile.framework_self_fallbacks,
4467
+ type_locators: [profile.method(:locate_type_file)]
4468
+ )
4469
+ collectors.each do |collector|
4470
+ metadata.merge_ports!(collector.declared_ports)
4471
+ metadata.merge_member_types!(collector.declared_member_types)
4472
+ metadata.merge_config_options!(collector.declared_config_options)
4473
+ collector.structured_owners.each { |owner| metadata.structured_owners << owner }
4474
+ collector.referenced_types.each { |type| metadata.referenced_types << type }
4475
+ end
4476
+ metadata
4477
+ end
4478
+
4479
+ def self.run(argv)
4480
+ json = false
4481
+ html_path = nil
4482
+ config_path = ENV["AUTOTYPE_CONFIG"]
4483
+ capability_names_path = nil
4484
+ naming_cache_path = nil
4485
+ naming_model = OpenAiCapabilityNamer::DEFAULT_MODEL
4486
+ naming_batch_size = OpenAiCapabilityNamer::DEFAULT_BATCH_SIZE
4487
+ dump_capabilities_path = nil
4488
+ parser = OptionParser.new do |options|
4489
+ options.banner = "Usage: autotype [OPTIONS] FILE..."
4490
+ options.on("--config PATH", "Path to autotype.yml (default: discover from cwd)") do |path|
4491
+ config_path = path
4492
+ end
4493
+ options.on("--json", "Emit machine-readable JSON") { json = true }
4494
+ options.on("--html PATH", "Write a self-contained HTML typedoc") { |path| html_path = path }
4495
+ options.on("--capability-names PATH", "Load semantic names from a JSON file or directory") do |path|
4496
+ capability_names_path = path
4497
+ end
4498
+ options.on("--name-capabilities PATH", "Call OpenAI Responses API and cache semantic names") do |path|
4499
+ naming_cache_path = path
4500
+ end
4501
+ options.on("--capability-model MODEL", "OpenAI model used for naming (default: gpt-5.6-luna)") do |model|
4502
+ naming_model = model
4503
+ end
4504
+ options.on("--capability-batch-size N", Integer, "Capabilities per model call (default: 100)") do |size|
4505
+ naming_batch_size = size
4506
+ end
4507
+ options.on("--dump-capabilities PATH", "Write universal definitions as JSON Lines") do |path|
4508
+ dump_capabilities_path = path
4509
+ end
4510
+ end
4511
+ files = parser.parse(argv)
4512
+ abort parser.to_s if files.empty?
4513
+ abort "--json and --html are mutually exclusive" if json && html_path
4514
+
4515
+ Autotype.configuration.config_path = config_path if config_path
4516
+ Autotype.configuration.reload_profile!
4517
+
4518
+ files.reject! { |path| skipped_files.include?(path.delete_prefix("./")) }
4519
+ profile = default_profile
4520
+ profile.prepare_search!(files) if profile.respond_to?(:prepare_search!)
4521
+ files = expand_referenced_type_files(files, profile: profile)
4522
+
4523
+ parse_errors = {}
4524
+ constants = {}
4525
+ includes = Hash.new { |hash, key| hash[key] = [] }
4526
+ collectors = []
4527
+ method_reports = files.flat_map do |path|
4528
+ result = Prism.parse_file(path)
4529
+ unless result.success?
4530
+ warn "#{path}: parse failed"
4531
+ result.errors.each { |error| warn " L#{error.location.start_line}: #{error.message}" }
4532
+ parse_errors[path] = result.errors.map { |error| "L#{error.location.start_line}: #{error.message}" }
4533
+ next []
4534
+ end
4535
+
4536
+ collector = Collector.new(path, profile: profile)
4537
+ result.value.accept(collector)
4538
+ collectors << collector
4539
+ constants.merge!(collector.constants)
4540
+ collector.includes.each { |owner, mods| includes[owner].concat(mods) }
4541
+ collector.methods.map { |method| [path, method] }
4542
+ end
4543
+ includes.transform_values! { |mods| mods.uniq }
4544
+ metadata = build_metadata(collectors, profile: profile)
4545
+ inferencer = FixedPointInferencer.new(
4546
+ method_reports.map(&:last),
4547
+ constants: constants,
4548
+ includes: includes,
4549
+ metadata: metadata
4550
+ )
4551
+ analyzed_methods = method_reports.map(&:last)
4552
+ solved_methods = inferencer.run
4553
+ method_reports = method_reports.zip(solved_methods, analyzed_methods).map do |(path, _method), solved_method, analyzed_method|
4554
+ [path, solved_method, analyzed_method]
4555
+ end
4556
+ unless json
4557
+ status = inferencer.converged? ? "converged" : "reached its iteration limit"
4558
+ puts "Type inference #{status} after #{inferencer.iterations} iteration(s)"
4559
+ end
4560
+ helper_registry = UniversalHelperRegistry.new(analyzed_methods)
4561
+ # Deterministic LLM-free names first; the OpenAI flags below override.
4562
+ HeuristicCapabilityNamer.new(helper_registry).name!
4563
+ if naming_cache_path
4564
+ OpenAiCapabilityNamer.new(
4565
+ helper_registry,
4566
+ cache_path: naming_cache_path,
4567
+ model: naming_model,
4568
+ batch_size: naming_batch_size
4569
+ ).name!
4570
+ end
4571
+ if dump_capabilities_path
4572
+ lines = helper_registry.definitions.map do |definition|
4573
+ JSON.generate(id: definition.fetch(:id), capability: definition.fetch(:definition))
4574
+ end
4575
+ File.write(dump_capabilities_path, "#{lines.join("\n")}\n")
4576
+ if html_path.nil? && !json
4577
+ puts "Wrote #{dump_capabilities_path} (#{lines.length} universal capability types)"
4578
+ return
4579
+ end
4580
+ end
4581
+ if capability_names_path
4582
+ helper_registry.apply_names!(load_capability_names(capability_names_path))
4583
+ end
4584
+ reports = method_reports.map do |path, method, analyzed_method|
4585
+ [path, Renderer.new(method, analyzed_method: analyzed_method, helper_registry: helper_registry)]
4586
+ end
4587
+
4588
+ if html_path
4589
+ document = HtmlDocument.new(reports, analyzed_files: files, parse_errors: parse_errors)
4590
+ File.write(html_path, document.render)
4591
+ puts "Wrote #{html_path} (#{reports.length} methods across #{reports.map(&:first).uniq.length} files)"
4592
+ elsif json
4593
+ puts JSON.pretty_generate(
4594
+ reports.group_by(&:first).transform_values { |entries| entries.map { |_, renderer| renderer.as_json } }
4595
+ )
4596
+ else
4597
+ reports.group_by(&:first).each do |path, entries|
4598
+ puts path
4599
+ puts "=" * path.length
4600
+ entries.each do |_, renderer|
4601
+ puts renderer.text
4602
+ puts
4603
+ end
4604
+ end
4605
+ end
4606
+ end
4607
+
4608
+ def self.load_capability_names(path)
4609
+ paths = File.directory?(path) ? Dir[File.join(path, "*.json")].sort : [path]
4610
+ paths.each_with_object({}) { |file, names| names.merge!(JSON.parse(File.read(file))) }
4611
+ end
4612
+ end
4613
+ end