ryac 0.2.0 → 0.3.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -16
  3. data/Steepfile +2 -0
  4. data/bin/ryac +41 -2
  5. data/lib/ryac/analysis/constant/collection.rb +49 -0
  6. data/lib/ryac/analysis/constant/rename_mapping.rb +15 -1
  7. data/lib/ryac/analysis/ivar/collection.rb +28 -2
  8. data/lib/ryac/analysis/lazy_regions.rb +87 -0
  9. data/lib/ryac/analysis/local_scopes.rb +110 -12
  10. data/lib/ryac/analysis/method/collection.rb +100 -3
  11. data/lib/ryac/analysis/method/rename_mapping.rb +17 -12
  12. data/lib/ryac/analysis/type_oracle.rb +7 -2
  13. data/lib/ryac/driver_file.rb +47 -0
  14. data/lib/ryac/minifier.rb +27 -26
  15. data/lib/ryac/packer.rb +127 -0
  16. data/lib/ryac/pipeline/analyzer.rb +25 -3
  17. data/lib/ryac/pipeline/concatenator.rb +127 -8
  18. data/lib/ryac/pipeline/data_types.rb +13 -5
  19. data/lib/ryac/pipeline/file_collector.rb +64 -4
  20. data/lib/ryac/pipeline/method_renamer.rb +15 -0
  21. data/lib/ryac/pipeline/stage.rb +4 -0
  22. data/lib/ryac/pipeline/stage_runner.rb +7 -3
  23. data/lib/ryac/version.rb +1 -1
  24. data/lib/ryac.rb +3 -0
  25. data/sig/ryac/analysis/constant/collection.rbs +3 -0
  26. data/sig/ryac/analysis/constant/rename_mapping.rbs +2 -1
  27. data/sig/ryac/analysis/ivar/collection.rbs +1 -0
  28. data/sig/ryac/analysis/lazy_regions.rbs +10 -0
  29. data/sig/ryac/analysis/local_scopes.rbs +9 -1
  30. data/sig/ryac/analysis/method/collection.rbs +3 -0
  31. data/sig/ryac/analysis/method/rename_mapping.rbs +3 -2
  32. data/sig/ryac/analysis/type_oracle.rbs +1 -1
  33. data/sig/ryac/driver_file.rbs +8 -0
  34. data/sig/ryac/minifier.rbs +1 -2
  35. data/sig/ryac/packer.rbs +12 -0
  36. data/sig/ryac/pipeline/analyzer.rbs +6 -0
  37. data/sig/ryac/pipeline/concatenator.rbs +16 -1
  38. data/sig/ryac/pipeline/data_types.rbs +8 -5
  39. data/sig/ryac/pipeline/file_collector.rbs +8 -3
  40. data/sig/ryac/pipeline/method_renamer.rbs +3 -0
  41. data/sig/ryac/pipeline/stage.rbs +1 -0
  42. data/sig/ryac/pipeline/stage_runner.rbs +2 -1
  43. metadata +15 -8
  44. data/bin/console +0 -11
  45. data/bin/setup +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bddb3f3efea0d09081975f55f29c156ef437180470e5dc08ef30046dd19b995
4
- data.tar.gz: 062e95d78fa295320ef0ea800d01aa3dff509e9f2c20b341781a06e9ed60671e
3
+ metadata.gz: 0e028fd595a5109347a3c20ddca86a61fa1ade6a043276471e771ab60c4723a9
4
+ data.tar.gz: aa11c81ee53ce49056f8f7dfd2679549b75855e9b4a912558e461d80348ef79a
5
5
  SHA512:
