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,1028 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Autotype do
6
+ def collect(source, path = "example.rb")
7
+ collector_for(source, path).methods
8
+ end
9
+
10
+ def collector_for(source, path = "example.rb", profile: nil)
11
+ result = Prism.parse(source)
12
+ expect(result).to be_success
13
+
14
+ collector = described_class::Collector.new(path, profile: profile)
15
+ result.value.accept(collector)
16
+ collector
17
+ end
18
+
19
+ def infer(source)
20
+ methods = collect(source)
21
+ registry = described_class::UniversalHelperRegistry.new(methods)
22
+ methods.to_h do |method|
23
+ [method.name, described_class::Renderer.new(method, helper_registry: registry)]
24
+ end
25
+ end
26
+
27
+ def solve(source, profile: Autotype.profile)
28
+ collector = collector_for(source, profile: profile)
29
+ analyzed_methods = collector.methods
30
+ metadata =
31
+ if profile
32
+ described_class::CLI.build_metadata([collector], profile: profile)
33
+ else
34
+ described_class::InferenceMetadata.empty
35
+ end
36
+ methods = described_class::FixedPointInferencer.new(
37
+ analyzed_methods,
38
+ constants: collector.constants,
39
+ includes: collector.includes,
40
+ metadata: metadata
41
+ ).run
42
+ registry = described_class::UniversalHelperRegistry.new(analyzed_methods)
43
+ analyzed_methods.zip(methods).to_h do |analyzed_method, solved_method|
44
+ [
45
+ solved_method.name,
46
+ described_class::Renderer.new(
47
+ solved_method,
48
+ analyzed_method: analyzed_method,
49
+ helper_registry: registry
50
+ )
51
+ ]
52
+ end
53
+ end
54
+
55
+ it "infers plain Ruby without a project profile" do
56
+ methods = solve(<<~RUBY, profile: nil)
57
+ class Counter
58
+ def initialize
59
+ @count = 0
60
+ end
61
+
62
+ def increment
63
+ @count + 1
64
+ end
65
+ end
66
+ RUBY
67
+
68
+ expect(methods.fetch("Counter#increment").text.lines.first).to include("-> Integer")
69
+ end
70
+
71
+ it "folds single-send string interpolation constraints into a helper type" do
72
+ methods = infer(<<~RUBY)
73
+ def greet(name)
74
+ "Hello, \#{name}!"
75
+ end
76
+ RUBY
77
+ text = methods.fetch("example#greet").text
78
+
79
+ expect(text).to match(/example#greet : \(name Capability_[a-f0-9]{10}\) -> String/)
80
+ expect(text).to match(/type Capability_[a-f0-9]{10} = \{ to_s\(\) -> String \}/)
81
+ expect(text).not_to include("responds to")
82
+ end
83
+
84
+ it "generalizes structural operator constraints into a helper type" do
85
+ methods = infer(<<~RUBY)
86
+ def add(a, b)
87
+ a + b
88
+ end
89
+ RUBY
90
+ text = methods.fetch("example#add").text
91
+
92
+ expect(text).to match(/example#add : \(a Capability_[a-f0-9]{10}\[Object, Object\], b Object\) -> Object/)
93
+ expect(text).to match(/type Capability_[a-f0-9]{10}<T1, T2> = \{ \+\(T1\) -> T2 \}/)
94
+ end
95
+
96
+ it "propagates return types through repository method calls to a fixed point" do
97
+ methods = solve(<<~RUBY)
98
+ class Example
99
+ def leaf = "done"
100
+ def middle = leaf
101
+ def root = middle
102
+ end
103
+ RUBY
104
+
105
+ expect(methods.fetch("Example#root").text).to start_with("Example#root : () -> String")
106
+ end
107
+
108
+ it "resolves inherited methods and cross-method instance variable types" do
109
+ methods = solve(<<~RUBY)
110
+ class Base
111
+ def emit(value) = value
112
+ end
113
+
114
+ class Child < Base
115
+ def initialize
116
+ @items = []
117
+ end
118
+
119
+ def finish
120
+ emit(@items.size)
121
+ end
122
+ end
123
+ RUBY
124
+
125
+ expect(methods.fetch("Child#finish").text).to start_with("Child#finish : () -> Integer")
126
+ end
127
+
128
+ it "flows instance variable types through synthesized attr_reader methods" do
129
+ methods = solve(<<~RUBY)
130
+ class Config
131
+ attr_reader :timeout
132
+
133
+ def initialize
134
+ @timeout = 30
135
+ end
136
+ end
137
+
138
+ class Client
139
+ def wait
140
+ Config.new.timeout
141
+ end
142
+ end
143
+ RUBY
144
+
145
+ expect(methods.fetch("Config#timeout").text).to start_with("Config#timeout : () -> Integer")
146
+ expect(methods.fetch("Client#wait").text).to start_with("Client#wait : () -> Integer")
147
+ end
148
+
149
+ it "unions nil initialization with later ivar assignments for accessors" do
150
+ methods = solve(<<~RUBY)
151
+ class Tracker
152
+ def reset
153
+ @elapsed = nil
154
+ end
155
+
156
+ def finalize
157
+ @elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
158
+ end
159
+
160
+ def elapsed
161
+ @elapsed
162
+ end
163
+ end
164
+ RUBY
165
+
166
+ expect(methods.fetch("Tracker#elapsed").text).to start_with("Tracker#elapsed : () -> Nullable[Float]")
167
+ end
168
+
169
+ it "drops raising branches from return unions" do
170
+ methods = solve(<<~RUBY)
171
+ def checked(flag)
172
+ raise ArgumentError, "nope" if flag
173
+ "ok"
174
+ end
175
+ RUBY
176
+
177
+ expect(methods.fetch("example#checked").text.lines.first).to include("-> String")
178
+ expect(methods.fetch("example#checked").text.lines.first).not_to include("noreturn")
179
+ end
180
+
181
+ it "constructs the owner class from bare new in singleton methods" do
182
+ methods = solve(<<~RUBY)
183
+ class Widget
184
+ def self.build
185
+ new
186
+ end
187
+ end
188
+ RUBY
189
+
190
+ expect(methods.fetch("Widget.build").text).to start_with("Widget.build : () -> Widget")
191
+ end
192
+
193
+ it "models common stdlib methods and question-mark predicates" do
194
+ methods = solve(<<~RUBY)
195
+ class Sample
196
+ def label(parts)
197
+ parts.join(", ").strip
198
+ end
199
+
200
+ def ready
201
+ engine.ready?
202
+ end
203
+ end
204
+ RUBY
205
+
206
+ expect(methods.fetch("Sample#label").text.lines.first).to include("-> String")
207
+ expect(methods.fetch("Sample#ready").text.lines.first).to include("-> bool")
208
+ end
209
+
210
+ it "namespaces methods defined inside block-based constant definitions" do
211
+ methods = solve(<<~RUBY)
212
+ module Entities
213
+ Hit = App::Entity.define(:title) do
214
+ def self.from_h(hash)
215
+ new
216
+ end
217
+ end
218
+ end
219
+ RUBY
220
+
221
+ expect(methods.fetch("Entities::Hit.from_h").text.lines.first).to include("-> Entities::Hit")
222
+ end
223
+
224
+ it "anchors entity factory constructors to nominal return types" do
225
+ methods = solve(<<~RUBY)
226
+ class Builder
227
+ def build
228
+ Entities::Hit.create(title: "Example")
229
+ end
230
+ end
231
+ RUBY
232
+
233
+ expect(methods.fetch("Builder#build").text).to start_with(
234
+ "Builder#build : () -> Entities::Hit"
235
+ )
236
+ end
237
+
238
+ it "handles forwarding and destructured block parameters" do
239
+ methods = infer(<<~RUBY)
240
+ def forward(...)
241
+ pairs.each { |(key, value)| target(key, value, ...) }
242
+ end
243
+ RUBY
244
+
245
+ expect(methods.fetch("example#forward").text).to include("example#forward : (... Object) -> Object")
246
+ end
247
+
248
+ it "renders unresolved outputs as Object instead of fake generics" do
249
+ methods = infer(<<~RUBY)
250
+ def perform
251
+ helper_call
252
+ end
253
+
254
+ def tagged(value)
255
+ value.tag
256
+ end
257
+ RUBY
258
+
259
+ expect(methods.fetch("example#perform").text).to start_with("example#perform : () -> Object")
260
+ expect(methods.fetch("example#tagged").text).to match(
261
+ /example#tagged : \(value Capability_[a-f0-9]{10}\[Object\]\) -> Object/
262
+ )
263
+ end
264
+
265
+ it "grounds constructor-assigned accessors from call-site arguments" do
266
+ methods = solve(<<~RUBY)
267
+ class Client
268
+ attr_reader :url
269
+
270
+ def initialize(url:)
271
+ @url = url
272
+ end
273
+ end
274
+
275
+ class Caller
276
+ def build
277
+ Client.new(url: "https://example.com")
278
+ end
279
+ end
280
+ RUBY
281
+
282
+ expect(methods.fetch("Client#url").text.lines.first).to include("-> String")
283
+ end
284
+
285
+ it "defaults residual returns from naming conventions" do
286
+ methods = solve(<<~RUBY)
287
+ class Judge
288
+ def relevant?
289
+ opaque_call
290
+ end
291
+
292
+ def to_tool_hash
293
+ opaque_call
294
+ end
295
+ end
296
+ RUBY
297
+
298
+ expect(methods.fetch("Judge#relevant?").text.lines.first).to include("-> bool")
299
+ expect(methods.fetch("Judge#to_tool_hash").text.lines.first).to include("-> Hash[")
300
+ end
301
+
302
+ it "keeps all union members visible instead of eliding unresolved branches" do
303
+ methods = solve(<<~RUBY)
304
+ class Picker
305
+ def pick(flag)
306
+ flag ? "yes" : mystery
307
+ end
308
+ end
309
+ RUBY
310
+
311
+ signature = methods.fetch("Picker#pick").text.lines.first
312
+ expect(signature).to include("-> Object | String")
313
+ expect(signature).not_to include("?")
314
+ end
315
+
316
+ it "exports local variable types including block-scoped assignments" do
317
+ methods = solve(<<~RUBY)
318
+ class Demo
319
+ def run(items)
320
+ found = nil
321
+ items.each do |item|
322
+ found = item.to_s
323
+ end
324
+ found
325
+ end
326
+ end
327
+ RUBY
328
+
329
+ locals = methods.fetch("Demo#run").as_json.fetch(:locals)
330
+ expect(locals.fetch("found")).to eq("Nullable[String]")
331
+ expect(locals).to have_key("item")
332
+ end
333
+
334
+ it "flows a caller block's return type through yield" do
335
+ methods = solve(<<~RUBY)
336
+ class Gate
337
+ def decide
338
+ yield
339
+ end
340
+
341
+ def run
342
+ decide { 42 }
343
+ end
344
+ end
345
+ RUBY
346
+
347
+ expect(methods.fetch("Gate#run").text.lines.first).to include("-> Integer")
348
+ expect(methods.fetch("Gate#decide").text.lines.first).to include("&block")
349
+ end
350
+
351
+ it "narrows index helper slots from caller argument contexts" do
352
+ methods = solve(<<~RUBY)
353
+ module TargetFilter
354
+ def filter_targets(raw_targets)
355
+ Array(raw_targets).reject { |target| vague_status?(target) }
356
+ end
357
+
358
+ def vague_status?(target)
359
+ concept_type = target["concept_type"] || target[:concept_type]
360
+ phrase = target["core_phrase"] || target[:core_phrase]
361
+ concept_type.to_s == "status" && phrase.to_s.include?("post")
362
+ end
363
+ end
364
+
365
+ class Parser
366
+ include TargetFilter
367
+
368
+ def run
369
+ filter_targets([{ concept_type: "status", core_phrase: "post op" }])
370
+ end
371
+ end
372
+ RUBY
373
+
374
+ signature = methods.fetch("TargetFilter#vague_status?").text.lines.first
375
+ expect(signature).to include("concept_type")
376
+ expect(signature).to include("core_phrase")
377
+ expect(signature).not_to match(/<B, C, D, E>/)
378
+ end
379
+
380
+ it "narrows unresolved returns from caller usage" do
381
+ methods = solve(<<~RUBY)
382
+ class Service
383
+ def payload
384
+ remote_fetch
385
+ end
386
+ end
387
+
388
+ class Consumer
389
+ def run
390
+ data = Service.new.payload
391
+ data.status_code
392
+ end
393
+ end
394
+ RUBY
395
+
396
+ expect(methods.fetch("Service#payload").text).to include("status_code")
397
+ expect(methods.fetch("Service#payload").text.lines.first).not_to eq("Service#payload : () -> Object\n")
398
+ end
399
+
400
+ it "types initialize as returning the owner instance" do
401
+ methods = solve(<<~RUBY)
402
+ class Widget
403
+ def initialize(name)
404
+ @name = name
405
+ end
406
+ end
407
+ RUBY
408
+
409
+ expect(methods.fetch("Widget#initialize").text.lines.first).to include("-> Widget")
410
+ end
411
+
412
+ it "infers optional parameter types from default values" do
413
+ methods = solve(<<~RUBY)
414
+ class Service
415
+ def call(retries = 3, label: "default")
416
+ retries + label.length
417
+ end
418
+ end
419
+ RUBY
420
+
421
+ signature = methods.fetch("Service#call").text.lines.first
422
+ expect(signature).to include("retries? Integer")
423
+ expect(signature).to include("label:? String")
424
+ expect(signature).to include("-> Integer")
425
+ end
426
+
427
+ it "widens nil-default keyword parameters to Nullable" do
428
+ methods = solve(<<~RUBY)
429
+ class Service
430
+ def call(token: nil)
431
+ token
432
+ end
433
+ end
434
+ RUBY
435
+
436
+ signature = methods.fetch("Service#call").text.lines.first
437
+ expect(signature).to include("token:? Nullable[Object]")
438
+ end
439
+
440
+ it "prefers explicit Data block initialize over synthesized member constructor" do
441
+ methods = solve(<<~RUBY)
442
+ Widget = Data.define(:id, :score) do
443
+ def initialize(id:, score: nil)
444
+ super
445
+ end
446
+ end
447
+ RUBY
448
+
449
+ inits = methods.values.select { |renderer| renderer.as_json.fetch(:name).end_with?("#initialize") }
450
+ expect(inits.length).to eq(1)
451
+ signature = inits.first.text.lines.first
452
+ expect(signature).to include("id: Object")
453
+ expect(signature).not_to include("id:?")
454
+ expect(signature).to include("score:? Nullable[Object]")
455
+ end
456
+
457
+ it "flattens nested Nullable wrappers in rendered output" do
458
+ methods = solve(<<~RUBY)
459
+ class Demo
460
+ def run(items)
461
+ items.filter_map { |item| item.to_i if item }
462
+ end
463
+ end
464
+ RUBY
465
+
466
+ text = methods.fetch("Demo#run").text
467
+ expect(text).not_to include("Nullable[Nullable[")
468
+ expect(text).not_to match(/Nullable\[[^\]]*(\|\ nil|nil \|\ )/)
469
+ end
470
+
471
+ it "grounds actor process parameters from input port declarations" do
472
+ methods = solve(<<~RUBY)
473
+ module Actors
474
+ class DemoActor < App::Actor
475
+ input :prompts, type: App::Entities::Prompt
476
+
477
+ def process(prompt, from:)
478
+ prompt.query
479
+ end
480
+ end
481
+ end
482
+ RUBY
483
+
484
+ signature = methods.fetch("Actors::DemoActor#process").text.lines.first
485
+ expect(signature).to include("prompt App::Entities::Prompt")
486
+ expect(signature).to include("from: Symbol")
487
+ end
488
+
489
+ it "narrows entity and ivar types from case from port branches" do
490
+ methods = solve(<<~RUBY)
491
+ module Actors
492
+ class DemoActor < App::Actor
493
+ input :prompts, type: App::Entities::Prompt
494
+ input :queries, type: App::Entities::Query
495
+
496
+ def process(entity, from:)
497
+ case from
498
+ when :prompts
499
+ @prompt = entity
500
+ when :queries
501
+ @query = entity
502
+ end
503
+ end
504
+ end
505
+ end
506
+ RUBY
507
+
508
+ json = methods.fetch("Actors::DemoActor#process").as_json
509
+ expect(json.fetch(:locals).fetch("entity")).to include("App::Entities::")
510
+ expect(json.fetch(:locals).fetch("@prompt")).to include("App::Entities::Prompt")
511
+ expect(json.fetch(:locals).fetch("@query")).to include("App::Entities::Query")
512
+ end
513
+
514
+ it "types actor options reads from option declarations" do
515
+ methods = solve(<<~RUBY)
516
+ module Actors
517
+ class DemoActor < App::Actor
518
+ option :base_url
519
+ option :timeout
520
+
521
+ def endpoint
522
+ options[:base_url]
523
+ end
524
+ end
525
+ end
526
+ RUBY
527
+
528
+ expect(methods.fetch("Actors::DemoActor#endpoint").text.lines.first).to include("-> String")
529
+ end
530
+
531
+ it "expands entity files referenced by actor ports" do
532
+ actor_path = File.expand_path("fixtures/pipeline/actors/demo_actor.rb", __dir__)
533
+ profile = Autotype::DiscoveryProfile.new(root: File.expand_path("fixtures", __dir__))
534
+ profile.prepare_search!([actor_path])
535
+ paths = described_class::CLI.expand_referenced_type_files([actor_path], profile: profile)
536
+
537
+ expect(paths).to include(
538
+ File.expand_path("fixtures/entities/tool_call.rb", __dir__),
539
+ File.expand_path("fixtures/entities/tool_result.rb", __dir__)
540
+ )
541
+ end
542
+
543
+ it "types Hash.new block defaults and element appends" do
544
+ methods = solve(<<~RUBY)
545
+ class Buffer
546
+ def reset!
547
+ @items = Hash.new { |hash, key| hash[key] = [] }
548
+ end
549
+
550
+ def push(item)
551
+ @items[1] << item
552
+ end
553
+ end
554
+ RUBY
555
+
556
+ expect(methods.fetch("Buffer#reset!").text.lines.first).to include("-> nil")
557
+ ivar_type = methods.fetch("Buffer#push").as_json.fetch(:locals).fetch("@items")
558
+ expect(ivar_type).to include("Hash[Integer, Array[")
559
+ end
560
+
561
+ it "grounds emit arguments from declared output ports" do
562
+ methods = solve(<<~RUBY)
563
+ module Actors
564
+ class DemoActor < App::Actor
565
+ output :chunks, type: App::Entities::StreamChunk
566
+
567
+ def send_chunk(entity)
568
+ emit(entity, to: :chunks)
569
+ end
570
+ end
571
+ end
572
+ RUBY
573
+
574
+ signature = methods.fetch("Actors::DemoActor#send_chunk").text.lines.first
575
+ expect(signature).to include("entity App::Entities::StreamChunk")
576
+ end
577
+
578
+ it "resolves entity member reads on union-typed pipeline inputs" do
579
+ methods = solve(<<~RUBY)
580
+ ToolCall = App::Entity.define(:step) do
581
+ end
582
+
583
+ ToolResult = App::Entity.define(:step) do
584
+ end
585
+
586
+ module Actors
587
+ class DemoActor < App::Actor
588
+ input :tool_calls, type: ToolCall
589
+ input :tool_results, type: ToolResult
590
+
591
+ def process(entity, from:)
592
+ entity.step
593
+ end
594
+ end
595
+ end
596
+ RUBY
597
+
598
+ expect(methods.fetch("Actors::DemoActor#process").text.lines.first).to include("-> Integer")
599
+ end
600
+
601
+ it "types entity to_h as a string-keyed hash" do
602
+ methods = solve(<<~RUBY)
603
+ DemoEntity = App::Entity.define(:id) do
604
+ def payload
605
+ to_h
606
+ end
607
+ end
608
+ RUBY
609
+
610
+ expect(methods.fetch("DemoEntity#payload").text.lines.first).to include("-> Hash[String, String]")
611
+ end
612
+
613
+ it "applies core member-name conventions to synthesized readers" do
614
+ methods = solve(<<~RUBY, profile: nil)
615
+ Record = Data.define(:name, :step) do
616
+ end
617
+
618
+ class Demo
619
+ def run(record)
620
+ record.name
621
+ record.step
622
+ end
623
+ end
624
+ RUBY
625
+
626
+ expect(methods.fetch("Record#name").text.lines.first).to include("-> String")
627
+ expect(methods.fetch("Record#step").text.lines.first).to include("-> Integer")
628
+ end
629
+
630
+ it "narrows hash dig chains with literal string keys" do
631
+ methods = solve(<<~RUBY, profile: nil)
632
+ class Demo
633
+ def read
634
+ data = { "detail" => { "locations" => [] } }
635
+ data.dig("detail", "locations")
636
+ end
637
+ end
638
+ RUBY
639
+
640
+ expect(methods.fetch("Demo#read").text.lines.first).to include("-> Nullable[Array[")
641
+ end
642
+
643
+ it "narrows array elements from appended parameters" do
644
+ methods = solve(<<~RUBY, profile: nil)
645
+ Widget = Data.define(:id)
646
+
647
+ class Demo
648
+ def buffer
649
+ items = []
650
+ items << Widget.new(id: "a")
651
+ end
652
+ end
653
+ RUBY
654
+
655
+ expect(methods.fetch("Demo#buffer").text.lines.first).to include("Array[Widget")
656
+ end
657
+
658
+ it "narrows dynamic symbol hash access after to_sym" do
659
+ methods = solve(<<~RUBY, profile: nil)
660
+ class Demo
661
+ def push(bucket, kind, item)
662
+ bucket[kind.to_sym] << item
663
+ end
664
+ end
665
+ RUBY
666
+
667
+ signature = methods.fetch("Demo#push").text.lines.first
668
+ expect(signature).to include("item Object")
669
+ expect(signature).not_to include("Appendable[Object, Object]")
670
+ end
671
+
672
+ it "narrows nested hash index append chains from method parameters" do
673
+ methods = solve(<<~RUBY, profile: nil)
674
+ Item = Data.define(:id)
675
+
676
+ class Demo
677
+ def buffer(step, kind)
678
+ item = Item.new(id: "a")
679
+ @buckets = Hash.new { |hash, key| hash[key] = { slots: [] } }
680
+ @buckets[step][kind.to_sym] << item
681
+ end
682
+ end
683
+ RUBY
684
+
685
+ signature = methods.fetch("Demo#buffer").text.lines.first
686
+ expect(signature).to include("Array[Item")
687
+ expect(signature).not_to include("Array[Object]")
688
+ end
689
+
690
+ it "narrows append targets when literal hashes expose multiple array slots" do
691
+ methods = solve(<<~RUBY, profile: nil)
692
+ Entity = Data.define(:step)
693
+
694
+ class Demo
695
+ def buffer(step, kind)
696
+ entity = Entity.new(step: 1)
697
+ @pending = Hash.new { |hash, key| hash[key] = { tool_calls: [], tool_results: [] } }
698
+ @pending[step][kind.to_sym] << entity
699
+ end
700
+ end
701
+ RUBY
702
+
703
+ signature = methods.fetch("Demo#buffer").text.lines.first
704
+ expect(signature).to include("Array[Entity")
705
+ expect(signature).not_to include("Array[Object]")
706
+ end
707
+
708
+ it "flows each block parameters into callee arguments" do
709
+ methods = solve(<<~RUBY, profile: nil)
710
+ class Demo
711
+ def run
712
+ @counts = { 1 => "a", 2 => "b" }
713
+ @counts.keys.sort.each { |step| consume(step) }
714
+ end
715
+
716
+ def consume(step)
717
+ step
718
+ end
719
+ end
720
+ RUBY
721
+
722
+ expect(methods.fetch("Demo#consume").text.lines.first).to include("(step Integer)")
723
+ end
724
+
725
+ it "types kernel Array() coercion without an explicit receiver" do
726
+ methods = solve(<<~RUBY, profile: nil)
727
+ class Demo
728
+ def wrap
729
+ Array([1, 2, 3])
730
+ end
731
+ end
732
+ RUBY
733
+
734
+ expect(methods.fetch("Demo#wrap").text.lines.first).to include("Array[Integer")
735
+ end
736
+
737
+ it "narrows case/when branches on Hash and Array" do
738
+ methods = solve(<<~RUBY, profile: nil)
739
+ class Demo
740
+ def walk(payload)
741
+ case payload
742
+ when Hash
743
+ payload.each_value { |value| value }
744
+ when Array
745
+ payload.each { |item| item }
746
+ end
747
+ end
748
+ end
749
+ RUBY
750
+
751
+ signature = methods.fetch("Demo#walk").text.lines.first
752
+ expect(signature).to include("Hash[String | Symbol, Object]")
753
+ expect(signature).to include("Array[Object]")
754
+ expect(signature).not_to include("(payload Object")
755
+ end
756
+
757
+ it "infers hash literal key names from core member conventions" do
758
+ methods = solve(<<~RUBY, profile: nil)
759
+ class Demo
760
+ def read(target)
761
+ target["concept_type"]
762
+ end
763
+ end
764
+ RUBY
765
+
766
+ expect(methods.fetch("Demo#read").text.lines.first).to include("-> String")
767
+ end
768
+
769
+ it "binds entity factory keyword params to declared member types" do
770
+ methods = solve(<<~RUBY)
771
+ Widget = App::Entity.define(:id, :name) do
772
+ field :id, required: true
773
+ field :name, required: true
774
+
775
+ def self.create(id:, name:)
776
+ new(id: id, name: name)
777
+ end
778
+ end
779
+ RUBY
780
+
781
+ signature = methods.fetch("Widget.create").text.lines.first
782
+ expect(signature).to include("name: String")
783
+ expect(signature).not_to include("name: Object")
784
+ end
785
+
786
+ it "reflows callee return types after callees converge" do
787
+ methods = solve(<<~RUBY)
788
+ class Demo
789
+ def process
790
+ buffer
791
+ end
792
+
793
+ def buffer
794
+ items = []
795
+ items << 1
796
+ end
797
+ end
798
+ RUBY
799
+
800
+ signature = methods.fetch("Demo#process").text.lines.first
801
+ expect(signature).to include("Array[Integer")
802
+ expect(signature).not_to include("Object")
803
+ end
804
+
805
+ it "types entity to_h from declared member fields" do
806
+ methods = solve(<<~RUBY)
807
+ DemoEntity = App::Entity.define(:id, :name) do
808
+ def payload
809
+ to_h
810
+ end
811
+ end
812
+ RUBY
813
+
814
+ signature = methods.fetch("DemoEntity#payload").text.lines.first
815
+ expect(signature).to include("-> Hash[String,")
816
+ expect(signature).to include("String")
817
+ end
818
+
819
+ it "types mutator methods ending in bang as nil returns" do
820
+ methods = solve(<<~RUBY, profile: nil)
821
+ class Demo
822
+ def remap!(payload)
823
+ payload["name"] = "x"
824
+ end
825
+ end
826
+ RUBY
827
+
828
+ expect(methods.fetch("Demo#remap!").text.lines.first).to include("-> nil")
829
+ end
830
+
831
+ it "uses stable JSON.parse value types" do
832
+ methods = solve(<<~RUBY, profile: nil)
833
+ class Demo
834
+ def read
835
+ JSON.parse("{}")
836
+ end
837
+ end
838
+ RUBY
839
+
840
+ signature = methods.fetch("Demo#read").text.lines.first
841
+ expect(signature).to include("Hash[String,")
842
+ expect(signature).not_to match(/Hash\[String, Hash\[String, Hash/)
843
+ end
844
+
845
+ it "names capability types without greek letter fragments" do
846
+ methods = [
847
+ *collect("def a(x); x[1]; x[2]; end", "a.rb"),
848
+ *collect("def b(y); y[:key]; y.dig(:a, :b); end", "b.rb"),
849
+ *collect("def c(z); z.map(&:to_s); end", "c.rb")
850
+ ]
851
+ registry = described_class::UniversalHelperRegistry.new(methods)
852
+ described_class::HeuristicCapabilityNamer.new(registry).name!
853
+ names = registry.definitions.map { |definition| definition.fetch(:name) }.join(" ")
854
+
855
+ expect(names).not_to match(/Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta|Iota|Kappa|Lambda|Mu|Nu|Xi|Omicron|Pi|Rho|Sigma|Tau|Upsilon|Phi|Chi|Psi|Omega/)
856
+ end
857
+
858
+ it "collapses repeated receiver constraints into helper types" do
859
+ methods = infer(<<~RUBY)
860
+ def context(prompt)
861
+ prompt.current_question
862
+ prompt.thread_context
863
+ end
864
+ RUBY
865
+ text = methods.fetch("example#context").text
866
+
867
+ expect(text).to match(
868
+ /example#context : \(prompt Capability_[a-f0-9]{10}\[Object, Object\]\) -> Object/
869
+ )
870
+ expect(text).to match(
871
+ /type Capability_[a-f0-9]{10}<T1, T2> = \{ current_question\(\) -> T1; thread_context\(\) -> T2 \}/
872
+ )
873
+ expect(text).not_to include("A responds to")
874
+ end
875
+
876
+ it "reuses canonical helper types across files" do
877
+ methods = [
878
+ *collect("def first(input); input.left; input.right; end", "one.rb"),
879
+ *collect("def second(value); value.left; value.right; end", "two.rb")
880
+ ]
881
+ registry = described_class::UniversalHelperRegistry.new(methods)
882
+ helper_names = methods.flat_map do |method|
883
+ described_class::Renderer.new(method, helper_registry: registry).as_json.fetch(:helpers).map { _1[:name] }
884
+ end
885
+
886
+ expect(helper_names.uniq.length).to eq(1)
887
+ expect(registry.definitions.first.fetch(:uses)).to eq(2)
888
+ end
889
+
890
+ it "applies semantic -able names to universal capability types" do
891
+ method = collect("def read(input); input.current_question; input.thread_context; end").first
892
+ registry = described_class::UniversalHelperRegistry.new([method])
893
+ id = registry.definitions.first.fetch(:id)
894
+ registry.apply_names!(id => "Context Readable")
895
+ text = described_class::Renderer.new(method, helper_registry: registry).text
896
+
897
+ expect(text).to include(
898
+ "ContextReadable[",
899
+ "type ContextReadable<T1, T2>"
900
+ )
901
+ end
902
+
903
+ it "consolidates same-structure helper shapes using union typed slots" do
904
+ methods = [
905
+ *collect("def first(a); a.fetch(1); end", "one.rb"),
906
+ *collect("def second(b); b.fetch(\"key\"); end", "two.rb")
907
+ ]
908
+ registry = described_class::UniversalHelperRegistry.new(methods)
909
+
910
+ expect(registry.definitions.length).to eq(1)
911
+ definition = registry.definitions.first
912
+ expect(definition.fetch(:definition)).to eq("{ fetch(Integer | String) -> T1 }")
913
+ expect(definition.fetch(:uses)).to eq(2)
914
+
915
+ renderer = described_class::Renderer.new(methods.first, helper_registry: registry)
916
+ expect(renderer.text).to match(/one#first : \(a Capability_[a-f0-9]{10}\[Object\]\) -> Object/)
917
+ end
918
+
919
+ it "derives deterministic -able names without an LLM" do
920
+ methods = [
921
+ *collect("def greet(name); \"hi \#{name}\"; end", "a.rb"),
922
+ *collect("def read(input); input.current_question; input.thread_context; end", "b.rb"),
923
+ *collect("def clean(doc); doc.strip_attachment_stubs(1); end", "c.rb")
924
+ ]
925
+ registry = described_class::UniversalHelperRegistry.new(methods)
926
+ described_class::HeuristicCapabilityNamer.new(registry).name!
927
+ names = registry.definitions.map { |definition| definition.fetch(:name) }
928
+
929
+ expect(names).to include(
930
+ "Stringifiable",
931
+ "CurrentQuestionThreadContextReadable",
932
+ "AttachmentStubsStrippable"
933
+ )
934
+ end
935
+
936
+ it "escalates colliding heuristic names deterministically without variants" do
937
+ methods = [
938
+ *collect("def a(x); x.fetch(1); end", "a.rb"),
939
+ *collect("def b(y); y.fetch(1, 2); end", "b.rb")
940
+ ]
941
+ registry = described_class::UniversalHelperRegistry.new(methods)
942
+ described_class::HeuristicCapabilityNamer.new(registry).name!
943
+ names = registry.definitions.map { |definition| definition.fetch(:name) }
944
+
945
+ expect(names.uniq.length).to eq(2)
946
+ expect(names).to include("Fetchable")
947
+ expect(names.join).not_to include("Variant")
948
+ expect(names.join).not_to match(/Capability_/)
949
+ end
950
+
951
+ it "resolves name collisions cluster by cluster instead of Variant-prefixing" do
952
+ methods = [
953
+ *collect("def first(a); a.title; end", "one.rb"),
954
+ *collect("def second(b); b.title(1); end", "two.rb")
955
+ ]
956
+ registry = described_class::UniversalHelperRegistry.new(methods)
957
+ ids = registry.definitions.map { |definition| definition.fetch(:id) }
958
+ expect(ids.length).to eq(2)
959
+
960
+ Tempfile.create("capability-names") do |cache|
961
+ path = cache.path
962
+ cache.close
963
+ File.delete(path)
964
+ calls = 0
965
+ runner = lambda do |prompt, _model|
966
+ calls += 1
967
+ case calls
968
+ when 1
969
+ JSON.generate(ids[0] => "TitleReadable", ids[1] => "TitleReadable")
970
+ else
971
+ expect(prompt).to include("TitleReadable", ids[0], ids[1])
972
+ JSON.generate(ids[0] => "TitleReadable", ids[1] => "IndexedTitleReadable")
973
+ end
974
+ end
975
+
976
+ described_class::OpenAiCapabilityNamer.new(registry, cache_path: path, runner: runner).name!
977
+
978
+ names = registry.definitions.map { |definition| definition.fetch(:name) }
979
+ expect(names).to contain_exactly("TitleReadable", "IndexedTitleReadable")
980
+ expect(names.join).not_to include("Variant")
981
+ expect(calls).to eq(2)
982
+ end
983
+ end
984
+
985
+ it "runs and caches batched OpenAI capability naming inside the script" do
986
+ method = collect("def read(input); input.current_question; input.thread_context; end").first
987
+ registry = described_class::UniversalHelperRegistry.new([method])
988
+ id = registry.definitions.first.fetch(:id)
989
+
990
+ Tempfile.create("capability-names") do |cache|
991
+ path = cache.path
992
+ cache.close
993
+ File.delete(path)
994
+ runner = lambda do |prompt, model|
995
+ expect(model).to eq("gpt-5.6-luna")
996
+ expect(prompt).to include(id, "current_question")
997
+ JSON.generate(id => "ContextReadable")
998
+ end
999
+
1000
+ described_class::OpenAiCapabilityNamer.new(
1001
+ registry,
1002
+ cache_path: path,
1003
+ batch_size: 1,
1004
+ runner: runner
1005
+ ).name!
1006
+
1007
+ expect(JSON.parse(File.read(path))).to eq(id => "ContextReadable")
1008
+ expect(registry.definitions.first.fetch(:name)).to eq("ContextReadable")
1009
+ end
1010
+ end
1011
+
1012
+ it "renders a searchable, escaped HTML report with coverage details" do
1013
+ renderer = infer("def identity(value) = value").fetch("example#identity")
1014
+ html = described_class::HtmlDocument.new(
1015
+ [["example.rb", renderer]],
1016
+ analyzed_files: ["example.rb", "fragment.rb"],
1017
+ parse_errors: { "fragment.rb" => ["L1: expected <end>"] }
1018
+ ).render
1019
+
1020
+ expect(html).to include(
1021
+ "<strong>2</strong><span>files analyzed</span>",
1022
+ "Parse failures (1)",
1023
+ "expected &lt;end&gt;",
1024
+ "data-search=",
1025
+ ".helper-name { display: block;"
1026
+ )
1027
+ end
1028
+ end