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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -2
  3. package/bin/optipack.js +12 -0
  4. package/example/manifest.json +27 -0
  5. package/example/scripts/drops.js +24 -0
  6. package/example/scripts/main.js +67 -0
  7. package/example/scripts/util.js +30 -0
  8. package/package.json +34 -6
  9. package/src/cli.js +180 -0
  10. package/src/config.js +141 -0
  11. package/src/diff.js +108 -0
  12. package/src/hotpath.js +158 -0
  13. package/src/lint.js +150 -0
  14. package/src/pack-walker.js +50 -0
  15. package/src/pass-utils.js +419 -0
  16. package/src/passes/b1-cache-getter-chains.js +406 -0
  17. package/src/passes/b2-closure-lift.js +316 -0
  18. package/src/passes/b3-string-build.js +167 -0
  19. package/src/passes/b4-dynprop-hoist.js +177 -0
  20. package/src/passes/b5-null-chain-flatten.js +137 -0
  21. package/src/passes/index.js +43 -0
  22. package/src/passes/r1-cross-tick-cache.js +192 -0
  23. package/src/passes/r2-json-fold.js +201 -0
  24. package/src/passes/r3-try-catch-strip.js +61 -0
  25. package/src/passes/r4-getter-cache-cross-barrier.js +29 -0
  26. package/src/passes/s1-const-fold.js +130 -0
  27. package/src/passes/s2-dead-code.js +183 -0
  28. package/src/passes/s3-bool-simplify.js +82 -0
  29. package/src/passes/s4-for-of-to-indexed.js +140 -0
  30. package/src/passes/s5-array-methods-to-loops.js +357 -0
  31. package/src/passes/s6-destructure-to-direct.js +186 -0
  32. package/src/passes/s7-hoist-invariant-members.js +327 -0
  33. package/src/pipeline.js +186 -0
  34. package/src/pragma.js +115 -0
  35. package/src/report.js +98 -0
  36. package/test/array-methods-to-loops/expected.js +58 -0
  37. package/test/array-methods-to-loops/expects.json +6 -0
  38. package/test/array-methods-to-loops/input.js +23 -0
  39. package/test/array-methods-to-loops/scenario.json +22 -0
  40. package/test/array-methods-to-loops-bail/expected.js +10 -0
  41. package/test/array-methods-to-loops-bail/input.js +24 -0
  42. package/test/bool-simplify/expected.js +14 -0
  43. package/test/bool-simplify/expects.json +11 -0
  44. package/test/bool-simplify/input.js +18 -0
  45. package/test/cache-getter-chains/expected.js +20 -0
  46. package/test/cache-getter-chains/expects.json +11 -0
  47. package/test/cache-getter-chains/input.js +20 -0
  48. package/test/cache-getter-chains/scenario.json +35 -0
  49. package/test/closure-lift/expected.js +58 -0
  50. package/test/closure-lift/expects.json +1 -0
  51. package/test/closure-lift/input.js +50 -0
  52. package/test/closure-lift/scenario.json +9 -0
  53. package/test/const-fold/expected.js +12 -0
  54. package/test/const-fold/expects.json +1 -0
  55. package/test/const-fold/input.js +12 -0
  56. package/test/const-fold-bail/expected.js +7 -0
  57. package/test/const-fold-bail/expects.json +3 -0
  58. package/test/const-fold-bail/input.js +7 -0
  59. package/test/cross-tick-cache/config.json +1 -0
  60. package/test/cross-tick-cache/expected.js +38 -0
  61. package/test/cross-tick-cache/expects.json +6 -0
  62. package/test/cross-tick-cache/input.js +13 -0
  63. package/test/cross-tick-cache/scenario.json +33 -0
  64. package/test/dead-code/expected.js +14 -0
  65. package/test/dead-code/expects.json +5 -0
  66. package/test/dead-code/input.js +23 -0
  67. package/test/destructure-to-direct/expected.js +37 -0
  68. package/test/destructure-to-direct/expects.json +11 -0
  69. package/test/destructure-to-direct/input.js +28 -0
  70. package/test/destructure-to-direct/scenario.json +22 -0
  71. package/test/destructure-to-direct-bail/expected.js +36 -0
  72. package/test/destructure-to-direct-bail/expects.json +11 -0
  73. package/test/destructure-to-direct-bail/input.js +25 -0
  74. package/test/differential.test.mjs +267 -0
  75. package/test/dynprop-hoist/expected.js +24 -0
  76. package/test/dynprop-hoist/expects.json +11 -0
  77. package/test/dynprop-hoist/input.js +25 -0
  78. package/test/dynprop-hoist/scenario.json +24 -0
  79. package/test/example-report-snapshot.json +107 -0
  80. package/test/example.test.mjs +115 -0
  81. package/test/for-of-to-indexed/expected.js +59 -0
  82. package/test/for-of-to-indexed/expects.json +11 -0
  83. package/test/for-of-to-indexed/input.js +43 -0
  84. package/test/for-of-to-indexed-bail/expected.js +25 -0
  85. package/test/for-of-to-indexed-bail/expects.json +11 -0
  86. package/test/for-of-to-indexed-bail/input.js +29 -0
  87. package/test/gen-expected.mjs +33 -0
  88. package/test/getter-cache-cross-barrier/config.json +1 -0
  89. package/test/getter-cache-cross-barrier/expected.js +10 -0
  90. package/test/getter-cache-cross-barrier/expects.json +6 -0
  91. package/test/getter-cache-cross-barrier/input.js +11 -0
  92. package/test/getter-cache-cross-barrier/scenario.json +28 -0
  93. package/test/harness/fixture.js +117 -0
  94. package/test/harness/setup.js +198 -0
  95. package/test/hoist-invariant-members/expected.js +37 -0
  96. package/test/hoist-invariant-members/expects.json +11 -0
  97. package/test/hoist-invariant-members/input.js +34 -0
  98. package/test/hoist-invariant-members/scenario.json +35 -0
  99. package/test/hoist-invariant-members-bail/expected.js +19 -0
  100. package/test/hoist-invariant-members-bail/expects.json +5 -0
  101. package/test/hoist-invariant-members-bail/input.js +22 -0
  102. package/test/infra.test.mjs +260 -0
  103. package/test/json-fold/config.json +1 -0
  104. package/test/json-fold/expected.js +14 -0
  105. package/test/json-fold/expects.json +12 -0
  106. package/test/json-fold/input.js +17 -0
  107. package/test/json-fold-bail/config.json +1 -0
  108. package/test/json-fold-bail/expected.js +10 -0
  109. package/test/json-fold-bail/expects.json +5 -0
  110. package/test/json-fold-bail/input.js +14 -0
  111. package/test/loader-hooks.mjs +38 -0
  112. package/test/mock/@minecraft/server.mjs +274 -0
  113. package/test/null-chain-flatten/expected.js +22 -0
  114. package/test/null-chain-flatten/expects.json +5 -0
  115. package/test/null-chain-flatten/input.js +22 -0
  116. package/test/null-chain-flatten/scenario.json +24 -0
  117. package/test/string-build/expected.js +29 -0
  118. package/test/string-build/expects.json +5 -0
  119. package/test/string-build/input.js +29 -0
  120. package/test/string-build/scenario.json +27 -0
  121. package/test/string-build-bail/expected.js +10 -0
  122. package/test/string-build-bail/input.js +21 -0
  123. package/test/transforms.test.mjs +43 -0
  124. package/test/try-catch-strip/config.json +1 -0
  125. package/test/try-catch-strip/expected.js +57 -0
  126. package/test/try-catch-strip/expects.json +1 -0
  127. package/test/try-catch-strip/input.js +34 -0
  128. package/test/try-catch-strip/scenario.json +28 -0
  129. package/test/update-example-snapshot.mjs +19 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 optipack contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,232 @@