6
- metadata.gz: 308731a116d5765d712d85be56b819821345847b21cdb1feb591a34071c865f0073addb6e36de31d21c117138e74b6967068fb1e4e5d897176b4a4edff44d5f6
7
- data.tar.gz: bcd1419c8b24b9f718e20a0b529636b265ecf531689d0468920f101d10f82e10e5019da28ac183a31fe5fa8c072cbd2f00ff1e9f78b5525b916009adf971ad42
6
+ metadata.gz: 980ad6bcee3e5db39ee429715b10555e831401b1fcd18f5f918d7128d4548c33185a441c5bb8b7554f0836f15b677067119e9a1cc26b8b8bd06a4ea488ba6e3e
7
+ data.tar.gz: b847884f580ed660733e9d23fa71aa706257257771f7a1e1bf3cc2c27801e400e6b38b289adbe7498e574c984c9627a1e91676470f465550322b79c268938dbe
data/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  A Ruby code minifier that uses [TypeProf](https://github.com/ruby/typeprof) for type-aware analysis and AST-based transformations to achieve high compression rates while preserving functional equivalence.
6
6
 
7
+ ## Status
8
+
9
+ ryac is under active development and no interface is settled yet. The architecture — the pipeline stages, the rename policies, the alias and lazy-region mechanisms — and with it the CLI options, the `Ryac::Minifier` API and the shape of the output may all change between releases. Pin an exact version if you depend on any of them.
10
+
7
11
  ## Philosophy
8
12
 
9
13
  This project takes an **aggressive optimization** approach. In Ruby, even comment removal requires context awareness — for example, `foo\n# comment\n.to_s` works because the comment connects the method chain, but removing it causes a syntax error. Whether a given comment is safe to remove depends entirely on context, making the boundary between safe and unsafe transformations inherently blurry. Since conservative minification is already difficult in Ruby, we choose to optimize as aggressively as possible. TypeProf's type analysis helps make these transformations more informed, but the goal is always maximum compression.
@@ -11,9 +15,10 @@ This project takes an **aggressive optimization** approach. In Ruby, even commen
11
15
  ## Features
12
16
 
13
17
  - **Multi-file support**: Follows `require_relative` and `autoload` to collect and concatenate dependencies into a single output
18
+ - **Dynamic requires**: A `require_relative "driver/#{name}"` inside a method bundles every file under that directory as a lazy region — registered at load, run the moment the require would have run — so a driver's own `require "ffi"` keeps its optional-dependency timing while the whole program shares one rename table (see [Dynamic requires](#dynamic-requires))
14
19
  - **Whitespace & comment removal**: Strips all unnecessary whitespace and comments
15
20
  - **AST transformations**: Boolean/char shortening, constant folding, control flow simplification, endless methods, parenthesis optimization
16
- - **Constant aliasing**: Renames user-defined constants and shortens repeated external constant paths
21
+ - **Constant aliasing**: Renames user-defined constants and shortens repeated external constant paths, emitting backward-compat aliases; once a program's dynamically loaded files are bundled, the only reader left outside is a launcher, which spells the class/module skeleton (`Optcarrot::NES.new.run`) and never a value constant — so value-constant aliases are dropped and the skeleton's kept
17
22
  - **Variable renaming**: Shortens local variables, keyword arguments, instance/class/global variables
18
23
  - **Method renaming**: Shortens method names with `send(:sym)` coordination and attr-backed ivar optimization
19
24
  - **Method alias shortening**: Replaces long stdlib method names with shorter aliases (e.g., `collect` → `map`)
@@ -22,6 +27,12 @@ This project takes an **aggressive optimization** approach. In Ruby, even commen
22
27
 
23
28
  ## Installation
24
29
 
30
+ ```bash
31
+ gem install ryac
32
+ ```
33
+
34
+ Or in a Gemfile:
35
+
25
36
  ```ruby
26
37
  gem 'ryac'
27
38
  ```
@@ -32,28 +43,34 @@ gem 'ryac'
32
43
 
33
44
  ```bash
34
45
  # Minify a file (follows require_relative automatically)
35
- bin/ryac path/to/entry.rb
46
+ ryac path/to/entry.rb
36
47
 
37
48
  # Specify compression level (stable or unstable)
38
- bin/ryac path/to/entry.rb -c stable
39
- bin/ryac path/to/entry.rb -c unstable
49
+ ryac path/to/entry.rb -c stable
50
+ ryac path/to/entry.rb -c unstable
40
51
 
41
52
  # Write to output file
42
- bin/ryac path/to/entry.rb -o minified.rb
53
+ ryac path/to/entry.rb -o minified.rb
43
54
 
44
55
  # Write constant aliases to a separate file
45
- bin/ryac path/to/entry.rb -o minified.rb -a aliases.rb
56
+ ryac path/to/entry.rb -o minified.rb -a aliases.rb
57
+
58
+ # Emit a self-extracting packed file (self = zero dependencies, zlib = smaller)
59
+ ryac path/to/entry.rb -o packed.rb --pack self
60
+
61
+ # Keep the program a pure library and put its lazy regions in a runner
62
+ ryac path/to/entry.rb -o minify.rb --driver driver.rb
46
63
 
47
64
  # Multiple entry points
48
- bin/ryac file1.rb file2.rb
65
+ ryac file1.rb file2.rb
49
66
 
50
67
  # Minify installed gem(s) by name (resolved via Gem::Specification)
51
- bin/ryac -g rack
52
- bin/ryac -g rack,rack-session -o bundle.rb
68
+ ryac -g rack
69
+ ryac -g rack,rack-session -o bundle.rb
53
70
 
54
71
  # Show version / help
55
- bin/ryac -v
56
- bin/ryac -h
72
+ ryac -v
73
+ ryac -h
57
74
  ```
58
75
 
59
76
  ### Ruby API
@@ -80,21 +97,58 @@ There are exactly two levels, named for their promise. The default is **`stable`
80
97
 
81
98
  | Level | Transformations | Promise |
82
99
  |-------|----------------|---------|
83
- | `stable` | AST compaction and folding, constant/class/module renaming with compatibility aliases, external prefix aliasing, local/keyword/instance/class/global variable renaming | Verified frame-for-frame on a real program (Optcarrot). Sound under closed-world analysis; reflection over *names the program renames* is the caveat. |
84
- | `unstable` | + Method renaming, attr-backed ivar coordination | A program can defeat method renaming by construction (names inside strings, `eval`'d source, `send(computed)`), so this works only when the program plays along. Verified by self-hosting. |
100
+ | `stable` | AST compaction and folding, constant/class/module renaming with compatibility aliases, external prefix aliasing, local/keyword/instance/class/global variable renaming, and method renaming under the `:safe` policy — a name is renamed only when type inference resolved every caller and no dynamic escape hatch (a string mention, a dynamic-ivar class, an uncalled def) touches it; attr declarations, their backing ivars and their call sites move together | Verified frame-for-frame on a real program (Optcarrot). Sound under closed-world analysis; reflection over *names the program renames* is the caveat. |
101
+ | `unstable` | The same stages; method renaming switches to `:aggressive`, which also renames names whose callers type inference could not resolve, betting that a same-named call is the same method | A program can defeat that bet by construction (names inside strings, `eval`'d source, `send(computed)`), so this works only when the program plays along. Verified by self-hosting. |
85
102
 
86
103
  Finer configurations are not levels: the pipeline is built from steps, and callers can pass an explicit stage list in place of a level name (`Minifier#call(path, level: [...stage defs...])`). The unit tests pin each step's behavior through exactly that mechanism.
87
104
 
105
+ ### Dynamic requires
106
+
107
+ A file the program loads by a computed path — optcarrot's `require_relative "driver/#{name}_#{type}"` inside `Driver.load_each` — is outside any static dependency graph, yet it runs against the program: subclassing its classes, reading its instance variables, overriding its methods. Renaming one side and not the other breaks exactly there. When the computed path starts with a static directory, ryac bundles every `.rb` file under it (and whatever those files require that nothing static did) into the same closed world, so the rename table covers both sides, and writes each as a *lazy region*:
108
+
109
+ ```ruby
110
+ RYAC_LAZY["optcarrot/driver/sdl2_video"] = -> { ...the file, minified... }
111
+ ```
112
+
113
+ The region's body runs when the original require would have run — a short loader keeps Ruby's contract (once and `true`, `false` on a repeat, a failed run stays retryable, an unregistered path falls through to a real `require_relative` relative to the bundle), and the require sites are rewritten to call it. So `require "ffi"` and `ffi_lib "SDL2"` at a driver's top level still execute only when that driver is selected, and the auto-selection's `rescue LoadError` still walks on to the next one.
114
+
115
+ The output is still one file. For optcarrot, it stands in for `lib/optcarrot.rb` under upstream's unmodified `bin/optcarrot`:
116
+
117
+ ```bash
118
+ ryac gem_tests/optcarrot/lib/optcarrot.rb -o /path/to/optcarrot/lib/optcarrot.rb
119
+ cd /path/to/optcarrot && bin/optcarrot examples/Lan_Master.nes
120
+ ```
121
+
122
+ A constant only a region defines (`SDL2Video`, the `SDL2` module) keeps its name — it does not exist until the region runs, so no alias at the end of the file could restore it — and an external constant a region references is never hoisted into the preamble. Neither costs much: a region's own names are few, and its references to the core rename like everything else.
123
+
124
+ Or keep the program a pure library and put the regions in a runner of their own:
125
+
126
+ ```bash
127
+ ryac gem_tests/optcarrot/lib/optcarrot.rb -o minify.rb --driver driver.rb
128
+ ruby driver.rb minify.rb --exec "Optcarrot::NES.new.run" examples/Lan_Master.nes
129
+ ```
130
+
131
+ `minify.rb` is the loader, the core and its aliases — nothing that runs. `driver.rb` loads it, registers the regions and evals the `--exec` expression at top level, its own two arguments already gone from `ARGV` so the program's option parsing sees only what follows (anything after `--` is never read). The split assumes the program loads its dynamic files after boot, from a method — the shape that made them lazy; a dynamic require the core ran while loading would miss its region. The expression is code outside the bundle, so it can spell only what survives outside: the class/module skeleton, which the aliases restore, and methods the program itself never calls — under `stable`'s safe policy an uncalled def keeps its name, and a launcher's entry point (`NES#run`) is exactly that. `--driver` needs `-o`, and cannot be combined with `-a` or with `--pack` (a packed core cannot be loaded).
132
+
133
+ ### Packed output
134
+
135
+ `--pack` is an output format, orthogonal to the levels: it wraps the minified program in a self-extracting stub — a short plain-Ruby decoder followed by the compressed bytes after `__END__`. On optcarrot it takes the artifact from 37% of the original source to 19.4% (`self`) or 15.1% (`zlib`).
136
+
137
+ - `--pack self` inlines a pure-Ruby LZSS decoder (282 bytes) — no `require` at all, runs anywhere Ruby runs
138
+ - `--pack zlib` deflates via the zlib default gem — smaller, but dead on a Ruby built without it
139
+
140
+ A packed file is a main-program format: the stub reads its own `__END__` data and `eval`s the program with `$0` as the file name, so `ruby packed.rb` runs it and a `$PROGRAM_NAME == __FILE__` launcher still fires. It cannot be `require`d — only the main script has `DATA` — and a program that itself uses `__END__` or reads `DATA` is refused with an error.
141
+
88
142
  See [`tests/ryac/pipeline/`](tests/ryac/pipeline/) for per-stage transformation examples, and [`tests/ryac/levels/`](tests/ryac/levels/) for end-to-end compression examples.
89
143
 
90
144
  ### What the levels are verified against
91
145
 
92
146
  The supported boundary is defined by two programs, both verified in CI:
93
147
 
94
- - **[Optcarrot](https://github.com/mame/optcarrot) at `stable`** — the minified emulator matches the original frame-for-frame across the 180-frame demo and three scripted play scenarios (1,820 frames of title menus, piece rotation, pausing and button mashing) ([`tests/test_optcarrot.rb`](tests/test_optcarrot.rb))
148
+ - **[Optcarrot](https://github.com/mame/optcarrot) at `stable`** — the minified emulator matches the original frame-for-frame across the 180-frame demo and three scripted play scenarios (1,820 frames of title menus, piece rotation, pausing and button mashing), and, standing in for `lib/optcarrot.rb` under upstream's own `bin/optcarrot`, its bundled png and wav drivers write the same frame and samples as the original's ([`tests/test_optcarrot.rb`](tests/test_optcarrot.rb))
95
149
  - **This minifier itself at `unstable`** — the minified minifier re-minifies the original source to identical output, and minifying its own output is a byte-identical fixed point ([`tests/test_integration.rb`](tests/test_integration.rb))
96
150
 
97
- Whether `unstable` holds for a given program depends on the program. Optcarrot stops at `stable` because it defeats method renaming by construction: it builds its CPU/PPU cores as source strings and `eval`s them, scans that text for `@ivar` names with a regexp, and dispatches through `send(computed_symbol)` — method names survive inside strings, out of reach of static analysis. The sinatra and rubocop suites run in CI as regression canaries on a keyword-only step composition, but they sit outside this boundary and do not define it.
151
+ Whether `unstable` holds for a given program depends on the program. Optcarrot stops at `stable` because it defeats aggressive method renaming by construction: it builds its CPU/PPU cores as source strings and `eval`s them, scans that text for `@ivar` names with a regexp, and dispatches through `send(computed_symbol)` — method names survive inside strings, out of reach of static analysis. The sinatra and rubocop suites run in CI as regression canaries on a keyword-only step composition, but they sit outside this boundary and do not define it.
98
152
 
99
153
  ## Development
100
154
 
@@ -133,7 +187,7 @@ FileCollector → Concatenator → StageRunner (Compactor → stage list) → Ou
133
187
  2. **Concatenator** — Topologically sorts files and concatenates them into a single source
134
188
  3. **StageRunner** — Compacts the source (AST rebuilt into minimal whitespace form), then walks the level's ordered stage list. Every stage implements one contract — `needs_analysis?` / `fixpoint?` / `collect(ctx, patches)` / `finish(ctx)` — and phase is list position:
135
189
  - **Syntactic stages** (no analysis): `ControlFlowSimplify`, `EndlessMethod`, `ConstantFold`, `BooleanShorten`, `CharShorten` before the rename batch; `ParenOptimizer` after it
136
- - **Analysis stages**: `ConstantAliaser`, `AttrDeclShorten`, `VariableRenamer` (plus `MethodRenamer` at `unstable`) — consecutive analysis stages form one batch sharing a single TypeProf pass, and the runner rejects a list that would need two
190
+ - **Analysis stages**: `ConstantAliaser`, `AttrDeclShorten`, `VariableRenamer`, `MethodRenamer` (`:safe` at `stable`, `:aggressive` at `unstable`) — consecutive analysis stages form one batch sharing a single TypeProf pass, and the runner rejects a list that would need two
137
191
 
138
192
  ## Dependencies
139
193
 
data/Steepfile CHANGED
@@ -5,5 +5,7 @@ target :lib do
5
5
 
6
6
  check "lib"
7
7
 
8
+ library "zlib"
9
+
8
10
  collection_config "rbs_collection.yaml"
9
11
  end
data/bin/ryac CHANGED
@@ -8,7 +8,9 @@ options = {
8
8
  output: nil,
9
9
  aliases: nil,
10
10
  level: nil,
11
- gems: nil
11
+ gems: nil,
12
+ pack: nil,
13
+ driver: nil
12
14
  }
13
15
 
14
16
  parser = OptionParser.new do |opts|
@@ -36,6 +38,20 @@ parser = OptionParser.new do |opts|
36
38
  end
37
39
  end
38
40
 
41
+ opts.on("-p", "--pack FORMAT", "Emit a self-extracting file (self or zlib)") do |value|
42
+ begin
43
+ options[:pack] = Ryac::Packer.resolve_format(value)
44
+ rescue ArgumentError => e
45
+ warn "Error: #{e.message}"
46
+ warn opts
47
+ exit 1
48
+ end
49
+ end
50
+
51
+ opts.on("-d", "--driver FILE", "Write the lazy regions to FILE as a runner (ruby FILE CORE --exec EXPR)") do |file|
52
+ options[:driver] = file
53
+ end
54
+
39
55
  opts.on("-h", "--help", "Display this help message") do
40
56
  puts opts
41
57
  exit 0
@@ -55,6 +71,21 @@ rescue OptionParser::InvalidOption => e
55
71
  exit 1
56
72
  end
57
73
 
74
+ if options[:driver]
75
+ unless options[:output]
76
+ warn "Error: --driver needs -o for the core file"
77
+ exit 1
78
+ end
79
+ if options[:aliases]
80
+ warn "Error: --driver cannot be combined with -a (the runner loads the core with its aliases)"
81
+ exit 1
82
+ end
83
+ if options[:pack]
84
+ warn "Error: --driver cannot be combined with --pack (a packed core cannot be loaded)"
85
+ exit 1
86
+ end
87
+ end
88
+
58
89
  if options[:gems]
59
90
  unless ARGV.empty?
60
91
  warn "Error: Cannot specify both --gem and file arguments"
@@ -108,9 +139,17 @@ begin
108
139
  result.full_content
109
140
  end
110
141
 
142
+ content = Ryac::Packer.pack(content, options[:pack]) if options[:pack]
143
+
144
+ if options[:driver]
145
+ content, driver = Ryac::DriverFile.split(content)
146
+ File.write(options[:driver], driver)
147
+ end
148
+
111
149
  if options[:output]
112
- File.write(options[:output], content)
150
+ File.binwrite(options[:output], content)
113
151
  else
152
+ $stdout.binmode if options[:pack]
114
153
  puts content
115
154
  end
116
155
 
@@ -101,6 +101,52 @@ module Ryac
101
101
  (value.name == :define && receiver.name == :Data)
102
102
  end
103
103
 
104
+ # A constant only a lazy region defines does not exist until the region
105
+ # runs, so no alias for it could execute at the end of the file: it keeps
106
+ # its name, and `Optcarrot.const_get(:SDL2Video)` finds it as written. A
107
+ # constant the flat code defines too is merely reopened by the region
108
+ # and renames as usual.
109
+ def exclude_lazy_definitions(prism_root)
110
+ return if @lazy_regions.empty?
111
+
112
+ flat = Set.new #: Set[Array[Symbol]]
113
+ lazy = Set.new #: Set[Array[Symbol]]
114
+ each_constant_event(prism_root) do |kind, node, cpath, _singleton, _in_def|
115
+ next unless cpath && (kind == :class_def || kind == :write)
116
+
117
+ (LazyRegions.contains?(@lazy_regions, node) ? lazy : flat) << cpath
118
+ end
119
+ (lazy - flat).each { |cpath| @constant_mapping.exclude_path(cpath) }
120
+ end
121
+
122
+ # `expr::NAME` reads a constant on a scope only the running program
123
+ # knows — `self.class::OPTIONS` in an included helper lands on whichever
124
+ # class included it — so no static path stands in for the reference and
125
+ # the name in the text is the name looked up. Every user constant called
126
+ # NAME keeps its name, so any of them can be the one meant.
127
+ def exclude_dynamic_root_reads(prism_root)
128
+ names = Set.new #: Set[Symbol]
129
+ each_constant_event(prism_root) do |kind, node, _cpath, _singleton, _in_def|
130
+ next unless kind == :read && node.is_a?(Prism::ConstantPathNode) && dynamic_root?(node)
131
+
132
+ name = node.name
133
+ names << name if name
134
+ end
135
+ return if names.empty?
136
+
137
+ pinned = [] #: Array[Array[Symbol]]
138
+ @constant_mapping.each_user_defined_path { |cpath| pinned << cpath if names.include?(cpath.last) }
139
+ pinned.each { |cpath| @constant_mapping.exclude_path(cpath) }
140
+ end
141
+
142
+ # A path whose outermost parent is neither a constant nor absent (`::X`)
143
+ # — `self::X`, `self.class::X`, `klass::X`.
144
+ def dynamic_root?(node)
145
+ current = node.parent
146
+ current = current.parent while current.is_a?(Prism::ConstantPathNode)
147
+ !(current.nil? || current.is_a?(Prism::ConstantReadNode))
148
+ end
149
+
104
150
  def collect_constants(prism_root)
105
151
  each_constant_event(prism_root) do |kind, node, cpath, singleton, in_def|
106
152
  case kind
@@ -224,6 +270,9 @@ module Ryac
224
270
  each_constant_event(prism_root) do |kind, node, _cpath, _singleton, _in_def|
225
271
  next unless kind == :read
226
272
  next if counted_prefix_ids.include?(node.object_id)
273
+ # A region's reference resolves when the region runs — after its own
274
+ # requires. A preamble alias for it would resolve at boot.
275
+ next if LazyRegions.contains?(@lazy_regions, node)
227
276
 
228
277
  mark_chain_prefixes(node, counted_prefix_ids)
229
278
  prefix = external_prefix_for(node)
@@ -58,8 +58,13 @@ module Ryac
58
58
  # minifier's own process, which over-approximates: everything the
59
59
  # minifier has loaded — including, under self-hosting, the analyzed
60
60
  # program itself — then looks like a reopening.
61
- def initialize(boot_roots: nil)
61
+ #
62
+ # alias_surface: which renames the alias block restores — :full, every
63
+ # one, or :skeleton, classes and modules only. See
64
+ # generate_alias_declarations.
65
+ def initialize(boot_roots: nil, alias_surface: :full)
62
66
  @boot_roots = boot_roots
67
+ @alias_surface = alias_surface
63
68
  @mappings = {} # Hash<Array<Symbol>, ConstantInfo> - key is static_cpath
64
69
  # keyed by the last path segment
65
70
  @by_name = {}
@@ -205,8 +210,17 @@ module Ryac
205
210
  # Generate backward-compatible alias declarations for renamed constants.
206
211
  # Returns array of strings like "OriginalName=ShortName" or
207
212
  # "ShortParent::OriginalName=ShortParent::ShortName" for nested constants.
213
+ #
214
+ # With the :full surface every rename stays restorable: nothing says
215
+ # which original names code outside the analyzed world spells. A program
216
+ # that dynamically loads files at runtime had enumerated its external
217
+ # readers, and bundling them as lazy regions brought every one inside;
218
+ # what remains outside is a launcher, and a launcher spells the
219
+ # class/module skeleton (`Optcarrot::NES.new.run`), never a value
220
+ # constant. That is the :skeleton surface.
208
221
  def generate_alias_declarations
209
222
  renamed = @mappings.values.select(&:short_name).sort_by { |info| [info.full_path.size, info.full_path] }
223
+ renamed.reject! { |info| info.definition_type == :value } if @alias_surface == :skeleton
210
224
  renamed.filter_map { |info| build_alias_declaration(info) }
211
225
  end
212
226
 
@@ -23,16 +23,42 @@ module Ryac
23
23
  case node
24
24
  when Prism::InstanceVariableReadNode, *IVAR_WRITE_NODES
25
25
  # @type var node: Prism::InstanceVariableReadNode | ivar_write_node
26
- next if attr_backed[cpath]&.include?(node.name)
26
+ next if attr_backed_ivar?(attr_backed, cpath, node.name)
27
27
  @ivar_rename_mapping.add_site(cpath, node.name, node)
28
28
  end
29
29
  end
30
30
  end
31
31
 
32
+ # An attr declared anywhere up the chain backs the ivar in every
33
+ # descendant: a subclass's `@palette = ...` fills the slot the
34
+ # ancestor's `attr_reader :palette` reads. Those sites belong to the
35
+ # attr coordination (which follows the declaring ancestor), not to plain
36
+ # ivar renaming — which would give the subclass its own name for the
37
+ # slot and leave the reader looking at nothing.
38
+ def attr_backed_ivar?(attr_backed, cpath, name)
39
+ backed = false
40
+ @oracle.each_ancestor_cpath(cpath, false) do |ancestor_cpath|
41
+ backed ||= attr_backed.fetch(ancestor_cpath, nil)&.include?(name) || false
42
+ end
43
+ backed || (attr_backed.fetch(cpath, nil)&.include?(name) || false)
44
+ end
45
+
46
+ # The attr-backed ivar names per class, closed upward through the
47
+ # program's own classes: an attr declared in D backs the slot in every
48
+ # ancestor of D the program defines, since a write there (`@label_text =
49
+ # ...` in a base class) fills what D's reader reads. Object and the rest
50
+ # of the built-in chain write nothing and are not closed over — they
51
+ # would make every attr back every class's ivar of that name.
32
52
  def collect_attr_backed_ivars(prism_root)
53
+ defined = Set.new #: Set[Array[Symbol]]
54
+ each_constant_event(prism_root) { |kind, _node, cpath, _singleton, _in_def| defined << cpath if kind == :class_def && cpath }
55
+
33
56
  result = Hash.new { |h, k| h[k] = Set.new } #: Hash[Array[Symbol], Set[Symbol]]
34
57
  each_attr_declaration(prism_root, ATTR_DECLARATION_METHODS, require_class_body: false) do |_node, cpath, _singleton, sym|
35
58
  result[cpath] << :"@#{sym}"
59
+ @oracle.each_ancestor_cpath(cpath, false) do |ancestor_cpath|
60
+ result[ancestor_cpath] << :"@#{sym}" if defined.include?(ancestor_cpath)
61
+ end
36
62
  end
37
63
  result
38
64
  end
@@ -151,7 +177,7 @@ module Ryac
151
177
  used_ivar_names = @ivar_rename_mapping.node_mapping.values.to_set
152
178
  used_ivar_names.merge(path_a_mapping.values)
153
179
  used_method_names = rename_map.values.to_set
154
- scope_vars = MethodRenameMapping.build_scope_vars(@scope_mappings)
180
+ scope_vars = @scope_visible_names
155
181
  generator = NameGenerator.new(prefix: "@")
156
182
 
157
183
  path_b_info
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ # A file the program loads at runtime through a dynamic require
5
+ # (optcarrot's `require_relative "driver/#{name}_#{type}"`) is bundled by
6
+ # the Concatenator as a registration:
7
+ #
8
+ # RYAC_LAZY["optcarrot/driver/sdl2_video"] = -> { <the file> }
9
+ #
10
+ # The lambda is the file's schedule and nothing more: its body runs when
11
+ # the original require would have, so a `require "ffi"` or `ffi_lib
12
+ # "SDL2"` at the file's top level keeps its optional-dependency timing.
13
+ # For analysis the body is ordinary code in the closed world — one rename
14
+ # table across the core and the files that subclass it.
15
+ #
16
+ # Two things about a region are special. TypeProf does not register a
17
+ # class defined inside a block, so it is handed a view of the source with
18
+ # the wrapper bytes blanked (typeprof_view): every byte of the body keeps
19
+ # its position, and the body reads as the top-level code it becomes when
20
+ # it runs. And a region does not exist at boot: a constant it alone
21
+ # defines cannot be aliased at the end of the file, and an external
22
+ # constant it references cannot be hoisted into the preamble.
23
+ #
24
+ # Regions are recognized by shape — a top-level `Const["key"] = -> { ... }`
25
+ # statement with a parameterless lambda — not by the registry's name,
26
+ # which the aliaser renames like any other constant. Reading a program's
27
+ # own statement of that shape as a region is safe: every consequence keeps
28
+ # more names, never fewer.
29
+ module LazyRegions
30
+ module_function
31
+
32
+ # The lambdas of every registration, in source order.
33
+ def collect(prism_root)
34
+ lambdas = [] #: Array[Prism::LambdaNode]
35
+ each_registration(prism_root) { |_statement, lambda_node| lambdas << lambda_node }
36
+ lambdas
37
+ end
38
+
39
+ def contains?(lambdas, node)
40
+ offset = node.location.start_offset
41
+ lambdas.any? { |lam| offset > lam.opening_loc.start_offset && offset < lam.closing_loc.start_offset }
42
+ end
43
+
44
+ # The source as TypeProf must read it: each registration's wrapper —
45
+ # `["key"] = -> {` after the registry constant, and the closing `}` —
46
+ # replaced by spaces, newlines kept, so the body sits at top level at
47
+ # its original byte positions. The registry constant stays, as its own
48
+ # statement: it is a reference the aliaser renames like any other, and
49
+ # it has to be renamed at the registration too.
50
+ def typeprof_view(content, prism_root)
51
+ bytes = content.b
52
+ each_registration(prism_root) do |statement, lambda_node|
53
+ # statement.receiver is the registry constant (registration_lambda)
54
+ from = statement.receiver.location.end_offset # steep:ignore NoMethod
55
+ blank(bytes, from, lambda_node.opening_loc.end_offset)
56
+ bytes.setbyte(from, 0x3b)
57
+ blank(bytes, lambda_node.closing_loc.start_offset, lambda_node.closing_loc.end_offset)
58
+ end
59
+ bytes.force_encoding(content.encoding)
60
+ end
61
+
62
+ def each_registration(prism_root)
63
+ prism_root.statements.body.each do |statement|
64
+ lambda_node = registration_lambda(statement)
65
+ yield statement, lambda_node if lambda_node
66
+ end
67
+ end
68
+
69
+ def registration_lambda(statement)
70
+ return nil unless statement.is_a?(Prism::CallNode) && statement.name == :[]=
71
+ return nil unless statement.receiver.is_a?(Prism::ConstantReadNode)
72
+
73
+ args = statement.arguments&.arguments
74
+ return nil unless args && args.size == 2
75
+
76
+ key, value = args
77
+ return nil unless key.is_a?(Prism::StringNode) && value.is_a?(Prism::LambdaNode)
78
+ return nil if value.parameters
79
+
80
+ value
81
+ end
82
+
83
+ def blank(bytes, from, to)
84
+ (from...to).each { |i| bytes.setbyte(i, 0x20) unless bytes.getbyte(i) == 0x0a }
85
+ end
86
+ end
87
+ end