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,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module AnalysisPhases
5
+ # One classified walk of every constant fact in the tree, shared by
6
+ # definition collection, usage counting, external-reference collection and
7
+ # the analyzer's resolution precompute — so the node-shape case analysis
8
+ # lives in exactly one place. Yields (kind, node, cpath, singleton, in_def):
9
+ #
10
+ # :class_def class/module definition; cpath is the path it creates
11
+ # :read a constant read at the node's own position (cpath nil)
12
+ # :write an assignment defining cpath
13
+ #
14
+ # A compound write (`X ||= 1`) and a qualified target in multiple
15
+ # assignment (`Foo::A, b = ary`) read as well as write, so they yield
16
+ # both a :read and a :write. Writes with no static path yield nothing.
17
+ def each_constant_event(prism_root)
18
+ Nesting.each(prism_root) do |node, nesting, singleton, in_def|
19
+ case node
20
+ when Prism::ClassNode, Prism::ModuleNode
21
+ cpath = class_definition_cpath(node, nesting)
22
+ yield :class_def, node, cpath, singleton, in_def if cpath
23
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
24
+ yield :read, node, nil, singleton, in_def
25
+ when Prism::ConstantWriteNode
26
+ yield :write, node, nesting + [node.name], singleton, in_def
27
+ when Prism::ConstantTargetNode
28
+ yield :write, node, nesting + [node.name], singleton, in_def
29
+ when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
30
+ yield :read, node, nil, singleton, in_def
31
+ yield :write, node, nesting + [node.name], singleton, in_def
32
+ when Prism::ConstantPathTargetNode
33
+ yield :read, node, nil, singleton, in_def
34
+ cpath = qualified_write_cpath(node, nesting)
35
+ yield :write, node, cpath, singleton, in_def if cpath
36
+ when Prism::ConstantPathWriteNode
37
+ cpath = qualified_write_cpath(node.target, nesting, strip_doubled_nesting: true)
38
+ yield :write, node, cpath, singleton, in_def if cpath
39
+ when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
40
+ cpath = qualified_write_cpath(node.target, nesting)
41
+ yield :write, node, cpath, singleton, in_def if cpath
42
+ end
43
+ end
44
+ end
45
+
46
+ # The fully-qualified path a class/module definition creates, or nil when
47
+ # the written path is not static.
48
+ def class_definition_cpath(node, nesting)
49
+ qualified_write_cpath(node.constant_path, nesting)
50
+ end
51
+
52
+ # The path a qualified assignment target names, or nil when it is not
53
+ # static (`self::X`, `expr::X`). A plain path write that repeats the
54
+ # lexical nesting (`Foo::Bar::X = 1` inside module Foo; module Bar) names
55
+ # the same constant the bare write would, so the doubled prefix is dropped
56
+ # — but only for that form: compound and multiple-target writes
57
+ # concatenate as written, mirroring the previous implementation.
58
+ def qualified_write_cpath(target, nesting, strip_doubled_nesting: false)
59
+ segments, absolute = Nesting.path_segments(target)
60
+ return nil unless segments
61
+ return segments if absolute
62
+ return segments if strip_doubled_nesting && segments.take(nesting.size) == nesting
63
+
64
+ nesting + segments
65
+ end
66
+
67
+ # The constant path as written, without any resolution — partial when the
68
+ # chain hangs off a dynamic root (`expr::CONST` gives just [:CONST]).
69
+ def syntactic_const_segments(node)
70
+ case node
71
+ when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
72
+ segments = [node.name]
73
+ current = node.parent
74
+ while current.is_a?(Prism::ConstantPathNode)
75
+ segments.unshift(current.name)
76
+ current = current.parent
77
+ end
78
+ segments.unshift(current.name) if current.is_a?(Prism::ConstantReadNode)
79
+ segments
80
+ else
81
+ # Reached only for bare named nodes (ConstantRead and the compound
82
+ # constant writes), which all carry #name; the parameter stays
83
+ # Prism::Node for the shared walk.
84
+ [node.name] # steep:ignore NoMethod
85
+ end
86
+ end
87
+
88
+ # `X = Struct.new(...)` / `X = Data.define(...)` assigns a class, so the
89
+ # constant registers as a class definition: it renames exactly when class
90
+ # renaming is on, and reopenings resolve through the same machinery.
91
+ def struct_definition_write?(node)
92
+ return false unless node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
93
+
94
+ value = node.value
95
+ return false unless value.is_a?(Prism::CallNode)
96
+
97
+ receiver = value.receiver
98
+ return false unless receiver.is_a?(Prism::ConstantReadNode)
99
+
100
+ (value.name == :new && receiver.name == :Struct) ||
101
+ (value.name == :define && receiver.name == :Data)
102
+ end
103
+
104
+ def collect_constants(prism_root)
105
+ each_constant_event(prism_root) do |kind, node, cpath, singleton, in_def|
106
+ case kind
107
+ when :class_def
108
+ type = node.is_a?(Prism::ClassNode) ? :class : :module
109
+ @constant_mapping.add_definition_with_path(cpath, definition_type: type)
110
+ when :write
111
+ # Skip constants defined inside `class << self` — they live on the
112
+ # metaclass and cannot be accessed as Foo::X, so alias declarations
113
+ # would fail at runtime. (An assignment inside a def body there is a
114
+ # plain assignment and still registers.)
115
+ next if singleton && !in_def
116
+
117
+ type = struct_definition_write?(node) ? :class : :value
118
+ @constant_mapping.add_definition_with_path(cpath, definition_type: type)
119
+ end
120
+ end
121
+ end
122
+
123
+ def exclude_private_constants(prism_root)
124
+ Nesting.each(prism_root) do |node, nesting, _singleton, _in_def|
125
+ next unless node.is_a?(Prism::CallNode)
126
+ next unless %i[private_constant public_constant].include?(node.name)
127
+
128
+ AstUtils.symbol_arguments(node).each do |sym|
129
+ @constant_mapping.exclude_path(nesting + [sym])
130
+ end
131
+ end
132
+ end
133
+
134
+ def count_constant_references(prism_root)
135
+ each_constant_event(prism_root) do |kind, node, cpath, _singleton, _in_def|
136
+ case kind
137
+ when :class_def, :write
138
+ @constant_mapping.increment_usage_by_path(cpath)
139
+ when :read
140
+ increment_constant_read_usage(node)
141
+ end
142
+ end
143
+ end
144
+
145
+ def increment_constant_read_usage(node)
146
+ user_path = resolve_user_defined_cpath(node)
147
+ if user_path
148
+ @constant_mapping.increment_usage_by_path(user_path)
149
+ else
150
+ @constant_mapping.increment_usage(node.name) # steep:ignore NoMethod
151
+ end
152
+ end
153
+
154
+ def resolve_user_defined_cpath(node)
155
+ [@oracle.resolve_constant_read(node), syntactic_const_segments(node)].each do |cpath|
156
+ return cpath if cpath && @constant_mapping.user_defined_path?(cpath)
157
+ end
158
+ nil
159
+ end
160
+
161
+ # Where a superclass reference lands, for the alias patcher. A qualified
162
+ # path is taken as written; a bare name is meaningful only when it names a
163
+ # user-defined constant in the class's enclosing scope.
164
+ def resolve_superclass_path(superclass_node, class_cpath)
165
+ case superclass_node
166
+ when Prism::ConstantPathNode
167
+ syntactic_const_segments(superclass_node)
168
+ when Prism::ConstantReadNode
169
+ full_path = class_cpath[0...-1] + [superclass_node.name] # steep:ignore NoMethod
170
+ full_path if @constant_mapping&.user_defined_path?(full_path)
171
+ end
172
+ end
173
+
174
+ # A closed-world walk sees each textual reference once, but type analysis
175
+ # can record more read sites than the text shows (reads reached through
176
+ # resolution rather than spelling). Take whichever count is higher, so a
177
+ # rename is never judged unprofitable on an undercount.
178
+ def augment_constant_counts_via_oracle
179
+ @constant_mapping.each_user_defined_path do |cpath|
180
+ oracle_count = @oracle.constant_read_count(cpath)
181
+ current_count = @constant_mapping.usage_count_for_path(cpath)
182
+ @constant_mapping.set_usage_count_by_path(cpath, oracle_count) if oracle_count > current_count
183
+ end
184
+ end
185
+
186
+ # Location-keyed maps the patchers consume: resolution and full path per
187
+ # read, write cpath per assignment, cpath and superclass per definition.
188
+ def precompute_constant_resolution
189
+ @const_resolution_map = {} #: Hash[location_key, Array[Symbol]?]
190
+ @const_full_path_map = {} #: Hash[location_key, Array[Symbol]]
191
+ @const_write_cpath_map = {} #: Hash[location_key, Array[Symbol]]
192
+ @class_cpath_map = {} #: Hash[location_key, Array[Symbol]]
193
+ @superclass_resolution_map = {} #: Hash[location_key, Array[Symbol]]
194
+
195
+ each_constant_event(@prism_root) do |kind, node, cpath, _singleton, _in_def|
196
+ case kind
197
+ when :read
198
+ record_constant_read(node)
199
+ when :write
200
+ @const_write_cpath_map[AstUtils.location_key(node)] = cpath
201
+ when :class_def
202
+ key = AstUtils.location_key(node)
203
+ @class_cpath_map[key] = cpath
204
+ if node.is_a?(Prism::ClassNode) && node.superclass
205
+ # cpath is never nil for a :class_def event.
206
+ resolved = resolve_superclass_path(node.superclass, cpath) # steep:ignore ArgumentTypeMismatch
207
+ @superclass_resolution_map[key] = resolved if resolved
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ def record_constant_read(node)
214
+ key = AstUtils.location_key(node)
215
+ resolved = @oracle.resolve_constant_read(node)
216
+ @const_resolution_map[key] = resolved
217
+ @const_full_path_map[key] = resolved || syntactic_const_segments(node)
218
+ end
219
+
220
+ def collect_external_references(prism_root)
221
+ counted_prefix_ids = Set.new
222
+ prefix_counts = Hash.new(0)
223
+
224
+ each_constant_event(prism_root) do |kind, node, _cpath, _singleton, _in_def|
225
+ next unless kind == :read
226
+ next if counted_prefix_ids.include?(node.object_id)
227
+
228
+ mark_chain_prefixes(node, counted_prefix_ids)
229
+ prefix = external_prefix_for(node)
230
+ prefix_counts[prefix] += 1 if prefix
231
+ end
232
+
233
+ prefix_counts.each do |prefix, count|
234
+ @constant_mapping.add_external_prefix(prefix, usage_count: count)
235
+ end
236
+ end
237
+
238
+ # Mark the reference's prefix chain so sub-paths are not counted again as
239
+ # references of their own.
240
+ def mark_chain_prefixes(node, counted_prefix_ids)
241
+ current = case node
242
+ when Prism::ConstantPathNode, Prism::ConstantPathTargetNode then node.parent
243
+ end
244
+ while current.is_a?(Prism::ConstantPathNode) || current.is_a?(Prism::ConstantReadNode)
245
+ counted_prefix_ids << current.object_id
246
+ current = current.is_a?(Prism::ConstantPathNode) ? current.parent : nil
247
+ end
248
+ end
249
+
250
+ # The external prefix this reference should be counted under, or nil when
251
+ # it has none: the reference must not touch the project's own namespace,
252
+ # and must either resolve through type analysis or — the fallback for gems
253
+ # with no RBS — be spelled in full from a root that one of the program's
254
+ # own top-level requires provides. The requires are re-emitted ahead of
255
+ # the preamble, so such a root provably exists when the alias declaration
256
+ # runs; without that anchor an alias would turn "NameError if this line is
257
+ # ever reached" into "NameError at boot", or capture the wrong constant
258
+ # for a reference that resolves through its nesting at runtime.
259
+ def external_prefix_for(node)
260
+ full_path = syntactic_const_segments(node)
261
+ resolved_cpath = @oracle.resolve_constant_read(node)
262
+ return nil if @constant_mapping.user_defined_path?(full_path)
263
+ return nil if resolved_cpath && @constant_mapping.user_defined_path?(resolved_cpath)
264
+
265
+ effective_path = resolved_cpath ||
266
+ (full_path if complete_const_chain?(node) && required_external_root?(full_path.first))
267
+ return nil unless effective_path
268
+ return nil if effective_path.size < 2
269
+ return nil if @constant_mapping.has_user_defined_prefix?(full_path)
270
+
271
+ prefix = effective_path[0...-1]
272
+ return nil if @constant_mapping.user_defined_path?(prefix)
273
+
274
+ prefix
275
+ end
276
+
277
+ # True when the qualified chain is rooted at a plain constant (or written
278
+ # absolute), so its syntactic segments are its complete spelling.
279
+ def complete_const_chain?(node)
280
+ case node
281
+ when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
282
+ !Nesting.path_segments(node).nil?
283
+ else
284
+ false
285
+ end
286
+ end
287
+
288
+ def required_external_root?(root)
289
+ @boot_constant_roots&.include?(root) || false
290
+ end
291
+
292
+ end
293
+
294
+ # The top-level constants that exist when the minified program boots,
295
+ # before any of its own code runs: Ruby's core names plus whatever its
296
+ # top-level requires provide. Asking a fresh interpreter is what makes
297
+ # this exact — no naming-convention guessing, and no contamination from
298
+ # the constants the minifier process itself happens to have loaded.
299
+ # nil when the probe fails, so callers can fall back conservatively.
300
+ #
301
+ # Its own module rather than a phase method: the memo is process state
302
+ # (one probe per require set, because the probe boots an interpreter),
303
+ # and process state does not belong on a mixin of per-run phases.
304
+ module BootConstants
305
+ @cache = {} #: Hash[Array[String], Set[Symbol]?]
306
+
307
+ class << self
308
+ def for(stdlib_requires)
309
+ key = stdlib_requires.sort
310
+ return @cache[key] if @cache.key?(key)
311
+
312
+ @cache[key] = probe(stdlib_requires)
313
+ end
314
+
315
+ private
316
+
317
+ # The probe must run outside the minifier's bundler context: with it, the
318
+ # subprocess boots bundler, bundler evaluates the analyzed project's
319
+ # gemspec-style Gemfile, and the program's own constants leak into the
320
+ # "exists at boot" answer — under self-hosting, Ryac itself.
321
+ def probe(stdlib_requires)
322
+ requires = stdlib_requires.map { |r| "begin;require #{r.dump};rescue LoadError;end" }.join(';')
323
+ script = "#{requires};puts Object.constants"
324
+ popen = -> { IO.popen([{ 'RUBYOPT' => nil }, RbConfig.ruby, '-e', script], &:read) } #: ^() -> String
325
+ out = defined?(Bundler) ? Bundler.with_unbundled_env(&popen) : popen.call
326
+ Process.last_status&.success? ? out.split.map(&:to_sym).to_set : nil
327
+ rescue StandardError
328
+ nil
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Ryac
6
+ # Represents an external library constant prefix that can be aliased
7
+ ExternalPrefixInfo = Struct.new(
8
+ :prefix_path, # Array<Symbol> - Prefix path (e.g., [:TypeProf, :Core, :AST])
9
+ :prefix_string, # String - Full prefix string (e.g., "TypeProf::Core::AST")
10
+ :short_name, # String - Assigned short name (e.g., "Z")
11
+ :usage_count, # Integer - Number of references using this prefix
12
+ :char_savings, # Integer - Net character savings from aliasing
13
+ keyword_init: true
14
+ ) do
15
+ def initialize(prefix_path: nil, prefix_string: nil, short_name: nil, # steep:ignore UndeclaredMethodDefinition
16
+ usage_count: nil, char_savings: nil)
17
+ # @type self: ExternalPrefixInfo
18
+ self.prefix_path = prefix_path || []
19
+ self.prefix_string = prefix_string
20
+ self.short_name = short_name
21
+ self.usage_count = usage_count || 0
22
+ self.char_savings = char_savings || 0
23
+ end
24
+ end
25
+
26
+ # Represents a user-defined constant found in the source file
27
+ ConstantInfo = Struct.new(
28
+ :original_name, # Symbol - Original constant name (e.g., :MyClass)
29
+ :full_path, # Array<Symbol> - Full namespace path (e.g., [:Foo, :Bar, :MyClass])
30
+ :short_name, # String - Assigned short name (e.g., "A")
31
+ :usage_count, # Integer - Number of references in the file
32
+ :definition_type, # Symbol - :class, :module, or :value
33
+ :scope_path, # Array<Symbol> - Module/class scope where defined
34
+ keyword_init: true
35
+ ) do
36
+ def initialize(original_name: nil, full_path: nil, short_name: nil, # steep:ignore UndeclaredMethodDefinition
37
+ usage_count: nil, definition_type: nil, scope_path: nil)
38
+ # @type self: ConstantInfo
39
+ self.original_name = original_name
40
+ self.full_path = full_path || []
41
+ self.short_name = short_name
42
+ self.usage_count = usage_count || 0
43
+ self.definition_type = definition_type
44
+ self.scope_path = scope_path || []
45
+ end
46
+ end
47
+
48
+ # Tracks the mapping between original and short constant names
49
+ # Uses static_cpath (full qualified path) as key to distinguish
50
+ # constants with same name in different modules.
51
+ # Also tracks external prefix aliases (absorbed from ExternalPrefixAliaser).
52
+ class ConstantRenameMapping
53
+ attr_reader :mappings, :used_short_names
54
+
55
+ # boot_roots: the top-level constants that exist when the minified
56
+ # program boots (core + its own requires), used to spot reopenings of
57
+ # classes the program didn't create. nil falls back to probing the
58
+ # minifier's own process, which over-approximates: everything the
59
+ # minifier has loaded — including, under self-hosting, the analyzed
60
+ # program itself — then looks like a reopening.
61
+ def initialize(boot_roots: nil)
62
+ @boot_roots = boot_roots
63
+ @mappings = {} # Hash<Array<Symbol>, ConstantInfo> - key is static_cpath
64
+ # keyed by the last path segment
65
+ @by_name = {}
66
+ @used_short_names = Set.new
67
+ @external_prefixes = {}
68
+ @prefix_counts = Hash.new(0) # Hash<Array<Symbol>, Integer> - raw prefix reference counts
69
+ @state = :empty
70
+ end
71
+
72
+ def empty?
73
+ @state == :empty
74
+ end
75
+
76
+ def finalized?
77
+ @state == :frozen
78
+ end
79
+
80
+ # Add a constant definition using TypeProf's static_cpath
81
+ def add_definition_with_path(static_cpath, definition_type:)
82
+ raise "Cannot add definitions when finalized" if finalized?
83
+ @state = :collecting if empty?
84
+
85
+ return if @mappings.key?(static_cpath)
86
+
87
+ name = static_cpath.last
88
+ scope_path = static_cpath[0...-1]
89
+
90
+ info = ConstantInfo.new(
91
+ original_name: name,
92
+ full_path: static_cpath,
93
+ definition_type: definition_type,
94
+ scope_path: scope_path
95
+ )
96
+ @mappings[static_cpath] = info
97
+
98
+ # Also index by simple name for backward compatibility
99
+ (@by_name[name] ||= []) << info
100
+ end
101
+
102
+ # Increment usage count for a constant by static_cpath
103
+ def increment_usage_by_path(static_cpath)
104
+ raise "Cannot increment usage when finalized" if finalized?
105
+ return unless @mappings.key?(static_cpath)
106
+
107
+ info = @mappings[static_cpath]
108
+ info.usage_count += 1
109
+ end
110
+
111
+ # Increment usage count by simple name (finds first match)
112
+ def increment_usage(name)
113
+ raise "Cannot increment usage when finalized" if finalized?
114
+ return unless @by_name.key?(name)
115
+
116
+ # Increment all constants with this name
117
+ @by_name[name].each do |info|
118
+ info.usage_count += 1
119
+ end
120
+ end
121
+
122
+ def exclude_path(static_cpath)
123
+ raise "Cannot exclude when finalized" if finalized?
124
+ info = @mappings.delete(static_cpath)
125
+ return unless info
126
+ name = static_cpath.last
127
+ @by_name[name]&.reject! { |i| i.full_path == static_cpath }
128
+ end
129
+
130
+ # Freeze the mapping and assign short names.
131
+ # Unified allocation: internal constants and external prefixes are merged
132
+ # into a single sorted list and allocated from the same NameGenerator.
133
+ # This follows the src.dest two-phase model: propagation (this method)
134
+ # determines ALL short names before any application.
135
+ def assign_short_names(name_generator, skip_class_modules: false)
136
+ raise "Already finalized" if finalized?
137
+
138
+ @state = :frozen
139
+ prefix_counts = @prefix_counts
140
+ @prefix_counts = nil
141
+
142
+ existing_names = @mappings.each_value.with_object(Set.new) { |info, s| s << info.original_name.to_s }
143
+
144
+ # Augment prefix counts with preamble-induced parent refs:
145
+ # each prefix's declaration (e.g., C5=RuboCop::Cop) references its parent.
146
+ # Adding unconditionally avoids the chicken-and-egg problem of needing to
147
+ # know which children are aliased before counting parent refs.
148
+ prefix_counts.keys.each do |prefix|
149
+ next if prefix.size < 2
150
+ prefix_counts[prefix[0...-1]] += 1
151
+ end
152
+
153
+ # Build unified allocation list: [gross_savings_estimate, :internal/:external, object]
154
+ entries = [] #: Array[[Integer, Symbol, untyped]]
155
+
156
+ @mappings.each_value do |info|
157
+ next if skip_class_modules && info.definition_type != :value
158
+ next if info.definition_type != :value && external_class_root?(info.full_path)
159
+ entries << [info.original_name.to_s.size * (info.usage_count + 1), :internal, info]
160
+ end
161
+
162
+ prefix_counts.each do |prefix, count|
163
+ prefix_string = prefix.map(&:to_s).join('::')
164
+ info = ExternalPrefixInfo.new(prefix_path: prefix, prefix_string: prefix_string, usage_count: count)
165
+ entries << [prefix_string.size * count, :external, info]
166
+ end
167
+
168
+ entries.sort_by! { |e| -e[0] }
169
+
170
+ # candidate is assigned on the first iteration and only replaced after
171
+ candidate = nil #: String?
172
+ entries.each do |_savings, kind, info|
173
+ if candidate.nil?
174
+ candidate = name_generator.next_name
175
+ candidate = name_generator.next_name while existing_names.include?(candidate)
176
+ end
177
+ cand = candidate #: String
178
+
179
+ case kind
180
+ when :internal
181
+ # Kept names make re-minification a fixed point: the names this
182
+ # pass assigns are themselves KEPT_NAME_MAX or shorter, and a
183
+ # second pass must not shuffle them again.
184
+ next if info.original_name.to_s.size <= NameGenerator::KEPT_NAME_MAX
185
+ next unless info.original_name.to_s.size - cand.size > 0
186
+ info.short_name = cand
187
+ @used_short_names << cand
188
+
189
+ when :external
190
+ saved_per_use = info.prefix_string.size - cand.size
191
+ next unless saved_per_use > 0
192
+ declaration_cost = cand.size + 1 + info.prefix_string.size + 1
193
+ net_savings = (saved_per_use * info.usage_count) - declaration_cost
194
+ next unless net_savings > 0
195
+ info.short_name = cand
196
+ info.char_savings = net_savings
197
+ existing_names << cand
198
+ @external_prefixes[info.prefix_path] = info
199
+ end
200
+
201
+ candidate = nil
202
+ end
203
+ end
204
+
205
+ # Generate backward-compatible alias declarations for renamed constants.
206
+ # Returns array of strings like "OriginalName=ShortName" or
207
+ # "ShortParent::OriginalName=ShortParent::ShortName" for nested constants.
208
+ def generate_alias_declarations
209
+ renamed = @mappings.values.select(&:short_name).sort_by { |info| [info.full_path.size, info.full_path] }
210
+ renamed.filter_map { |info| build_alias_declaration(info) }
211
+ end
212
+
213
+ # Get short name for a constant by static_cpath. The parameter is nilable
214
+ # for the callers that pass sliced sub-paths; a nil key simply misses.
215
+ def short_name_for_path(static_cpath)
216
+ info = static_cpath ? @mappings[static_cpath] : nil
217
+ info&.short_name
218
+ end
219
+
220
+ # Get short name for a constant by simple name (finds first match)
221
+ # Used when static_cpath is not available
222
+ def short_name_for(name)
223
+ return nil unless @by_name.key?(name)
224
+ infos = @by_name[name]
225
+ return nil if infos.empty?
226
+ # Return first match (for backward compatibility)
227
+ infos.first&.short_name
228
+ end
229
+
230
+ # Check if a constant is user-defined by static_cpath
231
+ def user_defined_path?(static_cpath)
232
+ @mappings.key?(static_cpath)
233
+ end
234
+
235
+ # Get usage count for a constant by static_cpath
236
+ def usage_count_for_path(static_cpath)
237
+ info = @mappings[static_cpath]
238
+ info ? info.usage_count : 0
239
+ end
240
+
241
+ # Set usage count for a constant by static_cpath
242
+ def set_usage_count_by_path(static_cpath, count)
243
+ raise "Cannot set usage when finalized" if finalized?
244
+ info = @mappings[static_cpath]
245
+ info.usage_count = count if info
246
+ end
247
+
248
+ # Iterate over user-defined constant paths
249
+ def each_user_defined_path(&block)
250
+ @mappings.each_key(&block)
251
+ end
252
+
253
+ # Check if a path is a class or module definition
254
+ def class_or_module_path?(static_cpath)
255
+ return false unless static_cpath
256
+ info = @mappings[static_cpath]
257
+ info && (info.definition_type == :class || info.definition_type == :module)
258
+ end
259
+
260
+ # Check if any sub-prefix of the path is user-defined
261
+ def has_user_defined_prefix?(full_path)
262
+ (1...full_path.size).any? { |i| user_defined_path?(full_path[0...i]) }
263
+ end
264
+
265
+ # Add an external prefix reference count (e.g., [:TypeProf, :Core, :AST] with count 20)
266
+ def add_external_prefix(prefix_path, usage_count:)
267
+ raise "Cannot add external prefix when finalized" if finalized?
268
+ @state = :collecting if empty?
269
+ @prefix_counts[prefix_path] += usage_count
270
+ end
271
+
272
+ # Get short name for the prefix of a full external path
273
+ def short_name_for_prefix(full_path)
274
+ return nil if full_path.nil? || full_path.size < 2
275
+ prefix = full_path[0...-1] #: Array[Symbol]
276
+ info = @external_prefixes[prefix]
277
+ info&.short_name
278
+ end
279
+
280
+ # Generate prefix declaration statements (e.g., ["Z=TypeProf::Core::AST"])
281
+ # Uses chained aliases when a sub-prefix is also aliased
282
+ def generate_prefix_declarations
283
+ sorted = @external_prefixes.values.sort_by { |info| [info.prefix_path.size, -info.char_savings] }
284
+
285
+ alias_map = {} #: Hash[Array[Symbol], String?]
286
+ sorted.map do |info|
287
+ decl_rhs = info.prefix_string
288
+ (info.prefix_path.size - 1).downto(2) do |len|
289
+ sub = info.prefix_path[0...len] #: Array[Symbol]
290
+ if alias_map.key?(sub)
291
+ rest = info.prefix_path[len..] #: Array[Symbol]
292
+ decl_rhs = "#{alias_map[sub]}::#{rest.map(&:to_s).join('::')}"
293
+ break
294
+ end
295
+ end
296
+ alias_map[info.prefix_path] = info.short_name
297
+ "#{info.short_name}=#{decl_rhs}"
298
+ end
299
+ end
300
+
301
+ private
302
+
303
+ # A class/module definition whose root the program didn't create is a
304
+ # reopening (e.g. `class Array`, or `module Prism` adding to the gem) and
305
+ # must not be renamed. With boot_roots the judgement is exact for the
306
+ # program's own runtime; without it, fall back to probing this process.
307
+ def external_class_root?(cpath)
308
+ return runtime_constant?(cpath) if @boot_roots.nil?
309
+
310
+ @boot_roots.include?(cpath.first)
311
+ end
312
+
313
+ # Check if a constant path already exists in the Ruby runtime.
314
+ # Used to detect class/module reopenings (e.g., `class Array` adding methods
315
+ # to a built-in class) which must not be renamed.
316
+ def runtime_constant?(cpath)
317
+ cpath.reduce(Object) do |mod, name|
318
+ return false unless mod.is_a?(Module) && mod.const_defined?(name, false)
319
+ mod.const_get(name, false)
320
+ end
321
+ true
322
+ rescue
323
+ false
324
+ end
325
+
326
+ # Build backward alias declaration for renamed value constants.
327
+ # Class/module constants are never renamed (no short_name assigned).
328
+ def build_alias_declaration(info)
329
+ path = info.full_path
330
+ # LHS: short parent path + original leaf name
331
+ lhs = path.each_index.map { |i|
332
+ if i < path.size - 1
333
+ short_name_for_path(path[0..i]) || path[i].to_s
334
+ else
335
+ path[i].to_s
336
+ end
337
+ }.join('::')
338
+ # RHS: full short path
339
+ rhs = path.each_index.map { |i|
340
+ short_name_for_path(path[0..i]) || path[i].to_s
341
+ }.join('::')
342
+ lhs == rhs ? nil : "#{lhs}=#{rhs}"
343
+ end
344
+ end
345
+
346
+ end