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
@@ -0,0 +1,327 @@
1
+ /**
2
+ * S7 hoist-invariant-members — hot functions only.
3
+ *
4
+ * Two hoist modes for for/while/do loops:
5
+ *
6
+ * 1. Body-top hoist (always safe): a member chain evaluated two or more times
7
+ * per iteration, in unconditional top-level body statements, is hoisted to
8
+ * a `const` at the top of the loop body. The root may be the loop variable.
9
+ *
10
+ * 2. Before-loop hoist: when the whole loop subtree is barrier-free (no calls,
11
+ * no await/yield, no property writes, no delete; assignments to plain local
12
+ * identifiers are allowed but their names must not appear in any hoisted
13
+ * chain), a host chain rooted at a value that is never reassigned anywhere
14
+ * in the enclosing function and not declared inside the loop is hoisted to
15
+ * a `const` BEFORE the loop — eliminating the per-iteration boundary
16
+ * crossing entirely (e.g. `total += chest.location.y`).
17
+ *
18
+ * Bail conditions (recorded): body contains calls / await / yield / property
19
+ * writes; chain root reassigned; chain contains computed accesses; root is a
20
+ * loop binding; qualifying chains exist but none could be hoisted.
21
+ *
22
+ * Known assumption (documented in README): the before-loop hoist evaluates the
23
+ * chain once even when the loop body never runs; for live host objects these
24
+ * reads are side-effect-free, but a null root combined with a zero-iteration
25
+ * loop would newly throw.
26
+ */
27
+ import traverse from '@babel/traverse';
28
+ import * as t from '@babel/types';
29
+ import { walkSameFunction, walkWithParents, HOST_MARKERS, ChainAnalyzer } from '../pass-utils.js';
30
+
31
+ const _traverse = traverse.default || traverse;
32
+
33
+ export const ID = 'hoist-invariant-members';
34
+ export const CODE = 'S7';
35
+
36
+ const LOOP_TYPES = ['ForStatement', 'WhileStatement', 'DoWhileStatement'];
37
+
38
+ export default function hoistInvariantMembers(programPath, ctx) {
39
+ const chains = new ChainAnalyzer();
40
+
41
+ programPath.traverse({
42
+ [LOOP_TYPES.join('|')](path) {
43
+ const node = path.node;
44
+ const line = node.loc ? node.loc.start.line : 0;
45
+ const fnParent = path.getFunctionParent();
46
+ if (!fnParent || !ctx.isHot(fnParent.node)) return;
47
+ if (ctx.isDisabled(path, ID, CODE)) return;
48
+
49
+ const fail = (reason) => {
50
+ ctx.bail(line, CODE, reason);
51
+ return null;
52
+ };
53
+
54
+ // ---- barrier scan over the loop subtree (excluding nested functions)
55
+ let hasCall = false;
56
+ let hasAwaitYield = false;
57
+ let hasPropWrite = false;
58
+ const assignTargets = new Set();
59
+ const scan = (allowLocalAssign) => (n) => {
60
+ if (t.isCallExpression(n) || t.isNewExpression(n) || t.isTaggedTemplateExpression(n) ||
61
+ t.isOptionalCallExpression(n)) { hasCall = true; return false; }
62
+ if (t.isAwaitExpression(n) || t.isYieldExpression(n)) { hasAwaitYield = true; return false; }
63
+ if (t.isAssignmentExpression(n)) {
64
+ if (t.isMemberExpression(n.left)) { hasPropWrite = true; return false; }
65
+ if (t.isIdentifier(n.left)) assignTargets.add(n.left.name);
66
+ return true;
67
+ }
68
+ if (t.isUpdateExpression(n)) {
69
+ if (t.isMemberExpression(n.argument)) { hasPropWrite = true; return false; }
70
+ if (t.isIdentifier(n.argument)) assignTargets.add(n.argument.name);
71
+ return true;
72
+ }
73
+ if (t.isUnaryExpression(n) && n.operator === 'delete') { hasPropWrite = true; return false; }
74
+ return true;
75
+ };
76
+ walkSameFunction(node.body, scan(true));
77
+ let forPartsBarrierFree = true;
78
+ if (node.type === 'ForStatement') {
79
+ for (const part of [node.test, node.update]) {
80
+ if (!part) continue;
81
+ walkSameFunction(part, (n) => {
82
+ if (t.isCallExpression(n) || t.isNewExpression(n) || t.isTaggedTemplateExpression(n)) { hasCall = true; forPartsBarrierFree = false; return false; }
83
+ if (t.isAwaitExpression(n) || t.isYieldExpression(n)) { hasAwaitYield = true; forPartsBarrierFree = false; return false; }
84
+ if ((t.isAssignmentExpression(n) || t.isUpdateExpression(n)) && t.isMemberExpression(n.left ?? n.argument)) {
85
+ hasPropWrite = true; forPartsBarrierFree = false; return false;
86
+ }
87
+ return true;
88
+ });
89
+ }
90
+ }
91
+ const barrierFree = !hasCall && !hasAwaitYield && !hasPropWrite;
92
+ if (!barrierFree) {
93
+ return fail(
94
+ hasCall ? 'loop body contains calls (mutation barrier)'
95
+ : hasAwaitYield ? 'loop body contains await/yield'
96
+ : 'loop body contains property writes'
97
+ );
98
+ }
99
+
100
+ // ---- collect member uses from top-level body statements.
101
+ // Only chains that are UNCONDITIONALLY evaluated when the body runs are
102
+ // collected: nested if/loop/try bodies and switch cases are excluded
103
+ // (their reads may never execute), while plain nested blocks and if/switch
104
+ // test expressions are included.
105
+ const uses = []; // { node, root, isThis, links }
106
+ const collectFromNode = (n) => {
107
+ walkWithParents(n, (m, parent) => {
108
+ if (t.isFunctionExpression(m) || t.isArrowFunctionExpression(m)) return false;
109
+ if (!t.isMemberExpression(m) || m.computed) return true;
110
+ // Never hoist a chain used as a call callee — extracting the method
111
+ // would detach `this`.
112
+ if (parent && t.isCallExpression(parent) && parent.callee === m) return true;
113
+ const links = [];
114
+ let cur = m;
115
+ let root = null;
116
+ let isThis = false;
117
+ let ok = true;
118
+ while (true) {
119
+ if (!t.isMemberExpression(cur)) break;
120
+ if (cur.computed || !t.isIdentifier(cur.property)) { ok = false; break; }
121
+ links.unshift(cur.property.name);
122
+ const obj = cur.object;
123
+ if (t.isMemberExpression(obj)) { cur = obj; continue; }
124
+ if (t.isIdentifier(obj)) root = obj;
125
+ else if (t.isThisExpression(obj)) { isThis = true; }
126
+ else { ok = false; }
127
+ break;
128
+ }
129
+ if (ok && (root || isThis) && links.length >= 1) {
130
+ uses.push({ node: m, root, isThis, links });
131
+ }
132
+ return true;
133
+ });
134
+ };
135
+ const collectFromStmt = (stmt) => {
136
+ if (t.isBlockStatement(stmt)) {
137
+ for (const s of stmt.body) collectFromStmt(s);
138
+ return;
139
+ }
140
+ if (t.isLabeledStatement(stmt)) { collectFromStmt(stmt.body); return; }
141
+ if (t.isIfStatement(stmt)) { collectFromNode(stmt.test); return; }
142
+ if (t.isSwitchStatement(stmt)) { collectFromNode(stmt.discriminant); return; }
143
+ if (t.isWhileStatement(stmt) || t.isDoWhileStatement(stmt) || t.isForStatement(stmt) ||
144
+ t.isForOfStatement(stmt) || t.isForInStatement(stmt) || t.isTryStatement(stmt)) {
145
+ return; // conditionally evaluated / barrier-heavy constructs
146
+ }
147
+ collectFromNode(stmt);
148
+ };
149
+ const bodyIsBlock = t.isBlockStatement(node.body);
150
+ const topLevelStmts = bodyIsBlock ? node.body.body : [node.body];
151
+ for (const stmt of topLevelStmts) collectFromStmt(stmt);
152
+ if (uses.length === 0) return;
153
+
154
+ // ---- group by chain identity
155
+ const bindingId = (name) => {
156
+ const binding = path.scope.getBinding(name);
157
+ return binding ? chains.bindingId(binding) : 'global:' + name;
158
+ };
159
+ const groups = new Map(); // key -> { root, isThis, links, uses: [] }
160
+ for (const u of uses) {
161
+ const key = (u.isThis ? '@this' : bindingId(u.root.name)) + '#' + u.links.join('.');
162
+ if (!groups.has(key)) groups.set(key, { root: u.root, isThis: u.isThis, links: u.links, uses: [] });
163
+ groups.get(key).uses.push(u);
164
+ }
165
+
166
+ // ---- qualification helpers
167
+ const rootInfo = (name) => {
168
+ const binding = path.scope.getBinding(name);
169
+ return binding;
170
+ };
171
+ const isParamOfEnclosingFn = (name) => {
172
+ const binding = rootInfo(name);
173
+ if (!binding) return false;
174
+ return binding.path.parentPath && binding.path.parentPath.isFunction() &&
175
+ binding.path.node === binding.path.parentPath.node.params.find((p) => t.isIdentifier(p) && p.name === name);
176
+ };
177
+ const declaredInsideLoop = (name) => {
178
+ const binding = rootInfo(name);
179
+ if (!binding) return false;
180
+ let p = binding.path;
181
+ while (p) {
182
+ if (p.node === node) return true;
183
+ p = p.parentPath;
184
+ }
185
+ return false;
186
+ };
187
+ const neverReassignedAnywhere = (name) => {
188
+ const binding = rootInfo(name);
189
+ if (!binding) return true;
190
+ return binding.constantViolations.length === 0;
191
+ };
192
+
193
+ // Longer chains first for deterministic, maximal sharing.
194
+ const entries = [...groups.entries()].sort((a, b) => {
195
+ const d = b[1].links.length - a[1].links.length;
196
+ return d || (a[0] < b[0] ? -1 : 1);
197
+ });
198
+
199
+ const hoists = []; // { where: 'before'|'body', links, root, isThis, uses, name }
200
+ for (const [, g] of entries) {
201
+ if (g.uses.length < 1) continue;
202
+ if (!g.isThis) {
203
+ if (assignTargets.has(g.root.name)) continue; // root mutated in loop
204
+ }
205
+ const hasMarker = g.links.some((l) => HOST_MARKERS.has(l));
206
+ const isParam = !g.isThis && isParamOfEnclosingFn(g.root.name);
207
+ const qualified = g.isThis || isParam || hasMarker;
208
+ if (!qualified) continue;
209
+
210
+ const beforeLoopEligible = barrierFree && forPartsBarrierFree && !g.isThis &&
211
+ neverReassignedAnywhere(g.root.name) && !declaredInsideLoop(g.root.name);
212
+
213
+ if (beforeLoopEligible && g.uses.length >= 1) {
214
+ hoists.push({ where: 'before', ...g });
215
+ } else if (g.uses.length >= 2 && g.root && !declaredInsideLoop(g.root.name)) {
216
+ // Body-top: root may be the loop variable only if declared before
217
+ // uses — loop bindings are declared in the head, which precedes the
218
+ // body, so this is fine. Body-declared roots must be declared before
219
+ // their first use.
220
+ const binding = rootInfo(g.root.name);
221
+ const firstUse = g.uses[0].node;
222
+ if (!binding || (binding.path.node.loc && firstUse.loc &&
223
+ binding.path.node.loc.start.line <= firstUse.loc.start.line)) {
224
+ hoists.push({ where: 'body', ...g });
225
+ }
226
+ }
227
+ }
228
+ if (hoists.length === 0) {
229
+ return fail('qualifying chains exist but none is repeated twice or is loop-invariant');
230
+ }
231
+
232
+ // ---- consume uses: longest chains first; a use consumed by a longer
233
+ // hoist also consumes its prefix nodes (they live inside the replaced
234
+ // subtree and would dangle).
235
+ const consumed = new Set();
236
+ const allUses = [];
237
+ for (const [, g] of entries) allUses.push(...g.uses);
238
+ const sameRoot = (a, b) =>
239
+ a.isThis === b.isThis && (a.isThis || (a.root && b.root && a.root.name === b.root.name &&
240
+ bindingId(a.root.name) === bindingId(b.root.name)));
241
+ const finalHoists = [];
242
+ for (const h of hoists) {
243
+ const live = h.uses.filter((u) => !consumed.has(u.node));
244
+ if (live.length === 0) continue;
245
+ if (h.where === 'body' && live.length < 2) continue;
246
+ for (const u of live) {
247
+ consumed.add(u.node);
248
+ // Consume prefix uses of the same root (they sit inside the subtree).
249
+ for (const u2 of allUses) {
250
+ if (consumed.has(u2.node)) continue;
251
+ if (!sameRoot(u2, u)) continue;
252
+ if (u2.links.length < u.links.length &&
253
+ u.links.slice(0, u2.links.length).join('.') === u2.links.join('.')) {
254
+ consumed.add(u2.node);
255
+ }
256
+ }
257
+ }
258
+ finalHoists.push({ ...h, uses: live });
259
+ }
260
+ if (finalHoists.length === 0) return fail('all chain uses were consumed by longer hoists');
261
+
262
+ // ---- emit
263
+ const beforeDecls = [];
264
+ const bodyDecls = [];
265
+ const scope = path.scope;
266
+ for (const h of finalHoists) {
267
+ const name = scope.generateUid('opH');
268
+ let expr = h.isThis ? t.thisExpression() : t.identifier(h.root.name);
269
+ for (const link of h.links) expr = t.memberExpression(expr, t.identifier(link));
270
+ const decl = t.variableDeclaration('const', [t.variableDeclarator(t.identifier(name), expr)]);
271
+ if (h.where === 'before') beforeDecls.push(decl);
272
+ else bodyDecls.push(decl);
273
+ for (const u of h.uses) {
274
+ u.node.__opHoistName = name;
275
+ }
276
+ }
277
+
278
+ // Rewrite uses: replace member nodes that carry __opHoistName.
279
+ const rewrite = (rootNode) => {
280
+ walkSameFunction(rootNode, (n) => {
281
+ if (n && n.__opHoistName) {
282
+ const name = n.__opHoistName;
283
+ delete n.__opHoistName;
284
+ // Replace this node within its parent object (AST surgery).
285
+ replaceNodeInParent(rootNode, n, t.identifier(name));
286
+ return false;
287
+ }
288
+ return true;
289
+ });
290
+ };
291
+ for (const stmt of topLevelStmts) rewrite(stmt);
292
+
293
+ if (beforeDecls.length > 0) {
294
+ const insertTarget = path.parentPath && path.parentPath.isLabeledStatement() ? path.parentPath : path;
295
+ insertTarget.insertBefore(beforeDecls);
296
+ }
297
+ if (bodyDecls.length > 0) {
298
+ if (bodyIsBlock) node.body.body = [...bodyDecls, ...node.body.body];
299
+ else node.body = t.blockStatement([...bodyDecls, node.body]);
300
+ }
301
+ ctx.applied(ID);
302
+ },
303
+ });
304
+ }
305
+
306
+ /** Replace oldNode with newNode anywhere in the tree rooted at rootNode. */
307
+ function replaceNodeInParent(rootNode, oldNode, newNode) {
308
+ const stack = [rootNode];
309
+ while (stack.length > 0) {
310
+ const cur = stack.pop();
311
+ for (const key of Object.keys(cur)) {
312
+ if (key === 'loc' || key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments' || key === 'extra') continue;
313
+ const v = cur[key];
314
+ if (v === oldNode) { cur[key] = newNode; return true; }
315
+ if (Array.isArray(v)) {
316
+ const idx = v.indexOf(oldNode);
317
+ if (idx >= 0) { v[idx] = newNode; return true; }
318
+ for (const item of v) {
319
+ if (item && typeof item === 'object' && typeof item.type === 'string') stack.push(item);
320
+ }
321
+ } else if (v && typeof v === 'object' && typeof v.type === 'string') {
322
+ stack.push(v);
323
+ }
324
+ }
325
+ }
326
+ return false;
327
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pipeline — parse, analyze, transform, verify, report. One file at a time.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parse } from '@babel/parser';
7
+ import traverse from '@babel/traverse';
8
+ import generate from '@babel/generator';
9
+
10
+ import { PragmaInfo } from './pragma.js';
11
+ import { analyzeHotPaths } from './hotpath.js';
12
+ import { PassContext, newFileReport, Report } from './report.js';
13
+ import { PASSES } from './passes/index.js';
14
+ import { LEVEL_RANK } from './config.js';
15
+ import { runLint } from './lint.js';
16
+ import { walkPack, copyFile, writeFile } from './pack-walker.js';
17
+
18
+ const _traverse = traverse.default || traverse;
19
+ const _generate = generate.default || generate;
20
+
21
+ export class ParseFailureError extends Error {
22
+ constructor(fileName, message, loc) {
23
+ // Babel parse errors carry a flat Position {line, column}; be tolerant.
24
+ const line = loc ? (loc.start ? loc.start.line : loc.line) : null;
25
+ const col = loc ? (loc.start ? loc.start.column : loc.column) : null;
26
+ const where = line !== null && line !== undefined ? `${line}:${col}` : '?';
27
+ super(`${fileName}:${where} ${message}`);
28
+ this.fileName = fileName;
29
+ this.loc = loc || null;
30
+ this.code = 2;
31
+ }
32
+ }
33
+
34
+ export class InternalError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.code = 3;
38
+ }
39
+ }
40
+
41
+ const PARSE_OPTIONS = {
42
+ sourceType: 'module',
43
+ errorRecovery: false,
44
+ ranges: true,
45
+ attachComment: true,
46
+ };
47
+
48
+ export function parseSource(fileName, source) {
49
+ try {
50
+ return parse(source, PARSE_OPTIONS);
51
+ } catch (e) {
52
+ throw new ParseFailureError(fileName, e.message, e.loc);
53
+ }
54
+ }
55
+
56
+ export function generateCode(ast) {
57
+ const out = _generate(ast, {
58
+ comments: true,
59
+ compact: false,
60
+ concise: false,
61
+ minified: false,
62
+ retainLines: false,
63
+ retainFunctionParens: true,
64
+ jsescOption: { minimal: true },
65
+ });
66
+ let code = out.code;
67
+ if (!code.endsWith('\n')) code += '\n';
68
+ return code;
69
+ }
70
+
71
+ /**
72
+ * Transform one script source. Returns { code, fileReport }.
73
+ */
74
+ export function transformSource(fileName, source, config) {
75
+ const fileReport = newFileReport(fileName, Buffer.byteLength(source, 'utf8'));
76
+ const ast = parseSource(fileName, source);
77
+
78
+ const pragmas = new PragmaInfo(ast);
79
+ if (pragmas.fileOff) {
80
+ // `// optipack: off` — emit unchanged.
81
+ fileReport.bytes.after = fileReport.bytes.before;
82
+ return { code: source, fileReport };
83
+ }
84
+
85
+ const hot = analyzeHotPaths(ast, pragmas, { transitiveDepth: config.hotPath.transitiveDepth });
86
+ fileReport.hotFunctions = hot.hotCount;
87
+
88
+ const warnings = [];
89
+ const ctx = new PassContext({
90
+ fileName,
91
+ pragmas,
92
+ hot,
93
+ fileReport,
94
+ warningsSink: (w) => warnings.push(w),
95
+ config,
96
+ });
97
+
98
+ // Program path reused across passes; scope is re-crawled before each pass
99
+ // so binding information stays fresh after rewrites.
100
+ let programPath = null;
101
+ _traverse(ast, {
102
+ Program(p) {
103
+ programPath = p;
104
+ p.stop();
105
+ },
106
+ });
107
+ if (!programPath) throw new InternalError(`${fileName}: no Program node`);
108
+
109
+ const only = config.only || null;
110
+ for (const pass of PASSES) {
111
+ // Level filter.
112
+ let enabled = LEVEL_RANK[pass.level] <= LEVEL_RANK[config.level];
113
+ // Explicit config overrides.
114
+ if (config.transforms[pass.id] === true) enabled = true;
115
+ if (config.transforms[pass.id] === false) enabled = false;
116
+ // only/except.
117
+ if (only) enabled = only.has(pass.id);
118
+ if (!enabled) continue;
119
+
120
+ try {
121
+ programPath.scope.crawl();
122
+ pass.run(programPath, ctx);
123
+ } catch (e) {
124
+ if (e instanceof ParseFailureError) throw e;
125
+ throw new InternalError(`${fileName}: pass ${pass.id} failed: ${e.message}\n${e.stack}`);
126
+ }
127
+ }
128
+
129
+ // Lint (no rewriting).
130
+ if (config.lint) {
131
+ try {
132
+ runLint(programPath, ctx, hot);
133
+ } catch (e) {
134
+ throw new InternalError(`${fileName}: lint failed: ${e.message}\n${e.stack}`);
135
+ }
136
+ }
137
+
138
+ // Generate + verify round-trip parse.
139
+ const code = generateCode(ast);
140
+ try {
141
+ parse(code, PARSE_OPTIONS);
142
+ } catch (e) {
143
+ throw new InternalError(
144
+ `${fileName}: round-trip parse failed (internal error): ${e.message} at line ${e.loc ? e.loc.start.line : '?'}`
145
+ );
146
+ }
147
+
148
+ fileReport.bytes.after = Buffer.byteLength(code, 'utf8');
149
+ fileReport.warnings = warnings.filter((w) => w.path === fileName).length;
150
+ return { code, fileReport, warnings };
151
+ }
152
+
153
+ /**
154
+ * Run the whole pack: transform scripts, copy everything else.
155
+ * Returns the aggregate Report.
156
+ */
157
+ export function runPack(inputDir, outputDir, config, { dryRun = false } = {}) {
158
+ if (!fs.existsSync(inputDir) || !fs.statSync(inputDir).isDirectory()) {
159
+ const err = new Error(`input pack directory not found: ${inputDir}`);
160
+ err.code = 1;
161
+ throw err;
162
+ }
163
+
164
+ const report = new Report(config.level);
165
+ const { scripts, copies } = walkPack(inputDir);
166
+
167
+ if (!dryRun) {
168
+ fs.mkdirSync(outputDir, { recursive: true });
169
+ }
170
+
171
+ for (const f of copies) {
172
+ if (!dryRun) copyFile(f.abs, path.join(outputDir, f.relPath));
173
+ }
174
+
175
+ for (const f of scripts) {
176
+ const source = fs.readFileSync(f.abs, 'utf8');
177
+ const { code, fileReport, warnings } = transformSource(f.relPath, source, config);
178
+ if (!dryRun) {
179
+ writeFile(path.join(outputDir, f.relPath), code);
180
+ }
181
+ report.addFile(fileReport);
182
+ for (const w of warnings) report.warn(w);
183
+ }
184
+
185
+ return report;
186
+ }
package/src/pragma.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pragma extraction and queries.
3
+ *
4
+ * Supported pragmas (exact syntax):
5
+ * // optipack: off (must be the FIRST comment in the file)
6
+ * // optipack-disable <transformId|all> (line immediately before a node)
7
+ * // optipack-hot (line immediately before a function)
8
+ *
9
+ * "Immediately before" tolerates other comment lines stacked in between, but
10
+ * nothing but comments may appear between the pragma and the target node.
11
+ */
12
+
13
+ const DISABLE_RE = /^optipack-disable\s+(.+?)\s*$/;
14
+ const HOT_RE = /^optipack-hot\s*$/;
15
+ const OFF_RE = /^optipack:\s*off\s*$/;
16
+
17
+ export function analyzePragmas(ast) {
18
+ const comments = ast.comments || [];
19
+ const first = comments.length > 0 ? comments[0] : null;
20
+ const fileOff = !!(first && first.type === 'CommentLine' && OFF_RE.test(first.value.trim()));
21
+
22
+ // Every line covered by any comment (for adjacency computation).
23
+ const coveredLines = new Set();
24
+ for (const c of comments) {
25
+ for (let l = c.loc.start.line; l <= c.loc.end.line; l++) coveredLines.add(l);
26
+ }
27
+
28
+ // line -> Set of ids (may include 'all') for the node starting on that line.
29
+ const disableByLine = new Map();
30
+ const hotLines = new Set();
31
+ for (const c of comments) {
32
+ if (c.type !== 'CommentLine') continue;
33
+ const val = c.value.trim();
34
+ const dm = DISABLE_RE.exec(val);
35
+ if (dm) {
36
+ const target = c.loc.end.line + 1;
37
+ if (!disableByLine.has(target)) disableByLine.set(target, new Set());
38
+ for (const id of dm[1].split(',')) {
39
+ const s = id.trim();
40
+ if (s) disableByLine.get(target).add(s);
41
+ }
42
+ } else if (HOT_RE.test(val)) {
43
+ hotLines.add(c.loc.end.line + 1);
44
+ }
45
+ }
46
+
47
+ return { fileOff, disableByLine, hotLines, coveredLines, comments };
48
+ }
49
+
50
+ /**
51
+ * Does the pragma comment on line `pragmaLine` (a CommentLine occupying that
52
+ * line) sit immediately before a node starting at `nodeStartLine`? Only other
53
+ * comment lines may appear in between.
54
+ */
55
+ function adjacent(coveredLines, pragmaLine, nodeStartLine) {
56
+ if (nodeStartLine <= pragmaLine) return false;
57
+ for (let l = pragmaLine + 1; l < nodeStartLine; l++) {
58
+ if (!coveredLines.has(l)) return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ export class PragmaInfo {
64
+ constructor(ast) {
65
+ this.info = analyzePragmas(ast);
66
+ }
67
+
68
+ get fileOff() {
69
+ return this.info.fileOff;
70
+ }
71
+
72
+ /**
73
+ * Set of ids disabled for `node` via an adjacent `// optipack-disable`
74
+ * comment, or null when none applies. Considers the node itself and its
75
+ * start line only; callers walk ancestors.
76
+ */
77
+ disabledAt(node) {
78
+ if (!node.loc) return null;
79
+ const start = node.loc.start.line;
80
+ const direct = this.info.disableByLine.get(start);
81
+ if (direct) return direct;
82
+ // Stacked pragmas: a pragma further up whose gap to the node is all comments.
83
+ for (const [line, ids] of this.info.disableByLine) {
84
+ if (line < start && adjacent(this.info.coveredLines, line - 1, start)) return ids;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Walk the path's ancestor chain (self included) looking for a disable
91
+ * pragma matching one of the given ids (transform ids or codes) or 'all'.
92
+ */
93
+ isDisabled(path, ...ids) {
94
+ let p = path;
95
+ while (p) {
96
+ const ids2 = this.disabledAt(p.node);
97
+ if (ids2) {
98
+ if (ids2.has('all')) return true;
99
+ for (const id of ids) if (ids2.has(id)) return true;
100
+ }
101
+ p = p.parentPath;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /** Is there an adjacent `// optipack-hot` comment for a node at this line? */
107
+ isHotLine(line) {
108
+ if (!line) return false;
109
+ if (this.info.hotLines.has(line)) return true;
110
+ for (const l of this.info.hotLines) {
111
+ if (l < line && adjacent(this.info.coveredLines, l - 1, line)) return true;
112
+ }
113
+ return false;
114
+ }
115
+ }