storm-lua-minify 0.9.0 → 0.9.1

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.
package/README.md CHANGED
@@ -86,28 +86,28 @@ storm-lua-minify --config storm-lua-minify.json --no-function-inlining script.lu
86
86
 
87
87
  ### 個別スイッチ
88
88
 
89
- | スイッチ | 既定 | 説明 |
90
- | -------------------------------------- | ------- | ------------------------------------------------- |
91
- | `local-renaming` | ON | local名を短くします |
92
- | `local-name-reuse` | ON | 生存期間が重ならないlocalで同じ名前を再利用します |
93
- | `global-renaming` | **OFF** | 内部で使うglobal名を短くします(※1) |
94
- | `field-renaming` | ON | 安全に変更できるfield名を短くします |
95
- | `global-aliasing` | ON | 繰り返し参照するglobalへ短いlocal名を割り当てます |
96
- | `local-declaration-merging` | ON | 連続するlocal宣言をまとめます |
97
- | `local-declaration-hoisting` | ON | local宣言を移動してまとめやすくします |
98
- | `table-read-merging` | ON | tableを読み取るlocal宣言をまとめます |
99
- | `field-sensitive-table-effects` | ON | tableへの書き込みの影響をfield単位で判定します |
100
- | `constant-expression-evaluation` | **OFF** | 定数だけからなる式を事前に計算します |
101
- | `local-constant-propagation` | **OFF** | 再代入されないlocalの定数を参照先へ伝えます |
102
- | `interprocedural-constant-propagation` | **OFF** | 関数の呼び出しを越えて定数を伝えます |
103
- | `parameter-pruning` | ON | 使われない関数parameterを取り除きます |
104
- | `function-inlining` | ON | 関数呼び出しを関数本体で置き換えます |
105
- | `function-specialization` | ON | 呼び出し方に合わせて関数を特殊化します |
106
- | `field-value-propagation` | ON | 安定したfieldの値を参照先へ伝えます |
107
- | `unused-local-removal` | ON | 未使用のlocalを取り除きます |
108
- | `unused-function-removal` | ON | 未使用のlocal関数を取り除きます |
109
- | `unused-field-initializer-removal` | ON | 読み取られないfieldの初期化を取り除きます |
110
- | `unused-export-removal` | ON | entryから到達できないmodule exportを取り除きます |
89
+ | スイッチ | 既定 | 説明 |
90
+ | -------------------------------------- | ------- | --------------------------------------------------------------------------- |
91
+ | `local-renaming` | ON | local名を短くします |
92
+ | `local-name-reuse` | ON | 生存期間が重ならないlocalで同じ名前を再利用します |
93
+ | `global-renaming` | **OFF** | 内部で使うglobal名を短くします(※1) |
94
+ | `field-renaming` | ON | 安全に変更できるfield名を短くします |
95
+ | `global-aliasing` | ON | プログラム内で代入されず、繰り返し参照するglobalへ短いlocal名を割り当てます |
96
+ | `local-declaration-merging` | ON | 連続するlocal宣言をまとめます |
97
+ | `local-declaration-hoisting` | ON | local宣言を移動してまとめやすくします |
98
+ | `table-read-merging` | ON | tableを読み取るlocal宣言をまとめます |
99
+ | `field-sensitive-table-effects` | ON | tableへの書き込みの影響をfield単位で判定します |
100
+ | `constant-expression-evaluation` | **OFF** | 定数だけからなる式を事前に計算します |
101
+ | `local-constant-propagation` | **OFF** | 再代入されないlocalの定数を参照先へ伝えます |
102
+ | `interprocedural-constant-propagation` | **OFF** | 関数の呼び出しを越えて定数を伝えます |
103
+ | `parameter-pruning` | ON | 使われない関数parameterを取り除きます |
104
+ | `function-inlining` | ON | 関数呼び出しを関数本体で置き換えます |
105
+ | `function-specialization` | ON | 呼び出し方に合わせて関数を特殊化します |
106
+ | `field-value-propagation` | ON | 安定したfieldの値を参照先へ伝えます |
107
+ | `unused-local-removal` | ON | 未使用のlocalを取り除きます |
108
+ | `unused-function-removal` | ON | 未使用のlocal関数を取り除きます |
109
+ | `unused-field-initializer-removal` | ON | 読み取られないfieldの初期化を取り除きます |
110
+ | `unused-export-removal` | ON | entryから到達できないmodule exportを取り除きます |
111
111
 
112
112
  安全性の条件を満たさない候補は、スイッチが有効でも実行されません。
113
113
 
