optipack 1.0.0 → 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.
@@ -0,0 +1,127 @@
1
+ # Benchmarking optipack
2
+
3
+ Two complementary ways to measure what optipack actually buys you.
4
+
5
+ ## 1. Automated A/B (`optipack bench`) — engine-independent crossing counts
6
+
7
+ ```bash
8
+ optipack bench <packDir> [--ticks N] [--scenario file.json] [--level safe|balanced|risky] [--json out.json]
9
+ ```
10
+
11
+ Builds the optimized pack, then runs the **original** and **optimized** scripts
12
+ side by side under the harness mock for N simulated ticks with identical
13
+ scenario input, and reports:
14
+
15
+ - **Host-boundary crossings by kind** — the number of `player.location` reads,
16
+ `getComponent()` calls, `getDynamicProperty()` calls, `getEntities()` sweeps,
17
+ etc. This is the metric optipack exists to reduce, and it is
18
+ **engine-independent**: a crossing is a crossing in V8 (the test harness) or
19
+ QuickJS (the game).
20
+ - **Wall time per tick** — *indicative only*. The harness runs in Node (V8, with
21
+ a JIT); Minecraft runs QuickJS (interpreter, no JIT). Use the in-game
22
+ workflow below for authoritative numbers.
23
+ - **Effect-trace equality** — if the two builds diverge behaviorally, the bench
24
+ is flagged invalid (`exit code 1`).
25
+
26
+ Example (real output over 147 community scripts from JaylyDev/ScriptAPI):
27
+
28
+ ```
29
+ optipack bench — test/corpus/jayly-pack (entry scripts/main.js, 100 ticks, level risky)
30
+ boundary crossing original optimized delta
31
+ boundary.getBlock 29 29 0%
32
+ boundary.getComponent 400 202 -49%
33
+ boundary.getPlayers 206 206 0%
34
+ boundary.location 20 10 -50%
35
+ TOTAL 655 447 -32%
36
+ transforms applied: 97
37
+ wall (indicative, Node/V8): original 6.5ms (65µs/tick), optimized 6.4ms (64µs/tick)
38
+ ```
39
+
40
+ Read it as: at `--level risky`, R1's cross-tick component cache halves
41
+ `getComponent` crossings and S7/B1 halve location reads, with identical
42
+ behavior verified. At `balanced`, crossings stay flat on this corpus because
43
+ its hot paths are call-dominated (caching those is exactly what R1 opts into).
44
+
45
+ Scenario files drive events between ticks (`test/corpus/jayly-scenario.json`
46
+ is an example): `{"reads": {...}, "state": {players: [{id: "p1"}]}, "steps":
47
+ [{"op": "fire", "event": "world.afterEvents.worldLoad", "payload": {}},
48
+ {"op": "fireMany", "event": "world.afterEvents.playerBreakBlock", "count": 30,
49
+ "payload": {...}}, {"op": "tick", "n": 0}]}`. Payloads may reference mock
50
+ objects with `{"__entity": "p1"}`, `{"__dimension": "minecraft:overworld"}`,
51
+ or `{"__block": {"location": {...}, "dimension": "..."}}`.
52
+
53
+ A real-world corpus of MIT-licensed community scripts is fetched with:
54
+
55
+ ```bash
56
+ npm run fetch-corpus
57
+ node bin/optipack.js bench test/corpus/jayly-pack --scenario test/corpus/jayly-scenario.json
58
+ ```
59
+
60
+ ## 2. In-game A/B with the script profiler — authoritative numbers
61
+
62
+ The numbers that matter run on the real engine: **QuickJS inside Minecraft**,
63
+ where the script watchdog and the server tick budget live. Bedrock ships a
64
+ built-in script profiler.
65
+
66
+ 1. **Build both packs.** The original is just a copy (optipack always
67
+ optimizes):
68
+
69
+ ```powershell
70
+ # Windows
71
+ robocopy MyPack MyPack-original /E
72
+ optipack MyPack MyPack-optimized --level balanced
73
+ ```
74
+
75
+ ```bash
76
+ # Linux/macOS
77
+ cp -r MyPack MyPack-original
78
+ optipack MyPack MyPack-optimized --level balanced
79
+ ```
80
+
81
+ 2. **Install one pack** into your `development_behavior_packs` folder and apply
82
+ it to a test world (enable the required experiments if the pack needs them).
83
+
84
+ 3. **Profile a consistent play pattern.** In the world, run:
85
+
86
+ ```
87
+ /script profiler start
88
+ ```
89
+
90
+ exercise the same actions for a fixed period (e.g. 60 seconds of the same
91
+ movement/interactions, or a repeatable redstone/tick-driven scenario), then:
92
+
93
+ ```
94
+ /script profiler stop
95
+ ```
96
+
97
+ The profiler writes the execution profile to the content log (visible in
98
+ the in-game content log screen and in the world's log files under
99
+ `content_log/`).
100
+
101
+ 4. **Repeat with the other pack**, same actions, same duration.
102
+
103
+ 5. **Compare.** What to look for in the two profiles:
104
+ - total script time per tick / per frame;
105
+ - time in your hottest handlers (the profiler reports per-function costs);
106
+ - the count of host API calls attributed to your entry points.
107
+
108
+ optipack's transforms reduce *calls into the host* (location reads,
109
+ component queries, dynamic properties) and *allocations per tick*
110
+ (closures, form objects, intermediate strings) — those show up as smaller
111
+ per-function times and fewer API-call entries, without any behavioral
112
+ difference (which the harness already proved).
113
+
114
+ 6. **Send the logs back.** If you're reporting results, include both content
115
+ log profile sections (original + optimized), the optipack report JSON from
116
+ the optimized build, and the level you used. The structured
117
+ `optipack bench --json` output pairs well with this.
118
+
119
+ ### Notes and caveats
120
+
121
+ - Enable the script watchdog thresholds or keep profiling sessions short —
122
+ a pathological pack can trip the watchdog during profiling; that itself is
123
+ useful signal.
124
+ - Profile at least two sessions per variant and compare like with like (same
125
+ world, same player count, same actions). Entity counts dominate host costs.
126
+ - `optipack bench` crossing counts are the cheapest regression signal for
127
+ CI; the in-game profiler is the ground truth for player-visible wins.
package/README.md CHANGED
@@ -1,232 +1,137 @@
1
1
  # optipack
