rigortype 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +114 -3
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +1 -1
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +2 -2
  24. data/lib/rigor/configuration.rb +2 -2
  25. data/lib/rigor/environment.rb +35 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +3 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +146 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +30 -9
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +5 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. metadata +52 -1
@@ -0,0 +1,1048 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../environment"
7
+ require_relative "../scope"
8
+ require_relative "../reflection"
9
+ require_relative "../type"
10
+ require_relative "../inference/scope_indexer"
11
+ require_relative "../inference/rbs_type_translator"
12
+
13
+ module Rigor
14
+ module SigGen
15
+ # Core generator for `rigor sig-gen` (ADR-14 slice 1 — MVP).
16
+ #
17
+ # Walks every `.rb` file under the input paths, builds a
18
+ # per-node scope index via {Rigor::Inference::ScopeIndexer},
19
+ # finds every `Prism::DefNode` whose enclosing class is
20
+ # nameable, types the body's last expression to derive an
21
+ # inferred return, looks up the project's existing RBS
22
+ # declaration (if any), and emits one {MethodCandidate} per
23
+ # def.
24
+ #
25
+ # The MVP keeps the scope deliberately narrow:
26
+ # - Only instance methods inside a `class` / `module` body
27
+ # are considered. Top-level / DSL-block / singleton defs
28
+ # are skipped (`sig.skipped.complex-shape`).
29
+ # - Parameter signatures are hard-coded to `untyped` per
30
+ # ADR-14 § "Robustness principle compliance" clause 2;
31
+ # `--params=observed` arrives in slice 3.
32
+ # - Optional / rest / keyword / block params disqualify the
33
+ # def (`sig.skipped.complex-shape`).
34
+ # - A `Dynamic[top]` inferred return becomes
35
+ # `sig.skipped.untyped-return` — emitting `untyped` would
36
+ # obscure rather than help.
37
+ # - Tighter-return detection compares the RBS-erased
38
+ # spellings only when the existing declared return
39
+ # strictly accepts the inferred one (acceptance check
40
+ # under the engine's current `:gradual` mode; ADR-14
41
+ # reserves the eventual `:strict` mode).
42
+ class Generator # rubocop:disable Metrics/ClassLength
43
+ # @param configuration [Rigor::Configuration]
44
+ # @param paths [Array<String>] files / directories to scan.
45
+ # @param observations [Hash{[String, Symbol] => Array<Array<Rigor::Type>>}]
46
+ # ADR-14 slice 3 — per-target-method arg-tuple observations
47
+ # produced by {ObservationCollector}. An empty Hash (the default)
48
+ # means "no observations available; emit `untyped` for every
49
+ # parameter position" per ADR-5 clause 2.
50
+ def initialize(configuration:, paths:, observations: {}, include_private: false)
51
+ @configuration = configuration
52
+ @paths = paths
53
+ @observations = normalize_observations(observations)
54
+ @include_private = include_private
55
+ end
56
+
57
+ # Lifts legacy plain-`Array[Type]` observation entries
58
+ # into {ObservedCall} carriers. Specs from the slice-3
59
+ # generation predate the carrier and pass observations
60
+ # as `{ [class, method] => [[type1, type2], ...] }`;
61
+ # the wrapper keeps those passing while internal code
62
+ # always sees the new shape.
63
+ def normalize_observations(map)
64
+ return map if map.empty?
65
+
66
+ map.transform_values { |entries| entries.map { |entry| ObservedCall.from(entry) } }
67
+ end
68
+
69
+ # @return [Array<MethodCandidate>]
70
+ def run
71
+ @environment = build_environment
72
+ resolved = resolve_paths(@paths)
73
+ resolved.flat_map { |path| analyse_file(path, @environment) }
74
+ end
75
+
76
+ private
77
+
78
+ def build_environment
79
+ Environment.for_project(
80
+ libraries: @configuration.libraries,
81
+ signature_paths: @configuration.signature_paths
82
+ )
83
+ end
84
+
85
+ def resolve_paths(args)
86
+ args.flat_map do |arg|
87
+ if File.directory?(arg)
88
+ Dir.glob(File.join(arg, "**/*.rb"), sort: true)
89
+ elsif File.file?(arg) && arg.end_with?(".rb")
90
+ [arg]
91
+ else
92
+ []
93
+ end
94
+ end.uniq
95
+ end
96
+
97
+ def analyse_file(path, environment)
98
+ source = File.read(path)
99
+ parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
100
+ return [] if parse_result.errors.any?
101
+
102
+ base_scope = Scope.empty(environment: environment)
103
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
104
+
105
+ @namespace_kinds = {}
106
+ @module_function_methods = Set.new
107
+ @class_shells = Set.new
108
+ defs = collect_method_definitions(parse_result.value)
109
+ candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
110
+ classify_def(path, def_node, class_name, kind, scope_index)
111
+ end
112
+ candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index)
113
+ end
114
+
115
+ # Walks the AST collecting `(def_node, class_name, kind)`
116
+ # tuples for every `def` Rigor can re-type. Slice 1
117
+ # covered instance `def foo` methods inside a nameable
118
+ # `class` / `module` body. Slice 4 extends this to
119
+ # singleton-side methods via `def self.foo` and
120
+ # `class << self; def foo; end`; top-level / DSL-block
121
+ # defs still degrade silently (no nameable receiver).
122
+ #
123
+ # ADR-14 gap-#3 follow-up tracks two extra pieces during
124
+ # the same walk so the Writer can emit kind-correct RBS
125
+ # without guessing:
126
+ #
127
+ # - `@namespace_kinds[qualified_name]` records whether
128
+ # each segment came from `class Foo` (`:class`) or
129
+ # `module Foo` (`:module`). Used by the writer's
130
+ # `wrap_in_modules` step to emit the right keyword for
131
+ # each intermediate segment AND the leaf.
132
+ # - `@module_function_methods` records `(class_name,
133
+ # method_name)` pairs where a `module_function` (no
134
+ # args) call preceded the `def` inside a module body.
135
+ # The renderer emits `def self?.name` for these, the
136
+ # RBS spelling that matches the dual instance +
137
+ # singleton dispatch the runtime produces.
138
+ def collect_method_definitions(root)
139
+ out = []
140
+ walk_defs(root, [], false, false, out)
141
+ out
142
+ end
143
+
144
+ def walk_defs(node, prefix, in_singleton_class, module_function_active, out)
145
+ return unless node.is_a?(Prism::Node)
146
+
147
+ case node
148
+ when Prism::ClassNode, Prism::ModuleNode
149
+ return if descend_into_namespace?(node, prefix, out)
150
+ when Prism::SingletonClassNode
151
+ if node.expression.is_a?(Prism::SelfNode) && node.body
152
+ walk_defs(node.body, prefix, true, false, out)
153
+ return
154
+ end
155
+ when Prism::DefNode
156
+ collect_def_node(node, prefix, in_singleton_class, module_function_active, out)
157
+ return
158
+ when Prism::ConstantWriteNode
159
+ register_data_struct_shell(node, prefix)
160
+ # fall through to recurse into the RHS so a trailing
161
+ # `do ... end` block carrying defs is still walked.
162
+ when Prism::StatementsNode
163
+ walk_statements(node, prefix, in_singleton_class, module_function_active, out)
164
+ return
165
+ end
166
+
167
+ node.compact_child_nodes.each do |child|
168
+ walk_defs(child, prefix, in_singleton_class, module_function_active, out)
169
+ end
170
+ end
171
+
172
+ def descend_into_namespace?(node, prefix, out)
173
+ name = qualified_constant_path(node.constant_path)
174
+ return false unless name
175
+
176
+ full = (prefix + [name]).join("::")
177
+ @namespace_kinds[full] = node.is_a?(Prism::ClassNode) ? :class : :module
178
+ walk_namespace_body(node, prefix + [name], out)
179
+ true
180
+ end
181
+
182
+ # ADR-14 gap-#3 (e): recognises
183
+ # `Const = Data.define(...)` and
184
+ # `Const = Struct.new(...)` as class declarations.
185
+ # The runtime side stamps a brand-new anonymous class
186
+ # at the RHS and binds it to `Const`, so the generated
187
+ # RBS needs an explicit `class Const` declaration even
188
+ # though no `class Const ... end` block appears in
189
+ # source. Without it, references to `Const` in return
190
+ # types fail to resolve under Steep (the canonical case
191
+ # is `GemResolver::Resolved | GemResolver::Unresolvable`
192
+ # where `Unresolvable = Data.define(:gem_name, :reason)`).
193
+ #
194
+ # The walker records the fully-qualified constant name
195
+ # in `@class_shells` (carried through to every
196
+ # candidate so the writer's tree-builder picks it up)
197
+ # AND in `@namespace_kinds` so the leaf's `class`
198
+ # keyword wins over the intermediate-segment `module`
199
+ # default.
200
+ def register_data_struct_shell(node, prefix)
201
+ return unless data_or_struct_call?(node.value)
202
+
203
+ full = (prefix + [node.name.to_s]).join("::")
204
+ @class_shells << full
205
+ @namespace_kinds[full] = :class
206
+ end
207
+
208
+ DATA_STRUCT_SHELL_HEADS = {
209
+ "Data" => :define,
210
+ "Struct" => :new
211
+ }.freeze
212
+ private_constant :DATA_STRUCT_SHELL_HEADS
213
+
214
+ def data_or_struct_call?(value)
215
+ return false unless value.is_a?(Prism::CallNode)
216
+
217
+ receiver = value.receiver
218
+ return false unless receiver.is_a?(Prism::ConstantReadNode)
219
+
220
+ DATA_STRUCT_SHELL_HEADS[receiver.name.to_s] == value.name
221
+ end
222
+
223
+ # Module / class bodies are walked through the
224
+ # `walk_statements` path so `module_function` (no-args)
225
+ # encountered as one statement applies to every
226
+ # subsequent sibling def in the same body. The
227
+ # directive is module-scoped semantically — classes
228
+ # inherit `module_function` via `Module`'s ancestor
229
+ # chain but don't honour it the same way at runtime, so
230
+ # tracking is only meaningful inside `ModuleNode`
231
+ # bodies. Generator emits `def self?.name` for the
232
+ # marked defs.
233
+ def walk_namespace_body(namespace_node, prefix, out)
234
+ return if namespace_node.body.nil?
235
+
236
+ walk_defs(namespace_node.body, prefix, false, false, out)
237
+ end
238
+
239
+ def walk_statements(stmts_node, prefix, in_singleton_class, module_function_active, out)
240
+ stmts_node.body.each do |stmt|
241
+ if module_function_directive?(stmt)
242
+ module_function_active = true
243
+ next
244
+ end
245
+ walk_defs(stmt, prefix, in_singleton_class, module_function_active, out)
246
+ end
247
+ end
248
+
249
+ def module_function_directive?(node)
250
+ return false unless node.is_a?(Prism::CallNode)
251
+ return false unless node.name == :module_function && node.receiver.nil?
252
+
253
+ (node.arguments&.arguments || []).empty?
254
+ end
255
+
256
+ def collect_def_node(node, prefix, in_singleton_class, module_function_active, out)
257
+ return if prefix.empty?
258
+
259
+ kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
260
+ class_name = prefix.join("::")
261
+ @module_function_methods << [class_name, node.name] if module_function_active && kind == :instance
262
+ out << [node, class_name, kind]
263
+ end
264
+
265
+ # Wraps `MethodCandidate.new` so every candidate carries
266
+ # the per-file `@namespace_kinds` map AND the
267
+ # `@class_shells` set — the Writer's nested-syntax
268
+ # emission consults both to pick `module` vs `class`
269
+ # for each segment and to emit empty
270
+ # `Const = Data.define(...)` declarations.
271
+ def build_candidate(**)
272
+ MethodCandidate.new(
273
+ namespace_kinds: @namespace_kinds || {},
274
+ class_shells: (@class_shells || Set.new).to_a,
275
+ **
276
+ )
277
+ end
278
+
279
+ # Returns "def self." (kind: :singleton),
280
+ # "def self?." (instance method declared inside a
281
+ # `module_function` region — both instance + singleton
282
+ # dispatch at runtime), or "def " (plain instance).
283
+ def method_def_prefix(class_name, method_name, kind)
284
+ return "def self." if kind == :singleton
285
+ return "def self?." if @module_function_methods&.include?([class_name, method_name])
286
+
287
+ "def "
288
+ end
289
+
290
+ # Slice-4 follow-up surfaced by the Rigor self-dogfood:
291
+ # most `lib/rigor/cli/*` files have a small public
292
+ # surface (`run`) and many private helpers. Emitting the
293
+ # private helpers into a `sig/` file is noise — private
294
+ # methods are implementation details, not part of the
295
+ # type contract downstream consumers (Steep, IDE, gem
296
+ # users) read. The default now skips private and
297
+ # protected methods; the `:include_private` flag
298
+ # restores the slice-4 behaviour for callers that want
299
+ # every method.
300
+ def visibility_excludes?(def_node, class_name, kind, scope_index)
301
+ return false if kind == :singleton
302
+ return false if @include_private
303
+
304
+ scope = scope_index[def_node] || scope_index.each_value.first
305
+ return false if scope.nil?
306
+
307
+ visibility = scope.discovered_method_visibility(class_name, def_node.name)
308
+ %i[private protected].include?(visibility)
309
+ end
310
+
311
+ # Ruby's `initialize` return value is never meaningful;
312
+ # the conventional RBS spelling is `() -> void`. The
313
+ # body-typing path types the last expression (often an
314
+ # ivar assignment whose rvalue happens to be `[]` /
315
+ # `{}`), which produces nonsense return types.
316
+ #
317
+ # Skipping `initialize` entirely is correct ONLY for
318
+ # default constructors — the `Object#initialize: () -> void`
319
+ # RBS fallback then covers the lookup. When the class
320
+ # has a non-trivial `initialize(argv:, ...)` (i.e. any
321
+ # parameter), partial-class sigs trip Steep's
322
+ # method-parameter-mismatch check: Steep sees the
323
+ # runtime `def initialize(...)` and compares against
324
+ # the inherited `Object#initialize: () -> void`. The
325
+ # mismatch surfaces a `Ruby::MethodParameterMismatch`
326
+ # warning even when `rigor check` itself is clean.
327
+ #
328
+ # Returning `nil` here causes `classify_def` to skip
329
+ # emission; returning `:emit_stub` causes
330
+ # `initialize_stub_candidate` to emit a permissive
331
+ # `(<param shape>) -> void` stub matching the
332
+ # runtime parameter list.
333
+ def initialize_excludes?(def_node, kind)
334
+ return false unless kind == :instance
335
+ return false unless def_node.name == :initialize
336
+
337
+ # Default constructor with no params — skip; the
338
+ # Object#initialize RBS fallback covers it.
339
+ params = def_node.parameters
340
+ params.nil? || trivial_initialize_params?(params)
341
+ end
342
+
343
+ def trivial_initialize_params?(params)
344
+ return true unless params.is_a?(Prism::ParametersNode)
345
+
346
+ params.requireds.empty? && params.optionals.empty? &&
347
+ params.rest.nil? && params.keywords.empty? &&
348
+ params.keyword_rest.nil? && params.block.nil?
349
+ end
350
+
351
+ def non_trivial_initialize?(def_node, kind)
352
+ kind == :instance && def_node.name == :initialize && !trivial_initialize_params?(def_node.parameters)
353
+ end
354
+
355
+ # Emits `def initialize: (<shape>) -> void`. The return
356
+ # is always `void` because Ruby's `initialize` return
357
+ # value is never meaningful. The parameter list mirrors
358
+ # the runtime shape (required / optional / rest /
359
+ # keyword / keyword-rest / block).
360
+ #
361
+ # When `--params=observed` populates `@observations` for
362
+ # `[class_name, :initialize]` (via the
363
+ # `ObservationCollector`'s `.new` → `:initialize`
364
+ # routing), positional and keyword arg types come from
365
+ # the per-position / per-keyword union of observed
366
+ # types; otherwise every position keeps `untyped` per
367
+ # ADR-5 clause 2.
368
+ def initialize_stub_candidate(path, def_node, class_name)
369
+ rbs = "def initialize: (#{render_initialize_param_list(def_node.parameters, class_name)}) -> void"
370
+ build_candidate(
371
+ path: path, class_name: class_name, method_name: :initialize,
372
+ kind: :instance, classification: Classification::NEW_METHOD,
373
+ inferred_return: Type::Combinator.untyped, rbs: rbs
374
+ )
375
+ end
376
+
377
+ def render_initialize_param_list(params, class_name)
378
+ return "" unless params.is_a?(Prism::ParametersNode)
379
+
380
+ observations = initialize_observations(class_name, params)
381
+ offset = 0
382
+ parts = []
383
+
384
+ params.requireds.each_with_index do |_, i|
385
+ parts << initialize_positional_type(observations, offset + i, "")
386
+ end
387
+ offset += params.requireds.size
388
+
389
+ params.optionals.each_with_index do |_, i|
390
+ parts << initialize_positional_type(observations, offset + i, "?")
391
+ end
392
+
393
+ parts << "*untyped" if params.rest
394
+ params.keywords.each { |kw| parts << render_keyword_param(kw, observations) }
395
+ parts << "**untyped" if params.keyword_rest
396
+ parts << "?{ (?) -> void }" if params.block
397
+ parts.join(", ")
398
+ end
399
+
400
+ # Picks observations under `[class_name, :initialize]`
401
+ # whose positional arity matches the def's accepted
402
+ # range (required..required+optional). Looser arities
403
+ # don't get used because they describe a different
404
+ # overload the stub cannot express.
405
+ def initialize_observations(class_name, params)
406
+ return [] if @observations.empty?
407
+
408
+ list = @observations[[class_name, :initialize]] || []
409
+ min = params.requireds.size
410
+ max = min + params.optionals.size
411
+ list.select { |obs| (min..max).cover?(obs.positional.size) }
412
+ end
413
+
414
+ def initialize_positional_type(observations, index, prefix)
415
+ types = observations.filter_map { |obs| obs.positional[index] }
416
+ "#{prefix}#{types.empty? ? 'untyped' : paren_wrap_union(union_erase(types))}"
417
+ end
418
+
419
+ def render_keyword_param(keyword, observations)
420
+ optional_marker = keyword.is_a?(Prism::OptionalKeywordParameterNode) ? "?" : ""
421
+ types = observations.filter_map { |obs| obs.keyword[keyword.name] }
422
+ rendered = types.empty? ? "untyped" : paren_wrap_union(union_erase(types))
423
+ "#{optional_marker}#{keyword.name}: #{rendered}"
424
+ end
425
+
426
+ def qualified_constant_path(constant_path)
427
+ case constant_path
428
+ when Prism::ConstantReadNode
429
+ constant_path.name.to_s
430
+ when Prism::ConstantPathNode
431
+ parent = qualified_constant_path(constant_path.parent) if constant_path.parent
432
+ name = constant_path.name&.to_s
433
+ return nil if name.nil?
434
+
435
+ parent ? "#{parent}::#{name}" : name
436
+ end
437
+ end
438
+
439
+ def classify_def(path, def_node, class_name, kind, scope_index)
440
+ return nil if visibility_excludes?(def_node, class_name, kind, scope_index)
441
+ return nil if initialize_excludes?(def_node, kind)
442
+ return initialize_stub_candidate(path, def_node, class_name) if non_trivial_initialize?(def_node, kind)
443
+
444
+ unless simple_parameter_shape?(def_node.parameters)
445
+ return skipped(path, def_node, class_name, kind, :complex_shape)
446
+ end
447
+
448
+ inferred = infer_return_type(def_node, scope_index)
449
+ return skipped(path, def_node, class_name, kind, :untyped_return) if inferred.nil? || dynamic_top?(inferred)
450
+
451
+ environment = scope_index[def_node]&.environment
452
+ method_def = lookup_existing_method(class_name, def_node.name, kind, environment, scope_index[def_node])
453
+
454
+ if method_def.nil?
455
+ new_method_candidate(path, def_node, class_name, kind, inferred)
456
+ else
457
+ compare_against_declared(path, def_node, class_name, kind, inferred, method_def)
458
+ end
459
+ end
460
+
461
+ # Required positionals only; the MVP's body-typing path
462
+ # gives well-defined returns for that shape. Optional /
463
+ # rest / keyword / block parameters route through the
464
+ # `sig.skipped.complex-shape` reason until slices 3+
465
+ # widen the param policy.
466
+ def simple_parameter_shape?(params)
467
+ return true if params.nil?
468
+ return false unless params.is_a?(Prism::ParametersNode)
469
+
470
+ params.optionals.empty? &&
471
+ params.rest.nil? &&
472
+ params.keywords.empty? &&
473
+ params.keyword_rest.nil? &&
474
+ params.block.nil?
475
+ end
476
+
477
+ # Mirrors the `def.return-type-mismatch` rule's body-type
478
+ # extraction: type the implicit-return expression under
479
+ # the scope the indexer associated with the body. The
480
+ # parameter bindings (typed `untyped` per the indexer's
481
+ # default) come from `with_local` inside
482
+ # `StatementEvaluator`; the result is the carrier the
483
+ # body proves *given an untyped argument tuple*.
484
+ #
485
+ # Post-dogfood enhancement: walk the body's AST for
486
+ # explicit `return X` statements and union their value
487
+ # types with the implicit-return expression's type. The
488
+ # earlier MVP only typed the implicit-return path, which
489
+ # routinely produced single-branch artefacts like
490
+ # `parse_options: () -> nil` (the actual runtime return
491
+ # is `options | nil`) or `find: () -> V` (actually
492
+ # `V | nil` via `return nil unless ...`). The walk
493
+ # excludes nested `DefNode` / lambda / block scopes
494
+ # whose returns belong to different methods.
495
+ def infer_return_type(def_node, scope_index)
496
+ body = def_node.body
497
+ return nil if body.nil?
498
+
499
+ last = body_last_expression(body)
500
+ return nil if last.nil?
501
+
502
+ inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
503
+ return nil if inner_scope.nil?
504
+
505
+ last_type = inner_scope.type_of(last)
506
+ union_with_explicit_returns(body, last_type, scope_index)
507
+ rescue StandardError
508
+ nil
509
+ end
510
+
511
+ def body_last_expression(body)
512
+ case body
513
+ when Prism::StatementsNode then body.body.last
514
+ when Prism::BeginNode then body_last_expression(body.statements)
515
+ else body
516
+ end
517
+ end
518
+
519
+ def union_with_explicit_returns(body, last_type, scope_index)
520
+ return_types = []
521
+ collect_return_types(body, scope_index, return_types)
522
+ return last_type if return_types.empty?
523
+
524
+ Type::Combinator.union(last_type, *return_types)
525
+ end
526
+
527
+ RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
528
+ private_constant :RETURN_BARRIER_NODES
529
+
530
+ def collect_return_types(node, scope_index, out)
531
+ return unless node.is_a?(Prism::Node)
532
+ return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
533
+
534
+ type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
535
+ node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
536
+ end
537
+
538
+ def type_return_node(return_node, scope_index, out)
539
+ args = return_node.arguments&.arguments || []
540
+ if args.empty?
541
+ out << Type::Combinator.constant_of(nil)
542
+ return
543
+ end
544
+
545
+ scope = scope_index[return_node] || scope_index[args.first]
546
+ return if scope.nil?
547
+
548
+ # `return a, b` packs into a Tuple at runtime; the MVP
549
+ # only handles the single-value form. Multi-arg returns
550
+ # contribute no type to keep the implementation
551
+ # focused.
552
+ return unless args.size == 1
553
+
554
+ type = safe_return_type_of(scope, args.first)
555
+ out << type unless type.nil?
556
+ end
557
+
558
+ def safe_return_type_of(scope, node)
559
+ scope.type_of(node)
560
+ rescue StandardError
561
+ nil
562
+ end
563
+
564
+ def dynamic_top?(type)
565
+ return true if type.is_a?(Type::Dynamic)
566
+ return true if type.respond_to?(:top?) && type.top?.yes?
567
+
568
+ # Post-dogfood: when explicit-return union absorbs
569
+ # Dynamic and the carrier ends up as a Union containing
570
+ # `Dynamic[top]`, the Bug-1 erasure rule renders it as
571
+ # `untyped`. Emitting `def m: () -> untyped` is the
572
+ # `sig.skipped.untyped-return` case — obscures rather
573
+ # than helps — so the skip check considers the erased
574
+ # form too.
575
+ type.respond_to?(:erase_to_rbs) && type.erase_to_rbs == "untyped"
576
+ end
577
+
578
+ def lookup_existing_method(class_name, method_name, kind, environment, scope)
579
+ return nil if environment.nil?
580
+
581
+ if kind == :singleton
582
+ Reflection.singleton_method_definition(class_name, method_name, scope: scope, environment: environment)
583
+ else
584
+ Reflection.instance_method_definition(class_name, method_name, scope: scope, environment: environment)
585
+ end
586
+ end
587
+
588
+ def new_method_candidate(path, def_node, class_name, kind, inferred)
589
+ build_candidate(
590
+ path: path,
591
+ class_name: class_name,
592
+ method_name: def_node.name,
593
+ kind: kind,
594
+ classification: Classification::NEW_METHOD,
595
+ inferred_return: inferred,
596
+ rbs: render_rbs_line(def_node, inferred, class_name, kind)
597
+ )
598
+ end
599
+
600
+ def compare_against_declared(path, def_node, class_name, kind, inferred, method_def)
601
+ declared = build_declared_return(method_def)
602
+ declared_rbs = declared&.erase_to_rbs
603
+ inferred_rbs = inferred.erase_to_rbs
604
+
605
+ if declared.nil? || declared_rbs == inferred_rbs
606
+ return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
607
+ end
608
+
609
+ unless tighter?(declared, inferred) && !computed_literal_tightening?(inferred, def_node)
610
+ return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
611
+ end
612
+
613
+ build_candidate(
614
+ path: path,
615
+ class_name: class_name,
616
+ method_name: def_node.name,
617
+ kind: kind,
618
+ classification: Classification::TIGHTER_RETURN,
619
+ inferred_return: inferred,
620
+ declared_return_rbs: declared_rbs,
621
+ rbs: render_rbs_line(def_node, inferred, class_name, kind)
622
+ )
623
+ end
624
+
625
+ def build_declared_return(method_def)
626
+ translated = method_def.method_types.filter_map { |mt| translate_method_type_return(mt) }
627
+ return nil if translated.empty?
628
+
629
+ translated.size == 1 ? translated.first : Type::Combinator.union(*translated)
630
+ end
631
+
632
+ def translate_method_type_return(method_type)
633
+ Inference::RbsTypeTranslator.translate(
634
+ method_type.type.return_type,
635
+ self_type: nil, instance_type: nil, type_vars: {}
636
+ )
637
+ rescue StandardError
638
+ nil
639
+ end
640
+
641
+ # ADR-14 § "What 'more precise' means". The MVP uses the
642
+ # engine's gradual-mode acceptance — `:strict` is
643
+ # reserved by `Inference::Acceptance` and lands in a
644
+ # follow-up. The "different spelling" guard ensures we
645
+ # never classify a same-string round-trip as tighter.
646
+ #
647
+ # The `loses_declared_union_member?` guard added after
648
+ # the Rigor self-dogfood pass refuses to classify as
649
+ # tighter-return when the declared form is a top-level
650
+ # Union and the inferred form collapses one or more of
651
+ # its declared members. The body-typing path in slice 1
652
+ # only inspects the implicit-return expression, so
653
+ # methods with `return nil unless ...` / boolean
654
+ # `false | true` shapes / `Float | Integer` numeric
655
+ # alternates routinely look "tighter" while actually
656
+ # dropping reachable branches. Treating those as
657
+ # equivalent matches the project rule that an
658
+ # inferred tightening contradicting an existing RBS
659
+ # member set is suspected incomplete inference until
660
+ # proven otherwise.
661
+ def tighter?(declared, inferred)
662
+ return false if inferred.is_a?(Type::Dynamic)
663
+ return false if loses_declared_lenience?(declared, inferred)
664
+
665
+ forward = declared.accepts(inferred)
666
+ return false unless forward.yes?
667
+
668
+ backward = inferred.accepts(declared)
669
+ !backward.yes?
670
+ end
671
+
672
+ # Composite guard: refuse to classify as tighter-return
673
+ # when the declared RBS expresses lenience that the
674
+ # inferred form removes. Three cases all signal
675
+ # incomplete inference rather than precision gain:
676
+ #
677
+ # 1. Top-level union losing one or more declared
678
+ # members. `return nil unless ...` paths, two-valued
679
+ # booleans, `Float | Integer` numeric alternates.
680
+ # 2. Generic collection narrowed to a fixed shape.
681
+ # `Array[T]` → `Tuple[T, ...]`, `Hash[K, V]` →
682
+ # HashShape — the body's last expression was a
683
+ # literal whose specific shape is not the method's
684
+ # contract.
685
+ # 3. `untyped` type-arg replaced by a concrete form.
686
+ # Declared `Hash[String, untyped]` carries the
687
+ # author's intentional value-type lenience; the
688
+ # inference's narrower Union should not override
689
+ # it.
690
+ def loses_declared_lenience?(declared, inferred)
691
+ loses_declared_union_member?(declared, inferred) ||
692
+ narrows_collection_to_shape?(declared, inferred) ||
693
+ replaces_untyped_type_arg?(declared, inferred)
694
+ end
695
+
696
+ def loses_declared_union_member?(declared, inferred)
697
+ return false unless declared.is_a?(Type::Union)
698
+
699
+ inferred_members = inferred.is_a?(Type::Union) ? inferred.members : [inferred]
700
+ declared.members.any? do |declared_member|
701
+ inferred_members.none? { |im| structurally_covers?(im, declared_member) }
702
+ end
703
+ end
704
+
705
+ def structurally_covers?(inferred_member, declared_member)
706
+ return true if inferred_member == declared_member
707
+
708
+ result = inferred_member.accepts(declared_member)
709
+ result.respond_to?(:yes?) && result.yes?
710
+ end
711
+
712
+ GENERIC_COLLECTION_CLASSES = %w[
713
+ Array Hash Set Range Enumerable Enumerator Enumerator::Lazy
714
+ ].freeze
715
+ private_constant :GENERIC_COLLECTION_CLASSES
716
+
717
+ def narrows_collection_to_shape?(declared, inferred)
718
+ return false unless declared.is_a?(Type::Nominal)
719
+ return false unless GENERIC_COLLECTION_CLASSES.include?(declared.class_name)
720
+
721
+ inferred.is_a?(Type::Tuple) || inferred.is_a?(Type::HashShape)
722
+ end
723
+
724
+ # Heuristic added after the third-round self-dogfood:
725
+ # `FallbackTracer#size` body is `@events.size`, where
726
+ # `@events` is initialised to `[]` and never assigned
727
+ # again at the class-ivar pre-pass level. The
728
+ # `Type::Tuple[]` (size 0) folds `.size` to
729
+ # `Constant<0>` — the carrier knows the empty-tuple
730
+ # cardinality exactly. But the runtime contract is
731
+ # `Integer` because callers add events through other
732
+ # methods. The signal is "the body's last expression
733
+ # is NOT a directly-authored literal but the inferred
734
+ # type IS a Constant"; in that case the precision
735
+ # came from inference over an internal computation,
736
+ # not the author's contract, so refuse to tighten.
737
+ def computed_literal_tightening?(inferred, def_node)
738
+ return false unless inferred.is_a?(Type::Constant)
739
+
740
+ last = body_last_expression(def_node.body)
741
+ !direct_literal_node?(last)
742
+ end
743
+
744
+ DIRECT_LITERAL_NODE_TYPES = [
745
+ Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode,
746
+ Prism::TrueNode, Prism::FalseNode, Prism::NilNode
747
+ ].freeze
748
+ private_constant :DIRECT_LITERAL_NODE_TYPES
749
+
750
+ def direct_literal_node?(node)
751
+ DIRECT_LITERAL_NODE_TYPES.any? { |klass| node.is_a?(klass) }
752
+ end
753
+
754
+ def replaces_untyped_type_arg?(declared, inferred)
755
+ return false unless declared.is_a?(Type::Nominal) && inferred.is_a?(Type::Nominal)
756
+ return false unless declared.class_name == inferred.class_name
757
+ return false unless declared.type_args.size == inferred.type_args.size
758
+
759
+ declared.type_args.zip(inferred.type_args).any? do |d_arg, i_arg|
760
+ d_arg.is_a?(Type::Dynamic) && !i_arg.is_a?(Type::Dynamic)
761
+ end
762
+ end
763
+
764
+ def equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
765
+ build_candidate(
766
+ path: path,
767
+ class_name: class_name,
768
+ method_name: def_node.name,
769
+ kind: kind,
770
+ classification: Classification::EQUIVALENT,
771
+ inferred_return: inferred,
772
+ declared_return_rbs: declared_rbs
773
+ )
774
+ end
775
+
776
+ def skipped(path, def_node, class_name, kind, reason)
777
+ build_candidate(
778
+ path: path,
779
+ class_name: class_name,
780
+ method_name: def_node.name,
781
+ kind: kind,
782
+ classification: Classification::SKIPPED,
783
+ skip_reason: reason
784
+ )
785
+ end
786
+
787
+ def render_rbs_line(def_node, inferred, class_name, kind)
788
+ arity = required_arity(def_node)
789
+ head = arity.zero? ? "()" : "(#{render_param_list(class_name, def_node.name, arity)})"
790
+ prefix = method_def_prefix(class_name, def_node.name, kind)
791
+ "#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred))}"
792
+ end
793
+
794
+ # Routes the inferred carrier through {TypeElaborator}
795
+ # so bare generic nominals (`Array` / `Hash` / `Set`
796
+ # / `Range` / `Enumerable`) get their `untyped` type
797
+ # parameters filled in before erasing to RBS. The
798
+ # elaborator consults the class's RBS-declared
799
+ # type-parameter list via `Reflection.class_type_param_names`.
800
+ def elaborated_rbs(type)
801
+ TypeElaborator.elaborate(type, environment: @environment).erase_to_rbs
802
+ end
803
+
804
+ # RBS / Steep require return-position unions to be
805
+ # parenthesised when they appear bare at the top
806
+ # level of a method type — `def m: () -> 0 | 1` fails
807
+ # the parser because the trailing `| 1` isn't a valid
808
+ # method-type start. Wrap when the erased form is a
809
+ # top-level union; single types and already-bracketed
810
+ # forms (e.g. `Array[A | B]`) parse without wrapping.
811
+ def paren_wrap_union(rendered)
812
+ top_level_union?(rendered) ? "(#{rendered})" : rendered
813
+ end
814
+
815
+ def top_level_union?(rendered)
816
+ return false unless rendered.include?(" | ")
817
+
818
+ depth = 0
819
+ rendered.each_char.with_index do |ch, i|
820
+ case ch
821
+ when "(", "[", "{" then depth += 1
822
+ when ")", "]", "}" then depth -= 1
823
+ when " "
824
+ return true if depth.zero? && rendered[i + 1] == "|"
825
+ end
826
+ end
827
+ false
828
+ end
829
+
830
+ def required_arity(def_node)
831
+ params = def_node.parameters
832
+ params.is_a?(Prism::ParametersNode) ? params.requireds.size : 0
833
+ end
834
+
835
+ # Per ADR-5 clause 2 the default is `untyped` for every
836
+ # position. Observed-policy callers (`--params=observed`)
837
+ # pass an `observations:` map at construction time; the
838
+ # generator unions per-position arg types whose tuple
839
+ # arity matches the def's required-positional count.
840
+ # Observations from arities other than the def's count
841
+ # are discarded — they describe a different overload
842
+ # the MVP does not emit.
843
+ def render_param_list(class_name, method_name, arity)
844
+ tuples = matching_observations(class_name, method_name, arity)
845
+ return Array.new(arity, "untyped").join(", ") if tuples.empty?
846
+
847
+ Array.new(arity) { |i| union_erase(tuples.map { |obs| obs.positional[i] }) }.join(", ")
848
+ end
849
+
850
+ def matching_observations(class_name, method_name, arity)
851
+ return [] if @observations.empty?
852
+
853
+ list = @observations[[class_name, method_name]] || []
854
+ list.select { |obs| obs.positional.size == arity }
855
+ end
856
+
857
+ def union_erase(types)
858
+ return "untyped" if types.empty?
859
+ return elaborated_rbs(types.first) if types.size == 1
860
+
861
+ # `Type::Combinator.union` dedupes by structural type
862
+ # equality. The carrier-level `erase_to_rbs` now
863
+ # absorbs `untyped` members and dedupes the post-erase
864
+ # strings (`String | String` → `String` for distinct
865
+ # `Constant<"Alice">` / `Constant<"Bob">` envelopes),
866
+ # so the sig-gen layer only needs to elaborate bare
867
+ # generics before erasing.
868
+ elaborated_rbs(Type::Combinator.union(*types))
869
+ end
870
+
871
+ # ADR-14 slice 4 — `attr_reader` / `attr_writer` /
872
+ # `attr_accessor` recognition. Each Symbol-named entry in
873
+ # the call's argument list yields one or two
874
+ # {MethodCandidate}s whose inferred return type is the
875
+ # corresponding instance-variable's accumulated type from
876
+ # `Scope#class_ivars_for(class_name)`. `attr_reader` adds
877
+ # one reader candidate; `attr_writer` adds one
878
+ # `name=`-method writer candidate; `attr_accessor` adds
879
+ # both.
880
+ ATTR_METHOD_NAMES = %i[attr_reader attr_writer attr_accessor].freeze
881
+ private_constant :ATTR_METHOD_NAMES
882
+
883
+ ATTR_KINDS = {
884
+ attr_reader: [:reader],
885
+ attr_writer: [:writer],
886
+ attr_accessor: %i[reader writer]
887
+ }.freeze
888
+ private_constant :ATTR_KINDS
889
+
890
+ # Per-file context the attr_* walker threads through its
891
+ # recursive descent. Keeps parameter lists in check.
892
+ AttrWalkContext = Struct.new(:path, :scope_index, :out, keyword_init: true)
893
+ private_constant :AttrWalkContext
894
+
895
+ def collect_attr_candidates(root, path, scope_index)
896
+ ctx = AttrWalkContext.new(path: path, scope_index: scope_index, out: [])
897
+ walk_attr_calls(root, [], false, ctx)
898
+ ctx.out
899
+ end
900
+
901
+ def walk_attr_calls(node, prefix, in_singleton_class, ctx)
902
+ return unless node.is_a?(Prism::Node)
903
+
904
+ case node
905
+ when Prism::ClassNode, Prism::ModuleNode
906
+ name = qualified_constant_path(node.constant_path)
907
+ if name
908
+ walk_attr_calls(node.body, prefix + [name], false, ctx) if node.body
909
+ return
910
+ end
911
+ when Prism::SingletonClassNode
912
+ walk_attr_calls(node.body, prefix, true, ctx) if node.body
913
+ return
914
+ when Prism::DefNode
915
+ # Skip method bodies — attr_* there would refer to
916
+ # whatever the method is doing dynamically, not a
917
+ # class-level declaration.
918
+ return
919
+ when Prism::CallNode
920
+ collect_attr_call(node, prefix, in_singleton_class, ctx)
921
+ end
922
+
923
+ node.compact_child_nodes.each { |child| walk_attr_calls(child, prefix, in_singleton_class, ctx) }
924
+ end
925
+
926
+ def collect_attr_call(call_node, prefix, in_singleton_class, ctx)
927
+ return unless ATTR_METHOD_NAMES.include?(call_node.name)
928
+ return if prefix.empty?
929
+ return if in_singleton_class
930
+
931
+ class_name = prefix.join("::")
932
+ symbol_names = extract_symbol_arguments(call_node)
933
+ return if symbol_names.empty?
934
+
935
+ ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name)
936
+ symbol_names.each do |attr_name|
937
+ ivar_type = ivar_lookup.call(attr_name)
938
+ ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
939
+ end
940
+ end
941
+
942
+ def extract_symbol_arguments(call_node)
943
+ (call_node.arguments&.arguments || []).filter_map do |arg|
944
+ arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
945
+ end
946
+ end
947
+
948
+ # Returns a closure that looks up `:@<attr_name>` in the
949
+ # class-ivar accumulator carried by the first scope the
950
+ # indexer associated with this file. The accumulator is
951
+ # populated by `ScopeIndexer#build_class_ivar_index`
952
+ # before any statement evaluation runs, so the lookup
953
+ # works even when attr_* declarations come before the
954
+ # corresponding ivar writes lexically.
955
+ def ivar_type_lookup(scope_index, class_name)
956
+ any_scope = scope_index.each_value.first
957
+ return ->(_) {} if any_scope.nil?
958
+
959
+ ivars = any_scope.class_ivars_for(class_name)
960
+ ->(attr_name) { ivars[:"@#{attr_name}"] }
961
+ end
962
+
963
+ def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
964
+ ATTR_KINDS.fetch(call_name).flat_map do |variant|
965
+ method_name = variant == :writer ? :"#{attr_name}=" : attr_name
966
+ candidate = build_attr_candidate(class_name, method_name, variant, ivar_type, ctx)
967
+ candidate ? [candidate] : []
968
+ end
969
+ end
970
+
971
+ def build_attr_candidate(class_name, method_name, variant, ivar_type, ctx)
972
+ if ivar_type.nil? || dynamic_top?(ivar_type)
973
+ return attr_skipped(ctx.path, class_name, method_name, :untyped_return)
974
+ end
975
+
976
+ scope = ctx.scope_index.each_value.first
977
+ environment = scope&.environment
978
+ method_def = lookup_existing_method(class_name, method_name, :instance, environment, scope)
979
+ if method_def.nil?
980
+ attr_new_candidate(ctx.path, class_name, method_name, variant, ivar_type)
981
+ else
982
+ attr_compare_against_declared(ctx.path, class_name, method_name, variant, ivar_type, method_def)
983
+ end
984
+ end
985
+
986
+ def attr_new_candidate(path, class_name, method_name, variant, ivar_type)
987
+ build_candidate(
988
+ path: path,
989
+ class_name: class_name,
990
+ method_name: method_name,
991
+ kind: :instance,
992
+ classification: Classification::NEW_METHOD,
993
+ inferred_return: ivar_type,
994
+ rbs: render_attr_rbs_line(method_name, variant, ivar_type)
995
+ )
996
+ end
997
+
998
+ def attr_compare_against_declared(path, class_name, method_name, variant, ivar_type, method_def)
999
+ declared = build_declared_return(method_def)
1000
+ declared_rbs = declared&.erase_to_rbs
1001
+ inferred_rbs = ivar_type.erase_to_rbs
1002
+
1003
+ if declared.nil? || declared_rbs == inferred_rbs || !tighter?(declared, ivar_type)
1004
+ return attr_equivalent(path, class_name, method_name, ivar_type, declared_rbs)
1005
+ end
1006
+
1007
+ build_candidate(
1008
+ path: path, class_name: class_name, method_name: method_name,
1009
+ kind: :instance, classification: Classification::TIGHTER_RETURN,
1010
+ inferred_return: ivar_type, declared_return_rbs: declared_rbs,
1011
+ rbs: render_attr_rbs_line(method_name, variant, ivar_type)
1012
+ )
1013
+ end
1014
+
1015
+ def attr_equivalent(path, class_name, method_name, ivar_type, declared_rbs)
1016
+ build_candidate(
1017
+ path: path, class_name: class_name, method_name: method_name,
1018
+ kind: :instance, classification: Classification::EQUIVALENT,
1019
+ inferred_return: ivar_type, declared_return_rbs: declared_rbs
1020
+ )
1021
+ end
1022
+
1023
+ def attr_skipped(path, class_name, method_name, reason)
1024
+ build_candidate(
1025
+ path: path, class_name: class_name, method_name: method_name,
1026
+ kind: :instance, classification: Classification::SKIPPED, skip_reason: reason
1027
+ )
1028
+ end
1029
+
1030
+ # Slice 4 emits attr_* in the long-form `def` spelling so
1031
+ # the existing writer's `MethodDefinition`-based merge
1032
+ # path applies without extra wiring. Users who prefer the
1033
+ # idiomatic `attr_reader name: Type` short form can
1034
+ # normalise post-emit; the writer-side member detection
1035
+ # (slice 2) treats existing `attr_*` declarations as
1036
+ # user-authored so a paired source-side `attr_reader`
1037
+ # never produces a duplicate `def` insertion.
1038
+ def render_attr_rbs_line(method_name, variant, ivar_type)
1039
+ erased = elaborated_rbs(ivar_type)
1040
+ wrapped = paren_wrap_union(erased)
1041
+ case variant
1042
+ when :reader then "def #{method_name}: () -> #{wrapped}"
1043
+ when :writer then "def #{method_name}: (#{erased}) -> #{wrapped}"
1044
+ end
1045
+ end
1046
+ end
1047
+ end
1048
+ end