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,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Autotype
6
+ # Infers framework wiring, entity layout, and type hints from Ruby source and
7
+ # the filesystem. Optional autotype.yml supplies skip globs and overrides when
8
+ # heuristics are wrong — nothing else is required.
9
+ class DiscoveryProfile
10
+ PORT_CALLS = %i[input output].freeze
11
+ OPTION_CALLS = %i[option].freeze
12
+ FIELD_CALLS = %i[field].freeze
13
+ HANDLER_METHOD_CANDIDATES = %i[process handle call perform run execute].freeze
14
+ DISPATCH_PARAM_CANDIDATES = %i[from source port input_name].freeze
15
+ EMIT_MESSAGES = %i[emit publish send emit_entity].freeze
16
+ EMIT_PORT_KEYWORDS = %i[to port output].freeze
17
+
18
+ def self.load_overrides(path, root: File.dirname(path))
19
+ raw = YAML.safe_load_file(path, aliases: true) || {}
20
+ new(root: root, overrides: raw, config_path: path)
21
+ end
22
+
23
+ def self.find(start_dir = Dir.pwd)
24
+ dir = File.expand_path(start_dir)
25
+ loop do
26
+ candidate = File.join(dir, "autotype.yml")
27
+ return candidate if File.file?(candidate)
28
+
29
+ parent = File.dirname(dir)
30
+ break if parent == dir
31
+
32
+ dir = parent
33
+ end
34
+ nil
35
+ end
36
+
37
+ def initialize(root: Dir.pwd, overrides: {}, config_path: nil)
38
+ @root = File.expand_path(root)
39
+ @overrides = stringify_keys(overrides)
40
+ @config_path = config_path
41
+ @search_roots = Set.new([@root])
42
+ @port_dsl_owners = Set.new
43
+ @referenced_constants = Set.new
44
+ reset_inference!
45
+ end
46
+
47
+ attr_reader :root
48
+
49
+ def path
50
+ @config_path
51
+ end
52
+
53
+ def prepare_search!(paths)
54
+ paths.each do |file|
55
+ next unless File.file?(file)
56
+
57
+ @search_roots << find_project_root(File.dirname(File.expand_path(file)))
58
+ end
59
+ end
60
+
61
+ def finalize!(collectors)
62
+ methods = collectors.flat_map(&:methods)
63
+ ports = merge_ports(collectors)
64
+
65
+ @port_wiring ||= infer_port_wiring(methods, ports)
66
+ @output_emit ||= infer_output_emit(methods, ports)
67
+ @config_hash ||= infer_config_hash(methods, collectors)
68
+ @side_effect_methods ||= infer_side_effect_methods(methods, ports)
69
+ @structured_type_prefixes = infer_structured_prefixes(collectors, ports) if @structured_type_prefixes.empty?
70
+ @framework_self_fallbacks = infer_self_fallbacks(methods, ports) if @framework_self_fallbacks.empty?
71
+ collectors.each { collect_referenced_constants(_1) }
72
+ @finalized = true
73
+ end
74
+
75
+ def skipped_files
76
+ Array(@overrides.fetch("skip", [])).to_set
77
+ end
78
+
79
+ def member_type_hints
80
+ @member_type_hints ||= parse_type_map(@overrides.dig("types", "members"))
81
+ end
82
+
83
+ def option_type_hints
84
+ @option_type_hints ||= parse_type_map(@overrides.dig("types", "options"))
85
+ end
86
+
87
+ def side_effect_methods
88
+ @side_effect_methods || Set.new
89
+ end
90
+
91
+ def structured_type_prefixes
92
+ @structured_type_prefixes || []
93
+ end
94
+
95
+ def port_wiring
96
+ @port_wiring
97
+ end
98
+
99
+ def output_emit
100
+ @output_emit
101
+ end
102
+
103
+ def config_hash
104
+ @config_hash
105
+ end
106
+
107
+ def framework_self_fallbacks
108
+ @framework_self_fallbacks || {}
109
+ end
110
+
111
+ def pipeline_class?(collector, owner)
112
+ return false if collector.singleton_context? || collector.namespace_empty?
113
+
114
+ return true if @port_dsl_owners.include?(owner)
115
+
116
+ superclass = collector.superclass_for(owner)
117
+ superclass == "Actor" || superclass&.end_with?("::Actor")
118
+ end
119
+
120
+ def entity_define_receiver?(receiver_slice)
121
+ return false unless receiver_slice
122
+
123
+ receiver_slice == "Entity" ||
124
+ receiver_slice == "Data" ||
125
+ receiver_slice.end_with?("::Entity")
126
+ end
127
+
128
+ def visit_call_node(collector, node)
129
+ return unless node.receiver.nil?
130
+ return if collector.singleton_context?
131
+
132
+ owner = collector.current_owner
133
+ if port_call?(node.name)
134
+ record_port(collector, node)
135
+ @port_dsl_owners << owner
136
+ elsif option_call?(node.name)
137
+ record_option(collector, node)
138
+ @port_dsl_owners << owner
139
+ elsif field_call?(node.name) && collector.structured_field_context?
140
+ record_field(collector, node)
141
+ end
142
+ end
143
+
144
+ def metadata_from(collector)
145
+ InferenceMetadata.from_collector(
146
+ collector,
147
+ member_type_hints: member_type_hints,
148
+ option_type_hints: option_type_hints,
149
+ side_effect_methods: side_effect_methods,
150
+ structured_type_prefixes: structured_type_prefixes,
151
+ port_wiring: port_wiring,
152
+ output_emit: output_emit,
153
+ config_hash: config_hash,
154
+ framework_self_fallbacks: framework_self_fallbacks,
155
+ type_locators: [method(:locate_type_file)]
156
+ )
157
+ end
158
+
159
+ def locate_type_file(type_name)
160
+ return @type_path_cache[type_name] if @type_path_cache.key?(type_name)
161
+
162
+ explicit = explicit_locator(type_name)
163
+ return cache_type_path(type_name, explicit) if explicit
164
+
165
+ basename = type_name.split("::").last
166
+ snake = underscore(basename)
167
+ candidates = @search_roots.flat_map do |search_root|
168
+ Dir.glob(File.join(search_root, "**", "#{snake}.rb"))
169
+ end.uniq
170
+
171
+ path = candidates.find { defines_type?(_1, type_name, basename) }
172
+ cache_type_path(type_name, path)
173
+ end
174
+
175
+ private
176
+
177
+ def reset_inference!
178
+ @finalized = false
179
+ @port_wiring = explicit_port_wiring
180
+ @output_emit = explicit_output_emit
181
+ @config_hash = explicit_config_hash
182
+ @side_effect_methods = explicit_side_effect_methods
183
+ @structured_type_prefixes = Array(@overrides.dig("structured", "prefixes"))
184
+ @framework_self_fallbacks = explicit_self_fallbacks
185
+ @type_path_cache = {}
186
+ end
187
+
188
+ def cache_type_path(type_name, path)
189
+ @type_path_cache[type_name] = path
190
+ end
191
+
192
+ def explicit_locator(type_name)
193
+ Array(@overrides.fetch("locators", [])).each do |locator|
194
+ prefix = locator.fetch("prefix")
195
+ next unless type_name.start_with?(prefix)
196
+
197
+ basename = type_name.split("::").last
198
+ snake = underscore(basename)
199
+ Array(locator.fetch("paths")).each do |template|
200
+ path = File.expand_path(template.gsub("{snake}", snake), @root)
201
+ return path if File.file?(path)
202
+ end
203
+ end
204
+ nil
205
+ end
206
+
207
+ def explicit_port_wiring
208
+ raw = @overrides.dig("wiring", "ports")
209
+ return unless raw
210
+
211
+ InferenceMetadata::PortWiring.new(
212
+ handler_method: raw.fetch("handler").to_sym,
213
+ dispatch_param: raw.fetch("dispatch_param").to_sym,
214
+ dispatch_param_type: TypeString.parse(raw.fetch("dispatch_type", "Symbol")),
215
+ entity_param: raw.fetch("entity_param", "first_required").to_sym
216
+ )
217
+ end
218
+
219
+ def explicit_output_emit
220
+ raw = @overrides.dig("wiring", "emit")
221
+ return unless raw
222
+
223
+ InferenceMetadata::OutputEmitWiring.new(
224
+ message: raw.fetch("message").to_sym,
225
+ port_keyword: raw.fetch("port_keyword").to_sym,
226
+ argument_index: raw.fetch("argument_index")
227
+ )
228
+ end
229
+
230
+ def explicit_config_hash
231
+ raw = @overrides.dig("wiring", "config_hash")
232
+ return unless raw
233
+
234
+ InferenceMetadata::ConfigHashWiring.new(
235
+ init_param: raw.fetch("init_param").to_sym,
236
+ reader_method: raw.fetch("reader").to_sym,
237
+ ivar: raw.fetch("ivar")
238
+ )
239
+ end
240
+
241
+ def explicit_side_effect_methods
242
+ methods = Array(@overrides.dig("pipeline", "side_effect_methods"))
243
+ return if methods.empty?
244
+
245
+ methods.map(&:to_sym).to_set
246
+ end
247
+
248
+ def explicit_self_fallbacks
249
+ @overrides.fetch("self_types", {}).each_with_object({}) do |(message, type_string), fallbacks|
250
+ type = TypeString.parse(type_string)
251
+ fallbacks[message.to_sym] = ->(_method, _capability) { type }
252
+ end
253
+ end
254
+
255
+ def merge_ports(collectors)
256
+ ports = Hash.new { |hash, key| hash[key] = { inputs: {}, outputs: {} } }
257
+ collectors.each do |collector|
258
+ collector.declared_ports.each do |owner, declared|
259
+ ports[owner][:inputs].merge!(declared[:inputs])
260
+ ports[owner][:outputs].merge!(declared[:outputs])
261
+ end
262
+ end
263
+ ports
264
+ end
265
+
266
+ def infer_port_wiring(methods, ports)
267
+ owners = ports.select { |_owner, declared| declared[:inputs].any? }.keys
268
+ observations = owners.filter_map do |owner|
269
+ observe_port_handler(methods, owner)
270
+ end
271
+ return observations.first if observations.length == 1
272
+
273
+ grouped = observations.group_by { |observation| [observation.handler_method, observation.dispatch_param] }
274
+ grouped.values.max_by(&:length)&.first
275
+ end
276
+
277
+ def observe_port_handler(methods, owner)
278
+ candidates = methods.select do |method|
279
+ method.owner == owner &&
280
+ method.kind == :instance &&
281
+ method.parameters.any? { |parameter| dispatch_param?(parameter) }
282
+ end
283
+ return if candidates.empty?
284
+
285
+ handler = HANDLER_METHOD_CANDIDATES.filter_map { |name| candidates.find { |method| method.method_name == name } }.first
286
+ handler ||= candidates.min_by { |method| method.line.to_i }
287
+
288
+ dispatch = handler.parameters.find { |parameter| dispatch_param?(parameter) }
289
+ InferenceMetadata::PortWiring.new(
290
+ handler_method: handler.method_name,
291
+ dispatch_param: dispatch.name,
292
+ dispatch_param_type: Named.new("Symbol"),
293
+ entity_param: :first_required
294
+ )
295
+ end
296
+
297
+ def dispatch_param?(parameter)
298
+ DISPATCH_PARAM_CANDIDATES.include?(parameter.name) &&
299
+ %i[keyword optional_keyword].include?(parameter.kind)
300
+ end
301
+
302
+ def infer_output_emit(methods, ports)
303
+ owners_with_outputs = ports.select { |_owner, declared| declared[:outputs].any? }.keys.to_set
304
+ observations = []
305
+ methods.each do |method|
306
+ next unless owners_with_outputs.include?(method.owner)
307
+
308
+ method.capabilities.each do |capability|
309
+ next unless capability.receiver == method.self_type
310
+ next unless EMIT_MESSAGES.include?(capability.message)
311
+
312
+ EMIT_PORT_KEYWORDS.each do |keyword|
313
+ next unless capability.keywords.key?(keyword)
314
+
315
+ observations << {
316
+ message: capability.message,
317
+ port_keyword: keyword,
318
+ argument_index: 0
319
+ }
320
+ end
321
+ end
322
+ end
323
+ return if observations.empty?
324
+
325
+ grouped = observations.group_by { |observation| observation.values_at(:message, :port_keyword, :argument_index) }
326
+ winner = grouped.max_by { |_key, group| group.length }&.last&.first
327
+ InferenceMetadata::OutputEmitWiring.new(
328
+ message: winner.fetch(:message),
329
+ port_keyword: winner.fetch(:port_keyword),
330
+ argument_index: winner.fetch(:argument_index)
331
+ )
332
+ end
333
+
334
+ def infer_config_hash(methods, collectors)
335
+ option_owners = collectors.flat_map { |collector| collector.declared_config_options.keys }.to_set
336
+ return if option_owners.empty?
337
+
338
+ observations = option_owners.filter_map do |owner|
339
+ reader = methods.find do |method|
340
+ method.owner == owner &&
341
+ method.kind == :instance &&
342
+ method.method_name == :options &&
343
+ method.parameters.empty?
344
+ end
345
+ next unless reader
346
+
347
+ {
348
+ init_param: :options,
349
+ reader_method: :options,
350
+ ivar: "@options"
351
+ }
352
+ end
353
+ return if observations.empty?
354
+
355
+ grouped = observations.group_by(&:itself)
356
+ winner = grouped.max_by { |_key, group| group.length }&.first
357
+ InferenceMetadata::ConfigHashWiring.new(
358
+ init_param: winner.fetch(:init_param),
359
+ reader_method: winner.fetch(:reader_method),
360
+ ivar: winner.fetch(:ivar)
361
+ )
362
+ end
363
+
364
+ def infer_side_effect_methods(methods, ports)
365
+ explicit = explicit_side_effect_methods
366
+ return explicit if explicit
367
+
368
+ pipeline_owners = ports.keys.to_set | @port_dsl_owners
369
+ methods.filter_map do |method|
370
+ next unless pipeline_owners.include?(method.owner)
371
+ next unless method.method_name.to_s.match?(/\Aon_[a-z_]+\z/)
372
+
373
+ method.method_name
374
+ end.to_set
375
+ end
376
+
377
+ def infer_structured_prefixes(collectors, ports)
378
+ explicit = Array(@overrides.dig("structured", "prefixes"))
379
+ return explicit if explicit.any?
380
+
381
+ constants = Set.new
382
+ collectors.each do |collector|
383
+ collector.referenced_types.each { constants << _1 }
384
+ collector.structured_owners.each { constants << _1 }
385
+ collector.declared_ports.each_value do |declared|
386
+ declared[:inputs].each_value { |type| constants << type.name if type.is_a?(Named) }
387
+ declared[:outputs].each_value { |type| constants << type.name if type.is_a?(Named) }
388
+ end
389
+ end
390
+
391
+ prefixes = constants
392
+ .select { |name| name.include?("::") }
393
+ .map { |name| name.sub(/::[^:]+\z/, "::") }
394
+ .group_by(&:itself)
395
+ .select { |_prefix, group| group.length >= 1 }
396
+ .keys
397
+ prefixes.sort_by { |prefix| -prefix.length }
398
+ end
399
+
400
+ def infer_self_fallbacks(methods, ports)
401
+ fallbacks = explicit_self_fallbacks
402
+ pipeline_owners = ports.keys.to_set | @port_dsl_owners
403
+ hash_type = Generic.new("Hash", [Named.new("Symbol"), Named.new("Object")])
404
+
405
+ %i[inputs outputs].each do |message|
406
+ next if fallbacks.key?(message)
407
+
408
+ used = methods.any? do |method|
409
+ pipeline_owners.include?(method.owner) &&
410
+ method.capabilities.any? { |capability| capability.message == message && capability.receiver == method.self_type }
411
+ end
412
+ fallbacks[message] = ->(_method, _capability) { hash_type } if used
413
+ end
414
+ fallbacks
415
+ end
416
+
417
+ def collect_referenced_constants(collector)
418
+ collector.referenced_types.each { @referenced_constants << _1 }
419
+ end
420
+
421
+ def port_call?(name) = PORT_CALLS.include?(name)
422
+ def option_call?(name) = OPTION_CALLS.include?(name)
423
+ def field_call?(name) = FIELD_CALLS.include?(name)
424
+
425
+ def record_port(collector, node)
426
+ port_name = collector.symbol_arguments(node).first
427
+ type_name = collector.keyword_constant(node.arguments, :type)
428
+ return unless port_name && type_name
429
+
430
+ owner = collector.current_owner
431
+ bucket = node.name == :input ? :inputs : :outputs
432
+ collector.declared_ports[owner][bucket][port_name] = Named.new(type_name)
433
+ collector.referenced_types << type_name if structured_constant?(type_name)
434
+ end
435
+
436
+ def record_option(collector, node)
437
+ option_name = collector.symbol_arguments(node).first
438
+ return unless option_name
439
+
440
+ collector.declared_config_options[collector.current_owner][option_name] =
441
+ option_type_hints[option_name] || infer_option_type(option_name)
442
+ end
443
+
444
+ def record_field(collector, node)
445
+ field_name = collector.symbol_arguments(node).first
446
+ return unless field_name
447
+
448
+ hint = member_type_hints[field_name] || infer_member_type(field_name)
449
+ collector.declared_member_types[collector.current_owner][field_name] = hint if hint
450
+ end
451
+
452
+ def infer_member_type(name)
453
+ type_name = CORE_MEMBER_NAME_TYPES[name]
454
+ Named.new(type_name) if type_name
455
+ end
456
+
457
+ def infer_option_type(name)
458
+ return member_type_hints[name] if member_type_hints.key?(name)
459
+
460
+ inferred = infer_member_type(name) || infer_name_pattern_type(name.to_s)
461
+ inferred || Named.new("Object")
462
+ end
463
+
464
+ def infer_name_pattern_type(name)
465
+ case name
466
+ when /\A(?:is_|has_|allow_|enable_|disable_)/ then Named.new("bool")
467
+ when /_(?:url|uri|path|token|model|mode|collection|actor|source|id|key|name|label)\z/ then Named.new("String")
468
+ when /_(?:timeout|size|count|batch|limit|max|min|page|step|iteration)\z/ then Named.new("Integer")
469
+ when /_(?:min|max|score|distance|ratio|threshold)\z/ then Named.new("Float")
470
+ end
471
+ end
472
+
473
+ def structured_constant?(type_name)
474
+ type_name.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)+\z/)
475
+ end
476
+
477
+ def defines_type?(path, type_name, basename)
478
+ return false unless File.file?(path)
479
+
480
+ source = File.read(path)
481
+ return true if source.match?(/\b#{Regexp.escape(type_name)}(?:\s*=|\s*\n)/)
482
+ return true if source.match?(/\bmodule\s+\w+(?:\s*;\s*\w+)*\s*\n(?:.*\n)*?\s*(?:#{Regexp.escape(basename)}\s*=|class\s+#{Regexp.escape(basename)}\b)/m)
483
+ return true if source.match?(/\bclass\s+#{Regexp.escape(basename)}\b/)
484
+ return true if source.match?(/\b#{Regexp.escape(basename)}\s*=\s*\w+(?:::)?(?:Entity|Data)\.define\b/)
485
+
486
+ false
487
+ rescue StandardError
488
+ false
489
+ end
490
+
491
+ def find_project_root(start_dir)
492
+ dir = File.expand_path(start_dir)
493
+ loop do
494
+ markers = %w[Gemfile .git autotype.yml].map { |name| File.join(dir, name) }
495
+ return dir if markers.any? { File.exist?(_1) }
496
+
497
+ parent = File.dirname(dir)
498
+ break if parent == dir
499
+
500
+ dir = parent
501
+ end
502
+ File.expand_path(start_dir)
503
+ end
504
+
505
+ def parse_type_map(map)
506
+ (map || {}).transform_keys(&:to_sym).transform_values { |type_string| TypeString.parse(type_string) }
507
+ end
508
+
509
+ def underscore(name)
510
+ name
511
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
512
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
513
+ .downcase
514
+ end
515
+
516
+ def stringify_keys(value)
517
+ case value
518
+ when Hash
519
+ value.each_with_object({}) { |(key, entry), hash| hash[key.to_s] = stringify_keys(entry) }
520
+ when Array
521
+ value.map { stringify_keys(_1) }
522
+ else
523
+ value
524
+ end
525
+ end
526
+ end
527
+ end