ryac 0.2.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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +145 -0
  4. data/Rakefile +45 -0
  5. data/Steepfile +9 -0
  6. data/bin/console +11 -0
  7. data/bin/ryac +134 -0
  8. data/bin/setup +8 -0
  9. data/lib/ryac/analysis/constant/collection.rb +332 -0
  10. data/lib/ryac/analysis/constant/rename_mapping.rb +346 -0
  11. data/lib/ryac/analysis/cvar/collection.rb +37 -0
  12. data/lib/ryac/analysis/gvar/collection.rb +36 -0
  13. data/lib/ryac/analysis/gvar/rename_mapping.rb +82 -0
  14. data/lib/ryac/analysis/ivar/collection.rb +281 -0
  15. data/lib/ryac/analysis/keyword/collection.rb +213 -0
  16. data/lib/ryac/analysis/keyword/rename_mapping.rb +167 -0
  17. data/lib/ryac/analysis/local_scopes.rb +582 -0
  18. data/lib/ryac/analysis/method/collection.rb +298 -0
  19. data/lib/ryac/analysis/method/rename_mapping.rb +337 -0
  20. data/lib/ryac/analysis/method_aliases.rb +83 -0
  21. data/lib/ryac/analysis/method_aliasing.rb +65 -0
  22. data/lib/ryac/analysis/nesting.rb +123 -0
  23. data/lib/ryac/analysis/scope_management.rb +25 -0
  24. data/lib/ryac/analysis/site_bucket_mapping.rb +127 -0
  25. data/lib/ryac/analysis/type_oracle.rb +386 -0
  26. data/lib/ryac/ast_utils.rb +321 -0
  27. data/lib/ryac/errors.rb +32 -0
  28. data/lib/ryac/gem_resolver.rb +55 -0
  29. data/lib/ryac/minifier.rb +169 -0
  30. data/lib/ryac/name_generator.rb +68 -0
  31. data/lib/ryac/pipeline/analyzer.rb +264 -0
  32. data/lib/ryac/pipeline/attr_decl_shorten.rb +61 -0
  33. data/lib/ryac/pipeline/boolean_shorten.rb +26 -0
  34. data/lib/ryac/pipeline/char_shorten.rb +20 -0
  35. data/lib/ryac/pipeline/compactor.rb +1202 -0
  36. data/lib/ryac/pipeline/concatenator.rb +248 -0
  37. data/lib/ryac/pipeline/constant_aliaser.rb +240 -0
  38. data/lib/ryac/pipeline/constant_fold.rb +93 -0
  39. data/lib/ryac/pipeline/control_flow_simplify.rb +352 -0
  40. data/lib/ryac/pipeline/data_types.rb +179 -0
  41. data/lib/ryac/pipeline/endless_method.rb +60 -0
  42. data/lib/ryac/pipeline/errors.rb +94 -0
  43. data/lib/ryac/pipeline/file_collector.rb +360 -0
  44. data/lib/ryac/pipeline/method_renamer.rb +151 -0
  45. data/lib/ryac/pipeline/paren_optimizer.rb +161 -0
  46. data/lib/ryac/pipeline/rename_patcher.rb +274 -0
  47. data/lib/ryac/pipeline/source_patcher.rb +54 -0
  48. data/lib/ryac/pipeline/spelling_shorten.rb +60 -0
  49. data/lib/ryac/pipeline/stage.rb +72 -0
  50. data/lib/ryac/pipeline/stage_runner.rb +84 -0
  51. data/lib/ryac/pipeline/variable_renamer.rb +125 -0
  52. data/lib/ryac/union_find.rb +42 -0
  53. data/lib/ryac/version.rb +5 -0
  54. data/lib/ryac.rb +29 -0
  55. data/rbs_collection.lock.yaml +224 -0
  56. data/rbs_collection.yaml +25 -0
  57. data/sig/ryac/analysis/constant/collection.rbs +30 -0
  58. data/sig/ryac/analysis/constant/rename_mapping.rbs +60 -0
  59. data/sig/ryac/analysis/cvar/collection.rbs +11 -0
  60. data/sig/ryac/analysis/gvar/collection.rbs +9 -0
  61. data/sig/ryac/analysis/gvar/rename_mapping.rbs +23 -0
  62. data/sig/ryac/analysis/ivar/collection.rbs +26 -0
  63. data/sig/ryac/analysis/keyword/collection.rbs +27 -0
  64. data/sig/ryac/analysis/keyword/rename_mapping.rbs +31 -0
  65. data/sig/ryac/analysis/local_scopes.rbs +77 -0
  66. data/sig/ryac/analysis/method/collection.rbs +25 -0
  67. data/sig/ryac/analysis/method/rename_mapping.rbs +82 -0
  68. data/sig/ryac/analysis/method_aliases.rbs +10 -0
  69. data/sig/ryac/analysis/method_aliasing.rbs +9 -0
  70. data/sig/ryac/analysis/nesting.rbs +9 -0
  71. data/sig/ryac/analysis/scope_management.rbs +5 -0
  72. data/sig/ryac/analysis/site_bucket_mapping.rbs +26 -0
  73. data/sig/ryac/analysis/type_oracle.rbs +61 -0
  74. data/sig/ryac/ast_utils.rbs +34 -0
  75. data/sig/ryac/errors.rbs +21 -0
  76. data/sig/ryac/gem_resolver.rbs +28 -0
  77. data/sig/ryac/minifier.rbs +33 -0
  78. data/sig/ryac/name_generator.rbs +14 -0
  79. data/sig/ryac/pipeline/analyzer.rbs +34 -0
  80. data/sig/ryac/pipeline/attr_decl_shorten.rbs +17 -0
  81. data/sig/ryac/pipeline/boolean_shorten.rbs +11 -0
  82. data/sig/ryac/pipeline/char_shorten.rbs +11 -0
  83. data/sig/ryac/pipeline/compactor.rbs +125 -0
  84. data/sig/ryac/pipeline/concatenator.rbs +28 -0
  85. data/sig/ryac/pipeline/constant_aliaser.rbs +35 -0
  86. data/sig/ryac/pipeline/constant_fold.rbs +20 -0
  87. data/sig/ryac/pipeline/control_flow_simplify.rbs +34 -0
  88. data/sig/ryac/pipeline/data_types.rbs +151 -0
  89. data/sig/ryac/pipeline/endless_method.rbs +14 -0
  90. data/sig/ryac/pipeline/errors.rbs +46 -0
  91. data/sig/ryac/pipeline/file_collector.rbs +34 -0
  92. data/sig/ryac/pipeline/method_renamer.rbs +25 -0
  93. data/sig/ryac/pipeline/paren_optimizer.rbs +16 -0
  94. data/sig/ryac/pipeline/rename_patcher.rbs +38 -0
  95. data/sig/ryac/pipeline/source_patcher.rbs +12 -0
  96. data/sig/ryac/pipeline/spelling_shorten.rbs +14 -0
  97. data/sig/ryac/pipeline/stage.rbs +28 -0
  98. data/sig/ryac/pipeline/stage_runner.rbs +18 -0
  99. data/sig/ryac/pipeline/variable_renamer.rbs +21 -0
  100. data/sig/ryac/union_find.rbs +17 -0
  101. data/sig/ryac.rbs +13 -0
  102. data/sig/shims/env.rbs +19 -0
  103. data/sig/shims/typeprof/core/ast.rbs +299 -0
  104. data/sig/shims/typeprof/core/type.rbs +22 -0
  105. data/sig/shims/typeprof/core.rbs +73 -0
  106. data/tools/regen_pins.rb +45 -0
  107. metadata +201 -0
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module AnalysisPhases
5
+ def collect_method_definitions(prism_root)
6
+ Nesting.each_method_definition(prism_root) do |node, method_key|
7
+ next if EXCLUDED_METHODS.include?(method_key[2])
8
+
9
+ @method_rename_mapping.add_method(method_key, node)
10
+ link_module_function_variant(node, method_key)
11
+ end
12
+
13
+ # attr_reader/attr_accessor define getters worth renaming; attr_writer's
14
+ # setter is derived from the getter name and is never registered on its
15
+ # own.
16
+ each_attr_declaration(prism_root, %i[attr_reader attr_accessor]) do |node, cpath, singleton, mid|
17
+ next if EXCLUDED_METHODS.include?(mid)
18
+
19
+ @method_rename_mapping.add_method([cpath, singleton, mid].freeze, node)
20
+ end
21
+ end
22
+
23
+ def resolve_method_calls
24
+ call_node_to_keys = Hash.new { |h, k| h[k] = [] } #: Hash[location_key, Array[method_key]]
25
+ resolved_call_keys = Set.new
26
+ super_merges = [] #: Array[[method_key, method_key]]
27
+ sig_dispatch_mids = Set.new
28
+
29
+ @method_rename_mapping.each_method_key do |key|
30
+ @oracle.each_caller(key[0], key[1], key[2]) do |info|
31
+ if info.super
32
+ # caller_cpath is always present on super records
33
+ entry = [info.caller_cpath, key[1], key[2]].freeze #: method_key
34
+ super_merges << [entry, key]
35
+ next
36
+ end
37
+
38
+ if info.prism_node.nil?
39
+ # The dispatch happens inside an RBS declaration, so there is no
40
+ # source site to rewrite; the name must survive on every class.
41
+ sig_dispatch_mids << key[2]
42
+ next
43
+ end
44
+
45
+ scope_id = info.receiver ? nil : @local_scopes.scope_id_of(info.prism_node)
46
+ @method_rename_mapping.add_call_site(info.prism_node, key, has_receiver: info.receiver, scope_id: scope_id)
47
+ loc = AstUtils.location_key(info.prism_node)
48
+ call_node_to_keys[loc] << key
49
+ resolved_call_keys << loc
50
+ end
51
+ end
52
+
53
+ @method_rename_mapping.exclude_methods_by_mid(sig_dispatch_mids) unless sig_dispatch_mids.empty?
54
+ merge_super_groups(super_merges)
55
+ merge_polymorphic_groups(call_node_to_keys)
56
+ merge_unresolved_calls(resolved_call_keys)
57
+ @method_rename_mapping.merge_blind_def_groups
58
+ end
59
+
60
+ # Visibility resets at every reopen of this module, so each collection
61
+ # file's `private` covers that file alone.
62
+ private
63
+
64
+ # module_function publishes a single `def` as both an instance and a
65
+ # singleton method, so the two must always be renamed together.
66
+ #
67
+ # The pair is identified by both entities pointing at the same definition
68
+ # site — an explicit `def self.foo` alongside `def foo` is two sites and
69
+ # must stay independent. The "no defs but has call boxes" shape is also
70
+ # accepted: older TypeProf releases recorded no def at all for the
71
+ # singleton side of module_function.
72
+ def link_module_function_variant(def_node, method_key)
73
+ cpath, singleton, mid = method_key
74
+ return unless @oracle.method_known?(cpath, !singleton, mid)
75
+
76
+ alt_def_keys = @oracle.method_definition_keys(cpath, !singleton, mid)
77
+ shares_definition = alt_def_keys.include?(AstUtils.location_key(def_node))
78
+ call_only = alt_def_keys.empty? && @oracle.method_call_count(cpath, !singleton, mid) > 0
79
+ return unless shares_definition || call_only
80
+
81
+ alt_key = [cpath, !singleton, mid].freeze
82
+ @method_rename_mapping.add_method(alt_key, nil)
83
+ @method_rename_mapping.merge_groups(method_key, alt_key)
84
+ end
85
+
86
+ def merge_super_groups(super_merges)
87
+ super_merges.each do |child_key, parent_key|
88
+ @method_rename_mapping.merge_groups(child_key, parent_key) if @method_rename_mapping.has_method?(child_key)
89
+ end
90
+ end
91
+
92
+ # One call site reaching the same method name on several classes is
93
+ # polymorphism: merge, so every receiver renames in lockstep. One call site
94
+ # reaching several *different* names is a computed dispatch — optcarrot's
95
+ # `send(mode)` resolves to imm/zpg/abs/… at once — and "merging" that would
96
+ # assign one short name to all of them, collapsing distinct methods into
97
+ # whichever def lands last. Those names must simply survive.
98
+ def merge_polymorphic_groups(call_node_to_keys)
99
+ computed_dispatch_mids = Set.new
100
+ call_node_to_keys.each_value do |keys|
101
+ next if keys.size < 2
102
+
103
+ mids = keys.map { |k| k[2] }.uniq
104
+ if mids.size > 1
105
+ computed_dispatch_mids.merge(mids)
106
+ next
107
+ end
108
+
109
+ (1...keys.size).each { |i| @method_rename_mapping.merge_groups(keys[i - 1], keys[i]) }
110
+ end
111
+ @method_rename_mapping.exclude_methods_by_mid(computed_dispatch_mids) unless computed_dispatch_mids.empty?
112
+ end
113
+
114
+ UNRESOLVED_CALL_NODES = [
115
+ Prism::CallNode,
116
+ Prism::CallOperatorWriteNode,
117
+ Prism::CallOrWriteNode,
118
+ Prism::CallAndWriteNode
119
+ ].freeze
120
+
121
+ def merge_unresolved_calls(resolved_call_keys)
122
+ method_mids = @method_rename_mapping.method_mids
123
+ unresolved_by_mid = Hash.new { |h, k| h[k] = [] } #: Hash[Symbol, Array[Prism::Node]]
124
+
125
+ AstUtils.each_node(@prism_root) do |node|
126
+ mids = case node
127
+ when Prism::CallNode
128
+ [node.name]
129
+ when Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
130
+ [node.read_name, node.write_name]
131
+ else
132
+ next
133
+ end
134
+
135
+ loc = AstUtils.location_key(node)
136
+ next if resolved_call_keys.include?(loc)
137
+
138
+ mids.each do |mid|
139
+ unresolved_by_mid[mid] << node if method_mids.include?(mid)
140
+ end
141
+ end
142
+
143
+ exclude_mids = Set.new
144
+ unresolved_by_mid.each do |mid, call_nodes|
145
+ mapped_calls = [] #: Array[Prism::Node]
146
+ should_exclude = false
147
+
148
+ call_nodes.each do |node|
149
+ verdict = classify_unresolved_call(mid, node)
150
+ case verdict
151
+ when :mapped then mapped_calls << node
152
+ when :exclude then should_exclude = true; break
153
+ # :unrelated — call targets a different class's method, skip
154
+ end
155
+ end
156
+
157
+ if should_exclude
158
+ exclude_mids << mid
159
+ elsif mapped_calls.any?
160
+ @method_rename_mapping.merge_all_by_mid(mid)
161
+ @method_rename_mapping.add_unresolved_sites_for_mid(mid, mapped_calls)
162
+ end
163
+ end
164
+ @method_rename_mapping.exclude_methods_by_mid(exclude_mids) unless exclude_mids.empty?
165
+ end
166
+
167
+ def classify_unresolved_call(mid, prism_node)
168
+ # Callers only pass the UNRESOLVED_CALL_NODES shapes, all of which carry
169
+ # a receiver.
170
+ # @type var prism_node: Prism::CallNode | Prism::CallOperatorWriteNode | Prism::CallOrWriteNode | Prism::CallAndWriteNode
171
+ return :mapped unless prism_node.receiver
172
+
173
+ target_keys = @oracle.resolved_targets(prism_node, mid)
174
+ return :exclude if target_keys.nil?
175
+
176
+ all_mapped = target_keys.all? { |key| @method_rename_mapping.has_method?(key) }
177
+ all_mapped ? :mapped : :unrelated
178
+ end
179
+
180
+ public
181
+
182
+ def scan_dynamic_method_references(prism_root)
183
+ dynamic_mids = Set.new
184
+ AstUtils.each_node(prism_root) do |node|
185
+ next unless node.is_a?(Prism::CallNode)
186
+ next unless DYNAMIC_DISPATCH_METHODS.include?(node.name)
187
+
188
+ @oracle.first_argument_symbols(node).each { |sym| dynamic_mids << sym }
189
+ end
190
+ @method_rename_mapping.exclude_methods_by_mid(dynamic_mids) unless dynamic_mids.empty?
191
+ end
192
+
193
+ # send/public_send/__send__ are NOT listed here because TypeProf resolves
194
+ # them as MethodCallBoxes on the target method. resolve_method_calls
195
+ # handles grouping automatically, and MethodRenamer patches the symbol arg.
196
+ DYNAMIC_DISPATCH_METHODS = %i[
197
+ method define_method respond_to? instance_method
198
+ ].freeze
199
+
200
+ # A hash/keyword shorthand can pun on a METHOD (`f(unit_label:)` calls
201
+ # unit_label for its value); the one identifier is both the key and the
202
+ # call, so renaming the method would rewrite the key with it. Those
203
+ # methods keep their names.
204
+ def collect_shorthand_pun_methods(prism_root)
205
+ punned = Set.new
206
+ AstUtils.each_node(prism_root) do |node|
207
+ AstUtils.shorthand_pun_values(node).each do |inner|
208
+ punned << inner.name if inner.is_a?(Prism::CallNode)
209
+ end
210
+ end
211
+ @method_rename_mapping.exclude_methods_by_mid(punned) if punned.any?
212
+ end
213
+
214
+ VISIBILITY_MODIFIERS = %i[private protected public module_function].freeze
215
+
216
+ def collect_visibility_modifier_methods(prism_root)
217
+ excluded_mids = Set.new
218
+ AstUtils.each_node(prism_root) do |node|
219
+ next unless node.is_a?(Prism::CallNode)
220
+ next unless VISIBILITY_MODIFIERS.include?(node.name)
221
+
222
+ excluded_mids.merge(AstUtils.symbol_arguments(node))
223
+ end
224
+ @method_rename_mapping.exclude_methods_by_mid(excluded_mids) unless excluded_mids.empty?
225
+ end
226
+
227
+ # attr_accessor's setter def is derived from the declared symbol and never
228
+ # registered as a method of its own, so a setter call site type inference
229
+ # missed is invisible to every other exclusion pass — renaming the
230
+ # declaration would strand that site on the old name until the path runs.
231
+ # Find such sites directly and keep their symbols. attr_writer never
232
+ # renames its declaration at all, so a reader sharing its symbol must stay
233
+ # too: renaming the reader side would split the getter from the ivar the
234
+ # writer-defined setter still assigns.
235
+ def collect_attr_write_exclusions(prism_root)
236
+ accessor_owners = Hash.new { |h, k| h[k] = [] } #: Hash[Symbol, Array[MethodRenameMapping::class_key]]
237
+ reader_syms = Set.new
238
+ writer_syms = Set.new
239
+ each_attr_declaration(prism_root, ATTR_DECLARATION_METHODS, require_class_body: false) do |node, cpath, singleton, sym|
240
+ case node.name
241
+ when :attr_accessor then accessor_owners[sym] << [cpath, singleton]
242
+ when :attr_reader then reader_syms << sym
243
+ when :attr_writer then writer_syms << sym
244
+ end
245
+ end
246
+
247
+ excluded = writer_syms & (reader_syms | accessor_owners.keys)
248
+
249
+ if accessor_owners.any?
250
+ known_setter_sites = Set.new
251
+ accessor_owners.each do |sym, owners|
252
+ owners.each do |cpath, singleton|
253
+ @oracle.each_call_site_key(cpath, singleton, :"#{sym}=") do |key|
254
+ known_setter_sites << key
255
+ end
256
+ end
257
+ end
258
+
259
+ AstUtils.each_node(prism_root) do |node|
260
+ write_name = case node
261
+ when Prism::CallNode
262
+ node.name
263
+ when Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
264
+ node.write_name
265
+ else
266
+ next
267
+ end
268
+
269
+ next unless AstUtils.setter_def_name?(write_name)
270
+
271
+ sym = write_name.to_s.chomp('=').to_sym
272
+ next unless accessor_owners.key?(sym)
273
+ next if known_setter_sites.include?(AstUtils.location_key(node))
274
+
275
+ excluded << sym
276
+ end
277
+ end
278
+
279
+ @method_rename_mapping.exclude_methods_by_mid(excluded) if excluded.any?
280
+ end
281
+
282
+ def collect_alias_undef_methods(prism_root)
283
+ excluded_mids = Set.new
284
+ AstUtils.each_node(prism_root) do |node|
285
+ case node
286
+ when Prism::AliasMethodNode
287
+ excluded_mids << node.new_name.unescaped.to_sym if node.new_name.is_a?(Prism::SymbolNode)
288
+ excluded_mids << node.old_name.unescaped.to_sym if node.old_name.is_a?(Prism::SymbolNode)
289
+ when Prism::UndefNode
290
+ node.names.each do |name|
291
+ excluded_mids << name.unescaped.to_sym if name.is_a?(Prism::SymbolNode)
292
+ end
293
+ end
294
+ end
295
+ @method_rename_mapping.exclude_methods_by_mid(excluded_mids) unless excluded_mids.empty?
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ EXCLUDED_METHODS = %i[
5
+ initialize initialize_copy initialize_clone initialize_dup
6
+ method_missing respond_to_missing? const_missing
7
+ inherited included extended prepended
8
+ marshal_dump marshal_load encode_with init_with
9
+ call to_s to_str to_i to_int to_f to_r to_c
10
+ to_a to_ary to_h to_hash to_io to_proc
11
+ inspect hash eql? equal? frozen? nil?
12
+ class is_a? kind_of? instance_of?
13
+ coerce respond_to? dup clone freeze
14
+ each __send__ __id__
15
+ <=> == === != < > <= >= + - * / % ** & | ^ << >> =~ !~
16
+ [] []= ! ~ +@ -@
17
+ ].to_set.freeze
18
+
19
+ class MethodRenameMapping
20
+ include UnionFind
21
+
22
+ # Below this many saved characters a rename is churn, not compression.
23
+ # KeywordRenameMapping applies the same bar.
24
+ MIN_GROUP_SAVINGS = 2
25
+
26
+ MethodGroupEntry = Struct.new(:keys, :original_name, :total_occurrences)
27
+
28
+ def initialize
29
+ uf_init
30
+ @methods = {} # method_key => { def_nodes: [], call_sites: [] }
31
+ @node_to_key = {} # node_object_id => method_key
32
+ @node_short_names = {} # node_object_id => short_name (after freeze)
33
+ @key_short_names = {} # method_key => short_name (after freeze)
34
+ @implicit_receiver_sites = {} # node_object_id => scope_id (for collision check)
35
+ @frozen = false
36
+ end
37
+
38
+ def add_method(method_key, def_node)
39
+ @methods[method_key] ||= { def_nodes: [], call_sites: [] }
40
+ if def_node
41
+ @methods[method_key][:def_nodes] << def_node
42
+ @node_to_key[def_node.object_id] = method_key
43
+ end
44
+ uf_add(method_key)
45
+ end
46
+
47
+ def has_method?(method_key)
48
+ @methods.key?(method_key)
49
+ end
50
+
51
+ # scope_id names the scope containing an implicit-receiver call site, so
52
+ # the method can avoid short names a visible local already took there — a
53
+ # bare `a` would parse as the local, not the call. The caller resolves it
54
+ # rather than this class holding a resolver: this code minifies itself at
55
+ # L5, and a call through a stored collaborator is exactly the dynamic
56
+ # dispatch TypeProf cannot pin down, leaving the resolver's method
57
+ # renamed on one side and not the other.
58
+ def add_call_site(call_node, method_key, has_receiver:, scope_id: nil)
59
+ @methods[method_key] ||= { def_nodes: [], call_sites: [] }
60
+ @methods[method_key][:call_sites] << call_node
61
+ @node_to_key[call_node.object_id] = method_key
62
+
63
+ @implicit_receiver_sites[call_node.object_id] = scope_id if !has_receiver && scope_id
64
+ end
65
+
66
+ def exclude_methods_by_mid(mids)
67
+ keys_to_remove = @methods.keys.select { |key| mids.include?(key[2]) }
68
+ keys_to_remove.each do |key|
69
+ data = @methods.delete(key) #: method_data
70
+ data[:def_nodes].each { |n| @node_to_key.delete(n.object_id) }
71
+ data[:call_sites].each do |n|
72
+ @node_to_key.delete(n.object_id)
73
+ @implicit_receiver_sites.delete(n.object_id)
74
+ end
75
+ uf_remove(key)
76
+ end
77
+ end
78
+
79
+ def merge_all_by_mid(mid)
80
+ keys = @methods.keys.select { |k| k[2] == mid }
81
+ return if keys.size <= 1
82
+ # keys.size > 1 is guaranteed above, so [1..] cannot be nil
83
+ keys[1..].each { |k| merge_groups(keys[0], k) } # steep:ignore NoMethod
84
+ end
85
+
86
+ # Inference can silently miss a caller, leaving a def in a group with no
87
+ # call sites while its mid is called — and therefore renamed — through
88
+ # another group. Runtime dispatch does not share the blind spot: a call
89
+ # site patched to the short name can still land on the def that kept its
90
+ # long one. Any mid renamed anywhere must cover every def of that mid, so
91
+ # the blind groups merge in and rename in lockstep.
92
+ def merge_blind_def_groups
93
+ sited_mids = Set.new
94
+ blind_mids = Set.new
95
+ groups_by_root.each_value do |keys|
96
+ sites = keys.sum { |key| @methods[key][:call_sites].size }
97
+ target = sites > 0 ? sited_mids : blind_mids
98
+ keys.each { |key| target << key[2] }
99
+ end
100
+
101
+ (blind_mids & sited_mids).each { |mid| merge_all_by_mid(mid) }
102
+ end
103
+
104
+ def add_unresolved_sites_for_mid(mid, call_nodes)
105
+ target_key = @methods.keys.find { |k| k[2] == mid }
106
+ return unless target_key
107
+ call_nodes.each { |node| add_call_site(node, target_key, has_receiver: true) }
108
+ end
109
+
110
+ def group_keys(method_key)
111
+ groups_by_root[uf_root(method_key)]
112
+ end
113
+
114
+ # Every call site registered for the key's whole rename group — including
115
+ # sites attached by the unresolved-call pass, which type inference alone
116
+ # does not report. Yields the scope id for implicit-receiver sites, nil
117
+ # otherwise.
118
+ def each_group_call_site(method_key)
119
+ group_keys(method_key).each do |key|
120
+ @methods[key][:call_sites].each do |node|
121
+ yield node, @implicit_receiver_sites[node.object_id]
122
+ end
123
+ end
124
+ end
125
+
126
+ def assign_short_names(scope_mappings, oracle = nil)
127
+ group_entries = build_group_entries(groups_by_root)
128
+ group_entries.sort_by! { |entry| -(entry.original_name.size * entry.total_occurrences) }
129
+
130
+ scope_vars = self.class.build_scope_vars(scope_mappings)
131
+ existing_methods, hierarchy = oracle ? build_existing_method_names(oracle) : [{}, {}] #: [Hash[class_key, Set[String]], hierarchy]
132
+
133
+ group_entries.each do |entry|
134
+ short_name = find_shortest_name(entry.keys, scope_vars, existing_methods, hierarchy)
135
+
136
+ savings_per_use = entry.original_name.size - short_name.size
137
+ next unless savings_per_use > 0
138
+
139
+ total_savings = savings_per_use * entry.total_occurrences
140
+ next unless total_savings > MIN_GROUP_SAVINGS
141
+
142
+ assign_short_name(entry.keys, short_name)
143
+ propagate_short_name(entry.keys, short_name, existing_methods, hierarchy)
144
+ end
145
+
146
+ verify_no_shadowing!(hierarchy)
147
+ @frozen = true
148
+ end
149
+
150
+ # A rename group never spans mids, so after assignment each (cpath,
151
+ # singleton) namespace must map its mids onto distinct final names — a
152
+ # renamed method landing on another's name, renamed or kept, would shadow
153
+ # it and the output would still parse. The module_function regression
154
+ # (instance and singleton halves allocated independently) was exactly this
155
+ # shape; this turns any recurrence into a failed run.
156
+ #
157
+ # The namespace a dispatch actually resolves against is the class plus
158
+ # everything it inherits and includes, so with the hierarchy known the
159
+ # same rule is enforced per inheritance-effective namespace: two mids on
160
+ # one final anywhere along a class's ancestor chain shadow each other.
161
+ def verify_no_shadowing!(hierarchy = {})
162
+ namespaces = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = Set.new } } #: Hash[class_key, Hash[String, Set[Symbol]]]
163
+ @methods.each_key do |key|
164
+ cpath, singleton, mid = key
165
+ final = @key_short_names[key] || mid.to_s
166
+ namespaces[[cpath, singleton]][final] << mid
167
+ end
168
+
169
+ namespaces.each do |(cpath, singleton), finals|
170
+ collisions = finals.filter_map { |name, mids| [name, mids.to_a] if mids.size > 1 } #: Array[[String, Array[Symbol]]]
171
+ next if collisions.empty?
172
+
173
+ label = "#{cpath.join('::')}#{singleton ? '.' : '#'}"
174
+ raise Pipeline::RenameCollisionError.new(label, collisions)
175
+ end
176
+
177
+ (hierarchy[:ancestors] || {}).each do |class_key, ancestor_keys|
178
+ next if ancestor_keys.none?
179
+
180
+ merged = Hash.new { |h, k| h[k] = Set.new } #: Hash[String, Set[Symbol]]
181
+ ([class_key] + ancestor_keys).each do |ck|
182
+ next unless namespaces.key?(ck)
183
+ namespaces[ck].each { |final, mids| merged[final].merge(mids) }
184
+ end
185
+
186
+ collisions = merged.filter_map { |name, mids| [name, mids.to_a] if mids.size > 1 } #: Array[[String, Array[Symbol]]]
187
+ next if collisions.empty?
188
+
189
+ label = "#{class_key[0].join('::')}#{class_key[1] ? '.' : '#'} (with ancestors)"
190
+ raise Pipeline::RenameCollisionError.new(label, collisions)
191
+ end
192
+ end
193
+
194
+ def short_name_for(node_location_key)
195
+ @node_short_names[node_location_key]
196
+ end
197
+
198
+ def short_name_for_key(method_key)
199
+ @key_short_names[method_key]
200
+ end
201
+
202
+ def node_mapping
203
+ @node_short_names.dup
204
+ end
205
+
206
+ def each_method_key(&block)
207
+ @methods.each_key(&block)
208
+ end
209
+
210
+ def method_mids
211
+ result = Set.new
212
+ @methods.each_key { |key| result << key[2] }
213
+ result
214
+ end
215
+
216
+ def each_cpath_for_mid(mid)
217
+ @methods.each_key do |key|
218
+ yield key[0], key[1] if key[2] == mid
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def build_group_entries(groups)
225
+ result = [] #: Array[MethodGroupEntry]
226
+ groups.each_value do |keys|
227
+ mid = keys.first[2]
228
+ next if EXCLUDED_METHODS.include?(mid)
229
+ next if mid.to_s.size <= NameGenerator::KEPT_NAME_MAX
230
+
231
+ total_call_sites = keys.sum { |key| @methods[key][:call_sites].size }
232
+ next if total_call_sites == 0
233
+
234
+ total_occurrences = keys.sum do |key|
235
+ data = @methods[key]
236
+ data[:def_nodes].size + data[:call_sites].size
237
+ end
238
+
239
+ result << MethodGroupEntry.new(keys, mid.to_s, total_occurrences)
240
+ end
241
+ result
242
+ end
243
+
244
+ # Class method: the attr coordination inverts scope_mappings the same
245
+ # way for its own collision check, so there is exactly one inversion.
246
+ def self.build_scope_vars(scope_mappings)
247
+ scope_vars = Hash.new { |h, k| h[k] = Set.new } #: Hash[scope_id, Set[String]]
248
+ scope_mappings.each do |cref_id, mapping|
249
+ mapping.each_value { |mangled| scope_vars[cref_id] << mangled }
250
+ end
251
+ scope_vars
252
+ end
253
+
254
+ def groups_by_root
255
+ groups = Hash.new { |h, k| h[k] = [] } #: Hash[method_key, Array[method_key]]
256
+ @methods.each_key { |key| groups[uf_root(key)] << key }
257
+ groups
258
+ end
259
+
260
+ def build_existing_method_names(oracle)
261
+ result = {} #: Hash[class_key, Set[String]]
262
+ includers = Hash.new { |h, k| h[k] = Set.new } #: Hash[class_key, Set[class_key]]
263
+ ancestors_map = {} #: Hash[class_key, Array[class_key]]
264
+
265
+ @methods.each_key do |key|
266
+ cache_key = [key[0], key[1]] #: class_key
267
+ next if result.key?(cache_key)
268
+ names = Set.new
269
+ ancestor_keys = [] #: Array[class_key]
270
+ oracle.each_ancestor_methods(key[0], key[1]) do |ancestor_cpath, s, mids|
271
+ mids.each { |mid| names << mid }
272
+ ancestor_key = [ancestor_cpath, s] #: class_key
273
+ if ancestor_key != cache_key
274
+ includers[ancestor_key] << cache_key
275
+ ancestor_keys << ancestor_key
276
+ end
277
+ end
278
+ next if names.empty? && ancestor_keys.empty?
279
+ result[cache_key] = names
280
+ ancestors_map[cache_key] = ancestor_keys
281
+ end
282
+
283
+ hierarchy = { includers: includers, ancestors: ancestors_map } #: hierarchy
284
+ [result, hierarchy]
285
+ end
286
+
287
+ def find_shortest_name(keys, scope_vars, existing_methods, hierarchy)
288
+ includers = hierarchy[:includers] || {}
289
+ generator = NameGenerator.new
290
+ loop do
291
+ candidate = generator.next_name
292
+ collides = keys.any? do |key|
293
+ var_collision = @methods[key][:call_sites].any? do |node|
294
+ cref_id = @implicit_receiver_sites[node.object_id]
295
+ cref_id && scope_vars[cref_id].include?(candidate)
296
+ end
297
+ next true if var_collision
298
+ class_key = [key[0], key[1]] #: class_key
299
+ next true if existing_methods[class_key]&.include?(candidate)
300
+ # The name is also visible in every class that inherits or includes
301
+ # this one, where a same-named private helper would shadow it for
302
+ # dispatch through the base — check those namespaces too.
303
+ (includers[class_key] || []).any? { |ck| existing_methods[ck]&.include?(candidate) }
304
+ end
305
+ return candidate unless collides
306
+ end
307
+ end
308
+
309
+ def propagate_short_name(keys, short_name, existing_methods, hierarchy)
310
+ includers = hierarchy[:includers] || {}
311
+ ancestors = hierarchy[:ancestors] || {}
312
+ keys.each do |key|
313
+ class_key = [key[0], key[1]] #: class_key
314
+ existing_methods[class_key] ||= Set.new
315
+ existing_methods[class_key] << short_name
316
+ (includers[class_key] || []).each do |ck|
317
+ existing_methods[ck] ||= Set.new
318
+ existing_methods[ck] << short_name
319
+ end
320
+ (ancestors[class_key] || []).each do |ck|
321
+ existing_methods[ck] ||= Set.new
322
+ existing_methods[ck] << short_name
323
+ end
324
+ end
325
+ end
326
+
327
+ def assign_short_name(keys, short_name)
328
+ keys.each do |key|
329
+ @key_short_names[key] = short_name
330
+ data = @methods[key]
331
+ data[:def_nodes].each { |n| @node_short_names[AstUtils.location_key(n)] = short_name }
332
+ data[:call_sites].each { |n| @node_short_names[AstUtils.location_key(n)] = short_name }
333
+ end
334
+ end
335
+
336
+ end
337
+ end