@@ -10,7 +10,7 @@ function addBooleanSwitch(command, name, description, shortName) {
10
10
  /** Build the v1 CLI surface without materializing inherited defaults. */
11
11
  function createCliProgram() {
12
12
  const command = new commander_1.Command()
13
- .version("0.9.0")
13
+ .version("0.9.1")
14
14
  .description("A Lua minifier also outputs source map")
15
15
  .option("--config <path>", "JSON configuration file")
16
16
  .addOption(new commander_1.Option("--runtime-profile <profile>", "Target runtime semantics")
@@ -1,16 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectProgramWrittenGlobals = collectProgramWrittenGlobals;
3
4
  exports.classifyAndRenameGlobals = classifyAndRenameGlobals;
4
5
  const renamer_1 = require("./renamer");
5
6
  const linker_1 = require("./linker");
7
+ /**
8
+ * Returns names whose shared global binding is written by any linked module.
9
+ * A module that only reads one of these names must not mistake it for a
10
+ * read-only value supplied by the runtime environment.
11
+ */
12
+ function collectProgramWrittenGlobals(moduleResolve) {
13
+ const written = new Set();
14
+ moduleResolve.forEach((resolved) => {
15
+ resolved.globals.forEach((binding) => {
16
+ if (binding.writes.length > 0)
17
+ written.add(binding.name);
18
+ });
19
+ });
20
+ return written;
21
+ }
6
22
  function classifyAndRenameGlobals(moduleResolve, neverRename, reserved) {
7
- const everWritten = new Set();
23
+ const everWritten = collectProgramWrittenGlobals(moduleResolve);
8
24
  const totalReferenceCount = new Map();
9
25
  moduleResolve.forEach((resolved) => {
10
26
  resolved.globals.forEach((binding) => {
11
- if (binding.writes.length > 0) {
12
- everWritten.add(binding.name);
13
- }
14
27
  totalReferenceCount.set(binding.name, (totalReferenceCount.get(binding.name) ?? 0) +
15
28
  binding.references.length);
16
29
  });
package/dist/minifier.js CHANGED
@@ -35,6 +35,41 @@ const NO_RENAME = {
35
35
  nameOf: () => undefined,
36
36
  usedNames: new Set(),
37
37
  };
38
+ class CostVariantProgress {
39
+ target;
40
+ pendingSteps = 0;
41
+ constructor(target) {
42
+ this.target = target;
43
+ }
44
+ addSteps(count) {
45
+ this.pendingSteps += count;
46
+ this.target.addSteps(count);
47
+ }
48
+ startStep(label) {
49
+ if (this.pendingSteps > 0)
50
+ this.pendingSteps--;
51
+ this.target.startStep(label);
52
+ }
53
+ tick() {
54
+ this.target.tick();
55
+ }
56
+ finishFailedCandidate() {
57
+ while (this.pendingSteps > 0) {
58
+ this.pendingSteps--;
59
+ this.target.startStep("Skip remaining failed cost-gate work");
60
+ }
61
+ }
62
+ }
63
+ // Remove consumers before producers. A producer is therefore measured while
64
+ // every downstream transformation that may expose its savings is still active.
65
+ const COST_GATE_ABLATION_ORDER = [
66
+ "fieldRename",
67
+ "scheduler",
68
+ "exportDce",
69
+ "fieldFact",
70
+ "aggregateSpecialization",
71
+ "functionRewrite",
72
+ ];
38
73
  function sourceRangeOf(node) {
39
74
  return node.range;
40
75
  }
@@ -69,13 +104,16 @@ class Minifier {
69
104
  exportDceVariant;
70
105
  fieldRenameVariant;
71
106
  progress;
107
+ // Transactional variants mutate independent AST copies, but their source
108
+ // bytes and initial parse are identical. Keep one untouched parse template
109
+ // for the whole variant tree and clone it at each link boundary.
110
+ inputCache;
72
111
  linkedAstGeneration = 0;
73
112
  wholeProgramObjectsValue;
74
113
  wholeProgramFieldsValue;
75
114
  wholeProgramExportsValue;
76
115
  wholeProgramFieldRenamesValue;
77
- exportDceChanged = false;
78
- constructor(entryFilePath, luaParseSettings, mode, schedulerVariantOrProgress, functionRewriteVariant, fieldFactVariant, aggregateSpecializationVariant, exportDceVariant, fieldRenameVariant, progress) {
116
+ constructor(entryFilePath, luaParseSettings, mode, schedulerVariantOrProgress, functionRewriteVariant, fieldFactVariant, aggregateSpecializationVariant, exportDceVariant, fieldRenameVariant, progress, inputCache) {
79
117
  this.schedulerVariant =
80
118
  typeof schedulerVariantOrProgress === "string"
81
119
  ? schedulerVariantOrProgress
@@ -89,6 +127,7 @@ class Minifier {
89
127
  typeof schedulerVariantOrProgress === "object"
90
128
  ? schedulerVariantOrProgress
91
129
  : progress;
130
+ this.inputCache = inputCache ?? new Map();
92
131
  this.entryFilePath = entryFilePath;
93
132
  this.identifiersInUse = new Set();
94
133
  this.moduleSourceText = new Map();
@@ -126,206 +165,176 @@ class Minifier {
126
165
  return this.wholeProgramFieldRenamesValue;
127
166
  }
128
167
  parse() {
129
- if (this.fieldRenameVariant === undefined && this.fieldRenamesEnabled()) {
130
- return this.parseWithFieldRenameSelection();
131
- }
132
- if (this.exportDceVariant === undefined && this.exportDceEnabled()) {
133
- return this.parseWithExportDceSelection();
134
- }
135
- if (this.fieldFactVariant === undefined && this.fieldFactsEnabled()) {
136
- return this.parseWithFieldFactSelection();
137
- }
138
- if (this.aggregateSpecializationVariant === undefined &&
139
- this.functionSpecializationEnabled()) {
140
- return this.parseWithAggregateSpecializationSelection();
141
- }
142
- if (this.functionRewriteVariant === undefined &&
143
- this.functionRewritesEnabled()) {
144
- return this.parseWithFunctionRewriteSelection();
145
- }
146
- if (this.schedulerVariant === undefined &&
147
- this.requiresSchedulerSelection()) {
148
- return this.parseWithSchedulerSelection();
149
- }
168
+ if (this.costGatesToSelect().length > 0)
169
+ return this.parseWithCostGateSelection();
150
170
  return this.parseOnce();
151
171
  }
152
- parseWithFieldRenameSelection() {
153
- this.progress?.addSteps(2);
154
- this.progress?.startStep("Evaluate field renaming baseline");
155
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, undefined, undefined, undefined, "baseline", this.progress);
156
- const baseline = baselineMinifier.parse();
157
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
158
- let trialMinifier;
159
- let trial;
172
+ /**
173
+ * Evaluate cost-gated transforms as one coalition, then remove a gate only
174
+ * when its absence makes the complete final output strictly shorter.
175
+ *
176
+ * An arbitrary final-byte cost function still requires 2^N evaluations to
177
+ * find its global minimum. This deterministic backward sweep deliberately
178
+ * chooses a narrower contract: it keeps interactions present in the full
179
+ * trial and finds the best point on one monotonic deletion path in at most
180
+ * N+2 evaluations. The all-off baseline remains the final upper bound.
181
+ */
182
+ parseWithCostGateSelection() {
183
+ const gates = this.costGatesToSelect();
184
+ const evaluated = new Map();
185
+ const baseline = this.evaluateCostVariant(this.costGateVariants("baseline"), "Evaluate cost-gated baseline", evaluated);
186
+ let fullTrial;
160
187
  try {
161
- this.progress?.startStep("Evaluate field renaming trial");
162
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, undefined, undefined, undefined, "trial", this.progress);
163
- trial = trialMinifier.parse();
188
+ fullTrial = this.evaluateCostVariant(this.costGateVariants("trial"), "Evaluate joint cost-gated trial", evaluated);
164
189
  }
165
190
  catch {
166
- this.copyDiagnosticsFrom(baselineMinifier);
167
- this.recordFinalFieldRenameDecision("rejected", "trial-failed");
168
- return this.adoptVariant(baselineMinifier, baseline);
191
+ this.copyDiagnosticsFrom(baseline.minifier);
192
+ gates.forEach((gate) => {
193
+ this.recordFinalGateDecision(gate, "rejected", "trial-failed");
194
+ });
195
+ this.recordFinalCostDecision("rejected", "trial-failed");
196
+ return this.adoptVariant(baseline.minifier, baseline.output);
169
197
  }
170
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
171
- if (trialBytes >= baselineBytes) {
172
- this.copyDiagnosticsFrom(trialMinifier);
173
- this.recordFinalFieldRenameDecision("rejected", "final-output-not-shorter");
174
- return this.adoptVariant(baselineMinifier, baseline);
198
+ let current = fullTrial;
199
+ for (const gate of COST_GATE_ABLATION_ORDER) {
200
+ if (!gates.includes(gate) || current.variants[gate] === "baseline")
201
+ continue;
202
+ const variants = {
203
+ ...current.variants,
204
+ [gate]: "baseline",
205
+ // Specialization is executed inside the function-rewrite pipeline;
206
+ // keep the mask and final diagnostics closed over that dependency.
207
+ ...(gate === "functionRewrite"
208
+ ? { aggregateSpecialization: "baseline" }
209
+ : {}),
210
+ };
211
+ let withoutGate;
212
+ try {
213
+ withoutGate = this.evaluateCostVariant(variants, `Evaluate joint trial without ${this.costGateLabel(gate)}`, evaluated);
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ // Keep ties in the joint trial: a gate whose isolated contribution is
219
+ // hidden can still participate in a later transformation's savings.
220
+ if (withoutGate.byteLength < current.byteLength)
221
+ current = withoutGate;
175
222
  }
176
- this.copyDiagnosticsFrom(trialMinifier);
177
- this.recordFinalFieldRenameDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
178
- return this.adoptVariant(trialMinifier, trial);
223
+ const accepted = current.byteLength < baseline.byteLength;
224
+ const selected = accepted ? current : baseline;
225
+ // Successful trial diagnostics describe attempted opportunities even when
226
+ // the corresponding AST is not selected, matching the former gate policy.
227
+ this.copyDiagnosticsFrom(fullTrial.minifier);
228
+ gates.forEach((gate) => {
229
+ this.recordFinalGateDecision(gate, accepted && current.variants[gate] === "trial"
230
+ ? "accepted"
231
+ : "rejected", accepted && current.variants[gate] === "trial"
232
+ ? "final-output-shorter"
233
+ : "final-output-not-shorter");
234
+ });
235
+ this.recordFinalCostDecision(accepted ? "accepted" : "rejected", accepted ? "final-output-shorter" : "final-output-not-shorter", accepted ? baseline.byteLength - current.byteLength : undefined);
236
+ return this.adoptVariant(selected.minifier, selected.output);
179
237
  }
180
- parseWithExportDceSelection() {
238
+ evaluateCostVariant(variants, label, evaluated) {
239
+ const key = COST_GATE_ABLATION_ORDER.map((gate) => variants[gate] === "trial" ? "1" : "0").join("");
240
+ const cached = evaluated.get(key);
241
+ if (cached)
242
+ return cached;
181
243
  this.progress?.addSteps(1);
182
- let trialMinifier;
183
- let trial;
244
+ this.progress?.startStep(label);
245
+ const candidateProgress = this.progress
246
+ ? new CostVariantProgress(this.progress)
247
+ : undefined;
248
+ const minifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, variants.scheduler, variants.functionRewrite, variants.fieldFact, variants.aggregateSpecialization, variants.exportDce, variants.fieldRename, candidateProgress, this.inputCache);
249
+ let output;
184
250
  try {
185
- this.progress?.startStep("Evaluate unused export removal trial");
186
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, undefined, undefined, "trial", this.fieldRenameVariant, this.progress);
187
- trial = trialMinifier.parse();
251
+ output = minifier.parseOnce();
188
252
  }
189
- catch {
190
- this.progress?.addSteps(1);
191
- this.progress?.startStep("Evaluate unused export removal baseline");
192
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, undefined, undefined, "baseline", this.fieldRenameVariant, this.progress);
193
- const baseline = baselineMinifier.parse();
194
- this.copyDiagnosticsFrom(baselineMinifier);
195
- this.recordFinalExportDceDecision("rejected", "trial-failed");
196
- return this.adoptVariant(baselineMinifier, baseline);
197
- }
198
- if (!trialMinifier.exportDceChanged) {
199
- this.copyDiagnosticsFrom(trialMinifier);
200
- this.recordFinalExportDceDecision("rejected", "final-output-not-shorter");
201
- return this.adoptVariant(trialMinifier, trial);
202
- }
203
- this.progress?.addSteps(1);
204
- this.progress?.startStep("Evaluate unused export removal baseline");
205
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, undefined, undefined, "baseline", this.fieldRenameVariant, this.progress);
206
- const baseline = baselineMinifier.parse();
207
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
208
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
209
- if (trialBytes >= baselineBytes) {
210
- this.copyDiagnosticsFrom(trialMinifier);
211
- this.recordFinalExportDceDecision("rejected", "final-output-not-shorter");
212
- return this.adoptVariant(baselineMinifier, baseline);
253
+ catch (error) {
254
+ candidateProgress?.finishFailedCandidate();
255
+ throw error;
213
256
  }
214
- this.copyDiagnosticsFrom(trialMinifier);
215
- this.recordFinalExportDceDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
216
- return this.adoptVariant(trialMinifier, trial);
257
+ const result = {
258
+ minifier,
259
+ output,
260
+ byteLength: new TextEncoder().encode(output.toString()).length,
261
+ variants,
262
+ };
263
+ evaluated.set(key, result);
264
+ return result;
217
265
  }
218
- parseWithFieldFactSelection() {
219
- this.progress?.addSteps(2);
220
- this.progress?.startStep("Evaluate field optimization baseline");
221
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, "baseline", this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
222
- const baseline = baselineMinifier.parse();
223
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
224
- let trialMinifier;
225
- let trial;
226
- try {
227
- this.progress?.startStep("Evaluate field optimization trial");
228
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, "trial", this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
229
- trial = trialMinifier.parse();
230
- }
231
- catch {
232
- this.copyDiagnosticsFrom(baselineMinifier);
233
- this.recordFinalFieldFactDecision("rejected", "trial-failed");
234
- return this.adoptVariant(baselineMinifier, baseline);
235
- }
236
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
237
- if (trialBytes >= baselineBytes) {
238
- this.copyDiagnosticsFrom(trialMinifier);
239
- this.recordFinalFieldFactDecision("rejected", "final-output-not-shorter");
240
- return this.adoptVariant(baselineMinifier, baseline);
241
- }
242
- this.copyDiagnosticsFrom(trialMinifier);
243
- this.recordFinalFieldFactDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
244
- return this.adoptVariant(trialMinifier, trial);
266
+ costGateVariants(fallback) {
267
+ const selected = new Set(this.costGatesToSelect());
268
+ const variant = (gate, explicit) => explicit ?? (selected.has(gate) ? fallback : "baseline");
269
+ const functionRewrite = variant("functionRewrite", this.functionRewriteVariant);
270
+ return {
271
+ fieldRename: variant("fieldRename", this.fieldRenameVariant),
272
+ scheduler: variant("scheduler", this.schedulerVariant),
273
+ exportDce: variant("exportDce", this.exportDceVariant),
274
+ fieldFact: variant("fieldFact", this.fieldFactVariant),
275
+ aggregateSpecialization: functionRewrite === "baseline"
276
+ ? "baseline"
277
+ : variant("aggregateSpecialization", this.aggregateSpecializationVariant),
278
+ functionRewrite,
279
+ };
245
280
  }
246
- parseWithAggregateSpecializationSelection() {
247
- this.progress?.addSteps(2);
248
- this.progress?.startStep("Evaluate function specialization baseline");
249
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, this.fieldFactVariant, "baseline", this.exportDceVariant, this.fieldRenameVariant, this.progress);
250
- const baseline = baselineMinifier.parse();
251
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
252
- let trialMinifier;
253
- let trial;
254
- try {
255
- this.progress?.startStep("Evaluate function specialization trial");
256
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, undefined, this.fieldFactVariant, "trial", this.exportDceVariant, this.fieldRenameVariant, this.progress);
257
- trial = trialMinifier.parse();
258
- }
259
- catch {
260
- this.copyDiagnosticsFrom(baselineMinifier);
261
- this.recordFinalAggregateSpecializationDecision("rejected", "trial-failed");
262
- return this.adoptVariant(baselineMinifier, baseline);
263
- }
264
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
265
- if (trialBytes >= baselineBytes) {
266
- this.copyDiagnosticsFrom(trialMinifier);
267
- this.recordFinalAggregateSpecializationDecision("rejected", "final-output-not-shorter");
268
- return this.adoptVariant(baselineMinifier, baseline);
269
- }
270
- this.copyDiagnosticsFrom(trialMinifier);
271
- this.recordFinalAggregateSpecializationDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
272
- return this.adoptVariant(trialMinifier, trial);
281
+ costGatesToSelect() {
282
+ return COST_GATE_ABLATION_ORDER.filter((gate) => {
283
+ switch (gate) {
284
+ case "fieldRename":
285
+ return (this.fieldRenameVariant === undefined && this.fieldRenamesEnabled());
286
+ case "scheduler":
287
+ return (this.schedulerVariant === undefined &&
288
+ this.requiresSchedulerSelection());
289
+ case "exportDce":
290
+ return this.exportDceVariant === undefined && this.exportDceEnabled();
291
+ case "fieldFact":
292
+ return (this.fieldFactVariant === undefined && this.fieldFactsEnabled());
293
+ case "aggregateSpecialization":
294
+ return (this.aggregateSpecializationVariant === undefined &&
295
+ this.functionSpecializationEnabled());
296
+ case "functionRewrite":
297
+ return (this.functionRewriteVariant === undefined &&
298
+ this.functionRewritesEnabled());
299
+ }
300
+ });
273
301
  }
274
- parseWithFunctionRewriteSelection() {
275
- this.progress?.addSteps(2);
276
- this.progress?.startStep("Evaluate function rewrites baseline");
277
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, "baseline", this.fieldFactVariant, this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
278
- const baseline = baselineMinifier.parse();
279
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
280
- let trialMinifier;
281
- let trial;
282
- try {
283
- this.progress?.startStep("Evaluate function rewrites trial");
284
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, undefined, "trial", this.fieldFactVariant, this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
285
- trial = trialMinifier.parse();
302
+ costGateLabel(gate) {
303
+ switch (gate) {
304
+ case "fieldRename":
305
+ return "field renaming";
306
+ case "scheduler":
307
+ return "statement scheduling";
308
+ case "exportDce":
309
+ return "unused export removal";
310
+ case "fieldFact":
311
+ return "field optimization";
312
+ case "aggregateSpecialization":
313
+ return "function specialization";
314
+ case "functionRewrite":
315
+ return "function rewrites";
286
316
  }
287
- catch {
288
- this.copyDiagnosticsFrom(baselineMinifier);
289
- this.recordFinalFunctionRewriteDecision("rejected", "trial-failed");
290
- return this.adoptVariant(baselineMinifier, baseline);
291
- }
292
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
293
- if (trialBytes >= baselineBytes) {
294
- this.copyDiagnosticsFrom(trialMinifier);
295
- this.recordFinalFunctionRewriteDecision("rejected", "final-output-not-shorter");
296
- return this.adoptVariant(baselineMinifier, baseline);
297
- }
298
- this.copyDiagnosticsFrom(trialMinifier);
299
- this.recordFinalFunctionRewriteDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
300
- return this.adoptVariant(trialMinifier, trial);
301
317
  }
302
- parseWithSchedulerSelection() {
303
- this.progress?.addSteps(2);
304
- this.progress?.startStep("Evaluate statement scheduling baseline");
305
- const baselineMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, "baseline", this.functionRewriteVariant, this.fieldFactVariant, this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
306
- const baseline = baselineMinifier.parseOnce();
307
- const baselineBytes = new TextEncoder().encode(baseline.toString()).length;
308
- let trialMinifier;
309
- let trial;
310
- try {
311
- this.progress?.startStep("Evaluate statement scheduling trial");
312
- trialMinifier = new Minifier(this.entryFilePath, this.luaParseSettings, this.mode, "trial", this.functionRewriteVariant, this.fieldFactVariant, this.aggregateSpecializationVariant, this.exportDceVariant, this.fieldRenameVariant, this.progress);
313
- trial = trialMinifier.parseOnce();
314
- }
315
- catch {
316
- this.copyDiagnosticsFrom(baselineMinifier);
317
- this.recordFinalSchedulerDecision("rejected", "trial-failed");
318
- return this.adoptVariant(baselineMinifier, baseline);
319
- }
320
- const trialBytes = new TextEncoder().encode(trial.toString()).length;
321
- if (trialBytes >= baselineBytes) {
322
- this.copyDiagnosticsFrom(trialMinifier);
323
- this.recordFinalSchedulerDecision("rejected", "final-output-not-shorter");
324
- return this.adoptVariant(baselineMinifier, baseline);
318
+ recordFinalGateDecision(gate, decision, reason) {
319
+ switch (gate) {
320
+ case "fieldRename":
321
+ this.recordFinalFieldRenameDecision(decision, reason);
322
+ return;
323
+ case "scheduler":
324
+ this.recordFinalSchedulerDecision(decision, reason);
325
+ return;
326
+ case "exportDce":
327
+ this.recordFinalExportDceDecision(decision, reason);
328
+ return;
329
+ case "fieldFact":
330
+ this.recordFinalFieldFactDecision(decision, reason);
331
+ return;
332
+ case "aggregateSpecialization":
333
+ this.recordFinalAggregateSpecializationDecision(decision, reason);
334
+ return;
335
+ case "functionRewrite":
336
+ this.recordFinalFunctionRewriteDecision(decision, reason);
325
337
  }
326
- this.copyDiagnosticsFrom(trialMinifier);
327
- this.recordFinalSchedulerDecision("accepted", "final-output-shorter", baselineBytes - trialBytes);
328
- return this.adoptVariant(trialMinifier, trial);
329
338
  }
330
339
  parseOnce() {
331
340
  this.progress?.addSteps(11);
@@ -427,7 +436,6 @@ class Minifier {
427
436
  }));
428
437
  if (!result.changed)
429
438
  return;
430
- this.exportDceChanged = true;
431
439
  this.linkOrder.forEach((moduleName) => {
432
440
  const ast = this.moduleAST.get(moduleName);
433
441
  if (!ast)
@@ -898,7 +906,6 @@ class Minifier {
898
906
  this.wholeProgramFieldsValue = minifier.wholeProgramFieldsValue;
899
907
  this.wholeProgramExportsValue = minifier.wholeProgramExportsValue;
900
908
  this.wholeProgramFieldRenamesValue = minifier.wholeProgramFieldRenamesValue;
901
- this.exportDceChanged = minifier.exportDceChanged;
902
909
  return output;
903
910
  }
904
911
  recordFinalSchedulerDecision(decision, reason, byteSavings) {
@@ -913,6 +920,18 @@ class Minifier {
913
920
  sourceRange: [0, fs_1.default.readFileSync(this.entryFilePath, "utf8").length],
914
921
  });
915
922
  }
923
+ recordFinalCostDecision(decision, reason, byteSavings) {
924
+ this.diagnosticCollector?.record({
925
+ pass: "optimizer-final-cost",
926
+ decision,
927
+ reason,
928
+ candidateSize: 1,
929
+ estimatedByteSavings: byteSavings,
930
+ runtimeProfile: this.mode.runtimeProfile,
931
+ moduleName: this.entryModule,
932
+ sourceRange: [0, fs_1.default.readFileSync(this.entryFilePath, "utf8").length],
933
+ });
934
+ }
916
935
  recordFinalFunctionRewriteDecision(decision, reason, byteSavings) {
917
936
  this.diagnosticCollector?.record({
918
937
  pass: "function-rewrite-final-cost",
@@ -1084,10 +1103,16 @@ class Minifier {
1084
1103
  * なり)、意味が壊れる(要修正が発覚した実例)。
1085
1104
  */
1086
1105
  transformAll() {
1106
+ // Global bindings are shared across linked modules. A name written in one
1107
+ // module is therefore program-owned even when another module only reads it;
1108
+ // treating that reader as an external-global alias candidate can capture
1109
+ // the value before the defining module runs (#102).
1110
+ const programWrittenGlobals = (0, globalRename_1.collectProgramWrittenGlobals)(this.moduleResolve);
1087
1111
  // globalRenames.keys()は8aが実際にリネームした(=代入もされていた)名前のみ。
1088
1112
  // neverRenameGlobalsは代入されていない名前にも及ぶ保護指定なので、8bのエイリアス化
1089
1113
  // が誤ってそれらを書き換えてしまわないよう、必ず両方をあわせてexcludeNamesに渡す。
1090
1114
  const excludeGlobalNames = new Set([
1115
+ ...programWrittenGlobals,
1091
1116
  ...this.globalRenames.keys(),
1092
1117
  ...(this.mode.neverRenameGlobals ?? []),
1093
1118
  ...this.annotationProtectedGlobals(),
@@ -1363,11 +1388,22 @@ class Minifier {
1363
1388
  visiting.add(moduleName);
1364
1389
  stack.push(moduleName);
1365
1390
  const fullResolvePath = path_1.default.join(this.dir, ...moduleName.split(".")) + ".lua";
1366
- if (!fs_1.default.existsSync(fullResolvePath)) {
1367
- throw new Error(moduleName + " is not found");
1391
+ let input = this.inputCache.get(fullResolvePath);
1392
+ if (!input) {
1393
+ if (!fs_1.default.existsSync(fullResolvePath)) {
1394
+ throw new Error(moduleName + " is not found");
1395
+ }
1396
+ const sourceText = fs_1.default.readFileSync(fullResolvePath).toString();
1397
+ const ast = luaparse_1.default.parse(sourceText, this.luaParseSettings);
1398
+ input = {
1399
+ sourceText,
1400
+ ast,
1401
+ references: (0, linker_1.findModuleReferences)(ast),
1402
+ };
1403
+ this.inputCache.set(fullResolvePath, input);
1368
1404
  }
1369
- const code = fs_1.default.readFileSync(fullResolvePath).toString();
1370
- const ast = luaparse_1.default.parse(code, this.luaParseSettings);
1405
+ const code = input.sourceText;
1406
+ const ast = structuredClone(input.ast);
1371
1407
  this.moduleMetadata.set(moduleName, new sourceMetadata_1.SourceMetadata(ast, code));
1372
1408
  // Resolveパス(#19): このモジュールのスコープ/シンボルを解析し、Renameパスの
1373
1409
  // 入力として使い回せるようキャッシュする。グローバル参照はプログラム全体で
@@ -1379,7 +1415,7 @@ class Minifier {
1379
1415
  // Source Mapの`sources`はURLとして解釈されるため、OS依存のpath.sepではなく
1380
1416
  // 常に"/"区切りで保持する(Windows上でのビルドでも壊れないように)。
1381
1417
  this.moduleNameAndFileName.set(moduleName, moduleName.replaceAll(".", "/") + ".lua");
1382
- (0, linker_1.findModuleReferences)(ast).forEach((ref) => {
1418
+ input.references.forEach((ref) => {
1383
1419
  visit(ref.moduleName);
1384
1420
  });
1385
1421
  visiting.delete(moduleName);
@@ -22,16 +22,16 @@ exports.EMPTY_OPTIMIZER_TUPLE = Object.freeze({
22
22
  tail: Object.freeze({ kind: "none" }),
23
23
  });
24
24
  function finiteOptimizerValue(atoms = [], unknownReasons = [], limits = exports.DEFAULT_OPTIMIZER_VALUE_DOMAIN_LIMITS) {
25
- validateLimits(limits);
25
+ validateCustomLimits(limits);
26
26
  const normalizedAtoms = uniqueSorted(atoms, atomKey);
27
27
  const normalizedReasons = uniqueSorted(unknownReasons, (reason) => reason);
28
28
  const atomOverflow = normalizedAtoms.length > limits.maxAtoms;
29
29
  const reasons = atomOverflow
30
- ? [...normalizedReasons, "atom-cap-exceeded"]
31
- : normalizedReasons;
30
+ ? capReasons([...normalizedReasons, "atom-cap-exceeded"], limits.maxUnknownReasons)
31
+ : capNormalizedReasons(normalizedReasons, limits.maxUnknownReasons);
32
32
  return Object.freeze({
33
33
  atoms: Object.freeze(normalizedAtoms.slice(0, limits.maxAtoms)),
34
- unknownReasons: Object.freeze(capReasons(reasons, limits.maxUnknownReasons)),
34
+ unknownReasons: Object.freeze(reasons),
35
35
  });
36
36
  }
37
37
  function unknownOptimizerValue(reason, limits = exports.DEFAULT_OPTIMIZER_VALUE_DOMAIN_LIMITS) {
@@ -41,7 +41,7 @@ function joinOptimizerValues(values, limits = exports.DEFAULT_OPTIMIZER_VALUE_DO
41
41
  return finiteOptimizerValue(values.flatMap((value) => value.atoms), values.flatMap((value) => value.unknownReasons), limits);
42
42
  }
43
43
  function finiteOptimizerTuple(prefix, tail = { kind: "none" }, limits = exports.DEFAULT_OPTIMIZER_VALUE_DOMAIN_LIMITS) {
44
- validateLimits(limits);
44
+ validateCustomLimits(limits);
45
45
  const normalizedPrefix = prefix
46
46
  .slice(0, limits.maxTuplePrefix)
47
47
  .map((value) => finiteOptimizerValue(value.atoms, value.unknownReasons, limits));
@@ -75,7 +75,7 @@ function valueAtOptimizerTupleSlot(tuple, index, limits = exports.DEFAULT_OPTIMI
75
75
  }
76
76
  }
77
77
  function joinOptimizerTuples(tuples, limits = exports.DEFAULT_OPTIMIZER_VALUE_DOMAIN_LIMITS) {
78
- validateLimits(limits);
78
+ validateCustomLimits(limits);
79
79
  if (tuples.length === 0)
80
80
  return exports.EMPTY_OPTIMIZER_TUPLE;
81
81
  const prefixLength = Math.min(Math.max(...tuples.map((tuple) => tuple.prefix.length)), limits.maxTuplePrefix);
@@ -151,6 +151,17 @@ function atomKey(atom) {
151
151
  }
152
152
  }
153
153
  function uniqueSorted(values, keyOf) {
154
+ if (values.length === 0)
155
+ return [];
156
+ if (values.length === 1)
157
+ return [values[0]];
158
+ if (values.length === 2) {
159
+ const firstKey = keyOf(values[0]);
160
+ const lastKey = keyOf(values[1]);
161
+ if (firstKey === lastKey)
162
+ return [values[1]];
163
+ return firstKey < lastKey ? [values[0], values[1]] : [values[1], values[0]];
164
+ }
154
165
  const byKey = new Map();
155
166
  values.forEach((value) => byKey.set(keyOf(value), value));
156
167
  return [...byKey.entries()]
@@ -159,6 +170,9 @@ function uniqueSorted(values, keyOf) {
159
170
  }
160
171
  function capReasons(reasons, maximum) {
161
172
  const normalized = uniqueSorted(reasons, (reason) => reason);
173
+ return capNormalizedReasons(normalized, maximum);
174
+ }
175
+ function capNormalizedReasons(normalized, maximum) {
162
176
  if (normalized.length <= maximum)
163
177
  return normalized;
164
178
  if (maximum === 0)
@@ -168,6 +182,12 @@ function capReasons(reasons, maximum) {
168
182
  "reason-cap-exceeded",
169
183
  ];
170
184
  }
185
+ function validateCustomLimits(limits) {
186
+ // The default is a module-owned frozen literal that satisfies the invariants.
187
+ // Nearly every optimizer value uses it, so do not enumerate its fields again.
188
+ if (limits !== exports.DEFAULT_OPTIMIZER_VALUE_DOMAIN_LIMITS)
189
+ validateLimits(limits);
190
+ }
171
191
  function validateLimits(limits) {
172
192
  Object.entries(limits).forEach(([name, value]) => {
173
193
  if (!Number.isInteger(value) || value < 0)
package/dist/renamer.js CHANGED
@@ -124,9 +124,44 @@ function buildVariableInterference(chunk, resolved, symbols, allowLocalNameReuse
124
124
  addEdge(graph, symbol, other);
125
125
  });
126
126
  });
127
+ // Liveness alone does not model Lua's lexical shadowing. An earlier local
128
+ // may be dead at a later declaration and then be assigned again; if both
129
+ // declarations receive one spelling, the later declaration captures that
130
+ // assignment and every following reference. Preserve reuse when all uses of
131
+ // the earlier binding precede the later declaration, but add an edge when
132
+ // any use follows it.
133
+ const declarationOrder = new Map(symbols.map((symbol) => [
134
+ symbol,
135
+ requireResolutionOrder(resolved, symbol.declaration),
136
+ ]));
137
+ const lastReferenceOrder = new Map(symbols.map((symbol) => [
138
+ symbol,
139
+ symbol.references.reduce((last, reference) => Math.max(last, requireResolutionOrder(resolved, reference)), -1),
140
+ ]));
141
+ for (let left = 0; left < symbols.length; left++) {
142
+ for (let right = left + 1; right < symbols.length; right++) {
143
+ const first = symbols[left];
144
+ const last = symbols[right];
145
+ if (first.scope !== last.scope)
146
+ continue;
147
+ const firstDeclarationOrder = declarationOrder.get(first) ?? -1;
148
+ const lastDeclarationOrder = declarationOrder.get(last) ?? -1;
149
+ const [earlier, laterDeclarationOrder] = firstDeclarationOrder < lastDeclarationOrder
150
+ ? [first, lastDeclarationOrder]
151
+ : [last, firstDeclarationOrder];
152
+ if ((lastReferenceOrder.get(earlier) ?? -1) > laterDeclarationOrder)
153
+ addEdge(graph, first, last);
154
+ }
155
+ }
127
156
  addLexicalEdges(graph, symbols, allowLocalNameReuse);
128
157
  return graph;
129
158
  }
159
+ function requireResolutionOrder(resolved, identifier) {
160
+ const order = resolved.resolutionOrderOf(identifier);
161
+ if (order === undefined)
162
+ throw new Error("Resolved identifier has no resolution order");
163
+ return order;
164
+ }
130
165
  function buildLexicalGraph(symbols, allowSameScopeReuse) {
131
166
  const graph = mutableGraph(symbols);
132
167
  addLexicalEdges(graph, symbols, allowSameScopeReuse);
@@ -170,35 +205,42 @@ function addEdge(graph, first, last) {
170
205
  /** Deterministic weighted DSATUR coloring. */
171
206
  function colorGraph(graph) {
172
207
  const colors = new Map();
208
+ // Keep the colored-neighbor set incrementally. Recomputing it inside the
209
+ // sort comparator makes dense module graphs dominate the whole pipeline.
210
+ const saturationColors = new Map([...graph.keys()].map((symbol) => [symbol, new Set()]));
173
211
  while (colors.size < graph.size) {
174
- const remaining = [...graph.keys()].filter((symbol) => !colors.has(symbol));
175
- remaining.sort((left, right) => {
176
- const saturationDifference = saturation(graph, colors, right) - saturation(graph, colors, left);
177
- if (saturationDifference !== 0)
178
- return saturationDifference;
179
- const weightDifference = weightOf(right) - weightOf(left);
180
- if (weightDifference !== 0)
181
- return weightDifference;
182
- const degreeDifference = (graph.get(right)?.size ?? 0) - (graph.get(left)?.size ?? 0);
183
- return degreeDifference !== 0 ? degreeDifference : left.id - right.id;
212
+ let symbol;
213
+ graph.forEach((_neighbors, candidate) => {
214
+ if (colors.has(candidate))
215
+ return;
216
+ if (symbol === undefined ||
217
+ coloringPriority(candidate, symbol, graph, saturationColors) < 0)
218
+ symbol = candidate;
184
219
  });
185
- const symbol = remaining[0];
186
- const unavailable = new Set([...(graph.get(symbol) ?? [])].flatMap((neighbor) => {
187
- const color = colors.get(neighbor);
188
- return color === undefined ? [] : [color];
189
- }));
220
+ if (symbol === undefined)
221
+ throw new Error("Uncolored symbol not found");
222
+ const unavailable = saturationColors.get(symbol) ?? new Set();
190
223
  let color = 0;
191
224
  while (unavailable.has(color))
192
225
  color++;
193
226
  colors.set(symbol, color);
227
+ graph.get(symbol)?.forEach((neighbor) => {
228
+ if (!colors.has(neighbor))
229
+ saturationColors.get(neighbor)?.add(color);
230
+ });
194
231
  }
195
232
  return colors;
196
233
  }
197
- function saturation(graph, colors, symbol) {
198
- return new Set([...(graph.get(symbol) ?? [])].flatMap((neighbor) => {
199
- const color = colors.get(neighbor);
200
- return color === undefined ? [] : [color];
201
- })).size;
234
+ function coloringPriority(left, right, graph, saturationColors) {
235
+ const saturationDifference = (saturationColors.get(right)?.size ?? 0) -
236
+ (saturationColors.get(left)?.size ?? 0);
237
+ if (saturationDifference !== 0)
238
+ return saturationDifference;
239
+ const weightDifference = weightOf(right) - weightOf(left);
240
+ if (weightDifference !== 0)
241
+ return weightDifference;
242
+ const degreeDifference = (graph.get(right)?.size ?? 0) - (graph.get(left)?.size ?? 0);
243
+ return degreeDifference !== 0 ? degreeDifference : left.id - right.id;
202
244
  }
203
245
  function weightOf(symbol) {
204
246
  return symbol.references.length + 1;
@@ -257,8 +299,17 @@ function validateBindings(chunk, original, names, globalRenames) {
257
299
  return;
258
300
  }
259
301
  [symbol.declaration, ...symbol.references].forEach((identifier) => {
260
- if (recolored.symbolOf(identifier)?.declaration !== symbol.declaration)
261
- throw new Error(`Identifier coloring changed binding for symbol ${String(symbol.id)}`);
302
+ const rebound = recolored.symbolOf(identifier);
303
+ if (rebound?.declaration !== symbol.declaration) {
304
+ const reboundOriginal = rebound
305
+ ? original.symbolOf(rebound.declaration)
306
+ : undefined;
307
+ throw new Error(`Identifier coloring changed binding for symbol ${String(symbol.id)} ` +
308
+ `(${symbol.name} -> ${names.get(symbol) ?? symbol.name}, ` +
309
+ `declaration ${formatIdentifierLocation(symbol.declaration)}, ` +
310
+ `reference ${formatIdentifierLocation(identifier)}, ` +
311
+ `rebound to ${reboundOriginal ? `symbol ${String(reboundOriginal.id)} (${reboundOriginal.name} -> ${names.get(reboundOriginal) ?? reboundOriginal.name}) at ${formatIdentifierLocation(reboundOriginal.declaration)}` : "global"})`);
312
+ }
262
313
  });
263
314
  });
264
315
  original.globals.forEach((binding) => {
@@ -268,3 +319,9 @@ function validateBindings(chunk, original, names, globalRenames) {
268
319
  });
269
320
  });
270
321
  }
322
+ function formatIdentifierLocation(identifier) {
323
+ const location = identifier.loc?.start;
324
+ return location
325
+ ? `${String(location.line)}:${String(location.column)}`
326
+ : "unknown";
327
+ }
package/dist/resolver.js CHANGED
@@ -6,6 +6,8 @@ function resolveScopes(chunk, options = {}) {
6
6
  const allSymbols = [];
7
7
  const globals = new Map();
8
8
  const identifierSymbols = new WeakMap();
9
+ const identifierResolutionOrders = new WeakMap();
10
+ let nextResolutionOrder = 0;
9
11
  const globalReferenceNodes = new WeakSet();
10
12
  const functionScopes = new WeakMap();
11
13
  const identifierName = options.identifierName ??
@@ -23,6 +25,7 @@ function resolveScopes(chunk, options = {}) {
23
25
  return scope;
24
26
  }
25
27
  function declare(scope, node, kind, implicit = false) {
28
+ identifierResolutionOrders.set(node, nextResolutionOrder++);
26
29
  const name = identifierName(node);
27
30
  const symbol = {
28
31
  id: nextSymbolId++,
@@ -41,6 +44,7 @@ function resolveScopes(chunk, options = {}) {
41
44
  return symbol;
42
45
  }
43
46
  function declareLabel(scope, node) {
47
+ identifierResolutionOrders.set(node, nextResolutionOrder++);
44
48
  const name = identifierName(node);
45
49
  const symbol = {
46
50
  id: nextSymbolId++,
@@ -75,6 +79,7 @@ function resolveScopes(chunk, options = {}) {
75
79
  return undefined;
76
80
  }
77
81
  function reference(scope, node, isWrite = false) {
82
+ identifierResolutionOrders.set(node, nextResolutionOrder++);
78
83
  const name = identifierName(node);
79
84
  const symbol = lookupBinding(scope, name);
80
85
  if (symbol) {
@@ -195,6 +200,7 @@ function resolveScopes(chunk, options = {}) {
195
200
  // hoistLabelsで宣言済みのため、ここでは何もしない
196
201
  return;
197
202
  case "GotoStatement": {
203
+ identifierResolutionOrders.set(statement.label, nextResolutionOrder++);
198
204
  const symbol = lookupLabel(scope, identifierName(statement.label));
199
205
  if (symbol) {
200
206
  symbol.references.push(statement.label);
@@ -319,6 +325,7 @@ function resolveScopes(chunk, options = {}) {
319
325
  chunkScope,
320
326
  symbols: allSymbols,
321
327
  globals,
328
+ resolutionOrderOf: (identifier) => identifierResolutionOrders.get(identifier),
322
329
  symbolOf: (identifier) => identifierSymbols.get(identifier),
323
330
  isGlobalReference: (identifier) => globalReferenceNodes.has(identifier),
324
331
  scopeOfFunction: (fn) => functionScopes.get(fn),
@@ -140,12 +140,15 @@ function applyStatementSchedule(schedule, metadata) {
140
140
  const actions = [
141
141
  ...schedule.localGroups.map((group) => ({ kind: "local", group })),
142
142
  ...schedule.tableGroups.map((group) => ({ kind: "table", group })),
143
- ].sort((left, right) => {
144
- if (left.group.body !== right.group.body)
145
- return 0;
146
- return right.group.indexes[0] - left.group.indexes[0];
147
- });
143
+ ];
144
+ const actionsByBody = new Map();
148
145
  actions.forEach((action) => {
146
+ const bodyActions = actionsByBody.get(action.group.body) ?? [];
147
+ bodyActions.push(action);
148
+ actionsByBody.set(action.group.body, bodyActions);
149
+ });
150
+ const orderedActions = [...actionsByBody.values()].flatMap((bodyActions) => bodyActions.sort((left, right) => right.group.indexes[0] - left.group.indexes[0]));
151
+ orderedActions.forEach((action) => {
149
152
  if (action.kind === "table") {
150
153
  const group = action.group;
151
154
  const combined = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "storm-lua-minify",
3
3
  "description": "A Lua minifier also outputs source map",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "engines": {
6
6
  "node": ">=22"
7
7
  },