optipack 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +231 -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 -6
- package/src/cli.js +180 -0
- package/src/config.js +141 -0
- package/src/diff.js +108 -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/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/harness/setup.js +198 -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 +260 -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/loader-hooks.mjs +38 -0
- package/test/mock/@minecraft/server.mjs +274 -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/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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Differential harness.
|
|
3
|
+
*
|
|
4
|
+
* Equivalence notion: transforms may legitimately eliminate READS — that is
|
|
5
|
+
* the point — so we compare EFFECTS, not API-call traces. Every mutating call
|
|
6
|
+
* (sendMessage, setDynamicProperty, runCommand, spawnEntity, applyDamage,
|
|
7
|
+
* component writes, runInterval/runTimeout/runJob registrations) must match
|
|
8
|
+
* exactly, in order, between original and optimized code.
|
|
9
|
+
*
|
|
10
|
+
* Reads return scripted sequences from the recorder, so data flows through
|
|
11
|
+
* the script deterministically.
|
|
12
|
+
*/
|
|
13
|
+
import { register } from 'node:module';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
if (!globalThis.__opkHooksRegistered) {
|
|
22
|
+
register(
|
|
23
|
+
pathToFileURL(path.join(here, '..', 'loader-hooks.mjs')).href,
|
|
24
|
+
pathToFileURL(path.join(here, 'setup.js')).href
|
|
25
|
+
);
|
|
26
|
+
globalThis.__opkHooksRegistered = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function makeRecorder({ reads = {}, state = {} } = {}) {
|
|
30
|
+
const r = {
|
|
31
|
+
effects: [],
|
|
32
|
+
handlers: new Map(),
|
|
33
|
+
intervals: [],
|
|
34
|
+
timeouts: [],
|
|
35
|
+
jobs: [],
|
|
36
|
+
readQueues: structuredClone(reads),
|
|
37
|
+
readIdx: {},
|
|
38
|
+
state,
|
|
39
|
+
tickCounter: 0,
|
|
40
|
+
};
|
|
41
|
+
r.effect = (call, args) => {
|
|
42
|
+
r.effects.push({ call, args });
|
|
43
|
+
};
|
|
44
|
+
// Scripted reads index by TICK, not by call: the engine is synchronous, so
|
|
45
|
+
// repeated reads within one tick return the same value, and values may only
|
|
46
|
+
// change between ticks. This models the real host boundary correctly.
|
|
47
|
+
r.scriptedRead = (key, fallback) => {
|
|
48
|
+
const q = r.readQueues[key];
|
|
49
|
+
if (!q || q.length === 0) return fallback;
|
|
50
|
+
return q[r.tickCounter % q.length];
|
|
51
|
+
};
|
|
52
|
+
r.addHandler = (name, cb) => {
|
|
53
|
+
if (!r.handlers.has(name)) r.handlers.set(name, []);
|
|
54
|
+
r.handlers.get(name).push(cb);
|
|
55
|
+
};
|
|
56
|
+
r.removeHandler = (name, cb) => {
|
|
57
|
+
const arr = r.handlers.get(name);
|
|
58
|
+
if (arr) {
|
|
59
|
+
const idx = arr.indexOf(cb);
|
|
60
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
r.addInterval = (cb, interval) => {
|
|
64
|
+
const id = r.intervals.length;
|
|
65
|
+
r.intervals.push({ fn: cb, interval });
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
r.addTimeout = (cb, delay) => {
|
|
69
|
+
const id = r.timeouts.length;
|
|
70
|
+
r.timeouts.push({ fn: cb, delay, due: r.tickCounter + delay });
|
|
71
|
+
return id;
|
|
72
|
+
};
|
|
73
|
+
r.addJob = (gen) => {
|
|
74
|
+
r.jobs.push({ gen });
|
|
75
|
+
};
|
|
76
|
+
// One game tick: run due interval callbacks, timeouts, and one job step each.
|
|
77
|
+
r.tick = (n = 1) => {
|
|
78
|
+
for (let t = 0; t < n; t++) {
|
|
79
|
+
r.tickCounter++;
|
|
80
|
+
const intervals = [...r.intervals];
|
|
81
|
+
for (const it of intervals) {
|
|
82
|
+
if (r.tickCounter % it.interval === 0) it.fn();
|
|
83
|
+
}
|
|
84
|
+
const due = r.timeouts.filter((to) => to.due <= r.tickCounter);
|
|
85
|
+
for (const to of due) {
|
|
86
|
+
to.fn();
|
|
87
|
+
}
|
|
88
|
+
r.timeouts = r.timeouts.filter((to) => to.due > r.tickCounter);
|
|
89
|
+
for (const j of r.jobs) {
|
|
90
|
+
try {
|
|
91
|
+
j.gen.next();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Generator exceptions propagate like engine errors; record them.
|
|
94
|
+
r.effects.push({ call: 'job.error', args: String(e && e.message) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
r.fire = (eventName, payload) => {
|
|
100
|
+
const handlers = [...(r.handlers.get(eventName) || [])];
|
|
101
|
+
const hydrated = hydrate(payload, r);
|
|
102
|
+
for (const cb of handlers) cb(hydrated);
|
|
103
|
+
};
|
|
104
|
+
r.tickCount = () => r.tickCounter;
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Recursively replace {__entity: id} markers in a payload with live entities. */
|
|
109
|
+
function hydrate(value, rec) {
|
|
110
|
+
if (globalThis.__opkEnsurePlayers) globalThis.__opkEnsurePlayers(rec);
|
|
111
|
+
if (value === null || typeof value !== 'object') return value;
|
|
112
|
+
if (Array.isArray(value)) return value.map((v) => hydrate(v, rec));
|
|
113
|
+
if (typeof value.__entity === 'string') {
|
|
114
|
+
const players = rec._players || [];
|
|
115
|
+
const found = players.find((p) => p.id === value.__entity);
|
|
116
|
+
return found || value;
|
|
117
|
+
}
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const k of Object.keys(value)) out[k] = hydrate(value[k], rec);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Load a script entry with a fresh recorder and run the step list.
|
|
125
|
+
* Steps: {op:'tick', n}, {op:'fire', event, payload}, {op:'call', fn(rec)}
|
|
126
|
+
*/
|
|
127
|
+
export async function runVariant(entryUrl, variant, { reads, state, steps = [] }) {
|
|
128
|
+
const rec = makeRecorder({ reads, state });
|
|
129
|
+
globalThis.__opkRecorder = rec;
|
|
130
|
+
try {
|
|
131
|
+
const mod = await import(`${entryUrl}?opkv=${variant}&t=${Date.now()}-${Math.floor(Math.random() * 1e9)}`);
|
|
132
|
+
for (const step of steps) {
|
|
133
|
+
if (step.op === 'tick') rec.tick(step.n);
|
|
134
|
+
else if (step.op === 'fire') rec.fire(step.event, step.payload);
|
|
135
|
+
else if (step.op === 'call') step.fn(rec);
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
globalThis.__opkRecorder = null;
|
|
139
|
+
}
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function assertSameEffects(a, b, label = '') {
|
|
144
|
+
const ja = JSON.stringify(a.effects);
|
|
145
|
+
const jb = JSON.stringify(b.effects);
|
|
146
|
+
if (ja !== jb) {
|
|
147
|
+
// Find the first divergence for a helpful message.
|
|
148
|
+
const la = a.effects;
|
|
149
|
+
const lb = b.effects;
|
|
150
|
+
let i = 0;
|
|
151
|
+
while (i < la.length && i < lb.length && JSON.stringify(la[i]) === JSON.stringify(lb[i])) i++;
|
|
152
|
+
const fmt = (e) => JSON.stringify(e);
|
|
153
|
+
throw new Error(
|
|
154
|
+
`effects diverge${label ? ' (' + label + ')' : ''} at index ${i}:\n` +
|
|
155
|
+
` original: ${la[i] ? fmt(la[i]) : '<none>'}\n` +
|
|
156
|
+
` optimized: ${lb[i] ? fmt(lb[i]) : '<none>'}\n` +
|
|
157
|
+
` (original had ${la.length} effects, optimized had ${lb.length})`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build a temp pack from sources, run the pipeline over it, and return both
|
|
164
|
+
* the original and optimized file paths.
|
|
165
|
+
*/
|
|
166
|
+
export async function buildVariantPack(files, config, testSuffix) {
|
|
167
|
+
const { normalizeConfig } = await import('../../src/config.js');
|
|
168
|
+
const { runPack } = await import('../../src/pipeline.js');
|
|
169
|
+
const inputDir = fs.mkdtempSync(path.join(os.tmpdir(), `optipack-${testSuffix}-in-`));
|
|
170
|
+
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), `optipack-${testSuffix}-out-`));
|
|
171
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
172
|
+
const abs = path.join(inputDir, relPath);
|
|
173
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
174
|
+
fs.writeFileSync(abs, content);
|
|
175
|
+
}
|
|
176
|
+
const cfg = normalizeConfig(config || { level: 'balanced' });
|
|
177
|
+
const report = runPack(inputDir, outputDir, cfg);
|
|
178
|
+
return { inputDir, outputDir, report };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function pathToUrl(p) {
|
|
182
|
+
return 'file:///' + p.replace(/\\/g, '/').replace(/^\//, '');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Full differential check: run original + optimized with the same scenario
|
|
187
|
+
* and compare effect traces.
|
|
188
|
+
*/
|
|
189
|
+
export async function differentialCheck(files, config, scenario, label) {
|
|
190
|
+
const { inputDir, outputDir } = await buildVariantPack(files, config, label.replace(/\W+/g, '-'));
|
|
191
|
+
const entryRel = scenario.entry; // e.g. 'scripts/main.js'
|
|
192
|
+
const origUrl = pathToUrl(path.join(inputDir, entryRel));
|
|
193
|
+
const optUrl = pathToUrl(path.join(outputDir, entryRel));
|
|
194
|
+
const a = await runVariant(origUrl, 'orig', scenario);
|
|
195
|
+
const b = await runVariant(optUrl, 'opt', scenario);
|
|
196
|
+
assertSameEffects(a, b, label);
|
|
197
|
+
return { a, b, outputDir };
|
|
198
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// S7: loop-invariant member hoisting inside hot functions.
|
|
2
|
+
import { world, system } from '@minecraft/server';
|
|
3
|
+
|
|
4
|
+
// Before-loop hoist: player.location read once instead of 10 times.
|
|
5
|
+
function onTick(player) {
|
|
6
|
+
let total = 0;
|
|
7
|
+
const _opH = player.location.y;
|
|
8
|
+
for (let i = 0; i < 10; i++) {
|
|
9
|
+
total += _opH;
|
|
10
|
+
}
|
|
11
|
+
world.setDynamicProperty('total', total);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Two chains, each used twice per iteration: both hoisted.
|
|
15
|
+
function onTick2(chest) {
|
|
16
|
+
let dx = 0;
|
|
17
|
+
const _opC = chest.location;
|
|
18
|
+
const _opH2 = _opC.x;
|
|
19
|
+
const _opH3 = _opC.y;
|
|
20
|
+
for (let i = 0; i < 5; i++) {
|
|
21
|
+
dx += _opH2 + _opH3;
|
|
22
|
+
}
|
|
23
|
+
world.setDynamicProperty('dx', dx);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Loop body with a call: barrier, bails.
|
|
27
|
+
function onTick3(player) {
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (let i = 0; i < 4; i++) {
|
|
30
|
+
total += player.location.y;
|
|
31
|
+
world.sendMessage('tick');
|
|
32
|
+
}
|
|
33
|
+
world.setDynamicProperty('total3', total);
|
|
34
|
+
}
|
|
35
|
+
system.runInterval(onTick, 20);
|
|
36
|
+
system.runInterval(onTick2, 20);
|
|
37
|
+
system.runInterval(onTick3, 20);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// S7: loop-invariant member hoisting inside hot functions.
|
|
2
|
+
import { world, system } from '@minecraft/server';
|
|
3
|
+
|
|
4
|
+
// Before-loop hoist: player.location read once instead of 10 times.
|
|
5
|
+
function onTick(player) {
|
|
6
|
+
let total = 0;
|
|
7
|
+
for (let i = 0; i < 10; i++) {
|
|
8
|
+
total += player.location.y;
|
|
9
|
+
}
|
|
10
|
+
world.setDynamicProperty('total', total);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Two chains, each used twice per iteration: both hoisted.
|
|
14
|
+
function onTick2(chest) {
|
|
15
|
+
let dx = 0;
|
|
16
|
+
for (let i = 0; i < 5; i++) {
|
|
17
|
+
dx += chest.location.x + chest.location.y;
|
|
18
|
+
}
|
|
19
|
+
world.setDynamicProperty('dx', dx);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Loop body with a call: barrier, bails.
|
|
23
|
+
function onTick3(player) {
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (let i = 0; i < 4; i++) {
|
|
26
|
+
total += player.location.y;
|
|
27
|
+
world.sendMessage('tick');
|
|
28
|
+
}
|
|
29
|
+
world.setDynamicProperty('total3', total);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
system.runInterval(onTick, 20);
|
|
33
|
+
system.runInterval(onTick2, 20);
|
|
34
|
+
system.runInterval(onTick3, 20);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reads": {
|
|
3
|
+
"location:p1": [
|
|
4
|
+
{
|
|
5
|
+
"y": 64,
|
|
6
|
+
"z": 2,
|
|
7
|
+
"x": 1
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"location:p2": [
|
|
11
|
+
{
|
|
12
|
+
"y": 70,
|
|
13
|
+
"z": 4,
|
|
14
|
+
"x": 3
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"entry": "scripts/input.js",
|
|
19
|
+
"steps": [
|
|
20
|
+
{
|
|
21
|
+
"n": 5,
|
|
22
|
+
"op": "tick"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"state": {
|
|
26
|
+
"players": [
|
|
27
|
+
{
|
|
28
|
+
"id": "p1"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "p2"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// S7 bails: loop-variable roots, computed members, reassignment.
|
|
2
|
+
import { world, system } from '@minecraft/server';
|
|
3
|
+
function loopVar(chests) {
|
|
4
|
+
let total = 0;
|
|
5
|
+
for (let i = 0; i < chests.length; i++) {
|
|
6
|
+
total += chests[i].location.y;
|
|
7
|
+
}
|
|
8
|
+
world.setDynamicProperty('t', total);
|
|
9
|
+
}
|
|
10
|
+
function reassigned(player) {
|
|
11
|
+
let total = 0;
|
|
12
|
+
for (let i = 0; i < 4; i++) {
|
|
13
|
+
total += player.location.y;
|
|
14
|
+
}
|
|
15
|
+
player = null;
|
|
16
|
+
world.setDynamicProperty('t', total);
|
|
17
|
+
}
|
|
18
|
+
system.runInterval(() => loopVar([1, 2]), 20);
|
|
19
|
+
system.runInterval(() => reassigned(world.getAllPlayers()[0]), 20);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// S7 bails: loop-variable roots, computed members, reassignment.
|
|
2
|
+
import { world, system } from '@minecraft/server';
|
|
3
|
+
|
|
4
|
+
function loopVar(chests) {
|
|
5
|
+
let total = 0;
|
|
6
|
+
for (let i = 0; i < chests.length; i++) {
|
|
7
|
+
total += chests[i].location.y;
|
|
8
|
+
}
|
|
9
|
+
world.setDynamicProperty('t', total);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function reassigned(player) {
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (let i = 0; i < 4; i++) {
|
|
15
|
+
total += player.location.y;
|
|
16
|
+
}
|
|
17
|
+
player = null;
|
|
18
|
+
world.setDynamicProperty('t', total);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
system.runInterval(() => loopVar([1, 2]), 20);
|
|
22
|
+
system.runInterval(() => reassigned(world.getAllPlayers()[0]), 20);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure tests: pragmas, config handling, CLI behaviors, determinism.
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const root = path.join(here, '..');
|
|
14
|
+
const CLI = path.join(root, 'bin', 'optipack.js');
|
|
15
|
+
|
|
16
|
+
const { normalizeConfig, loadConfigFile, ConfigError } = await import('../src/config.js');
|
|
17
|
+
const { transformSource, runPack } = await import('../src/pipeline.js');
|
|
18
|
+
|
|
19
|
+
function mkPack(files) {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-infra-'));
|
|
21
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
22
|
+
const abs = path.join(dir, rel);
|
|
23
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
24
|
+
fs.writeFileSync(abs, content);
|
|
25
|
+
}
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cfg = (over = {}) => normalizeConfig({ level: 'balanced', ...over });
|
|
30
|
+
|
|
31
|
+
test('config: normalize + validation', () => {
|
|
32
|
+
assert.equal(cfg().level, 'balanced');
|
|
33
|
+
assert.equal(cfg({ level: 'risky' }).level, 'risky');
|
|
34
|
+
assert.equal(cfg({ hotPath: { transitiveDepth: 3 } }).hotPath.transitiveDepth, 3);
|
|
35
|
+
assert.throws(() => normalizeConfig({ level: 'nope' }), ConfigError);
|
|
36
|
+
assert.throws(() => normalizeConfig({ transforms: { 'x': 1 } }), ConfigError);
|
|
37
|
+
assert.throws(() => normalizeConfig({ hotPath: { transitiveDepth: -1 } }), ConfigError);
|
|
38
|
+
assert.throws(() => normalizeConfig({ unknownKey: true }), ConfigError);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('config: file loading with BOM + explicit path', () => {
|
|
42
|
+
const dir = mkPack({ 'optipack.config.json': '\uFEFF{"level":"safe","transforms":{"json-fold":false}}' });
|
|
43
|
+
const { config } = loadConfigFile(path.join(dir, 'optipack.config.json'));
|
|
44
|
+
assert.equal(config.level, 'safe');
|
|
45
|
+
assert.equal(config.transforms['json-fold'], false);
|
|
46
|
+
assert.throws(() => loadConfigFile(path.join(dir, 'nope.json')), ConfigError);
|
|
47
|
+
});
|
|
48
|
+
test('pragma: file-level optipack: off disables everything', () => {
|
|
49
|
+
const src = [
|
|
50
|
+
'// optipack: off',
|
|
51
|
+
"export const a = 1 + 2;",
|
|
52
|
+
"export function f() {",
|
|
53
|
+
" let s = '';",
|
|
54
|
+
" for (const x of ['a']) { s += x; }",
|
|
55
|
+
" return s;",
|
|
56
|
+
'}',
|
|
57
|
+
].join('\n');
|
|
58
|
+
const { code, fileReport } = transformSource('t.js', src, cfg());
|
|
59
|
+
assert.equal(code, src);
|
|
60
|
+
assert.deepEqual(fileReport.transforms, {});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('pragma: optipack-disable on the line before a statement', () => {
|
|
64
|
+
const src = [
|
|
65
|
+
'// const-fold disabled for this declaration only',
|
|
66
|
+
'// optipack-disable const-fold',
|
|
67
|
+
'export const a = 1 + 2;',
|
|
68
|
+
'export const b = 3 + 4;',
|
|
69
|
+
].join('\n');
|
|
70
|
+
const { code, fileReport } = transformSource('t.js', src, cfg());
|
|
71
|
+
assert.ok(code.includes('export const a = 1 + 2;'), 'disabled line unchanged');
|
|
72
|
+
assert.ok(code.includes('export const b = 7;'), 'enabled line folded');
|
|
73
|
+
assert.equal(fileReport.transforms['const-fold'], 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('pragma: optipack-disable all', () => {
|
|
77
|
+
const src = [
|
|
78
|
+
'export function f() {',
|
|
79
|
+
' // optipack-disable all',
|
|
80
|
+
' return 1 + 2;',
|
|
81
|
+
'}',
|
|
82
|
+
'export const g = 2 + 3;',
|
|
83
|
+
].join('\n');
|
|
84
|
+
const { code, fileReport } = transformSource('t.js', src, cfg());
|
|
85
|
+
assert.ok(code.includes('return 1 + 2;'), 'disabled return unchanged');
|
|
86
|
+
assert.ok(code.includes('export const g = 5;'), 'other code folded');
|
|
87
|
+
assert.equal(fileReport.transforms['const-fold'], 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('pragma: optipack-hot force-marks a function', () => {
|
|
91
|
+
const src = [
|
|
92
|
+
"import { world } from '@minecraft/server';",
|
|
93
|
+
'// optipack-hot',
|
|
94
|
+
'function notRegistered(player) {',
|
|
95
|
+
' const a = player.location.x;',
|
|
96
|
+
' const b = player.location.x;',
|
|
97
|
+
' world.sendMessage(String(a + b));',
|
|
98
|
+
'}',
|
|
99
|
+
].join('\n');
|
|
100
|
+
const { fileReport } = transformSource('t.js', src, cfg());
|
|
101
|
+
// Hot-marked -> B1 applies even without registration.
|
|
102
|
+
assert.ok(fileReport.transforms['cache-getter-chains'] >= 1, 'B1 applied to pragma-hot function');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('pipeline: determinism — same input produces byte-identical output', () => {
|
|
106
|
+
const src = [
|
|
107
|
+
"import { world, system } from '@minecraft/server';",
|
|
108
|
+
'function onTick() {',
|
|
109
|
+
" const tags = ['a', 'b'].filter((t) => t.length > 0);",
|
|
110
|
+
' let s = 0;',
|
|
111
|
+
' for (const t of tags) { s += t.length; }',
|
|
112
|
+
" world.sendMessage('' + s);",
|
|
113
|
+
'}',
|
|
114
|
+
'system.runInterval(onTick, 20);',
|
|
115
|
+
].join('\n');
|
|
116
|
+
const r1 = transformSource('t.js', src, cfg());
|
|
117
|
+
const r2 = transformSource('t.js', src, cfg());
|
|
118
|
+
assert.equal(r1.code, r2.code);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('pipeline: output round-trips as ESM', () => {
|
|
122
|
+
const src = [
|
|
123
|
+
"import { world } from '@minecraft/server';",
|
|
124
|
+
'export class Thing { #n = 1; get n() { return this.#n; } }',
|
|
125
|
+
'export const arrow = async (x) => x ?? 0;',
|
|
126
|
+
].join('\n');
|
|
127
|
+
const { code } = transformSource('t.js', src, cfg());
|
|
128
|
+
assert.ok(code.includes('class Thing'), 'modern syntax preserved');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('pipeline: parse failure reports file and line', () => {
|
|
132
|
+
assert.throws(() => transformSource('scripts/bad.js', 'function {{{', cfg()), (e) => {
|
|
133
|
+
assert.equal(e.code, 2);
|
|
134
|
+
assert.ok(e.message.includes('scripts/bad.js'), 'file name in message');
|
|
135
|
+
assert.match(e.message, /:\d+:\d+/, 'line:column in message');
|
|
136
|
+
return true;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('pack walker: non-script files copied verbatim, manifest untouched', () => {
|
|
141
|
+
const input = mkPack({
|
|
142
|
+
'manifest.json': '{"format_version": 2, "header": {"name": "keep"}}',
|
|
143
|
+
'scripts/main.js': 'export const a = 1 + 2;\n',
|
|
144
|
+
'functions/tick.mcfunction': 'say hi\n',
|
|
145
|
+
'texts/en_US.lang': 'item.name=Thing\n',
|
|
146
|
+
'textures/foo.png': 'not-really-a-png',
|
|
147
|
+
'scripts/nested/sub.js': 'const b = 2 + 2;\n',
|
|
148
|
+
});
|
|
149
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'pack');
|
|
150
|
+
const report = runPack(input, out, cfg());
|
|
151
|
+
assert.equal(fs.readFileSync(path.join(out, 'manifest.json'), 'utf8'), '{"format_version": 2, "header": {"name": "keep"}}');
|
|
152
|
+
assert.equal(fs.readFileSync(path.join(out, 'functions/tick.mcfunction'), 'utf8'), 'say hi\n');
|
|
153
|
+
assert.equal(fs.readFileSync(path.join(out, 'textures/foo.png'), 'utf8'), 'not-really-a-png');
|
|
154
|
+
assert.equal(fs.readFileSync(path.join(out, 'texts/en_US.lang'), 'utf8'), 'item.name=Thing\n');
|
|
155
|
+
const mainOut = fs.readFileSync(path.join(out, 'scripts/main.js'), 'utf8');
|
|
156
|
+
assert.ok(mainOut.includes('3'), 'script transformed');
|
|
157
|
+
assert.ok(fs.existsSync(path.join(out, 'scripts/nested/sub.js')));
|
|
158
|
+
assert.equal(report.files.length, 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('CLI: exit 0 on the example pack, report written, manifest identical', () => {
|
|
162
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-cli-')), 'out');
|
|
163
|
+
const res = spawnSync(process.execPath, [CLI, path.join(root, 'example'), out, '--report', path.join(out, 'report.json')], { encoding: 'utf8' });
|
|
164
|
+
assert.equal(res.status, 0, res.stderr);
|
|
165
|
+
assert.ok(fs.existsSync(path.join(out, 'manifest.json')));
|
|
166
|
+
assert.ok(fs.existsSync(path.join(out, 'report.json')));
|
|
167
|
+
assert.ok(fs.existsSync(path.join(out, 'scripts/main.js')));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('CLI: exit 1 on usage errors', () => {
|
|
171
|
+
const res1 = spawnSync(process.execPath, [CLI], { encoding: 'utf8' });
|
|
172
|
+
assert.equal(res1.status, 1);
|
|
173
|
+
const res2 = spawnSync(process.execPath, [CLI, 'Z:/definitely/missing/dir', 'out'], { encoding: 'utf8' });
|
|
174
|
+
assert.equal(res2.status, 1);
|
|
175
|
+
const res3 = spawnSync(process.execPath, [CLI, path.join(root, 'example'), 'out', '--level', 'bogus'], { encoding: 'utf8' });
|
|
176
|
+
assert.equal(res3.status, 1);
|
|
177
|
+
const res4 = spawnSync(process.execPath, [CLI, path.join(root, 'example'), 'out', '--only', 'no-such-transform'], { encoding: 'utf8' });
|
|
178
|
+
assert.equal(res4.status, 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('CLI: exit 2 on parse failure with file+line in stderr', () => {
|
|
182
|
+
const input = mkPack({
|
|
183
|
+
'manifest.json': '{}',
|
|
184
|
+
'scripts/broken.js': 'function broken( {\n return 1;\n}\n',
|
|
185
|
+
});
|
|
186
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'out');
|
|
187
|
+
const res = spawnSync(process.execPath, [CLI, input, out], { encoding: 'utf8' });
|
|
188
|
+
assert.equal(res.status, 2);
|
|
189
|
+
assert.ok(res.stderr.includes('scripts/broken.js'), res.stderr);
|
|
190
|
+
assert.match(res.stderr, /:\d+:/, 'line number present');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('CLI: --dry-run writes nothing', () => {
|
|
194
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'out');
|
|
195
|
+
const res = spawnSync(process.execPath, [CLI, path.join(root, 'example'), out, '--dry-run'], { encoding: 'utf8' });
|
|
196
|
+
assert.equal(res.status, 0, res.stderr);
|
|
197
|
+
assert.ok(res.stdout.includes('"level"'), 'report printed to stdout');
|
|
198
|
+
assert.equal(fs.existsSync(out), false, 'no output directory created');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('CLI: --only restricts to the named transforms', () => {
|
|
202
|
+
const input = mkPack({ 'scripts/main.js': "export const a = 1 + 2;\nexport const b = 'x' + 'y';\n" });
|
|
203
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'out');
|
|
204
|
+
const res = spawnSync(process.execPath, [CLI, input, out, '--only', 'for-of-to-indexed', '--report', path.join(out, 'r.json')], { encoding: 'utf8' });
|
|
205
|
+
assert.equal(res.status, 0, res.stderr);
|
|
206
|
+
const outCode = fs.readFileSync(path.join(out, 'scripts/main.js'), 'utf8');
|
|
207
|
+
assert.ok(outCode.includes('1 + 2'), 'const-fold excluded');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('CLI: --except disables a transform', () => {
|
|
211
|
+
const input = mkPack({ 'scripts/main.js': "export const a = 1 + 2;\n" });
|
|
212
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'out');
|
|
213
|
+
const res = spawnSync(process.execPath, [CLI, input, out, '--except', 'const-fold', '--report', path.join(out, 'r.json')], { encoding: 'utf8' });
|
|
214
|
+
assert.equal(res.status, 0, res.stderr);
|
|
215
|
+
assert.ok(fs.readFileSync(path.join(out, 'scripts/main.js'), 'utf8').includes('1 + 2'));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('CLI: --diff prints unified diffs', () => {
|
|
219
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'out');
|
|
220
|
+
const res = spawnSync(process.execPath, [CLI, path.join(root, 'example'), out, '--diff', '--report', path.join(out, 'r.json')], { encoding: 'utf8' });
|
|
221
|
+
assert.equal(res.status, 0, res.stderr);
|
|
222
|
+
assert.ok(res.stdout.includes('--- a/scripts/main.js'), 'diff header present');
|
|
223
|
+
assert.ok(res.stdout.includes('@@'), 'hunk headers present');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('CLI: --level safe skips balanced passes', () => {
|
|
227
|
+
const input = mkPack({
|
|
228
|
+
'scripts/main.js': [
|
|
229
|
+
"import { world, system } from '@minecraft/server';",
|
|
230
|
+
'function onTick(player) {',
|
|
231
|
+
' const a = player.location.x;',
|
|
232
|
+
' const b = player.location.x;',
|
|
233
|
+
" world.sendMessage('' + (a + b));",
|
|
234
|
+
'}',
|
|
235
|
+
'system.runInterval(onTick, 20);',
|
|
236
|
+
].join('\n'),
|
|
237
|
+
});
|
|
238
|
+
const outSafe = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'optipack-out-')), 'safe');
|
|
239
|
+
spawnSync(process.execPath, [CLI, input, outSafe, '--level', 'safe', '--report', path.join(outSafe, 'r.json')], { encoding: 'utf8' });
|
|
240
|
+
const safeCode = fs.readFileSync(path.join(outSafe, 'scripts/main.js'), 'utf8');
|
|
241
|
+
assert.ok(safeCode.includes('player.location.x'), 'B1 did not run at safe level');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('CLI: risky passes only run at risky level or explicit config', () => {
|
|
245
|
+
const src = [
|
|
246
|
+
"import { world, system } from '@minecraft/server';",
|
|
247
|
+
"const CFG = JSON.parse('{\"n\":1}');",
|
|
248
|
+
'function onTick(player) {',
|
|
249
|
+
" world.sendMessage('' + CFG.n + player.id);",
|
|
250
|
+
'}',
|
|
251
|
+
'system.runInterval(onTick, 20);',
|
|
252
|
+
].join('\n');
|
|
253
|
+
const balanced = transformSource('t.js', src, cfg({ level: 'balanced' }));
|
|
254
|
+
assert.ok(balanced.code.includes('JSON.parse'), 'R2 did not run at balanced');
|
|
255
|
+
const risky = transformSource('t.js', src, cfg({ level: 'risky' }));
|
|
256
|
+
assert.ok(risky.code.includes('_opJson'), 'R2 ran at risky level');
|
|
257
|
+
assert.ok(risky.fileReport.warnings >= 1, 'R2 emitted a warning');
|
|
258
|
+
const explicit = transformSource('t.js', src, cfg({ level: 'safe', transforms: { 'json-fold': true } }));
|
|
259
|
+
assert.ok(explicit.code.includes('_opJson'), 'R2 ran via explicit config');
|
|
260
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"level": "risky"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// R2: JSON.parse on a constant string folded to a module const (risky).
|
|
2
|
+
import { world } from '@minecraft/server';
|
|
3
|
+
const _opJson = JSON.parse('{"region":"eu","retries":3,"features":["a","b"]}');
|
|
4
|
+
export function describeConfig() {
|
|
5
|
+
return _opJson.region + ':' + _opJson.retries + ':' + _opJson.features.length;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Reassignment of the parsed object: bails.
|
|
9
|
+
const MUT = JSON.parse('{"n":1}');
|
|
10
|
+
export function bump() {
|
|
11
|
+
MUT.n = 2;
|
|
12
|
+
return MUT.n;
|
|
13
|
+
}
|
|
14
|
+
world.sendMessage(describeConfig());
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// R2: JSON.parse on a constant string folded to a module const (risky).
|
|
2
|
+
import { world } from '@minecraft/server';
|
|
3
|
+
|
|
4
|
+
const CONFIG = JSON.parse('{"region":"eu","retries":3,"features":["a","b"]}');
|
|
5
|
+
|
|
6
|
+
export function describeConfig() {
|
|
7
|
+
return CONFIG.region + ':' + CONFIG.retries + ':' + CONFIG.features.length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Reassignment of the parsed object: bails.
|
|
11
|
+
const MUT = JSON.parse('{"n":1}');
|
|
12
|
+
export function bump() {
|
|
13
|
+
MUT.n = 2;
|
|
14
|
+
return MUT.n;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
world.sendMessage(describeConfig());
|