2
2
 
3
- A QuickJS-targeted **micro-optimizing pre-processor** for Minecraft Bedrock Edition behavior-pack scripts.
3
+ **A build-time optimizer for Minecraft Bedrock behavior-pack scripts.**
4
4
 
5
- optipack takes an entire behavior pack as input, statically analyzes the JavaScript under `scripts/`, rewrites it with semantics-preserving micro-optimizations tuned for the QuickJS engine that executes MCBE's Script API, and emits an optimized copy of the pack plus a machine-readable report.
6
-
7
- **Why:** MCBE scripts run on QuickJS — an interpreter with no JIT, minimal inline caching, a reference-counted GC with no escape analysis, and an expensive JS↔native boundary for every host-object property read (`player.location` allocates a fresh Vector3 *every read*). All script work shares the server's tick budget. Pack code written for readability re-reads the same host getters every tick, allocates closures and arrays per tick, and builds strings with `+=`. optipack mechanically removes a large class of that overhead at build time.
5
+ Bedrock runs your scripts on QuickJS: an interpreter with no JIT, no escape analysis, and an expensive crossing every time script code touches the host (`player.location` allocates a fresh Vector3 *every read*). All of it shares one tick budget. optipack rewrites your `scripts/` folder with 16 semantics-preserving transforms that cut those crossings and per-tick allocations — and proves behavior is unchanged while doing it.
8
6
 
9
7
  ```
10
- npm install -g . # or: node bin/optipack.js
11
- npx optipack <inputPack> <outputPack> [options]
8
+ npm install -g optipack
9
+ optipack MyPack MyPack-optimized
12
10
  ```
13
11
 
14
- - Requires Node ≥ 18.
15
- - Runtime deps: `@babel/parser`, `@babel/traverse`, `@babel/generator`, `@babel/types`, `commander`.
16
- - Only `scripts/**/*.js|/mjs` is transformed; everything else (manifest.json, JSON, functions, texts, textures) is copied byte-for-byte. `manifest.json` is never modified. `node_modules/` and `.git/` are skipped.
12
+ ## Measured impact
17
13
 
18
- ## CLI
14
+ Host-boundary crossings before → after (engine-independent: identical in Node and in-game), with behavior verified identical by the test harness:
19
15
 
