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.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/Rakefile +21 -0
- data/autotype.example.yml +19 -0
- data/autotype.gemspec +38 -0
- data/exe/autotype +6 -0
- data/ext/autotype/Makefile +273 -0
- data/ext/autotype/autotype.bundle.dSYM/Contents/Info.plist +20 -0
- data/ext/autotype/autotype.bundle.dSYM/Contents/Resources/Relocations/aarch64/autotype.bundle.yml +5 -0
- data/ext/autotype/autotype.c +277 -0
- data/ext/autotype/extconf.rb +15 -0
- data/lib/autotype/discovery_profile.rb +527 -0
- data/lib/autotype/engine.rb +4613 -0
- data/lib/autotype/native.rb +34 -0
- data/lib/autotype/native_bridge.rb +121 -0
- data/lib/autotype/profile.rb +46 -0
- data/lib/autotype/type_string.rb +48 -0
- data/lib/autotype/version.rb +5 -0
- data/lib/autotype.rb +15 -0
- data/native/autotype/Makefile +23 -0
- data/native/autotype/include/stc.h +107 -0
- data/native/autotype/src/main.c +50 -0
- data/native/autotype/src/solver.c +296 -0
- data/native/autotype/src/type.c +223 -0
- data/spec/autotype_config_spec.rb +91 -0
- data/spec/autotype_native_spec.rb +71 -0
- data/spec/autotype_spec.rb +1028 -0
- data/spec/examples.txt +72 -0
- data/spec/fixtures/autotype.yml +9 -0
- data/spec/fixtures/entities/tool_call.rb +7 -0
- data/spec/fixtures/entities/tool_result.rb +7 -0
- data/spec/fixtures/pipeline/actors/demo_actor.rb +8 -0
- data/spec/spec_helper.rb +25 -0
- metadata +149 -0
|
@@ -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 <end>",
|
|
1024
|
+
"data-search=",
|
|
1025
|
+
".helper-name { display: block;"
|
|
1026
|
+
)
|
|
1027
|
+
end
|
|
1028
|
+
end
|