optipack 0.0.0-stage → 1.0.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/BENCHMARKING.md +127 -0
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/bin/optipack.js +12 -0
- package/example/manifest.json +27 -0
- package/example/scripts/drops.js +24 -0
- package/example/scripts/main.js +67 -0
- package/example/scripts/util.js +30 -0
- package/package.json +34 -4
- package/src/bench.js +231 -0
- package/src/cli.js +245 -0
- package/src/config.js +141 -0
- package/src/diff.js +108 -0
- package/src/harness/driver.js +216 -0
- package/src/harness/loader-hooks.mjs +63 -0
- package/src/harness/mock/@minecraft/common.mjs +17 -0
- package/src/harness/mock/@minecraft/debug-utilities.mjs +10 -0
- package/src/harness/mock/@minecraft/diagnostics.mjs +14 -0
- package/src/harness/mock/@minecraft/server-admin.mjs +14 -0
- package/src/harness/mock/@minecraft/server-editor.mjs +39 -0
- package/src/harness/mock/@minecraft/server-extras.mjs +538 -0
- package/src/harness/mock/@minecraft/server-gametest.mjs +22 -0
- package/src/harness/mock/@minecraft/server-graphics.mjs +21 -0
- package/src/harness/mock/@minecraft/server-net.mjs +15 -0
- package/src/harness/mock/@minecraft/server-ui.mjs +125 -0
- package/src/harness/mock/@minecraft/server.mjs +394 -0
- package/src/harness/mock/permissive.mjs +71 -0
- package/src/hotpath.js +158 -0
- package/src/lint.js +150 -0
- package/src/pack-walker.js +50 -0
- package/src/pass-utils.js +419 -0
- package/src/passes/b1-cache-getter-chains.js +406 -0
- package/src/passes/b2-closure-lift.js +316 -0
- package/src/passes/b3-string-build.js +167 -0
- package/src/passes/b4-dynprop-hoist.js +177 -0
- package/src/passes/b5-null-chain-flatten.js +137 -0
- package/src/passes/index.js +43 -0
- package/src/passes/r1-cross-tick-cache.js +192 -0
- package/src/passes/r2-json-fold.js +201 -0
- package/src/passes/r3-try-catch-strip.js +61 -0
- package/src/passes/r4-getter-cache-cross-barrier.js +29 -0
- package/src/passes/s1-const-fold.js +130 -0
- package/src/passes/s2-dead-code.js +183 -0
- package/src/passes/s3-bool-simplify.js +82 -0
- package/src/passes/s4-for-of-to-indexed.js +140 -0
- package/src/passes/s5-array-methods-to-loops.js +357 -0
- package/src/passes/s6-destructure-to-direct.js +186 -0
- package/src/passes/s7-hoist-invariant-members.js +327 -0
- package/src/pipeline.js +186 -0
- package/src/pragma.js +115 -0
- package/src/report.js +98 -0
- package/test/array-methods-to-loops/expected.js +58 -0
- package/test/array-methods-to-loops/expects.json +6 -0
- package/test/array-methods-to-loops/input.js +23 -0
- package/test/array-methods-to-loops/scenario.json +22 -0
- package/test/array-methods-to-loops-bail/expected.js +10 -0
- package/test/array-methods-to-loops-bail/input.js +24 -0
- package/test/bool-simplify/expected.js +14 -0
- package/test/bool-simplify/expects.json +11 -0
- package/test/bool-simplify/input.js +18 -0
- package/test/cache-getter-chains/expected.js +20 -0
- package/test/cache-getter-chains/expects.json +11 -0
- package/test/cache-getter-chains/input.js +20 -0
- package/test/cache-getter-chains/scenario.json +35 -0
- package/test/closure-lift/expected.js +58 -0
- package/test/closure-lift/expects.json +1 -0
- package/test/closure-lift/input.js +50 -0
- package/test/closure-lift/scenario.json +9 -0
- package/test/const-fold/expected.js +12 -0
- package/test/const-fold/expects.json +1 -0
- package/test/const-fold/input.js +12 -0
- package/test/const-fold-bail/expected.js +7 -0
- package/test/const-fold-bail/expects.json +3 -0
- package/test/const-fold-bail/input.js +7 -0
- package/test/cross-tick-cache/config.json +1 -0
- package/test/cross-tick-cache/expected.js +38 -0
- package/test/cross-tick-cache/expects.json +6 -0
- package/test/cross-tick-cache/input.js +13 -0
- package/test/cross-tick-cache/scenario.json +33 -0
- package/test/dead-code/expected.js +14 -0
- package/test/dead-code/expects.json +5 -0
- package/test/dead-code/input.js +23 -0
- package/test/destructure-to-direct/expected.js +37 -0
- package/test/destructure-to-direct/expects.json +11 -0
- package/test/destructure-to-direct/input.js +28 -0
- package/test/destructure-to-direct/scenario.json +22 -0
- package/test/destructure-to-direct-bail/expected.js +36 -0
- package/test/destructure-to-direct-bail/expects.json +11 -0
- package/test/destructure-to-direct-bail/input.js +25 -0
- package/test/differential.test.mjs +267 -0
- package/test/dynprop-hoist/expected.js +24 -0
- package/test/dynprop-hoist/expects.json +11 -0
- package/test/dynprop-hoist/input.js +25 -0
- package/test/dynprop-hoist/scenario.json +24 -0
- package/test/example-report-snapshot.json +107 -0
- package/test/example.test.mjs +115 -0
- package/test/fetch-corpus.mjs +92 -0
- package/test/for-of-to-indexed/expected.js +59 -0
- package/test/for-of-to-indexed/expects.json +11 -0
- package/test/for-of-to-indexed/input.js +43 -0
- package/test/for-of-to-indexed-bail/expected.js +25 -0
- package/test/for-of-to-indexed-bail/expects.json +11 -0
- package/test/for-of-to-indexed-bail/input.js +29 -0
- package/test/gen-expected.mjs +33 -0
- package/test/getter-cache-cross-barrier/config.json +1 -0
- package/test/getter-cache-cross-barrier/expected.js +10 -0
- package/test/getter-cache-cross-barrier/expects.json +6 -0
- package/test/getter-cache-cross-barrier/input.js +11 -0
- package/test/getter-cache-cross-barrier/scenario.json +28 -0
- package/test/harness/fixture.js +117 -0
- package/test/hoist-invariant-members/expected.js +37 -0
- package/test/hoist-invariant-members/expects.json +11 -0
- package/test/hoist-invariant-members/input.js +34 -0
- package/test/hoist-invariant-members/scenario.json +35 -0
- package/test/hoist-invariant-members-bail/expected.js +19 -0
- package/test/hoist-invariant-members-bail/expects.json +5 -0
- package/test/hoist-invariant-members-bail/input.js +22 -0
- package/test/infra.test.mjs +270 -0
- package/test/json-fold/config.json +1 -0
- package/test/json-fold/expected.js +14 -0
- package/test/json-fold/expects.json +12 -0
- package/test/json-fold/input.js +17 -0
- package/test/json-fold-bail/config.json +1 -0
- package/test/json-fold-bail/expected.js +10 -0
- package/test/json-fold-bail/expects.json +5 -0
- package/test/json-fold-bail/input.js +14 -0
- package/test/null-chain-flatten/expected.js +22 -0
- package/test/null-chain-flatten/expects.json +5 -0
- package/test/null-chain-flatten/input.js +22 -0
- package/test/null-chain-flatten/scenario.json +24 -0
- package/test/server-ui-forms/expected.js +15 -0
- package/test/server-ui-forms/expects.json +1 -0
- package/test/server-ui-forms/input.js +25 -0
- package/test/server-ui-forms/scenario.json +1 -0
- package/test/string-build/expected.js +29 -0
- package/test/string-build/expects.json +5 -0
- package/test/string-build/input.js +29 -0
- package/test/string-build/scenario.json +27 -0
- package/test/string-build-bail/expected.js +10 -0
- package/test/string-build-bail/input.js +21 -0
- package/test/transforms.test.mjs +43 -0
- package/test/try-catch-strip/config.json +1 -0
- package/test/try-catch-strip/expected.js +57 -0
- package/test/try-catch-strip/expects.json +1 -0
- package/test/try-catch-strip/input.js +34 -0
- package/test/try-catch-strip/scenario.json +28 -0
- package/test/update-example-snapshot.mjs +19 -0
- package/test/update-mock-stubs.mjs +91 -0
package/src/bench.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `optipack bench` — A/B benchmark of a pack's original vs optimized scripts
|
|
3
|
+
* under the harness mock.
|
|
4
|
+
*
|
|
5
|
+
* Measures, for identical simulated scenarios:
|
|
6
|
+
* - host-boundary crossings by kind (the metric optipack exists to reduce:
|
|
7
|
+
* location reads, getComponent, getDynamicProperty, getEntities, ...).
|
|
8
|
+
* Engine-independent: a crossing is a crossing in V8 or QuickJS.
|
|
9
|
+
* - wall-clock time (INDICATIVE ONLY: Node is V8 with a JIT, Minecraft is
|
|
10
|
+
* QuickJS without one — use the in-game `/script profiler` workflow in
|
|
11
|
+
* BENCHMARKING.md for authoritative numbers).
|
|
12
|
+
* - effect-trace equality (a bench between non-equivalent builds is invalid
|
|
13
|
+
* and is flagged).
|
|
14
|
+
*
|
|
15
|
+
* Determinism: a virtual clock replaces Date during variant execution and
|
|
16
|
+
* advances a fixed 50ms per tick, so time-gated script logic sees identical
|
|
17
|
+
* clocks on both sides regardless of how fast each variant actually runs.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import { performance } from 'node:perf_hooks';
|
|
23
|
+
|
|
24
|
+
import { makeRecorder, runVariant, assertSameEffects, pathToUrl, ensureHooks } from './harness/driver.js';
|
|
25
|
+
import { runPack } from './pipeline.js';
|
|
26
|
+
import { normalizeConfig } from './config.js';
|
|
27
|
+
|
|
28
|
+
const TICK_MS = 50; // 20 TPS
|
|
29
|
+
|
|
30
|
+
function installVirtualClock() {
|
|
31
|
+
const RealDate = Date;
|
|
32
|
+
let now = RealDate.now();
|
|
33
|
+
class VirtualDate extends RealDate {
|
|
34
|
+
constructor(...args) {
|
|
35
|
+
if (args.length === 0) super(now);
|
|
36
|
+
else super(...args);
|
|
37
|
+
}
|
|
38
|
+
static now() {
|
|
39
|
+
return now;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
globalThis.Date = VirtualDate;
|
|
43
|
+
return {
|
|
44
|
+
advance(ms) { now += ms; },
|
|
45
|
+
restore() { globalThis.Date = RealDate; },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run one variant end to end and collect measurements.
|
|
51
|
+
*/
|
|
52
|
+
async function measureVariant(entryAbsPath, variant, scenario, ticks) {
|
|
53
|
+
const clock = installVirtualClock();
|
|
54
|
+
const rec = makeRecorder({ reads: scenario.reads, state: scenario.state });
|
|
55
|
+
globalThis.__opkRecorder = rec;
|
|
56
|
+
const t = { importMs: 0, ticksMs: 0 };
|
|
57
|
+
try {
|
|
58
|
+
const importStart = performance.now();
|
|
59
|
+
await import(`${pathToUrl(entryAbsPath)}?opkv=${variant}&t=${Date.now()}-${Math.floor(performance.now() * 1000)}`);
|
|
60
|
+
t.importMs = performance.now() - importStart;
|
|
61
|
+
|
|
62
|
+
const steps = scenario.steps || [];
|
|
63
|
+
const tickStart = performance.now();
|
|
64
|
+
for (const step of steps) {
|
|
65
|
+
if (step.op === 'tick') {
|
|
66
|
+
for (let i = 0; i < step.n; i++) {
|
|
67
|
+
rec.tick(1);
|
|
68
|
+
clock.advance(TICK_MS);
|
|
69
|
+
}
|
|
70
|
+
} else if (step.op === 'fire') {
|
|
71
|
+
rec.fire(step.event, step.payload);
|
|
72
|
+
} else if (step.op === 'fireMany') {
|
|
73
|
+
for (let i = 0; i < (step.count || 1); i++) rec.fire(step.event, step.payload);
|
|
74
|
+
} else if (step.op === 'call') {
|
|
75
|
+
step.fn(rec);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ticks > 0) {
|
|
79
|
+
for (let i = 0; i < ticks; i++) {
|
|
80
|
+
rec.tick(1);
|
|
81
|
+
clock.advance(TICK_MS);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
t.ticksMs = performance.now() - tickStart;
|
|
85
|
+
} finally {
|
|
86
|
+
globalThis.__opkRecorder = null;
|
|
87
|
+
clock.restore();
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
counters: rec.counters,
|
|
91
|
+
effects: rec.effects,
|
|
92
|
+
wall: t,
|
|
93
|
+
totalTicks: rec.tickCounter,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compareCounters(a, b) {
|
|
98
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
99
|
+
return keys.map((k) => {
|
|
100
|
+
const av = a[k] || 0;
|
|
101
|
+
const bv = b[k] || 0;
|
|
102
|
+
const delta = bv - av;
|
|
103
|
+
const pct = av === 0 ? (bv === 0 ? 0 : 100) : Math.round((delta / av) * 100);
|
|
104
|
+
return { kind: k, original: av, optimized: bv, delta, pct };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Full bench: build the optimized pack, measure both variants, report.
|
|
110
|
+
*/
|
|
111
|
+
export async function benchPack(inputDirRaw, options = {}) {
|
|
112
|
+
const inputDir = path.resolve(inputDirRaw);
|
|
113
|
+
const {
|
|
114
|
+
ticks = 200,
|
|
115
|
+
entry = null,
|
|
116
|
+
scenario: scenarioPath = null,
|
|
117
|
+
quiet = false,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
const configRaw = { ...(options.config || {}) };
|
|
121
|
+
if (options.level) configRaw.level = options.level;
|
|
122
|
+
const config = normalizeConfig(configRaw);
|
|
123
|
+
ensureHooks();
|
|
124
|
+
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-bench-'));
|
|
125
|
+
const report = runPack(inputDir, outputDir, config);
|
|
126
|
+
|
|
127
|
+
// Locate the entry: explicit, manifest, or first script with subscribers.
|
|
128
|
+
let entryRel = entry;
|
|
129
|
+
if (!entryRel) {
|
|
130
|
+
const manifestPath = path.join(inputDir, 'manifest.json');
|
|
131
|
+
if (fs.existsSync(manifestPath)) {
|
|
132
|
+
try {
|
|
133
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
134
|
+
const scriptModule = (manifest.modules || []).find((m) => m.type === 'script');
|
|
135
|
+
if (scriptModule && scriptModule.entry) entryRel = scriptModule.entry;
|
|
136
|
+
} catch { /* fall through */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!entryRel) {
|
|
140
|
+
const scripts = report.files.map((f) => f.path);
|
|
141
|
+
entryRel = scripts[0];
|
|
142
|
+
}
|
|
143
|
+
if (!entryRel) throw new Error('bench: pack has no script files');
|
|
144
|
+
if (!report.files.some((f) => f.path === entryRel)) {
|
|
145
|
+
throw new Error(`bench: entry "${entryRel}" is not among the pack's script files`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scenario = scenarioPath
|
|
149
|
+
? JSON.parse(fs.readFileSync(scenarioPath, 'utf8').replace(/^\uFEFF/, ''))
|
|
150
|
+
: { reads: {}, state: {}, steps: [] };
|
|
151
|
+
|
|
152
|
+
const entryOriginal = path.join(inputDir, entryRel);
|
|
153
|
+
const entryOptimized = path.join(outputDir, entryRel);
|
|
154
|
+
|
|
155
|
+
// 3 alternating rounds per variant; report the median tick-phase time. A
|
|
156
|
+
// single round is dominated by V8 JIT warm-up noise (Node is only indicative
|
|
157
|
+
// anyway — see BENCHMARKING.md for the in-game workflow).
|
|
158
|
+
const rounds = 3;
|
|
159
|
+
const run = (v) => measureVariant(v === 'orig' ? entryOriginal : entryOptimized, v, scenario, ticks);
|
|
160
|
+
const aRuns = [];
|
|
161
|
+
const bRuns = [];
|
|
162
|
+
for (let i = 0; i < rounds; i++) {
|
|
163
|
+
aRuns.push(await run('orig'));
|
|
164
|
+
bRuns.push(await run('opt'));
|
|
165
|
+
}
|
|
166
|
+
const a = aRuns[0];
|
|
167
|
+
const b = bRuns[0];
|
|
168
|
+
a.wall.ticksMs = median(aRuns.map((r) => r.wall.ticksMs));
|
|
169
|
+
b.wall.ticksMs = median(bRuns.map((r) => r.wall.ticksMs));
|
|
170
|
+
|
|
171
|
+
let identical = true;
|
|
172
|
+
try {
|
|
173
|
+
assertSameEffects(a, b, 'bench');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
identical = false;
|
|
176
|
+
if (!quiet) console.error(`optipack bench: WARNING — effect traces diverge, benchmark is not meaningful:\n${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const counters = compareCounters(a.counters, b.counters);
|
|
180
|
+
const totalCrossings = (v) => Object.values(v).reduce((x, y) => x + y, 0);
|
|
181
|
+
const result = {
|
|
182
|
+
pack: inputDir,
|
|
183
|
+
entry: entryRel,
|
|
184
|
+
ticks: a.totalTicks,
|
|
185
|
+
level: config.level,
|
|
186
|
+
effectsIdentical: identical,
|
|
187
|
+
boundary: counters,
|
|
188
|
+
boundaryTotals: { original: totalCrossings(a.counters), optimized: totalCrossings(b.counters) },
|
|
189
|
+
wall: {
|
|
190
|
+
original: a.wall,
|
|
191
|
+
optimized: b.wall,
|
|
192
|
+
note: 'Node/V8 numbers are indicative only; Minecraft runs QuickJS (no JIT).',
|
|
193
|
+
},
|
|
194
|
+
transforms: report.totals.transforms,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!quiet) printBench(result);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fmtPct(pct) {
|
|
202
|
+
const s = pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function median(values) {
|
|
207
|
+
const sorted = [...values].sort((x, y) => x - y);
|
|
208
|
+
const mid = Math.floor(sorted.length / 2);
|
|
209
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function printBench(result) {
|
|
213
|
+
const rows = result.boundary;
|
|
214
|
+
const width = Math.max(10, ...rows.map((r) => r.kind.length));
|
|
215
|
+
console.log(`optipack bench — ${result.pack} (entry ${result.entry}, ${result.ticks} ticks, level ${result.level})`);
|
|
216
|
+
if (!result.effectsIdentical) {
|
|
217
|
+
console.log(' !! effect traces DIVERGE — the two builds are not behavior-equivalent; treat numbers with suspicion');
|
|
218
|
+
}
|
|
219
|
+
console.log(` ${'boundary crossing'.padEnd(width)} original optimized delta`);
|
|
220
|
+
for (const r of rows) {
|
|
221
|
+
console.log(` ${r.kind.padEnd(width)} ${String(r.original).padStart(8)} ${String(r.optimized).padStart(9)} ${fmtPct(r.pct)}`);
|
|
222
|
+
}
|
|
223
|
+
const tot = result.boundaryTotals;
|
|
224
|
+
const totalPct = tot.original === 0 ? 0 : Math.round(((tot.optimized - tot.original) / tot.original) * 100);
|
|
225
|
+
console.log(` ${'TOTAL'.padEnd(width)} ${String(tot.original).padStart(8)} ${String(tot.optimized).padStart(9)} ${fmtPct(totalPct)}`);
|
|
226
|
+
const w = result.wall;
|
|
227
|
+
const usPerTick = (v) => (result.ticks > 0 ? Math.round((v.ticksMs / result.ticks) * 1000) : 0);
|
|
228
|
+
console.log(` transforms applied: ${result.transforms}`);
|
|
229
|
+
console.log(` wall (indicative, Node/V8): original ${w.original.ticksMs.toFixed(1)}ms (${usPerTick(w.original)}µs/tick)` +
|
|
230
|
+
`, optimized ${w.optimized.ticksMs.toFixed(1)}ms (${usPerTick(w.optimized)}µs/tick)`);
|
|
231
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* optipack CLI.
|
|
3
|
+
*
|
|
4
|
+
* optipack <inputPackDir> <outputPackDir> [options]
|
|
5
|
+
* optipack bench <inputPackDir> [options]
|
|
6
|
+
*
|
|
7
|
+
* Exit codes: 0 success · 1 usage/config error · 2 parse failure · 3 internal error
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { Command } from 'commander';
|
|
12
|
+
|
|
13
|
+
import { loadConfigFile, mergeCliOverrides, LEVELS } from './config.js';
|
|
14
|
+
import { runPack, ParseFailureError, InternalError } from './pipeline.js';
|
|
15
|
+
import { PASS_IDS, PASS_CODES } from './passes/index.js';
|
|
16
|
+
import { unifiedDiff } from './diff.js';
|
|
17
|
+
|
|
18
|
+
function parseBoolOpt(v) {
|
|
19
|
+
if (v === 'true' || v === '' || v === undefined) return true;
|
|
20
|
+
if (v === 'false') return false;
|
|
21
|
+
// Signal an invalid value; validated by the caller.
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseOptInt(v) {
|
|
26
|
+
const n = Number.parseInt(v, 10);
|
|
27
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runOptimize(inputDir, outputDir, opts) {
|
|
31
|
+
try {
|
|
32
|
+
const exit = await runOptimizeInner(inputDir, outputDir, opts);
|
|
33
|
+
process.exitCode = exit;
|
|
34
|
+
return exit;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
process.exitCode = 3;
|
|
37
|
+
console.error(`optipack: internal error — ${e && e.stack ? e.stack : String(e)}`);
|
|
38
|
+
return 3;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runOptimizeInner(inputDir, outputDir, opts) {
|
|
43
|
+
if (!inputDir || !outputDir) {
|
|
44
|
+
console.error('optipack: both <inputPackDir> and <outputPackDir> are required');
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
if (opts.level !== undefined && !LEVELS.includes(opts.level)) {
|
|
48
|
+
console.error(`optipack: invalid --level "${opts.level}" (expected one of ${LEVELS.join(', ')})`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
if (opts.lint !== undefined && typeof opts.lint !== 'boolean') {
|
|
52
|
+
console.error('optipack: --lint expects true or false');
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve config file: explicit path, else ./optipack.config.json.
|
|
57
|
+
let configPath = opts.config;
|
|
58
|
+
if (configPath && !fs.existsSync(configPath)) {
|
|
59
|
+
console.error(`optipack: config file not found: ${configPath}`);
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
let config;
|
|
63
|
+
try {
|
|
64
|
+
const loaded = loadConfigFile(configPath);
|
|
65
|
+
config = loaded.config;
|
|
66
|
+
configPath = loaded.path;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`optipack: ${e.message}`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cli = {
|
|
73
|
+
level: opts.level,
|
|
74
|
+
only: opts.only,
|
|
75
|
+
except: opts.except,
|
|
76
|
+
lint: opts.lint,
|
|
77
|
+
config: configPath,
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
config = mergeCliOverrides(config, cli, PASS_IDS, PASS_CODES);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error(`optipack: ${e.message}`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const dryRun = !!opts.dryRun;
|
|
87
|
+
const reportPath = opts.report !== undefined
|
|
88
|
+
? opts.report
|
|
89
|
+
: path.join(outputDir, 'optipack-report.json');
|
|
90
|
+
|
|
91
|
+
// Capture original sources for --diff.
|
|
92
|
+
const beforeSources = new Map();
|
|
93
|
+
if (opts.diff) {
|
|
94
|
+
const { walkPack } = await import('./pack-walker.js');
|
|
95
|
+
if (fs.existsSync(inputDir)) {
|
|
96
|
+
const { scripts } = walkPack(inputDir);
|
|
97
|
+
for (const s of scripts) beforeSources.set(s.relPath, fs.readFileSync(s.abs, 'utf8'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const report = runPack(inputDir, outputDir, config, { dryRun });
|
|
103
|
+
const json = report.serialize();
|
|
104
|
+
|
|
105
|
+
if (dryRun) {
|
|
106
|
+
if (opts.report !== undefined) {
|
|
107
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
108
|
+
fs.writeFileSync(reportPath, json);
|
|
109
|
+
console.log(`optipack: dry run — wrote report to ${reportPath} (no pack files written)`);
|
|
110
|
+
} else {
|
|
111
|
+
console.log(json);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
115
|
+
fs.writeFileSync(reportPath, json);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (opts.diff) {
|
|
119
|
+
const { walkPack } = await import('./pack-walker.js');
|
|
120
|
+
const { scripts } = walkPack(inputDir);
|
|
121
|
+
for (const s of scripts) {
|
|
122
|
+
const outAbs = path.join(outputDir, s.relPath);
|
|
123
|
+
if (!fs.existsSync(outAbs)) continue;
|
|
124
|
+
const after = fs.readFileSync(outAbs, 'utf8');
|
|
125
|
+
const before = beforeSources.get(s.relPath) ?? '';
|
|
126
|
+
const d = unifiedDiff(s.relPath, s.relPath, before, after);
|
|
127
|
+
if (d) process.stdout.write(d);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!dryRun || opts.report !== undefined) {
|
|
132
|
+
const t = report.totals;
|
|
133
|
+
console.log(
|
|
134
|
+
`optipack: ${report.files.length} script file(s), level ${report.level}, ` +
|
|
135
|
+
`${t.transforms} transform(s) applied, ${t.bails} bail(s), ${t.warnings} warning(s)`
|
|
136
|
+
);
|
|
137
|
+
console.log(`optipack: report written to ${reportPath}${dryRun ? ' (dry run: no pack files written)' : ''}`);
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e instanceof ParseFailureError) {
|
|
142
|
+
console.error(`optipack: parse failure — ${e.message}`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
if (e instanceof InternalError) {
|
|
146
|
+
console.error(`optipack: internal error — ${e.message}`);
|
|
147
|
+
return 3;
|
|
148
|
+
}
|
|
149
|
+
if (e && e.code === 1) {
|
|
150
|
+
console.error(`optipack: ${e.message}`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
console.error(`optipack: internal error — ${e && e.stack ? e.stack : String(e)}`);
|
|
154
|
+
return 3;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function runBench(packDir, opts) {
|
|
159
|
+
const run = async () => {
|
|
160
|
+
const { benchPack } = await import('./bench.js');
|
|
161
|
+
const result = await benchPack(packDir, {
|
|
162
|
+
ticks: opts.ticks,
|
|
163
|
+
entry: opts.entry,
|
|
164
|
+
scenario: opts.scenario,
|
|
165
|
+
config: opts.config && JSON.parse(fs.readFileSync(opts.config, 'utf8').replace(/^\uFEFF/, '')),
|
|
166
|
+
level: opts.level,
|
|
167
|
+
});
|
|
168
|
+
if (opts.json) {
|
|
169
|
+
fs.mkdirSync(path.dirname(opts.json), { recursive: true });
|
|
170
|
+
fs.writeFileSync(opts.json, JSON.stringify(result, null, 2) + '\n');
|
|
171
|
+
console.log(`optipack bench: JSON written to ${opts.json}`);
|
|
172
|
+
}
|
|
173
|
+
return result.effectsIdentical ? 0 : 1;
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const code = await run();
|
|
177
|
+
process.exitCode = code;
|
|
178
|
+
return code;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
const code = e instanceof ParseFailureError ? 2
|
|
181
|
+
: e instanceof InternalError ? 3
|
|
182
|
+
: 1;
|
|
183
|
+
console.error(`optipack bench: ${e instanceof ParseFailureError || e instanceof InternalError ? e.message : (e && e.message ? e.message : String(e))}`);
|
|
184
|
+
process.exitCode = code;
|
|
185
|
+
return code;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function main(argv) {
|
|
190
|
+
const program = new Command();
|
|
191
|
+
program
|
|
192
|
+
.name('optipack')
|
|
193
|
+
.description('QuickJS-targeted micro-optimizing pre-processor for Minecraft Bedrock behavior-pack scripts')
|
|
194
|
+
.version('1.0.0')
|
|
195
|
+
.exitOverride()
|
|
196
|
+
.configureOutput({ writeErr: () => {} }); // actions print errors themselves
|
|
197
|
+
|
|
198
|
+
program
|
|
199
|
+
.command('bench')
|
|
200
|
+
.description('A/B benchmark: original vs optimized scripts under the harness mock (boundary crossings + indicative wall time). See BENCHMARKING.md for the in-game workflow.')
|
|
201
|
+
.argument('<inputPackDir>', 'behavior pack directory to benchmark')
|
|
202
|
+
.option('--ticks <n>', 'simulated ticks to run (default 200)', parseOptInt)
|
|
203
|
+
.option('--entry <path>', 'script entry to load (default: manifest script entry)')
|
|
204
|
+
.option('--scenario <path>', 'scenario JSON (reads/state/steps) driving events between ticks')
|
|
205
|
+
.option('--json <path>', 'also write the bench result as JSON to this path')
|
|
206
|
+
.option('--level <level>', 'optimization level for the optimized build')
|
|
207
|
+
.option('--config <path>', 'config file for the optimized build')
|
|
208
|
+
.action(function (packDir, cmdOpts, command) {
|
|
209
|
+
// --level is declared on both this subcommand and the default command;
|
|
210
|
+
// Commander absorbs shadowed names into the parent scope, so merge.
|
|
211
|
+
return runBench(packDir, command.optsWithGlobals());
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
program
|
|
215
|
+
.argument('<inputPackDir>', 'behavior pack directory to read')
|
|
216
|
+
.argument('<outputPackDir>', 'directory to write the optimized pack into')
|
|
217
|
+
.option('--level <level>', 'optimization level: safe | balanced | risky')
|
|
218
|
+
.option('--config <path>', 'config file (default: ./optipack.config.json when present)')
|
|
219
|
+
.option('--only <ids>', 'comma-separated transform ids to run exclusively')
|
|
220
|
+
.option('--except <ids>', 'comma-separated transform ids to skip')
|
|
221
|
+
.option('--dry-run', 'analyze + report, write nothing (prints report to stdout unless --report is given)')
|
|
222
|
+
.option('--diff', 'print unified diffs per changed file')
|
|
223
|
+
.option('--report <path>', 'report file (default: <outputPackDir>/optipack-report.json)')
|
|
224
|
+
.option('--lint <bool>', 'enable/disable the lint pass (true|false)', parseBoolOpt)
|
|
225
|
+
.action((inputDir, outputDir) => runOptimize(inputDir, outputDir, program.opts()));
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await program.parseAsync(argv, { from: 'node' });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e && e.code === 'commander.version') {
|
|
231
|
+
console.log(e.message);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (e && (e.code === 'commander.help' || e.code === 'commander.helpDisplayed')) {
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
if (e && (e.code === 'commander.missingArgument' || e.code === 'commander.missingSubcommandArgument')) {
|
|
238
|
+
console.error(`optipack: ${e.message}`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
console.error(`optipack: ${e && e.message ? e.message : String(e)}`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
return process.exitCode ?? 0;
|
|
245
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const LEVELS = ['safe', 'balanced', 'risky'];
|
|
5
|
+
export const LEVEL_RANK = { safe: 0, balanced: 1, risky: 2 };
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = {
|
|
8
|
+
level: 'balanced',
|
|
9
|
+
transforms: {},
|
|
10
|
+
hotPath: { transitiveDepth: 1 },
|
|
11
|
+
lint: true,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ConfigError extends Error {}
|
|
15
|
+
|
|
16
|
+
function isPlainObject(v) {
|
|
17
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseLevel(v) {
|
|
21
|
+
if (typeof v !== 'string' || !LEVELS.includes(v)) {
|
|
22
|
+
throw new ConfigError(`invalid level "${String(v)}" (expected one of ${LEVELS.join(', ')})`);
|
|
23
|
+
}
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a raw parsed config object and normalize it onto the defaults.
|
|
29
|
+
* Throws ConfigError on any structural problem.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeConfig(raw) {
|
|
32
|
+
if (raw === undefined || raw === null) return { ...DEFAULT_CONFIG, transforms: {}, hotPath: { transitiveDepth: 1 } };
|
|
33
|
+
if (!isPlainObject(raw)) throw new ConfigError('config root must be a JSON object');
|
|
34
|
+
const cfg = {
|
|
35
|
+
level: DEFAULT_CONFIG.level,
|
|
36
|
+
transforms: {},
|
|
37
|
+
hotPath: { transitiveDepth: 1 },
|
|
38
|
+
lint: true,
|
|
39
|
+
};
|
|
40
|
+
if (raw.level !== undefined) cfg.level = parseLevel(raw.level);
|
|
41
|
+
if (raw.lint !== undefined) {
|
|
42
|
+
if (typeof raw.lint !== 'boolean') throw new ConfigError('lint must be a boolean');
|
|
43
|
+
cfg.lint = raw.lint;
|
|
44
|
+
}
|
|
45
|
+
if (raw.transforms !== undefined) {
|
|
46
|
+
if (!isPlainObject(raw.transforms)) throw new ConfigError('transforms must be an object of booleans');
|
|
47
|
+
for (const [k, v] of Object.entries(raw.transforms)) {
|
|
48
|
+
if (typeof v !== 'boolean') throw new ConfigError(`transforms["${k}"] must be a boolean`);
|
|
49
|
+
cfg.transforms[k] = v;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (raw.hotPath !== undefined) {
|
|
53
|
+
if (!isPlainObject(raw.hotPath)) throw new ConfigError('hotPath must be an object');
|
|
54
|
+
if (raw.hotPath.transitiveDepth !== undefined) {
|
|
55
|
+
const d = raw.hotPath.transitiveDepth;
|
|
56
|
+
if (typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 10) {
|
|
57
|
+
throw new ConfigError('hotPath.transitiveDepth must be an integer between 0 and 10');
|
|
58
|
+
}
|
|
59
|
+
cfg.hotPath.transitiveDepth = d;
|
|
60
|
+
}
|
|
61
|
+
for (const k of Object.keys(raw.hotPath)) {
|
|
62
|
+
if (k !== 'transitiveDepth') throw new ConfigError(`unknown hotPath option "${k}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const k of Object.keys(raw)) {
|
|
66
|
+
if (!['level', 'transforms', 'hotPath', 'lint'].includes(k)) {
|
|
67
|
+
throw new ConfigError(`unknown config key "${k}"`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cfg;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load the config file. `explicitPath` wins; otherwise `./optipack.config.json`
|
|
75
|
+
* is used when present relative to cwd. Returns { config, path } where path is
|
|
76
|
+
* null when no file was found.
|
|
77
|
+
*/
|
|
78
|
+
export function loadConfigFile(explicitPath, cwd = process.cwd()) {
|
|
79
|
+
let file = explicitPath;
|
|
80
|
+
if (!file) {
|
|
81
|
+
const def = path.join(cwd, 'optipack.config.json');
|
|
82
|
+
if (fs.existsSync(def)) file = def;
|
|
83
|
+
}
|
|
84
|
+
if (!file) return { config: normalizeConfig(undefined), path: null };
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = fs.readFileSync(file, 'utf8');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new ConfigError(`cannot read config file ${file}: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // tolerate BOM
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = JSON.parse(text);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
throw new ConfigError(`config file ${file} is not valid JSON: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
return { config: normalizeConfig(raw), path: file };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Merge CLI flags over a loaded config. CLI flags always win.
|
|
103
|
+
* Only recognized transform ids may appear in the transforms map; the caller
|
|
104
|
+
* passes the known id set (from the pass registry).
|
|
105
|
+
*/
|
|
106
|
+
export function mergeCliOverrides(config, cli, knownPassIds, knownPassCodes) {
|
|
107
|
+
const out = {
|
|
108
|
+
level: config.level,
|
|
109
|
+
transforms: { ...config.transforms },
|
|
110
|
+
hotPath: { ...config.hotPath },
|
|
111
|
+
lint: config.lint,
|
|
112
|
+
};
|
|
113
|
+
if (cli.level !== undefined) out.level = parseLevel(cli.level);
|
|
114
|
+
if (cli.config && cli.lint !== undefined) out.lint = cli.lint;
|
|
115
|
+
else if (!cli.config && cli.lint !== undefined) out.lint = cli.lint;
|
|
116
|
+
|
|
117
|
+
const normalizeList = (flag, list) => {
|
|
118
|
+
if (!list) return null;
|
|
119
|
+
const ids = list
|
|
120
|
+
.split(',')
|
|
121
|
+
.map((s) => s.trim())
|
|
122
|
+
.filter((s) => s.length > 0);
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
if (!knownPassIds.has(id) && !knownPassCodes.has(id)) {
|
|
125
|
+
throw new ConfigError(`unknown transform "${id}" in --${flag} (known: ${[...knownPassIds].join(', ')})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
};
|
|
130
|
+
const only = normalizeList('only', cli.only);
|
|
131
|
+
const except = normalizeList('except', cli.except);
|
|
132
|
+
if (except) {
|
|
133
|
+
for (const id of except) out.transforms[knownPassIds.has(id) ? id : knownPassCodes.get(id)] = false;
|
|
134
|
+
}
|
|
135
|
+
if (only) {
|
|
136
|
+
// --only replaces the enabled set entirely.
|
|
137
|
+
out.only = new Set(only.map((id) => (knownPassIds.has(id) ? id : knownPassCodes.get(id))));
|
|
138
|
+
}
|
|
139
|
+
if (cli.lint !== undefined) out.lint = cli.lint;
|
|
140
|
+
return out;
|
|
141
|
+
}
|