20
- ```
21
- optipack <inputPackDir> <outputPackDir> [options]
22
-
23
- --level <safe|balanced|risky> optimization level (default: balanced)
24
- --config <path> config file (default: ./optipack.config.json when present)
25
- --only <id,id,...> run only these transforms
26
- --except <id,id,...> skip these transforms
27
- --dry-run analyze + report, write nothing
28
- (prints the report to stdout; with an explicit
29
- --report it writes only that file)
30
- --diff print unified diffs per changed file
31
- --report <path> report file (default: <outputPackDir>/optipack-report.json)
32
- ```
16
+ | Benchmark | Level | Crossings | Top reduction |
17
+ |---|---|---|---|
18
+ | [example pack](example/) (300 ticks) | balanced | 237 → **177** (−25%) | `location` reads −50% |
19
+ | [JaylyDev/ScriptAPI](https://github.com/JaylyDev/ScriptAPI) corpus, 147 real scripts | balanced | 655 → 655 (0%)¹ | — |
20
+ | same corpus | risky | 655 → **447** (−32%) | `getComponent` −49%, `location` −50% |
33
21
 
34
- Exit codes: `0` success · `1` usage/config error · `2` parse failure (file + line printed to stderr) · `3` internal error.
22
+ ¹ *Honest number: this corpus's hot paths are call-dominated (`getComponent` every tick) — caching those is exactly the opt-in risky tier. Safe transforms only ever remove reads.*
35
23
 
36
- Every output file is re-parsed with `@babel/parser` (ESM) before being written; a round-trip failure aborts the build with exit code 3.
24
+ Reproduce with `optipack bench` (see [BENCHMARKING.md](BENCHMARKING.md)), which also covers the in-game `/script profiler` A/B workflow for authoritative QuickJS numbers.
37
25
 
38
- ## Config file
26
+ ## How it works
39
27
 
40
- `optipack.config.json` (CLI flags override it):
41
-
42
- ```json
43
- {
44
- "level": "balanced",
45
- "transforms": {
46
- "for-of-to-indexed": true,
47
- "json-fold": false
48
- },
49
- "hotPath": { "transitiveDepth": 1 },
50
- "lint": true
51
- }
28
+ ```
29
+ optipack <inputPack> <outputPack> [--level safe|balanced|risky] [--diff] [--dry-run] [--only ids] [--except ids] [--report path]
52
30
  ```
53
31
 
54
- - `level` — one of `safe | balanced | risky`. Running level N enables all passes at level ≤ N.
55
- - `transforms` — per-pass overrides. `false` always disables a pass; `true` enables a pass even below its level (this is the only way to run a RISKY pass without `--level risky`).
56
- - `hotPath.transitiveDepth` — how many levels of same-file callees of hot functions are treated as hot (default 1).
57
- - `lint` — run the lint/report pass (default true).
58
-
59
- Unknown keys, unknown transform ids in `--only/--except`, and type errors are usage errors (exit 1).
60
-
61
- ## Pragmas
62
-
63
- Exact syntax, implemented for real:
64
-
65
- | Pragma | Placement | Effect |
66
- | --- | --- | --- |
67
- | `// optipack: off` | first comment in the file | disables all transforms for that file (the file is emitted unchanged) |
68
- | `// optipack-disable <transformId\|all>` | on the line before a statement or function | disables that transform (or all) for the immediately following node and everything inside it; comma-separated lists work (`// optipack-disable const-fold,dead-code`) |
69
- | `// optipack-hot` | on the line before a function | force-marks that function hot (as if registered on a tick) |
70
-
71
- Disabled nodes keep their pragmas in the output, so re-running optipack on its own output is stable.
72
-
73
- ## Hot-path analysis (shared prerequisite)
74
-
75
- A function-like node is **hot** when any of these hold:
76
-
77
- - it is passed directly to `system.runInterval`, `system.runTimeout`, `system.run`, or `system.runJob`;
78
- - it is passed to any `.subscribe(...)` call (world/system before+after events, entity and item events);
79
- - it carries an `// optipack-hot` pragma;
80
- - it is a same-file function called **only** from hot functions, up to `hotPath.transitiveDepth` (default 1). Same-file call graph only (see Limitations).
32
+ - Only `scripts/**/*.js` is transformed; the manifest, JSON, functions, and textures are copied byte-for-byte. `import` declarations are never touched.
33
+ - Every output file is re-parsed (valid ESM, ES2022-grade syntax — nothing is down-leveled, no bundling, no minification).
34
+ - Same input + config → byte-identical output. Exit codes: `0` ok · `1` usage · `2` parse failure (file+line) · `3` internal error.
81
35
 
82
- Barrier rules every downstream pass respects:
36
+ ```bash
37
+ optipack bench <packDir> --level risky # A/B benchmark: original vs optimized (see BENCHMARKING.md)
38
+ ```
83
39
 
84
- - every `yield` is a barrier (e.g. `system.runJob` iterators) — nothing cached before a `yield` is reused after it;
85
- - every `await` is a barrier, same reason;
86
- - every call expression or assignment is a mutation barrier for host-object caching — SAFE/BALANCED passes stop at barriers; RISKY passes may cross them and say so.
40
+ ## The 16 transforms
87
41
 
88
- Implementation note: a statement containing calls is not a blanket barrier for reads *inside that statement*. Argument/object sub-expressions evaluate before the call itself, so `f(p.location.x, p.location.x)` may share one cache even though `f` is a call. The passes implement this precisely (an evaluation-order "corridor" check) and bail whenever a call can execute between two reads.
42
+ Passes run in order; each is an independent pass with an ID, a level, and strict bail conditions — when a site can't be proven safe, the pass **bails and records the reason** (line + reason in the report) instead of guessing.
89
43
 
90
- ## Transform catalog
44
+ <details>
45
+ <summary><strong>SAFE — provably behavior-identical (enabled at every level)</strong></summary>
91
46
 
92
- Passes run in registry order (S1 → R4). Every application increments a counter in the report; every skipped opportunity is recorded with line + reason.
47
+ | ID | Name | What it does |
48
+ |---|---|---|
49
+ | S1 | `const-fold` | Folds literal arithmetic, string concat, `typeof`/`!` on literals, fully-literal templates, logical expressions with literal left side. Never across identifiers or calls. |
50
+ | S2 | `dead-code` | Removes statements after `return`/`throw`/`break`/`continue`, constant branches, unused locals with pure initializers. |
51
+ | S3 | `bool-simplify` | `x ? true : false` → `!!x`; `if (x) return true; else return false;` → `return !!x;`; `x === true` → `x` when statically boolean. |
52
+ | S4 | `for-of-to-indexed` | `for (const v of it)` → indexed loop when `it` is *provably* an array (literal, `.map/.filter/.split`, `Object.entries`, …). Never on host returns, Map/Set, strings, generators. |
53
+ | S5 | `array-methods-to-loops` | **Hot paths only.** Inline-arrow `.forEach/.map/.filter/.some/.every/.find` → indexed loops with exact per-method return semantics. |
54
+ | S6 | `destructure-to-direct` | **Hot paths only.** `const { x, y } = obj` → `obj.x` reads at use sites (≤ 3 uses); defaults hoisted to an `if`. |
55
+ | S7 | `hoist-invariant-members` | **Hot paths only.** Hoists loop-invariant host chains out of barrier-free loops — `total += chest.location.y` reads `chest.location` once instead of N times. |
93
56
 
94
- ### SAFE — provably behavior-identical
57
+ </details>
95
58
 
96
- | ID | Name | What it does | Bails when |
97
- | --- | --- | --- | --- |
98
- | S1 | `const-fold` | Folds literal arithmetic/comparisons/bitwise ops, string concatenation, `typeof` on literals, `!`/`~` on literals, fully-literal template literals, and logical expressions with a literal left operand. Exit-phase traversal so nested folds cascade. | Either operand is not a static literal; result would be `-0`, `NaN`, or non-finite; `in`/`instanceof`; identifiers or calls anywhere (`i + 1`, `Math.floor(1.5)` are never folded). |
99
- | S2 | `dead-code` | Removes statements after an unconditional `return`/`throw`/`break`/`continue`, branches made constant by S1 (`if`, `?:`, `while (false)`, `for (;false;)`), and unused local declarations with side-effect-free initializers (no calls, no member writes, no `await`). | Initializer has calls/assignments/await; binding is exported; function carries an `// optipack-hot` pragma (explicit user intent). |
100
- | S3 | `bool-simplify` | `x ? true : false` → `!!x`; `if (x) return true; else return false;` → `return !!x;`; `x === true` / `x !== false` → `x` (plus duals `x === false` / `x !== true` → `!x`). | Operand not statically boolean-typed (comparison, `!`, `Boolean(...)` call, or and/or of those). |
101
- | S4 | `for-of-to-indexed` | `for (const v of it)` → indexed loop with a per-iteration `const v = it[i]` binding and a live `.length` check (mid-loop pushes extend the loop exactly like the array iterator). Array-pattern bindings (incl. holes and defaults) are translated elementwise. | Iterable not provably an array: host returns (`getAllPlayers()`, `getEntities()`), Map/Set, strings, generators, parameters with unknown provenance. Also bails on `for await`, destructuring with rest, and a scope binding named `Symbol`. |
102
- | S5 | `array-methods-to-loops` | **Hot functions only.** Inline-arrow `.forEach`, `.map`, `.filter`, `.some`, `.every`, `.find` → plain indexed loops with exact per-method return semantics (map pushes at fall-off-end, some/every/find early-exit with `break`). The receiver is always evaluated exactly once (hoisted to a `const`), so call-containing receivers never duplicate side effects — this supersedes the naive "receiver referenced twice" bail. | Callback not an inline arrow/function expression; uses `this`/`arguments`; async/generator or contains await/yield; non-identifier params; receiver optionally chained; call conditionally evaluated within its statement (`?:`, logical right, spread, concise arrow body, loop test/update); receiver provenance may be a sparse array (`new Array(n)`, holey literals). |
103
- | S6 | `destructure-to-direct` | **Hot functions only.** `const { x, y } = obj` → direct `obj.x` reads at use sites, when `obj` is a simple never-reassigned identifier and total uses ≤ 3. Defaults are hoisted to an `if` at the declaration site (`let z = obj.z; if (z === undefined) z = 42;`) so they never re-evaluate per use. | Nested patterns, rest elements, computed keys, `obj` a member chain (that is B1's job), `obj` reassigned or property-written, > 3 total uses, or any call/assignment between declaration and uses that could mutate `obj` (evaluation-order corridor check inside the use's own statement). |
104
- | S7 | `hoist-invariant-members` | **Hot functions only.** Hoists loop-invariant member chains out of for/while/do loops: body-top hoists for chains used ≥ 2× per iteration, and — when the whole loop subtree is barrier-free (no calls, await/yield, property writes, delete) and the root is never reassigned — a **before-loop** hoist even for single-use chains (`total += chest.location.y` reads `chest.location` once instead of N times). | Body contains calls/await/yield/property writes; chain has computed members; root reassigned or declared inside the loop; root is a local whose declaration follows the first use. Assumption documented below: the before-loop hoist evaluates the chain once even if the body never runs (safe for live host objects; a null root + zero-iteration loop would newly throw). |
59
+ <details>
60
+ <summary><strong>BALANCED — per-site-checked assumptions (default level)</strong></summary>
105
61
 
106
- ### BALANCED — per-site-checked assumptions
62
+ | ID | Name | What it does |
63
+ |---|---|---|
64
+ | B1 | `cache-getter-chains` | Repeated identical host chains in barrier-free regions → one hoisted `const` (`p.location.x` twice = 2 crossings + 2 allocations → 0). |
65
+ | B2 | `closure-lift` | Arrows created inside hot functions (re-allocated every tick) lifted to module scope when they capture nothing / module bindings / const primitives (inlined) / never-reassigned locals via a module-level cell. |
66
+ | B3 | `string-build` | `s += x` in a hot loop → `parts.push()` + one `join("")` (QuickJS strings are immutable flat buffers: `+=` is O(n²), parts+join is O(n)). |
67
+ | B4 | `dynprop-hoist` | `entity.getDynamicProperty("k")` with a constant key, read in a hot loop, hoisted above the loop. |
68
+ | B5 | `null-chain-flatten` | After a proven guard (`if (!a) return;`), `a?.b` → `a.b` within the barrier-free region. |
107
69
 
108
- | ID | Name | What it does | Bails when |
109
- | --- | --- | --- | --- |
110
- | B1 | `cache-getter-chains` | Within a hot function's barrier-free regions, repeated identical member chains rooted at a host object (root is a parameter, `this`, a previously hoisted const, or the chain contains `world.*`, `getComponent(`, `getEntities(`, `getAllPlayers()`, `dimension`, `location`, …) are replaced by one hoisted `const`. Removes both repeated boundary crossings and repeated allocations (`p.location.x` twice = 2 allocations + 2 crossings → 0). Chains used inside one call-containing statement share a cache when no call can execute between them (corridor rule). | Chain overlaps a property write; root reassigned in scope; no recognizable host root; uses split by call/assignment barriers (recorded as a bail, not silently skipped). |
111
- | B2 | `closure-lift` | Arrow/function expressions created lexically inside hot functions (re-allocated every tick) are lifted to module scope when they capture nothing, only module-level identifiers/globals, or only `const`-bound primitives with literal initializable values (inlined as module consts). **Cell capture**: an arrow capturing a never-reassigned per-invocation local is also lifted through a module-level cell (`let __opCellN;`) assigned at the arrow's creation point — sound when every reference of the arrow is a direct synchronous call in the enclosing function, the arrow is not created inside a loop, the enclosing function is sync (no await/yield), and the captured local's declaration precedes the creation statement. Lifted functions keep their hotness. | References `this`, `arguments`, `super`, or `new.target`; captures a reassigned local; captures a live local but the arrow escapes (registered/stored/returned — each registration would need its own capture and a shared cell could be observed stale); captures a live local and is created inside a loop; captures a live local inside an async/generator function; captured local declared at or after the creation statement (TDZ hazard). |
112
- | B3 | `string-build` | `s += x` accumulated in a loop inside a hot function → `parts.push("" + x)` + one `s = parts.join("")` after the loop. QuickJS strings are immutable flat buffers, so `+=` over long loops is O(n²); parts+join is O(n). `"" + x` preserves exact ToString semantics (null → `"null"`, symbol throws) at the same evaluation point. | `s` not a `let` initialized to `""`; any read of `s` inside the loop; writes split across loops or inside nested closures; prepend form (`s = x + s`); the append is not a standalone statement. |
113
- | B4 | `dynprop-hoist` | `X.getDynamicProperty("k")` with a constant key, read in a loop in a hot function, hoisted above the loop. Never cached across tick invocations at this level — that is R1 territory. | Loop contains any other calls, await/yield, or property writes; receiver reassigned inside the loop; the read is inside a conditional within the body. |
114
- | B5 | `null-chain-flatten` | After S1/S2 prove a guard (`if (!a) return;`, `if (a == null) throw …`), `a?.b` → `a.b` for the guarded identifier, within the barrier-aware window after the guard. Only the FIRST optional hop is flattened (`a?.b?.c` keeps its second `?.` — `a.b` itself may still be nullish). | Guarded identifier reassigned anywhere in the function; use inside a conditional; a call in the guard window that could execute before the use (corridor check). |
70
+ </details>
115
71
 
116
- ### RISKY — opt-in (`--level risky` or explicit config); every application emits a warning
72
+ <details>
73
+ <summary><strong>RISKY — opt-in (<code>--level risky</code> or config); every application warns, each has a documented failure mode</strong></summary>
117
74
 
118
75
  | ID | Name | What it does | Failure mode |
119
- | --- | --- | --- | --- |
120
- | R1 | `cross-tick-cache` | Module-level `WeakMap` caches keyed by entity/player for `getComponent("…")` results and for `entity.id` / `entity.typeId`, reused across ticks with no invalidation. Injects two small helper functions and rewrites reads inside hot functions. | **Stale component views after death / dimension change.** The component view may also become invalid if the entity is removed. Even the "immutable" `id`/`typeId` subset is reported as risky. Equivalence holds exactly when the cached value is stable for the entity's lifetime (see the differential harness notes below). |
121
- | R2 | `json-fold` | `const x = JSON.parse('{"a":1}')` on a constant string hoisted to a module-level `const` shared by every use. The literal must parse at build time; the proof obligation is that every use is a pure read — property reads of *primitive* JSON values may flow anywhere (a copy), but the parsed object itself never escapes to calls, returns, throws, spreads, iterations, or aliases. | **Shared mutable state** if any code mutates the parsed object (now shared across all uses). The tool proves non-mutation per site; it bails (e.g. property writes, method calls, aliasing) and records the reason. |
122
- | R3 | `try-catch-strip` | Removes `try { … } catch {}` in hot functions when the try body contains no calls (and the catch body is empty). With no calls, the only throws left are pathological engine-level ones. | **A pathological engine-level throw now crashes the pack script** instead of being swallowed (e.g. a null-deref on a host proxy). Bails when the catch body is non-empty, a `finally` is present, or the try body contains calls/await/yield. |
123
- | R4 | `getter-cache-cross-barrier` | B1, but allowed to cross call and assignment barriers within one synchronous callback invocation (`await`/`yield` remain barriers). | **Stale cached values if an intervening call moves or modifies the entity** — teleport-then-read-cached-`location` returns the pre-teleport object. Every hoisted chain warns. |
76
+ |---|---|---|---|
77
+ | R1 | `cross-tick-cache` | Module-level WeakMap caches `getComponent("…")` and `entity.id`/`typeId` across ticks. | **Stale component views after death/dimension change.** |
78
+ | R2 | `json-fold` | `const x = JSON.parse('…')` hoisted to a module const when provably never mutated or passed to unknown code. | Shared mutable state if mutated anyway. |
79
+ | R3 | `try-catch-strip` | Removes `try { } catch { }` with no calls in the try body, in hot functions. | A pathological engine-level throw now crashes the script instead of being swallowed. |
80
+ | R4 | `getter-cache-cross-barrier` | B1, allowed to cross call barriers within one synchronous callback. | Cached values stale if an intervening call teleports/damages the entity. |
124
81
 
125
- ## Lint & report (no rewriting)
82
+ </details>
126
83
 
127
- Reported anti-patterns, per file with line numbers:
84
+ ## Pragmas
128
85
 
129
- - `per-tick-getAllPlayers` — `world.getAllPlayers()` / `dimension.getEntities()` / `dimension.players` unconditionally inside per-tick callbacks (suggests event-driven redesign).
130
- - `dynprop-in-loop` — `getDynamicProperty` calls inside tick loops beyond what B4 hoisted.
131
- - `residual-allocs` — counts of closures / arrays / object literals remaining inside hot callbacks after all transforms (the GC-pressure metric, in `residualAllocs`).
132
- - `subscribe-in-interval` — `subscribe()` called inside `runInterval` handlers.
133
- - `json-in-hot` — `JSON.parse` / `JSON.stringify` / `structuredClone` in hot functions.
134
- - `try-catch-wraps-tick` — try/catch wrapping an entire tick-handler body.
86
+ ```js
87
+ // optipack: off ← first comment in the file: skip the whole file
88
+ // optipack-disable const-fold ← line before a statement/function: skip that node (or `all`, or comma lists)
89
+ // optipack-hot ← line before a function: force-mark it hot
90
+ ```
135
91
 
136
- Report schema:
92
+ ## Config
93
+
94
+ `optipack.config.json` (CLI flags win):
137
95
 
138
96
  ```json
139
97
  {
140
98
  "level": "balanced",
141
- "files": [
142
- {
143
- "path": "scripts/main.js",
144
- "hotFunctions": 4,
145
- "transforms": { "for-of-to-indexed": 1, "cache-getter-chains": 1 },
146
- "bails": [{ "line": 18, "transform": "S4", "reason": "iterable not provably an array" }],
147
- "lint": [{ "line": 16, "rule": "per-tick-getAllPlayers", "message": "…" }],
148
- "residualAllocs": { "closures": 0, "arrays": 2, "objects": 0 },
149
- "bytes": { "before": 1977, "after": 2263 }
150
- }
151
- ],
152
- "warnings": [
153
- { "path": "scripts/main.js", "line": 6, "transform": "R2", "message": "…" }
154
- ],
155
- "totals": { "transforms": 6, "bails": 6, "warnings": 0 }
99
+ "transforms": { "json-fold": true },
100
+ "hotPath": { "transitiveDepth": 1 },
101
+ "lint": true
156
102
  }
157
103
  ```
158
104
 
159
- Bail records use the pass code (`S1`…`R4`); transform counters use the pass id (`const-fold`…). Warnings carry file, line, transform, and message; `totals.warnings` counts them.
160
-
161
- ## Effects-equivalence (how correctness is tested)
162
-
163
- Transforms may legitimately eliminate **reads** — that is the point — so the test harness compares **effects, not API-call traces**.
164
-
165
- The differential harness (`test/harness/`) injects a mock `@minecraft/server` module via Node loader hooks. The mock implements `world`, `system`, entities, components, and events with deterministic behavior and an **effect recorder**:
166
-
167
- - Every mutating call — `sendMessage`, `setDynamicProperty`, `runCommand`, `spawnEntity`, `applyDamage`, component writes, `teleport`, `kill`, and every `runInterval`/`runTimeout`/`run`/`runJob`/`subscribe` registration — is recorded as an ordered effect.
168
- - Reads return scripted sequences and are **not** recorded. Scripted read values index by tick: repeated reads within one synchronous tick return the same value (the engine is synchronous), and values may change between ticks.
169
- - Scenario steps (`tick N`, `fire event`) run identically against the original and the optimized entry, including the whole relative-import graph (each variant loads its own transformed files).
170
- - Equivalence: the recorded effect sequences must match exactly, in order. A divergence fails the build.
105
+ `transforms` can force any pass on/off regardless of level — that's how you run RISKY passes without `--level risky`.
171
106
 
172
- This catches real bugs: during development the harness caught a missing filter accumulator declaration and a broken map fall-through translation before any fixture comparison did.
107
+ ## What the report tells you
173
108
 
174
- The example pack (`example/`) is run through the CLI by the suite: parse validity, manifest fidelity, effects-equivalence over a scripted worldLoad/tick/playerSpawn scenario, a stable report snapshot, and byte-identical determinism across runs.
109
+ Each build writes `optipack-report.json`: transforms applied per file, every bail (line + reason — the tool never silently skips), lint findings (e.g. `getAllPlayers()` in tick handlers, residual allocations), and byte sizes.
175
110
 
176
- For R1 specifically, the harness documents the precise guarantee: with a *stable* component value, effects match exactly. When a component's value changes across ticks, R1's stale-cache failure mode becomes observable — that is the documented risk you accept by opting in.
111
+ ## Correctness: effects, not vibes
177
112
 
178
- ## Not implemented (explicitly)
113
+ The test suite (68 tests) proves **effects-equivalence**: scripts run against a mock `@minecraft/server` with an effect recorder that logs every *mutating* call and registration in order — reads return scripted sequences and are free to be eliminated. Original and optimized must produce **identical effect traces** across simulated ticks and events, for every fixture and for the example pack. Mocks exist for all ten `@minecraft/*` runtime modules (functional for `server`/`server-ui`, generated from real type definitions for the rest).
179
114
 
180
- - **B2 parameter threading for escaping arrows.** The spec allows threading per-invocation locals as parameters "when the sole call site is the registration call (`system.runInterval(() => fn(player))` shape)". As literally written this is not expressible: `runInterval`/`subscribe`/`runJob` invoke callbacks with no arguments, so a lifted `(player) => fn(player)` would receive `undefined` every tick. The sound equivalent — **cell capture** — is implemented for arrows whose every use is a synchronous call in the enclosing function (see the B2 row). Arrows that *escape* (are registered) while capturing live locals still bail: each registration needs its own capture, a single module-level cell cannot represent that, and the enclosing function's invocation count is statically unprovable. Note that escaping arrows are also a one-time cost (allocated at registration, not per tick), so there is no per-tick win being left on the table.
181
- - **Cross-file hot-path analysis.** A helper in `util.js` called only from a hot handler in `main.js` is not hot *in its own file* (same-file call graph only, per v1 scope). Annotate such helpers with `// optipack-hot`.
182
- - **Bundling, minification, down-leveling.** Out of scope. Output is valid ESM at the input's syntax level (ES2022-grade); `import` declarations are never rewritten, reordered, or removed, and exported names are never renamed.
183
- - **`slice()` on unknown receivers** is treated as array-returning only when the receiver itself is provably an array (string receivers return strings — S4 refuses them).
184
-
185
- ## Platform notes & conflicts
186
-
187
- Platform reference: <https://wiki.bedrock.dev/scripting/introduction-to-scripting/>.
188
-
189
- - Scripts execute as ESM declared by the manifest's script module (`"entry": "scripts/main.js"`). optipack never touches the manifest.
190
- - Most world APIs only work after `world.afterEvents.worldLoad` (Script API 2.0.0 semantics) — the example pack subscribes to it first.
191
- - Where the spec and current platform documentation could conflict, optipack prefers the safe direction (bail) and records the reason. Two conservative deviations worth noting: S4 never treats host returns (`getAllPlayers()` etc.) as provable arrays even though the API types say `Entity[]` — an API change or polyfill would otherwise break the conversion silently; and B1 refuses to cache any chain that overlaps a property write, even in R4 mode.
115
+ ```bash
116
+ npm test # 68 tests
117
+ npm run fetch-corpus # pull the real-world benchmark corpus (JaylyDev/ScriptAPI, MIT)
118
+ npm run update-snapshot # regenerate the example report snapshot
119
+ ```
192
120
 
193
121
  ## Limitations
194
122
 
195
- - Same-file analysis only (no cross-file call graph, no type checking).
196
- - The tool assumes dense arrays for S5's method inlining (true for all Script API arrays and typical JS; a pathological sparse array would change forEach/map/some/every/find/filter semantics — receivers with statically holey provenance bail instead).
197
- - The S7 before-loop hoist evaluates the chain once per loop entry even if the body runs zero times; for live host objects these reads are side-effect-free, but a null root combined with a zero-iteration loop would newly throw.
198
- - Comments are preserved (license headers survive), but formatting is normalized by the generator; byte-identical output is guaranteed only for identical input + config.
199
- - TS/JSX are not parsed; `.ts` files under `scripts/` are copied verbatim.
123
+ - Same-file analysis only; cross-file helpers aren't auto-hot (annotate with `// optipack-hot`).
124
+ - No bundling/minification — pair with esbuild etc. if you use npm libraries like `@minecraft/math`.
125
+ - S5 assumes dense arrays (sparse-array provenance bails); S7's before-loop hoist evaluates once per loop entry (safe for live host objects, documented edge for null roots + zero-iteration loops).
200
126
 
201
127
  ## Development
202
128
 
203
129
  ```
204
130
  npm install
205
- npm test # 65 tests: fixtures, bails, differentials, CLI, example
206
- npm run update-snapshot # regenerate test/example-report-snapshot.json
207
- node test/gen-expected.mjs # regenerate expected.js for test fixtures (after an
208
- # intentional pass-output change — review the diff!)
131
+ npm test
132
+ npm run update-snapshot # example report snapshot
133
+ npm run update-mock-stubs # regenerate @minecraft/* stubs from real type defs
134
+ npm run fetch-corpus # real-world benchmark corpus
209
135
  ```
210
136
 
211
- The test harness injects the `@minecraft/server` mock through Node's `module.register()` loader hooks, which need **Node ≥ 18.19** (the runtime tool itself is fine on any Node ≥ 18).
212
-
213
- Fixture layout: `test/<transformId>/input.js` + `expected.js` (exact pipeline output) + `config.json` (optional level) + `expects.json` (report assertions: transform counts, recorded bails, warnings, hot-function count) + `scenario.json` (optional differential scenario run against the mock).
214
-
215
- Layout:
216
-
217
- ```
218
- bin/optipack.js executable
219
- src/cli.js argument parsing, exit codes, --diff
220
- src/config.js config loading, validation, CLI overrides
221
- src/pack-walker.js pack traversal, verbatim copy rules
222
- src/pragma.js optipack: off / disable / hot
223
- src/hotpath.js hot classification + transitive propagation
224
- src/pass-utils.js barriers, purity, provably-array, corridor check
225
- src/passes/index.js ordered registry
226
- src/passes/s1…r4-*.js one file per pass
227
- src/report.js src/lint.js report accumulation, lint rules
228
- src/pipeline.js parse → analyze → passes → verify → generate
229
- src/diff.js dependency-free unified diff
230
- test/ fixtures per transform + differential harness + mock
231
- example/ sample behavior pack
232
- ```
137
+ MIT — see [LICENSE](LICENSE). Benchmark corpus scripts belong to their original authors (see `test/corpus/SOURCES.json`).
package/package.json CHANGED
@@ -1,34 +1,36 @@
1
- {
2
- "name": "optipack",
3
- "version": "1.0.0",
4
- "description": "QuickJS-targeted micro-optimizing pre-processor for Minecraft Bedrock behavior-pack scripts",
5
- "type": "module",
6
- "license": "MIT",
7
- "bin": {
8
- "optipack": "bin/optipack.js"
9
- },
10
- "engines": {
11
- "node": "\u003e=18"
12
- },
13
- "main": "src/pipeline.js",
14
- "scripts": {
15
- "test": "node --test \"test/*.test.mjs\"",
16
- "update-snapshot": "node test/update-example-snapshot.mjs"
17
- },
18
- "dependencies": {
19
- "@babel/generator": "^7.26.0",
20
- "@babel/parser": "^7.26.0",
21
- "@babel/traverse": "^7.26.0",
22
- "@babel/types": "^7.26.0",
23
- "commander": "^12.1.0"
24
- },
25
- "author": "lettucebecaesar",
26
- "repository": {
27
- "type": "git",
28
- "url": "git+https://github.com/lettucebecaesar/optipack.git"
29
- },
30
- "bugs": {
31
- "url": "https://github.com/lettucebecaesar/optipack/issues"
32
- },
33
- "homepage": "https://github.com/lettucebecaesar/optipack#readme"
34
- }
1
+ {
2
+ "name": "optipack",
3
+ "version": "1.0.1",
4
+ "description": "QuickJS-targeted micro-optimizing pre-processor for Minecraft Bedrock behavior-pack scripts",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "optipack": "bin/optipack.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "main": "src/pipeline.js",
14
+ "scripts": {
15
+ "test": "node --test \"test/*.test.mjs\"",
16
+ "update-snapshot": "node test/update-example-snapshot.mjs",
17
+ "fetch-corpus": "node test/fetch-corpus.mjs",
18
+ "update-mock-stubs": "node test/update-mock-stubs.mjs"
19
+ },
20
+ "dependencies": {
21
+ "@babel/generator": "^7.26.0",
22
+ "@babel/parser": "^7.26.0",
23
+ "@babel/traverse": "^7.26.0",
24
+ "@babel/types": "^7.26.0",
25
+ "commander": "^12.1.0"
26
+ },
27
+ "author": "lettucebecaesar",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/lettucebecaesar/optipack.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/lettucebecaesar/optipack/issues"
34
+ },
35
+ "homepage": "https://github.com/lettucebecaesar/optipack#readme"
36
+ }