1
- # Temporary Holding Version
1
+ # optipack
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A QuickJS-targeted **micro-optimizing pre-processor** for Minecraft Bedrock Edition behavior-pack scripts.
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.
8
+
9
+ ```
10
+ npm install -g . # or: node bin/optipack.js
11
+ npx optipack <inputPack> <outputPack> [options]
12
+ ```
13
+
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.
17
+
18
+ ## CLI
19
+
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
+ ```
33
+
34
+ Exit codes: `0` success · `1` usage/config error · `2` parse failure (file + line printed to stderr) · `3` internal error.
35
+
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.
37
+
38
+ ## Config file
39
+
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
+ }
52
+ ```
53
+
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).
81
+
82
+ Barrier rules every downstream pass respects:
83
+
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.
87
+
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.
89
+
90
+ ## Transform catalog
91
+
92
+ Passes run in registry order (S1 → R4). Every application increments a counter in the report; every skipped opportunity is recorded with line + reason.
93
+
94
+ ### SAFE — provably behavior-identical
95
+
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). |
105
+
106
+ ### BALANCED — per-site-checked assumptions
107
+
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). |
115
+
116
+ ### RISKY — opt-in (`--level risky` or explicit config); every application emits a warning
117
+
118
+ | 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. |
124
+
125
+ ## Lint & report (no rewriting)
126
+
127
+ Reported anti-patterns, per file with line numbers:
128
+
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.
135
+
136
+ Report schema:
137
+
138
+ ```json
139
+ {
140
+ "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 }
156
+ }
157
+ ```
158
+
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.
171
+
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.
173
+
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.
175
+
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.
177
+
178
+ ## Not implemented (explicitly)
179
+
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.
192
+
193
+ ## Limitations
194
+
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.
200
+
201
+ ## Development
202
+
203
+ ```
204
+ 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!)
209
+ ```
210
+
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
+ ```
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv).then(
5
+ (code) => {
6
+ process.exitCode = code;
7
+ },
8
+ (err) => {
9
+ console.error(err && err.stack ? err.stack : String(err));
10
+ process.exitCode = 3;
11
+ }
12
+ );
@@ -0,0 +1,27 @@
1
+ {
2
+ "format_version": 2,
3
+ "header": {
4
+ "name": "optipack example pack",
5
+ "description": "Tiny behavior pack exercising tick loops, event handlers, a runJob generator and dynamic properties. Used by the optipack test suite.",
6
+ "uuid": "6f2a1c8e-3d44-4b0a-9d21-77aa01bb2f01",
7
+ "version": [1, 0, 0],
8
+ "min_engine_version": [1, 21, 0]
9
+ },
10
+ "modules": [
11
+ {
12
+ "type": "data",
13
+ "uuid": "9c1b3f60-8e2d-4c6a-b1e4-52dd03cc2f02",
14
+ "version": [1, 0, 0]
15
+ },
16
+ {
17
+ "type": "script",
18
+ "language": "javascript",
19
+ "uuid": "b7d4e2a0-5f31-4a88-8c70-63ee04dd2f03",
20
+ "entry": "scripts/main.js",
21
+ "version": [1, 0, 0]
22
+ }
23
+ ],
24
+ "dependencies": [
25
+ { "module_name": "@minecraft/server", "version": "2.0.0" }
26
+ ]
27
+ }
@@ -0,0 +1,24 @@
1
+ // optipack example — drop tables and dynamic property loops.
2
+ // Constant JSON folded at build time when R2 (json-fold) is enabled.
3
+
4
+ const DROP_TABLE = JSON.parse('{"common":["apple","bread"],"rare":["diamond"]}');
5
+
6
+ export function buildDropSummary(count) {
7
+ const items = DROP_TABLE.common;
8
+ let out = '';
9
+ for (let i = 0; i < items.length && i < count; i++) {
10
+ out += items[i] + ';';
11
+ }
12
+ return out;
13
+ }
14
+
15
+ // Hot loop re-reading dynamic properties: the B4 pattern when called per-tick.
16
+ export function pollDropState(entity) {
17
+ let picked = 0;
18
+ for (let i = 0; i < 3; i++) {
19
+ if (entity.getDynamicProperty('optipack:drops') !== undefined) {
20
+ picked = picked + 1;
21
+ }
22
+ }
23
+ return picked;
24
+ }
@@ -0,0 +1,67 @@
1
+ // optipack example — main entry.
2
+ // Registers a tick loop, an event handler, and a runJob generator.
3
+ import { world, system } from '@minecraft/server';
4
+ import { toZoneLabel, trackBaseline } from './util.js';
5
+ import { buildDropSummary, pollDropState } from './drops.js';
6
+
7
+ world.afterEvents.worldLoad.subscribe(() => {
8
+ world.sendMessage('optipack example loaded');
9
+ system.runInterval(onTick, 20);
10
+ world.afterEvents.playerSpawn.subscribe(onPlayerSpawn);
11
+ });
12
+
13
+ // Per-tick handler: re-reads host getters every run, builds a string with +=,
14
+ // and iterates provable arrays with for...of + array methods.
15
+ function onTick() {
16
+ const players = world.getAllPlayers();
17
+ let summary = '';
18
+ for (const player of players) {
19
+ const label = toZoneLabel(player);
20
+ const dist = player.location.x + player.location.y;
21
+ summary += label + ' dist=' + dist;
22
+ player.setDynamicProperty('optipack:lastDist', dist);
23
+ // Per-player baseline: re-reads the same host chain every iteration (S7).
24
+ let baseline = 0;
25
+ for (let i = 0; i < 4; i++) {
26
+ baseline += player.location.y;
27
+ }
28
+ player.setDynamicProperty('optipack:baseline', baseline);
29
+ }
30
+ if (summary.length > 0) {
31
+ world.sendMessage(summary);
32
+ }
33
+
34
+ const zones = ['overworld', 'nether', 'the_end'].filter((z) => z.length > 4);
35
+ for (const z of zones) {
36
+ world.setDynamicProperty('optipack:zone:' + z, true);
37
+ }
38
+
39
+ system.runJob(chunkScan());
40
+
41
+ if (players.length > 0) {
42
+ pollDropState(players[0]);
43
+ }
44
+ }
45
+
46
+ // runJob iterator: every yield is a barrier for host-object caching.
47
+ function* chunkScan() {
48
+ for (let cx = 0; cx < 4; cx++) {
49
+ const before = world.getDynamicProperty('optipack:scan:' + cx);
50
+ yield;
51
+ world.setDynamicProperty('optipack:scan:' + cx, (before ?? 0) + 1);
52
+ }
53
+ }
54
+
55
+ function onPlayerSpawn(ev) {
56
+ const p = ev.player;
57
+ if (!p) return;
58
+ world.sendMessage('welcome ' + p.name);
59
+ const health = p.getComponent('minecraft:health');
60
+ if (health) {
61
+ p.setDynamicProperty('optipack:spawnHp', health.currentValue);
62
+ }
63
+ const drops = buildDropSummary(2);
64
+ p.setDynamicProperty('optipack:drops', drops);
65
+ // Hot helper defined in util.js — exercises transitive hot-path analysis.
66
+ trackBaseline(p);
67
+ }
@@ -0,0 +1,30 @@
1
+ // optipack example — utilities. Hot helpers called from the tick loop.
2
+ import { world } from '@minecraft/server';
3
+
4
+ export function toZoneLabel(player) {
5
+ const x = player.location.x;
6
+ const z = player.location.z;
7
+ return 'pos:' + Math.floor(x) + ',' + Math.floor(z);
8
+ }
9
+
10
+ export function clamp(v, lo, hi) {
11
+ if (v < lo) return lo;
12
+ if (v > hi) return hi;
13
+ return v;
14
+ }
15
+
16
+ // Called from onPlayerSpawn which is called from a subscribed handler, and
17
+ // re-reads the same host chain in a loop: the S7 pattern.
18
+ export function trackBaseline(player) {
19
+ let sum = 0;
20
+ for (let i = 0; i < 8; i++) {
21
+ sum += player.location.y;
22
+ }
23
+ world.setDynamicProperty('optipack:baseline', sum);
24
+ return sum;
25
+ }
26
+
27
+ // Bool-shape helpers (S3).
28
+ export function isAboveWater(player) {
29
+ return player.location.y > 62 ? true : false;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
- {
2
- "name": "optipack",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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
+ }
package/src/cli.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * optipack CLI.
3
+ *
4
+ * optipack <inputPackDir> <outputPackDir> [options]
5
+ *
6
+ * Exit codes: 0 success · 1 usage/config error · 2 parse failure · 3 internal error
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { Command } from 'commander';
11
+
12
+ import { loadConfigFile, mergeCliOverrides, LEVELS } from './config.js';
13
+ import { runPack, ParseFailureError, InternalError } from './pipeline.js';
14
+ import { PASS_IDS, PASS_CODES } from './passes/index.js';
15
+ import { unifiedDiff } from './diff.js';
16
+
17
+ export async function main(argv) {
18
+ const program = new Command();
19
+ program
20
+ .name('optipack')
21
+ .description('QuickJS-targeted micro-optimizing pre-processor for Minecraft Bedrock behavior-pack scripts')
22
+ .version('1.0.0')
23
+ .argument('<inputPackDir>', 'behavior pack directory to read')
24
+ .argument('<outputPackDir>', 'directory to write the optimized pack into')
25
+ .option('--level <level>', 'optimization level: safe | balanced | risky')
26
+ .option('--config <path>', 'config file (default: ./optipack.config.json when present)')
27
+ .option('--only <ids>', 'comma-separated transform ids to run exclusively')
28
+ .option('--except <ids>', 'comma-separated transform ids to skip')
29
+ .option('--dry-run', 'analyze + report, write nothing (prints report to stdout unless --report is given)')
30
+ .option('--diff', 'print unified diffs per changed file')
31
+ .option('--report <path>', `report file (default: <outputPackDir>/optipack-report.json)`)
32
+ .option('--lint <bool>', 'enable/disable the lint pass (true|false)', parseBoolOpt)
33
+ .exitOverride();
34
+
35
+ let opts;
36
+ const args = [];
37
+ try {
38
+ program.parse(argv, { from: 'node' });
39
+ opts = program.opts();
40
+ args.push(...program.args);
41
+ } catch (e) {
42
+ if (e && e.code === 'commander.version') {
43
+ console.log(e.message);
44
+ return 0;
45
+ }
46
+ if (e && (e.code === 'commander.help' || e.code === 'commander.helpDisplayed')) {
47
+ return 0;
48
+ }
49
+ if (e && e.code === 'commander.missingArgument') {
50
+ console.error(`optipack: ${e.message}`);
51
+ return 1;
52
+ }
53
+ console.error(`optipack: ${e && e.message ? e.message : String(e)}`);
54
+ return 1;
55
+ }
56
+
57
+ const [inputDir, outputDir] = args;
58
+ if (!inputDir || !outputDir) {
59
+ console.error('optipack: both <inputPackDir> and <outputPackDir> are required');
60
+ return 1;
61
+ }
62
+ if (opts.level !== undefined && !LEVELS.includes(opts.level)) {
63
+ console.error(`optipack: invalid --level "${opts.level}" (expected one of ${LEVELS.join(', ')})`);
64
+ return 1;
65
+ }
66
+ if (opts.lint !== undefined && typeof opts.lint !== 'boolean') {
67
+ console.error('optipack: --lint expects true or false');
68
+ return 1;
69
+ }
70
+
71
+ // Resolve config file: explicit path, else ./optipack.config.json.
72
+ let configPath = opts.config;
73
+ if (configPath && !fs.existsSync(configPath)) {
74
+ console.error(`optipack: config file not found: ${configPath}`);
75
+ return 1;
76
+ }
77
+ let config;
78
+ try {
79
+ const loaded = loadConfigFile(configPath);
80
+ config = loaded.config;
81
+ configPath = loaded.path;
82
+ } catch (e) {
83
+ console.error(`optipack: ${e.message}`);
84
+ return 1;
85
+ }
86
+
87
+ // CLI overrides.
88
+ const cli = {
89
+ level: opts.level,
90
+ only: opts.only,
91
+ except: opts.except,
92
+ lint: opts.lint,
93
+ config: configPath,
94
+ };
95
+ try {
96
+ config = mergeCliOverrides(config, cli, PASS_IDS, PASS_CODES);
97
+ } catch (e) {
98
+ console.error(`optipack: ${e.message}`);
99
+ return 1;
100
+ }
101
+
102
+ const dryRun = !!opts.dryRun;
103
+ const reportPath = opts.report !== undefined
104
+ ? opts.report
105
+ : path.join(outputDir, 'optipack-report.json');
106
+
107
+ // Capture original sources for --diff.
108
+ const beforeSources = new Map();
109
+ if (opts.diff) {
110
+ const { walkPack } = await import('./pack-walker.js');
111
+ if (fs.existsSync(inputDir)) {
112
+ const { scripts } = walkPack(inputDir);
113
+ for (const s of scripts) beforeSources.set(s.relPath, fs.readFileSync(s.abs, 'utf8'));
114
+ }
115
+ }
116
+
117
+ try {
118
+ const report = runPack(inputDir, outputDir, config, { dryRun });
119
+ const json = report.serialize();
120
+
121
+ if (dryRun) {
122
+ if (opts.report !== undefined) {
123
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
124
+ fs.writeFileSync(reportPath, json);
125
+ console.log(`optipack: dry run — wrote report to ${reportPath} (no pack files written)`);
126
+ } else {
127
+ console.log(json);
128
+ }
129
+ } else {
130
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
131
+ fs.writeFileSync(reportPath, json);
132
+ }
133
+
134
+ if (opts.diff) {
135
+ const { walkPack } = await import('./pack-walker.js');
136
+ const { scripts } = walkPack(inputDir);
137
+ for (const s of scripts) {
138
+ const outAbs = path.join(outputDir, s.relPath);
139
+ if (!fs.existsSync(outAbs)) continue;
140
+ const after = fs.readFileSync(outAbs, 'utf8');
141
+ const before = beforeSources.get(s.relPath) ?? '';
142
+ const d = unifiedDiff(s.relPath, s.relPath, before, after);
143
+ if (d) process.stdout.write(d);
144
+ }
145
+ }
146
+
147
+ // Human summary on success (only when not printing the full report).
148
+ if (!dryRun || opts.report !== undefined) {
149
+ const t = report.totals;
150
+ console.log(
151
+ `optipack: ${report.files.length} script file(s), level ${report.level}, ` +
152
+ `${t.transforms} transform(s) applied, ${t.bails} bail(s), ${t.warnings} warning(s)`
153
+ );
154
+ console.log(`optipack: report written to ${reportPath}${dryRun ? ' (dry run: no pack files written)' : ''}`);
155
+ }
156
+ return 0;
157
+ } catch (e) {
158
+ if (e instanceof ParseFailureError) {
159
+ console.error(`optipack: parse failure — ${e.message}`);
160
+ return 2;
161
+ }
162
+ if (e instanceof InternalError) {
163
+ console.error(`optipack: internal error — ${e.message}`);
164
+ return 3;
165
+ }
166
+ if (e && e.code === 1) {
167
+ console.error(`optipack: ${e.message}`);
168
+ return 1;
169
+ }
170
+ console.error(`optipack: internal error — ${e && e.stack ? e.stack : String(e)}`);
171
+ return 3;
172
+ }
173
+ }
174
+
175
+ function parseBoolOpt(v) {
176
+ if (v === 'true' || v === '' || v === undefined) return true;
177
+ if (v === 'false') return false;
178
+ // Signal an invalid value; validated by the caller.
179
+ return v;
180
+ }